diff --git a/.github/skills/thunder-forge/SKILL.md b/.github/skills/thunder-forge/SKILL.md index cdedc23..1d2fe95 100644 --- a/.github/skills/thunder-forge/SKILL.md +++ b/.github/skills/thunder-forge/SKILL.md @@ -28,8 +28,8 @@ uv run thunder-forge runtime setup-daemon --node infer-01 --admin-user - uv run thunder-forge runtime restart --node infer-01 --manager daemon --apply uv run thunder-forge service restart --service omlx --node infer-01 --manager daemon --apply uv run thunder-forge runtime smoke --node infer-01 --model -make olla-install -make olla-restart +make bootstrap +make restart make bootstrap infer-01 make restart infer-01 make smoke infer-01 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a827c65 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync --frozen + + - name: Check formatting hazards + run: git diff --check + + - name: Lint + run: uv run ruff check . + + - name: Test + run: uv run pytest --tb=short -q diff --git a/Makefile b/Makefile index ead34eb..02cc7a2 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ UV ?= uv run -_TARGETS := help cli-help dev-sync dev-test dev-lint dev-check bootstrap restart smoke status sync prune config usage-report usage-report-json usage-trim usage-duckdb opencode hermes +_TARGETS := help cli-help dev-sync dev-test dev-lint dev-check bootstrap restart smoke status sync prune config usage-report usage-report-json usage-trim usage-duckdb edge-keys edge-usage opencode hermes ARG ?= $(word 2,$(MAKECMDGOALS)) +EDGE_CLIENTS ?= ifneq ($(ARG),) $(ARG): @@ -26,6 +27,8 @@ help: @printf " %-24s %s\n" "usage-report-json [day]" "print usage summary as JSON" @printf " %-24s %s\n" "usage-trim [days]" "trim local TF logs (default 3 days)" @printf " %-24s %s\n" "usage-duckdb [day]" "run DuckDB daily usage SQL over JSONL logs" + @printf " %-24s %s\n" "edge-keys [clients]" "create missing TF edge client keys" + @printf " %-24s %s\n" "edge-usage" "summarize TF edge access logs" @printf " %-24s %s\n" "opencode [id]" "create client key and print/copy OpenCode config" @printf " %-24s %s\n" "hermes [id]" "create client key and print/copy Hermes config" @echo "" @@ -96,6 +99,16 @@ usage-duckdb: fi duckdb -readonly -cmd ".mode table" -cmd ".headers on" -cmd ".param set period $(if $(strip $(ARG)),$(ARG),$(shell date -u +%F))" -f docs/operations/daily-usage-duckdb.sql +edge-keys: + @clients="$(if $(strip $(EDGE_CLIENTS)),$(EDGE_CLIENTS),$(ARG))"; \ + if [ -z "$$clients" ]; then echo 'usage: make edge-keys EDGE_CLIENTS="client-a client-b"'; exit 2; fi; \ + args=""; \ + for client in $$clients; do args="$$args --client $$client"; done; \ + $(UV) thunder-forge edge keys $$args + +edge-usage: + $(UV) thunder-forge edge usage + opencode: @$(UV) thunder-forge edge client-config opencode --copy $(if $(strip $(ARG)),--inject-api-key --create-missing-key "$(ARG)") diff --git a/pyproject.toml b/pyproject.toml index 6daa765..61389ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,17 +4,17 @@ version = "0.2.0" description = "CLI for managing a local MLX inference cluster" requires-python = ">=3.12" dependencies = [ - "httpx>=0.27", - "python-dotenv>=1.0", + "httpx>=0.28", + "python-dotenv>=1.2", "pyyaml>=6.0", - "typer>=0.15", + "typer>=0.26", ] [project.scripts] thunder-forge = "thunder_forge.cli:app" [build-system] -requires = ["hatchling"] +requires = ["hatchling>=1.30"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] @@ -22,12 +22,8 @@ packages = ["src/thunder_forge"] [dependency-groups] dev = [ - "bcrypt>=4.2", - "httpx>=0.27", - "paramiko>=5.0.0", - "psycopg[binary]>=3.2", - "pytest>=8.0", - "ruff>=0.11", + "pytest>=9.0", + "ruff>=0.15", ] [tool.ruff] @@ -37,9 +33,5 @@ line-length = 120 [tool.ruff.lint] select = ["E", "F", "I", "UP"] -[tool.ruff.lint.per-file-ignores] -"v1/**/*.py" = ["ALL"] - [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--ignore=v1" diff --git a/src/thunder_forge/cli.py b/src/thunder_forge/cli.py index 8202421..fe78cf2 100644 --- a/src/thunder_forge/cli.py +++ b/src/thunder_forge/cli.py @@ -11,6 +11,8 @@ import threading import time import urllib.request +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import cast @@ -18,8 +20,10 @@ import typer from thunder_forge.cluster.artifacts import ( + ArtifactDownloadPlan, ArtifactPresence, ArtifactReadinessAction, + ArtifactSyncPlan, build_artifact_download_plan, build_artifact_identity, build_artifact_readiness_plan, @@ -70,6 +74,18 @@ local_base_url, resolve_port, ) +from thunder_forge.cluster.remote_cache import ( + cache_hub_setup_command as _cache_hub_setup_command, +) +from thunder_forge.cluster.remote_cache import ( + remote_artifact_download_command as _remote_artifact_download_command, +) +from thunder_forge.cluster.remote_cache import ( + remote_cache_sync_command as _remote_cache_sync_command, +) +from thunder_forge.cluster.remote_cache import ( + remote_transport_plan_probe_command as _remote_transport_plan_probe_command, +) from thunder_forge.cluster.ssh import ssh_run from thunder_forge.cluster.usage import extract_hot_loaded_models, summarize_daily_usage @@ -100,6 +116,17 @@ REMOTE_CACHE_EXEC_ENV = "TF_CACHE_REMOTE_EXEC" +@dataclass(frozen=True) +class ArtifactSyncExecutionPlan: + source_path: str + destination: str + command: str + runtime_model_id: str + model_dir_name: str + readiness_actions: list[ArtifactReadinessAction] + sync_plan: ArtifactSyncPlan | None = None + + def _load_config() -> tuple[ClusterConfig, Path]: """Load the TF cluster config. Returns (ClusterConfig, repo_root Path).""" import thunder_forge.cluster.config as _cfg @@ -112,6 +139,25 @@ def _load_config() -> tuple[ClusterConfig, Path]: return _cfg.load_cluster_config(cluster_config_path), repo_root +def _load_config_if_present() -> tuple[ClusterConfig, Path] | None: + """Load cluster config when it exists without making ad-hoc smoke checks require it.""" + import thunder_forge.cluster.config as _cfg + + repo_root = _cfg.find_repo_root() + cluster_config_path = _cfg.default_cluster_config_path(repo_root) + if not cluster_config_path.exists(): + return None + return _cfg.load_cluster_config(cluster_config_path), repo_root + + +def _resolve_olla_port_from_optional_config(port: int | None) -> int: + if port is not None: + return resolve_port(port, default=DEFAULT_OLLA_PORT) + loaded = _load_config_if_present() + default_port = loaded[0].services.olla_port if loaded is not None else DEFAULT_OLLA_PORT + return resolve_port(None, default=default_port) + + def _repo_root() -> Path: from thunder_forge.cluster.config import find_repo_root @@ -284,15 +330,6 @@ def _dispatch_cache_command_if_remote( return True -def _cache_hub_setup_command() -> str: - return ( - "set -euo pipefail; " - 'CACHE_DIR="${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}"; ' - '/bin/mkdir -p "$CACHE_DIR"; ' - 'echo "cache: oMLX model hub ready at $CACHE_DIR"' - ) - - def _remote_artifact_complete_on_cache(*, cache_node: Node, model_dir_name: str, timeout: int = 30) -> bool: command = ( "set -euo pipefail; " @@ -314,427 +351,6 @@ def _remote_artifact_complete_on_cache(*, cache_node: Node, model_dir_name: str, return result.returncode == 0 -def _remote_artifact_download_command(*, repo_id: str, model_dir_name: str, timeout: int) -> str: - payload_b64 = base64.b64encode( - json.dumps( - { - "repo_id": repo_id, - "model_dir_name": model_dir_name, - "timeout": timeout, - } - ).encode("utf-8") - ).decode("ascii") - return ( - "python3 -u - <<'PY'\n" - "import base64\n" - "import json\n" - "import os\n" - "import shutil\n" - "import secrets\n" - "import subprocess\n" - "import sys\n" - "import time\n" - "import urllib.error\n" - "import urllib.request\n" - "from http.cookiejar import CookieJar\n" - "payload = json.loads(base64.b64decode(" + repr(payload_b64) + ").decode())\n" - "repo_id = str(payload['repo_id'])\n" - "model_dir_name = str(payload['model_dir_name'])\n" - "timeout_seconds = int(payload['timeout'])\n" - "cache_root = os.environ.get('TF_CACHE_OMLX_MODELS_DIR') or os.path.expanduser('~/.omlx/models')\n" - "model_dir = os.path.join(cache_root, model_dir_name)\n" - "base_url = 'http://127.0.0.1:8020'\n" - "env = dict(os.environ)\n" - "env.pop('ALL_PROXY', None)\n" - "env.pop('all_proxy', None)\n" - "def _model_complete(path):\n" - " if not os.path.isdir(path):\n" - " return False\n" - " if not os.path.isfile(os.path.join(path, 'config.json')):\n" - " return False\n" - " if os.path.exists(os.path.join(path, '.rsync-partial')):\n" - " return False\n" - " has_weights = False\n" - " for root, _, files in os.walk(path):\n" - " for name in files:\n" - " if name.endswith('.incomplete'):\n" - " return False\n" - " if name.endswith('.safetensors') or name.endswith('.bin'):\n" - " has_weights = True\n" - " return has_weights\n" - "def _request(method, path, payload=None, opener=None):\n" - " req = urllib.request.Request(base_url + path, method=method)\n" - " req.add_header('Content-Type', 'application/json')\n" - " data = None\n" - " if payload is not None:\n" - " data = json.dumps(payload).encode('utf-8')\n" - " client = opener if opener is not None else urllib.request\n" - " return client.open(req, data=data, timeout=30)\n" - "def _health_ready():\n" - " try:\n" - " with urllib.request.urlopen(base_url + '/health', timeout=2) as response:\n" - " return 200 <= response.status < 300\n" - " except Exception:\n" - " return False\n" - "def _resolve_omlx_bin():\n" - " if env.get('OMLX_BIN'):\n" - " candidate = os.path.expanduser(env['OMLX_BIN'])\n" - " if os.path.isfile(candidate) and os.access(candidate, os.X_OK):\n" - " return candidate\n" - " candidate = shutil.which('omlx', path=env.get('PATH'))\n" - " if candidate:\n" - " return candidate\n" - " shell = env.get('SHELL') or '/bin/zsh'\n" - " try:\n" - " probe = subprocess.run(\n" - " [shell, '-lc', 'command -v omlx'],\n" - " check=False,\n" - " capture_output=True,\n" - " text=True,\n" - " timeout=8,\n" - " env=env,\n" - " )\n" - " resolved = (probe.stdout or '').strip()\n" - " if probe.returncode == 0 and resolved:\n" - " return resolved\n" - " except Exception:\n" - " pass\n" - " tool_dirs = [\n" - " os.path.expanduser('~/.local/bin'),\n" - " os.path.expanduser('~/.cargo/bin'),\n" - " os.path.expanduser('~/.local/share/uv/tools/omlx/bin'),\n" - " ]\n" - " for directory in tool_dirs:\n" - " candidate = os.path.join(directory, 'omlx')\n" - " if os.path.isfile(candidate) and os.access(candidate, os.X_OK):\n" - " return candidate\n" - " return None\n" - "if _model_complete(model_dir):\n" - " print('download_status: already_ready')\n" - " raise SystemExit(0)\n" - "downloader_api_key = env.get('TF_OMLX_DOWNLOADER_API_KEY') or env.get('OMLX_API_KEY')\n" - "server_proc = None\n" - "server_started = False\n" - "server_stderr = ''\n" - "try:\n" - " if not _health_ready():\n" - " downloader_api_key = secrets.token_urlsafe(24)\n" - " omlx_bin = _resolve_omlx_bin()\n" - " if not omlx_bin:\n" - " raise RuntimeError(\n" - " 'oMLX CLI is not on PATH for non-interactive shell; '\n" - " 'set OMLX_BIN or ensure command -v omlx works in login shell'\n" - " )\n" - " serve_variants = [\n" - " [\n" - " omlx_bin, 'serve', '--host', '127.0.0.1', '--port', '8020',\n" - " '--model-dir', cache_root, '--max-model-memory', 'disabled',\n" - " '--api-key', downloader_api_key,\n" - " ],\n" - " [\n" - " omlx_bin, 'serve', '--host', '127.0.0.1', '--port', '8020',\n" - " '--model-dir', cache_root, '--api-key', downloader_api_key,\n" - " ],\n" - " ]\n" - " for idx, serve_args in enumerate(serve_variants):\n" - " server_proc = subprocess.Popen(\n" - " serve_args,\n" - " stdout=subprocess.DEVNULL,\n" - " stderr=subprocess.PIPE,\n" - " text=True,\n" - " env=env,\n" - " )\n" - " server_started = True\n" - " deadline = time.monotonic() + 60\n" - " while time.monotonic() < deadline:\n" - " if server_proc.poll() is not None:\n" - " _, stderr_text = server_proc.communicate()\n" - " server_stderr = (stderr_text or '').strip()\n" - " break\n" - " if _health_ready():\n" - " break\n" - " time.sleep(1)\n" - " if _health_ready():\n" - " break\n" - " if server_proc.poll() is None:\n" - " server_proc.terminate()\n" - " try:\n" - " _, stderr_text = server_proc.communicate(timeout=10)\n" - " except subprocess.TimeoutExpired:\n" - " server_proc.kill()\n" - " _, stderr_text = server_proc.communicate(timeout=10)\n" - " server_stderr = (stderr_text or '').strip()\n" - " if idx == len(serve_variants) - 1:\n" - " detail = (\n" - " f': {server_stderr}' if server_stderr else ''\n" - " )\n" - " raise RuntimeError(\n" - " f'oMLX downloader server exited with code {server_proc.returncode}{detail}'\n" - " )\n" - " else:\n" - " raise TimeoutError('oMLX downloader server did not become ready: ' + base_url)\n" - " opener = urllib.request.build_opener(\n" - " urllib.request.HTTPCookieProcessor(CookieJar())\n" - " )\n" - " if downloader_api_key:\n" - " with _request(\n" - " 'POST',\n" - " '/admin/api/login',\n" - " {'api_key': downloader_api_key, 'remember': False},\n" - " opener=opener,\n" - " ) as response:\n" - " if response.status >= 400:\n" - " raise RuntimeError('admin login failed')\n" - " hf_token = env.get('HF_TOKEN', '')\n" - " try:\n" - " with _request(\n" - " 'POST',\n" - " '/admin/api/hf/download',\n" - " {'repo_id': repo_id, 'hf_token': hf_token},\n" - " opener=opener,\n" - " ) as response:\n" - " task = json.loads(response.read().decode('utf-8')).get('task')\n" - " except urllib.error.HTTPError as exc:\n" - " body = exc.read().decode('utf-8', errors='replace')\n" - " if exc.code == 400 and 'already in progress' in body:\n" - " with _request('GET', '/admin/api/hf/tasks', opener=opener) as response:\n" - " tasks = json.loads(response.read().decode('utf-8')).get('tasks', [])\n" - " task = next(\n" - " (\n" - " item\n" - " for item in tasks\n" - " if item.get('repo_id') == repo_id\n" - " and item.get('status') in {'pending', 'downloading'}\n" - " ),\n" - " None,\n" - " )\n" - " if task is None:\n" - " raise RuntimeError('No active oMLX download task found for ' + repo_id)\n" - " elif exc.code == 401:\n" - " raise RuntimeError('oMLX downloader admin API requires authentication')\n" - " else:\n" - " raise RuntimeError(body or str(exc))\n" - " if not isinstance(task, dict) or not task.get('task_id'):\n" - " raise RuntimeError('oMLX downloader returned no task for ' + repo_id)\n" - " task_id = task['task_id']\n" - " deadline = time.monotonic() + timeout_seconds\n" - " last_bucket = -1\n" - " while time.monotonic() < deadline:\n" - " with _request('GET', '/admin/api/hf/tasks', opener=opener) as response:\n" - " tasks = json.loads(response.read().decode('utf-8')).get('tasks', [])\n" - " current = next((item for item in tasks if item.get('task_id') == task_id), None)\n" - " if current is None:\n" - " time.sleep(2)\n" - " continue\n" - " status = str(current.get('status') or 'unknown')\n" - " progress = float(current.get('progress') or 0.0)\n" - " bucket = int(progress)\n" - " if bucket != last_bucket:\n" - " print(f'download_progress: {status} {progress:.1f}%')\n" - " last_bucket = bucket\n" - " if status == 'completed':\n" - " print('download_status: completed')\n" - " raise SystemExit(0)\n" - " if status in {'failed', 'cancelled'}:\n" - " error = current.get('error') or ('download ' + status)\n" - " raise RuntimeError('oMLX download failed for ' + repo_id + ': ' + str(error))\n" - " time.sleep(2)\n" - " raise TimeoutError('Timed out waiting for oMLX download ' + repo_id)\n" - "finally:\n" - " if server_started and server_proc is not None:\n" - " if server_proc.poll() is None:\n" - " server_proc.terminate()\n" - " try:\n" - " server_proc.wait(timeout=10)\n" - " except subprocess.TimeoutExpired:\n" - " server_proc.kill()\n" - " server_proc.wait(timeout=10)\n" - "PY" - ) - - -def _remote_cache_sync_command( - *, - repo_id: str, - runtime_node: Node, - node_home_dir: str, - transport_host: str, - ssh_host_key_alias: str | None, -) -> tuple[str, str, str]: - identity = build_artifact_identity(repo_id) - source_root = '${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}' - source_path = f"{source_root}/{identity.model_dir_name}/" - remote_omlx_models_dir = f"{node_home_dir}/.omlx/models" - remote_model_parent_dir = f"{remote_omlx_models_dir}/{identity.namespace}" - destination = f"{runtime_node.user}@{transport_host}:{remote_omlx_models_dir}/{identity.model_dir_name}/" - - ssh_options = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"] - if ssh_host_key_alias: - ssh_options.extend(["-o", f"HostKeyAlias={ssh_host_key_alias}"]) - - ssh_mkdir_cmd = " ".join( - shlex.quote(arg) - for arg in [ - "ssh", - *ssh_options, - f"{runtime_node.user}@{transport_host}", - "mkdir", - "-p", - remote_model_parent_dir, - ] - ) - rsync_cmd = " ".join( - shlex.quote(arg) - for arg in [ - "rsync", - "-a", - "--progress", - "--partial-dir=.rsync-partial", - "--exclude", - ".cache/", - "-e", - "ssh " + " ".join(shlex.quote(option) for option in ssh_options), - "__TF_SOURCE_PATH__", - destination, - ] - ).replace(shlex.quote("__TF_SOURCE_PATH__"), '"$SOURCE_PATH"') - - command = ( - "set -euo pipefail; " - 'CACHE_ROOT="${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}"; ' - f'SOURCE_PATH="$CACHE_ROOT/{identity.model_dir_name}/"; ' - 'if [[ ! -d "$SOURCE_PATH" ]]; then ' - 'echo "missing cache oMLX model dir: $SOURCE_PATH" >&2; ' - "exit 2; " - "fi; " - f"{ssh_mkdir_cmd}; " - f"{rsync_cmd}" - ) - return source_path, destination, command - - -def _remote_transport_plan_probe_command(*, payload_b64: str) -> str: - return ( - "python3 - <<'PY'\n" - "import base64\n" - "import ipaddress\n" - "import json\n" - "import platform\n" - "import re\n" - "import subprocess\n" - f"payload = json.loads(base64.b64decode({payload_b64!r}).decode())\n" - "requested_transport = payload['requested_transport']\n" - "management_host = payload['management_host']\n" - "node_user = payload['node_user']\n" - "fabric_host = payload['fabric_host']\n" - "timeout = payload['timeout']\n" - "plan = {\n" - " 'requested_transport': requested_transport,\n" - " 'management_host': management_host,\n" - " 'transport_host': management_host,\n" - " 'resolved_transport_host': management_host,\n" - " 'fabric_fallback': '',\n" - " 'error': '',\n" - "}\n" - "if requested_transport not in {'auto', 'fabric', 'management'}:\n" - " plan['error'] = '--transport must be one of: auto, fabric, management'\n" - "elif requested_transport == 'management':\n" - " pass\n" - "elif not fabric_host:\n" - " if requested_transport == 'fabric':\n" - " plan['error'] = 'fabric probe disabled for node'\n" - "else:\n" - " def _run(cmd, extra_timeout=0):\n" - " try:\n" - " return subprocess.run(\n" - " cmd,\n" - " check=False,\n" - " capture_output=True,\n" - " text=True,\n" - " timeout=timeout + extra_timeout,\n" - " )\n" - " except (FileNotFoundError, subprocess.TimeoutExpired):\n" - " return None\n" - " def _extract_thunderbolt_devices(text):\n" - " devices = set()\n" - " for block in re.split(r'\\n\\s*\\n', text):\n" - " if 'Thunderbolt' not in block:\n" - " continue\n" - " match = re.search(r'^Device:\\s*(\\S+)\\s*$', block, flags=re.MULTILINE)\n" - " if match:\n" - " devices.add(match.group(1))\n" - " return devices\n" - " def _acceptable(address):\n" - " try:\n" - " parsed = ipaddress.ip_address(address)\n" - " except ValueError:\n" - " return False\n" - " return parsed.version == 4 and parsed.is_link_local\n" - " def _extract_link_local_ipv4(text, allowed_interfaces):\n" - " addresses = []\n" - " current_interface = ''\n" - " for line in text.splitlines():\n" - " interface_match = re.match(r'^([A-Za-z0-9._-]+):', line)\n" - " if interface_match:\n" - " current_interface = interface_match.group(1)\n" - " if current_interface not in allowed_interfaces:\n" - " continue\n" - " match = re.search(r'\\binet\\s+(169\\.254\\.\\d{1,3}\\.\\d{1,3})\\b', line)\n" - " if match:\n" - " address = match.group(1)\n" - " if _acceptable(address) and address not in addresses:\n" - " addresses.append(address)\n" - " return addresses\n" - " def _route_uses_allowed_interface(address, allowed_interfaces):\n" - " result = _run(['route', '-n', 'get', address])\n" - " if result is None or result.returncode != 0:\n" - " return False\n" - " match = re.search(r'^\\s*interface:\\s*(\\S+)\\s*$', result.stdout, flags=re.MULTILINE)\n" - " return bool(match and match.group(1) in allowed_interfaces)\n" - " def _ssh_hostname_check(address):\n" - " result = _run([\n" - " 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',\n" - " '-o', f'HostKeyAlias={management_host}',\n" - " f'{node_user}@{address}', 'hostname'\n" - " ], extra_timeout=8)\n" - " return result is not None and result.returncode == 0\n" - " resolved = None\n" - " if platform.system() == 'Darwin':\n" - " local_inventory = _run(['networksetup', '-listallhardwareports'])\n" - " if local_inventory is not None and local_inventory.returncode == 0:\n" - " local_devices = _extract_thunderbolt_devices(local_inventory.stdout)\n" - " if local_devices:\n" - " remote_inventory = _run([\n" - " 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',\n" - " f'{node_user}@{management_host}',\n" - " (\n" - " \"networksetup -listallhardwareports 2>/dev/null; \"\n" - " \"printf '\\n__TF_IFCONFIG__\\n'; ifconfig\"\n" - " ),\n" - " ], extra_timeout=8)\n" - " if remote_inventory is not None and remote_inventory.returncode == 0:\n" - " inventory, _, ifconfig_text = remote_inventory.stdout.partition('__TF_IFCONFIG__')\n" - " remote_devices = _extract_thunderbolt_devices(inventory)\n" - " for address in _extract_link_local_ipv4(\n" - " ifconfig_text, remote_devices\n" - " ):\n" - " if _route_uses_allowed_interface(\n" - " address, local_devices\n" - " ) and _ssh_hostname_check(address):\n" - " resolved = address\n" - " break\n" - " if resolved is not None:\n" - " plan['transport_host'] = resolved\n" - " plan['resolved_transport_host'] = resolved\n" - " elif requested_transport == 'fabric':\n" - " plan['error'] = 'no reachable fabric address discovered'\n" - " else:\n" - " plan['fabric_fallback'] = 'dynamic probe unresolved'\n" - "print('__TF_TRANSPORT_PLAN__' + json.dumps(plan, sort_keys=True))\n" - "PY" - ) - - def _resolve_transport_plan_for_sync( *, requested_transport: str, @@ -833,16 +449,6 @@ def _prepare_cache_role_node(*, cache_name: str, cache_node: Node, timeout: int) raise typer.Exit(setup_result.returncode) -def _run_preflight(config: object, *, target_node: str | None = None) -> None: - """Run pre-flight checks. Exit on failure.""" - from thunder_forge.cluster.preflight import print_preflight_result, run_preflight - - errors = run_preflight(config, target_node=target_node) - print_preflight_result(errors, config) - if errors: - raise typer.Exit(1) - - def _get_runtime_node(config: ClusterConfig, node: str) -> Node: """Return a configured oMLX runtime node or exit with a CLI error.""" from thunder_forge.cluster.config import RuntimeType @@ -1224,9 +830,10 @@ def _assigned_repo_ids_for_node(config: ClusterConfig, node_name: str, runtime_n def _assigned_model_dirs_for_node(config: ClusterConfig, node_name: str, runtime_node: Node) -> set[str]: + repo_ids = _assigned_repo_ids_for_node(config, node_name, runtime_node) return { build_artifact_identity(repo_id).model_dir_name - for repo_id in _assigned_repo_ids_for_node(config, node_name, runtime_node) + for repo_id in repo_ids } @@ -1260,7 +867,7 @@ def _list_node_cache_model_dirs(runtime_node: Node, *, timeout: int) -> list[str if not line: continue if line.startswith(prefix): - line = line[len(prefix) :] + line = line.removeprefix(prefix) if "/" not in line: continue model_dirs.append(line) @@ -1602,7 +1209,7 @@ def _omlx_version_for_node(runtime_node: Node) -> str: def _latest_olla_release_version(*, timeout: int = 5) -> str: request = urllib.request.Request(LATEST_OLLA_RELEASE_API, headers={"User-Agent": "thunder-forge"}) try: - with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 + with urllib.request.urlopen(request, timeout=timeout) as response: payload = json.loads(response.read().decode()) except (OSError, TimeoutError, ValueError, TypeError): return "" @@ -2660,8 +2267,7 @@ def olla_smoke( timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds."), ) -> None: """Run a black-box smoke test against a running Olla router.""" - config, _ = _load_config() - resolved_base_url = base_url or local_base_url(resolve_port(None, default=config.services.olla_port)) + resolved_base_url = base_url or local_base_url(_resolve_olla_port_from_optional_config(None)) result = smoke_olla_router( base_url=resolved_base_url, model=model, @@ -2710,8 +2316,7 @@ def olla_dev_smoke( ), ) -> None: """Generate Olla config, spawn Olla, smoke, teardown. Single-command dev workflow.""" - config, _ = _load_config() - resolved_port = resolve_port(port, default=config.services.olla_port) + resolved_port = _resolve_olla_port_from_optional_config(port) result = dev_smoke_olla( binary=binary, model=model, @@ -2800,7 +2405,8 @@ def edge_serve( ) if not clients_by_key: typer.echo( - f"Error: no edge API keys found. Run `make edge-keys EDGE_CLIENTS=...` or set {users_env}.", + "Error: no edge API keys found. Run " + f"`thunder-forge edge keys --client ...` or `make edge-keys EDGE_CLIENTS=...`, or set {users_env}.", err=True, ) raise typer.Exit(1) @@ -2850,9 +2456,9 @@ def _run_edge_metrics_collector() -> None: snapshots = _collect_node_metric_snapshots(cluster_config, timeout=metrics_timeout) if snapshots: _append_snapshots(metrics_path, snapshots) - except Exception: + except Exception as exc: # Keep edge serving even when metrics sampling fails transiently. - pass + typer.echo(f"node_metrics_collector_error: {exc}") if stop_metrics_collector.wait(metrics_interval_seconds): break @@ -3018,6 +2624,7 @@ def artifact_status( cache_target = _first_cache_node(config) presence = probe_artifact_presence( repo_id=model, + node_user=runtime_node.user, node_host=runtime_node.host, node_home_dir=node_home_dir, cache_omlx_models_dir=cache_omlx_models_dir, @@ -3056,39 +2663,31 @@ def artifact_status( typer.echo(f" - {action.value}") -@artifact_app.command("download") -def artifact_download( - model: str = typer.Option(..., "--model", help="Hugging Face model repo id."), - dry_run: bool = typer.Option( - True, - "--dry-run/--apply", - help="Print download command without executing by default.", - ), - timeout: int = typer.Option(7200, "--timeout", help="Timeout in seconds for model download when applying."), -) -> None: - """Download a model directly into cache role's oMLX model directory.""" - config, _ = _load_config() - remote_cache_target = _remote_cache_target(config) - identity = build_artifact_identity(model) +def _artifact_download_display(plan: ArtifactDownloadPlan, *, remote_cache: bool) -> tuple[str, str]: + if not remote_cache: + return plan.destination, plan.command - _load_repo_dotenv() - plan = build_artifact_download_plan( - repo_id=model, - cache_omlx_models_dir=cache_omlx_models_dir_from_env(), + remote_cache_root = '${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}' + display_destination = f"{remote_cache_root}/{plan.model_dir_name}" + display_command = ( + "python3 remote-helper (cache host) -> " + "omlx serve --host 127.0.0.1 --port 8020 " + f"--model-dir {remote_cache_root}; " + f"POST http://127.0.0.1:8020/admin/api/hf/download repo_id={plan.repo_id} hf_token=$HF_TOKEN" ) + return display_destination, display_command - if remote_cache_target is not None: - remote_cache_root = '${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}' - display_destination = f"{remote_cache_root}/{identity.model_dir_name}" - display_command = ( - "python3 remote-helper (cache host) -> " - "omlx serve --host 127.0.0.1 --port 8020 " - f"--model-dir {remote_cache_root}; " - f"POST http://127.0.0.1:8020/admin/api/hf/download repo_id={model} hf_token=$HF_TOKEN" - ) - else: - display_destination = plan.destination - display_command = plan.command + +def _print_artifact_download_plan( + *, + model: str, + plan: ArtifactDownloadPlan, + remote_cache_target: tuple[str, Node] | None, +) -> None: + display_destination, display_command = _artifact_download_display( + plan, + remote_cache=remote_cache_target is not None, + ) typer.echo(f"model: {model}") typer.echo(f"model_dir_name: {plan.model_dir_name}") @@ -3097,32 +2696,36 @@ def artifact_download( typer.echo("action: download_to_cache_omlx") typer.echo(f"command: {display_command}") - if dry_run: - typer.echo("mode: dry-run") - return - if remote_cache_target is not None: - cache_name, cache_node = remote_cache_target - typer.echo(f"cache_exec: remote {cache_name} ({cache_node.host})") - result = ssh_run( - cache_node.user, - cache_node.host, - _remote_artifact_download_command( - repo_id=model, - model_dir_name=plan.model_dir_name, - timeout=timeout, - ), - timeout=max(timeout + 120, 300), - stream=True, - shell=cache_node.shell, - node_name=cache_name, - ) - if result.returncode != 0: - typer.echo(f"Error: download failed with exit code {result.returncode}", err=True) - raise typer.Exit(result.returncode) - typer.echo("status: downloaded") - return +def _run_remote_artifact_download( + *, + model: str, + plan: ArtifactDownloadPlan, + remote_cache_target: tuple[str, Node], + timeout: int, +) -> None: + cache_name, cache_node = remote_cache_target + typer.echo(f"cache_exec: remote {cache_name} ({cache_node.host})") + result = ssh_run( + cache_node.user, + cache_node.host, + _remote_artifact_download_command( + repo_id=model, + model_dir_name=plan.model_dir_name, + timeout=timeout, + ), + timeout=max(timeout + 120, 300), + stream=True, + shell=cache_node.shell, + node_name=cache_name, + ) + if result.returncode != 0: + typer.echo(f"Error: download failed with exit code {result.returncode}", err=True) + raise typer.Exit(result.returncode) + typer.echo("status: downloaded") + +def _artifact_download_progress_printer() -> Callable[[dict], None]: last_progress_bucket: int | None = None last_progress_status: str | None = None @@ -3148,7 +2751,15 @@ def print_progress(task: dict) -> None: else: typer.echo(f"download_progress: {status}") - result = run_artifact_download(plan, timeout=timeout, progress_callback=print_progress) + return print_progress + + +def _run_local_artifact_download(plan: ArtifactDownloadPlan, *, timeout: int) -> None: + result = run_artifact_download( + plan, + timeout=timeout, + progress_callback=_artifact_download_progress_printer(), + ) if result.returncode != 0: typer.echo(f"Error: download failed with exit code {result.returncode}", err=True) if result.stderr: @@ -3157,6 +2768,183 @@ def print_progress(task: dict) -> None: typer.echo("status: downloaded") +@artifact_app.command("download") +def artifact_download( + model: str = typer.Option(..., "--model", help="Hugging Face model repo id."), + dry_run: bool = typer.Option( + True, + "--dry-run/--apply", + help="Print download command without executing by default.", + ), + timeout: int = typer.Option(7200, "--timeout", help="Timeout in seconds for model download when applying."), +) -> None: + """Download a model directly into cache role's oMLX model directory.""" + config, _ = _load_config() + remote_cache_target = _remote_cache_target(config) + + _load_repo_dotenv() + plan = build_artifact_download_plan( + repo_id=model, + cache_omlx_models_dir=cache_omlx_models_dir_from_env(), + ) + + _print_artifact_download_plan(model=model, plan=plan, remote_cache_target=remote_cache_target) + if dry_run: + typer.echo("mode: dry-run") + return + + if remote_cache_target is not None: + _run_remote_artifact_download( + model=model, + plan=plan, + remote_cache_target=remote_cache_target, + timeout=timeout, + ) + return + + _run_local_artifact_download(plan, timeout=timeout) + + +def _remote_cache_target_for_artifact_workflow(config: ClusterConfig) -> tuple[str, Node] | None: + if os.environ.get(REMOTE_CACHE_EXEC_ENV) == "1": + return None + cache_target = _first_cache_node(config) + if cache_target is None: + return None + cache_name, cache_node = cache_target + if _is_local_host(cache_node.host): + return None + return cache_name, cache_node + + +def _build_artifact_sync_execution_plan( + *, + repo_id: str, + node: str, + runtime_node: Node, + node_home_dir: str, + cache_omlx_models_dir: str, + transport_plan: TransportPlan, + remote_cache_target: tuple[str, Node] | None, +) -> ArtifactSyncExecutionPlan: + ssh_host_key_alias = runtime_node.host if transport_plan.uses_fabric else None + if remote_cache_target is not None: + source_path, destination, command = _remote_cache_sync_command( + repo_id=repo_id, + runtime_node=runtime_node, + node_home_dir=node_home_dir, + transport_host=transport_plan.resolved_transport_host, + ssh_host_key_alias=ssh_host_key_alias, + ) + identity = build_artifact_identity(repo_id) + return ArtifactSyncExecutionPlan( + source_path=source_path, + destination=destination, + command=command, + runtime_model_id=identity.runtime_model_id, + model_dir_name=identity.model_dir_name, + readiness_actions=[], + ) + + presence = probe_artifact_presence( + repo_id=repo_id, + node_user=runtime_node.user, + node_host=runtime_node.host, + node_home_dir=node_home_dir, + cache_omlx_models_dir=cache_omlx_models_dir, + ) + readiness_plan = build_artifact_readiness_plan( + repo_id=repo_id, + node=node, + node_home_dir=node_home_dir, + presence=presence, + cache_omlx_models_dir=cache_omlx_models_dir, + ) + sync_plan = build_artifact_sync_plan( + repo_id=repo_id, + node_user=runtime_node.user, + node_host=transport_plan.resolved_transport_host, + node_home_dir=node_home_dir, + cache_omlx_models_dir=cache_omlx_models_dir, + ssh_host_key_alias=ssh_host_key_alias, + ) + return ArtifactSyncExecutionPlan( + source_path=sync_plan.source_path, + destination=sync_plan.destination, + command=sync_plan.command, + runtime_model_id=sync_plan.runtime_model_id, + model_dir_name=sync_plan.model_dir_name, + readiness_actions=readiness_plan.actions, + sync_plan=sync_plan, + ) + + +def _print_artifact_sync_execution_plan( + *, + node: str, + runtime_node: Node, + repo_id: str, + plan: ArtifactSyncExecutionPlan, + transport_plan: TransportPlan, +) -> None: + typer.echo(f"model: {repo_id}") + typer.echo(f"model_dir_name: {plan.model_dir_name}") + typer.echo(f"runtime_model_id: {plan.runtime_model_id}") + _print_runtime_node_header(node, runtime_node) + typer.echo("source: cache") + typer.echo(f"transport_host: {transport_plan.transport_host}") + if transport_plan.resolved_transport_host != transport_plan.transport_host: + typer.echo(f"resolved_transport_host: {transport_plan.resolved_transport_host}") + if transport_plan.fabric_fallback: + typer.echo(f"fabric_fallback: {transport_plan.fabric_fallback}") + typer.echo(f"source_path: {plan.source_path}") + typer.echo(f"destination: {plan.destination}") + typer.echo("action: sync_to_node_omlx") + typer.echo(f"command: {plan.command}") + + +def _local_artifact_sync_needed(plan: ArtifactSyncExecutionPlan) -> bool: + if ArtifactReadinessAction.DOWNLOAD_TO_CACHE_OMLX in plan.readiness_actions: + typer.echo( + "Error: cache oMLX model directory is missing or incomplete; " + "download the model to cache oMLX models first", + err=True, + ) + raise typer.Exit(1) + if ArtifactReadinessAction.SYNC_TO_NODE_OMLX not in plan.readiness_actions: + typer.echo("status: sync not needed") + return False + return True + + +def _run_artifact_sync_execution( + *, + plan: ArtifactSyncExecutionPlan, + remote_cache_target: tuple[str, Node] | None, + timeout: int, +) -> None: + if remote_cache_target is None: + if plan.sync_plan is None: + msg = "local artifact sync plan is missing" + raise RuntimeError(msg) + result = run_artifact_sync(plan.sync_plan, timeout=timeout) + else: + cache_name, cache_node = remote_cache_target + result = ssh_run( + cache_node.user, + cache_node.host, + plan.command, + timeout=timeout, + stream=True, + shell=cache_node.shell, + node_name=cache_name, + ) + if result.returncode != 0: + typer.echo(f"Error: sync failed with exit code {result.returncode}", err=True) + raise typer.Exit(result.returncode) + typer.echo("status: synced") + + def _run_artifact_sync_workflow( *, config: ClusterConfig, @@ -3167,31 +2955,7 @@ def _run_artifact_sync_workflow( management: bool, timeout: int, ) -> None: - remote_cache_target: tuple[str, Node] | None = None - if os.environ.get(REMOTE_CACHE_EXEC_ENV) != "1": - cache_target = _first_cache_node(config) - if cache_target is not None: - cache_name, cache_node = cache_target - if not _is_local_host(cache_node.host): - remote_cache_target = (cache_name, cache_node) - - sync_mode = "--dry-run" if dry_run else "--apply" - sync_args = [ - "artifact", - "sync", - "--node", - node, - sync_mode, - "--transport", - transport, - "--timeout", - str(timeout), - ] - if model: - sync_args.extend(["--model", model]) - if management: - sync_args.append("--management") - + remote_cache_target = _remote_cache_target_for_artifact_workflow(config) runtime_node = _get_runtime_node(config, node) node_home_dir = runtime_node.home_dir or f"/Users/{runtime_node.user}" cache_omlx_models_dir = cache_omlx_models_dir_from_env() @@ -3222,92 +2986,34 @@ def _run_artifact_sync_workflow( if len(repo_ids) > 1: typer.echo("") typer.echo(f"== Model {index}/{len(repo_ids)} ==") - if remote_cache_target is None: - presence = probe_artifact_presence( - repo_id=repo_id, - node_host=runtime_node.host, - node_home_dir=node_home_dir, - cache_omlx_models_dir=cache_omlx_models_dir, - ) - readiness_plan = build_artifact_readiness_plan( - repo_id=repo_id, - node=node, - node_home_dir=node_home_dir, - presence=presence, - cache_omlx_models_dir=cache_omlx_models_dir, - ) - sync_plan = build_artifact_sync_plan( - repo_id=repo_id, - node_user=runtime_node.user, - node_host=transport_plan.resolved_transport_host, - node_home_dir=node_home_dir, - cache_omlx_models_dir=cache_omlx_models_dir, - ssh_host_key_alias=runtime_node.host if transport_plan.uses_fabric else None, - ) - source_path = sync_plan.source_path - destination = sync_plan.destination - command = sync_plan.command - runtime_model_id = sync_plan.runtime_model_id - model_dir_name = sync_plan.model_dir_name - else: - source_path, destination, command = _remote_cache_sync_command( - repo_id=repo_id, - runtime_node=runtime_node, - node_home_dir=node_home_dir, - transport_host=transport_plan.resolved_transport_host, - ssh_host_key_alias=runtime_node.host if transport_plan.uses_fabric else None, - ) - identity = build_artifact_identity(repo_id) - runtime_model_id = identity.runtime_model_id - model_dir_name = identity.model_dir_name - - typer.echo(f"model: {repo_id}") - typer.echo(f"model_dir_name: {model_dir_name}") - typer.echo(f"runtime_model_id: {runtime_model_id}") - _print_runtime_node_header(node, runtime_node) - typer.echo("source: cache") - typer.echo(f"transport_host: {transport_plan.transport_host}") - if transport_plan.resolved_transport_host != transport_plan.transport_host: - typer.echo(f"resolved_transport_host: {transport_plan.resolved_transport_host}") - if transport_plan.fabric_fallback: - typer.echo(f"fabric_fallback: {transport_plan.fabric_fallback}") - typer.echo(f"source_path: {source_path}") - typer.echo(f"destination: {destination}") - typer.echo("action: sync_to_node_omlx") - typer.echo(f"command: {command}") - - if remote_cache_target is None: - if ArtifactReadinessAction.DOWNLOAD_TO_CACHE_OMLX in readiness_plan.actions: - typer.echo( - "Error: cache oMLX model directory is missing or incomplete; " - "download the model to cache oMLX models first", - err=True, - ) - raise typer.Exit(1) - if ArtifactReadinessAction.SYNC_TO_NODE_OMLX not in readiness_plan.actions: - typer.echo("status: sync not needed") - continue + sync_execution_plan = _build_artifact_sync_execution_plan( + repo_id=repo_id, + node=node, + runtime_node=runtime_node, + node_home_dir=node_home_dir, + cache_omlx_models_dir=cache_omlx_models_dir, + transport_plan=transport_plan, + remote_cache_target=remote_cache_target, + ) + _print_artifact_sync_execution_plan( + node=node, + runtime_node=runtime_node, + repo_id=repo_id, + plan=sync_execution_plan, + transport_plan=transport_plan, + ) + + if remote_cache_target is None and not _local_artifact_sync_needed(sync_execution_plan): + continue if dry_run: typer.echo("mode: dry-run") continue - if remote_cache_target is None: - result = run_artifact_sync(sync_plan, timeout=timeout) - else: - cache_name, cache_node = remote_cache_target - result = ssh_run( - cache_node.user, - cache_node.host, - command, - timeout=timeout, - stream=True, - shell=cache_node.shell, - node_name=cache_name, - ) - if result.returncode != 0: - typer.echo(f"Error: sync failed with exit code {result.returncode}", err=True) - raise typer.Exit(result.returncode) - typer.echo("status: synced") + _run_artifact_sync_execution( + plan=sync_execution_plan, + remote_cache_target=remote_cache_target, + timeout=timeout, + ) if len(repo_ids) > 1: status = "node sync dry-run complete" if dry_run else "node sync complete" diff --git a/src/thunder_forge/cluster/artifacts.py b/src/thunder_forge/cluster/artifacts.py index 9ffd8c2..83238b9 100644 --- a/src/thunder_forge/cluster/artifacts.py +++ b/src/thunder_forge/cluster/artifacts.py @@ -14,6 +14,8 @@ import httpx +from thunder_forge.cluster.ssh import ssh_run + DEFAULT_OMLX_MODELS_DIR = "~/.omlx/models" CACHE_OMLX_MODELS_DIR_ENV = "TF_CACHE_OMLX_MODELS_DIR" @@ -330,7 +332,7 @@ def run_artifact_download( client.close() stdout = f"download_task: {task['task_id']}\nstatus: {task['status']}\n" return subprocess.CompletedProcess(args=plan.args, returncode=0, stdout=stdout, stderr=stderr) - except Exception as exc: # noqa: BLE001 + except Exception as exc: stderr = str(exc) return subprocess.CompletedProcess(args=plan.args, returncode=1, stdout=stdout, stderr=stderr) finally: @@ -468,7 +470,7 @@ def is_local_artifact_complete(model_dir: Path) -> bool: return any(model_dir.rglob("*.safetensors")) or any(model_dir.rglob("*.bin")) -def _remote_artifact_complete(host: str, path: str) -> bool: +def _remote_artifact_complete(*, user: str, host: str, path: str) -> bool: quoted_path = shlex.quote(path) quoted_config = shlex.quote(f"{path}/config.json") command = " && ".join( @@ -480,12 +482,7 @@ def _remote_artifact_complete(host: str, path: str) -> bool: (f"test -n \"$(find {quoted_path} \\( -name '*.safetensors' -o -name '*.bin' \\) -type f -print -quit)\""), ] ) - result = subprocess.run( - ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host, command], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + result = ssh_run(user, host, command, timeout=30) return result.returncode == 0 @@ -517,6 +514,7 @@ def _validate_host(host: str, *, name: str) -> None: def probe_artifact_presence( *, repo_id: str, + node_user: str, node_host: str, node_home_dir: str, cache_omlx_models_dir: str | None = None, @@ -529,5 +527,5 @@ def probe_artifact_presence( return ArtifactPresence( cache_omlx_model_dir=is_local_artifact_complete(cache_omlx_model_dir), - node_omlx_model_dir=_remote_artifact_complete(node_host, node_omlx_model_dir), + node_omlx_model_dir=_remote_artifact_complete(user=node_user, host=node_host, path=node_omlx_model_dir), ) diff --git a/src/thunder_forge/cluster/bootstrap.py b/src/thunder_forge/cluster/bootstrap.py index 5184582..99cacb1 100644 --- a/src/thunder_forge/cluster/bootstrap.py +++ b/src/thunder_forge/cluster/bootstrap.py @@ -151,7 +151,7 @@ def _download_text(url: str, *, timeout: int) -> str: def _download_bytes(url: str, *, timeout: int, progress: Progress | None = None) -> bytes: request = urllib.request.Request(url, headers={"User-Agent": "thunder-forge"}) - with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 + with urllib.request.urlopen(request, timeout=timeout) as response: total_raw = response.headers.get("Content-Length", "0") total = int(total_raw) if total_raw.isdigit() else 0 chunks: list[bytes] = [] diff --git a/src/thunder_forge/cluster/config.py b/src/thunder_forge/cluster/config.py index c3d3c7b..6c8afd1 100644 --- a/src/thunder_forge/cluster/config.py +++ b/src/thunder_forge/cluster/config.py @@ -36,13 +36,6 @@ DEFAULT_SMOKE_TIMEOUT = 30.0 -class ServingMode(StrEnum): - CHAT = "chat" - EMBEDDING = "embedding" - CLI = "cli" - MLX_OPENAI_SERVER = "mlx-openai-server" - - class RuntimeType(StrEnum): OMLX = "omlx" @@ -119,7 +112,6 @@ class Model: ram_gb: float | None = None active_params: str = "" max_context: int = 0 - serving: ServingMode | str = "" notes: str = "" extra_args: list[str] | None = None enable_thinking: bool | None = None @@ -326,6 +318,9 @@ def _parse_service_port(raw: dict, service_name: str, default: int) -> int: def _parse_positive_int(raw: object, *, name: str, default: int) -> int: if raw is None: return default + if isinstance(raw, bool): + msg = f"{name} must be an integer" + raise ValueError(msg) try: value = int(raw) except (TypeError, ValueError) as exc: @@ -360,6 +355,25 @@ def _parse_bool(raw: object, *, name: str, default: bool) -> bool: raise ValueError(msg) +def _parse_optional_positive_int(raw: object, *, name: str) -> int | None: + if raw is None: + return None + return _parse_positive_int(raw, name=name, default=1) + + +def _parse_optional_non_empty_string(raw: object, *, name: str, default: str | None = None) -> str | None: + if raw is None: + return default + if not isinstance(raw, str): + msg = f"{name} must be a string" + raise ValueError(msg) + value = raw.strip() + if not value: + msg = f"{name} must not be empty" + raise ValueError(msg) + return value + + def _parse_transport(raw: object, *, name: str, default: str) -> str: value = str(raw or default).strip() if value not in {"auto", "fabric", "management"}: @@ -465,26 +479,60 @@ def _parse_operations(raw: object) -> OperationConfig: ) -def _parse_node_runtime(raw: dict | None, *, default_port: int = DEFAULT_OMLX_PORT) -> NodeRuntime | None: +def _parse_node_runtime( + raw: object, + *, + node_name: str, + default_port: int = DEFAULT_OMLX_PORT, +) -> NodeRuntime | None: if raw is None: return None + path = f"nodes.{node_name}.runtime" + if not isinstance(raw, dict): + msg = f"{path} must be a mapping" + raise ValueError(msg) + runtime_type_raw = _parse_optional_non_empty_string(raw.get("type"), name=f"{path}.type") + if runtime_type_raw is None: + msg = f"{path}.type is required" + raise ValueError(msg) + try: + runtime_type = RuntimeType(runtime_type_raw) + except ValueError as exc: + msg = f"{path}.type must be one of: {', '.join(item.value for item in RuntimeType)}" + raise ValueError(msg) from exc return NodeRuntime( - type=RuntimeType(raw["type"]), - port=parse_port(raw.get("port", default_port), name="runtime.port"), - model_dir=raw.get("model_dir"), - bind_host=raw.get("bind_host", "0.0.0.0"), - base_path=raw.get("base_path"), - log_level=raw.get("log_level"), - max_model_memory=raw.get("max_model_memory"), - max_process_memory=raw.get("max_process_memory"), - max_concurrent_requests=raw.get("max_concurrent_requests"), - paged_ssd_cache_dir=raw.get("paged_ssd_cache_dir"), - paged_ssd_cache_max_size=raw.get("paged_ssd_cache_max_size"), - hot_cache_max_size=raw.get("hot_cache_max_size"), - no_cache=raw.get("no_cache", False), - mcp_config=raw.get("mcp_config"), - hf_endpoint=raw.get("hf_endpoint"), - trusted_network=raw.get("trusted_network", False), + type=runtime_type, + port=parse_port(raw.get("port", default_port), name=f"{path}.port"), + model_dir=_parse_optional_non_empty_string(raw.get("model_dir"), name=f"{path}.model_dir"), + bind_host=_parse_optional_non_empty_string(raw.get("bind_host"), name=f"{path}.bind_host", default="0.0.0.0") + or "0.0.0.0", + base_path=_parse_optional_non_empty_string(raw.get("base_path"), name=f"{path}.base_path"), + log_level=_parse_optional_non_empty_string(raw.get("log_level"), name=f"{path}.log_level"), + max_model_memory=_parse_optional_non_empty_string(raw.get("max_model_memory"), name=f"{path}.max_model_memory"), + max_process_memory=_parse_optional_non_empty_string( + raw.get("max_process_memory"), + name=f"{path}.max_process_memory", + ), + max_concurrent_requests=_parse_optional_positive_int( + raw.get("max_concurrent_requests"), + name=f"{path}.max_concurrent_requests", + ), + paged_ssd_cache_dir=_parse_optional_non_empty_string( + raw.get("paged_ssd_cache_dir"), + name=f"{path}.paged_ssd_cache_dir", + ), + paged_ssd_cache_max_size=_parse_optional_non_empty_string( + raw.get("paged_ssd_cache_max_size"), + name=f"{path}.paged_ssd_cache_max_size", + ), + hot_cache_max_size=_parse_optional_non_empty_string( + raw.get("hot_cache_max_size"), + name=f"{path}.hot_cache_max_size", + ), + no_cache=_parse_bool(raw.get("no_cache"), name=f"{path}.no_cache", default=False), + mcp_config=_parse_optional_non_empty_string(raw.get("mcp_config"), name=f"{path}.mcp_config"), + hf_endpoint=_parse_optional_non_empty_string(raw.get("hf_endpoint"), name=f"{path}.hf_endpoint"), + trusted_network=_parse_bool(raw.get("trusted_network"), name=f"{path}.trusted_network", default=False), ) @@ -520,6 +568,9 @@ def _default_runtime_model_id(model_id: str, raw: dict) -> str: def _parse_model(model_id: str, raw: dict) -> Model: + if "serving" in raw: + msg = f"models.{model_id}.serving is not supported; use model_info.mode for catalog metadata" + raise ValueError(msg) server_args_raw = raw.get("server_args") model_info_raw = raw.get("model_info") return Model( @@ -531,7 +582,6 @@ def _parse_model(model_id: str, raw: dict) -> Model: ram_gb=raw.get("ram_gb"), active_params=raw.get("active_params", ""), max_context=raw.get("max_context", 0), - serving=ServingMode(raw["serving"]) if raw.get("serving") else "", notes=raw.get("notes", ""), extra_args=raw.get("extra_args"), enable_thinking=raw.get("enable_thinking"), @@ -565,8 +615,10 @@ def parse_cluster_config(raw: dict) -> ClusterConfig: admin_user=str(v.get("admin_user", "")).strip(), roles=roles, fabric_host=_parse_fabric_host(v.get("fabric_host"), node_name=k), - runtime=_parse_node_runtime(v.get("runtime"), default_port=services.omlx_port), + runtime=_parse_node_runtime(v.get("runtime"), node_name=k, default_port=services.omlx_port), models=list(v.get("models", [])), + platform=v.get("platform"), + shell=v.get("shell"), home_dir=v.get("home_dir"), homebrew_prefix=v.get("homebrew_prefix"), ) @@ -738,6 +790,38 @@ def lint_cluster_config(config: ClusterConfig) -> list[ConfigLintIssue]: else: runtime_model_ids[model.runtime_model_id] = model_id + gateway_nodes = [node for node in config.nodes.values() if node.has_role(NodeRole.GATEWAY)] + inference_nodes = [node for node in config.nodes.values() if node.has_role(NodeRole.INFERENCE)] + if not gateway_nodes: + issues.append( + ConfigLintIssue( + severity="error", + path="nodes", + message="no gateway node configured", + ) + ) + if not inference_nodes: + issues.append( + ConfigLintIssue( + severity="error", + path="nodes", + message="no inference node configured", + ) + ) + elif not any( + node.runtime is not None + and node.runtime.type == RuntimeType.OMLX + and any(model_id in config.models for model_id in node.models) + for node in inference_nodes + ): + issues.append( + ConfigLintIssue( + severity="error", + path="nodes", + message="no routable model placements configured for inference nodes", + ) + ) + for node_name, node in config.nodes.items(): if node.models and node.runtime is None: issues.append( diff --git a/src/thunder_forge/cluster/edge.py b/src/thunder_forge/cluster/edge.py index 8c6a7fe..3c9dc1e 100644 --- a/src/thunder_forge/cluster/edge.py +++ b/src/thunder_forge/cluster/edge.py @@ -7,6 +7,7 @@ from __future__ import annotations +import hmac import json import math import os @@ -46,6 +47,7 @@ EDGE_USER_PREFIX = "TF_USER_" EDGE_DEFAULT_PORT = DEFAULT_EDGE_PORT EDGE_LAUNCHD_LABEL_PREFIX = "com.thunder-forge.edge" +EDGE_DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024 _ENV_LINE_RE = re.compile( r"^(?P\s*)(?P[A-Za-z_][A-Za-z0-9_]*)(?P\s*=\s*)(?P.*?)(?P\s*)$" @@ -89,6 +91,7 @@ class EdgeProxyConfig: access_log_sink: Callable[[str], None] | None = None model_catalog: list[EdgeModelCatalogEntry] = field(default_factory=list) timeout: float = 60.0 + max_body_bytes: int = EDGE_DEFAULT_MAX_BODY_BYTES @dataclass(frozen=True) @@ -126,6 +129,15 @@ class EdgeProxyUpstreamRequest: started: float +@dataclass(frozen=True) +class EdgeUsageTokens: + """Token usage extracted from an upstream OpenAI-compatible JSON response.""" + + prompt_tokens: int | None = None + completion_tokens: int | None = None + total_tokens: int | None = None + + @dataclass class EdgeSmokeResult: """Result of a black-box smoke test against a running TF edge.""" @@ -234,10 +246,12 @@ def to_json_dict(self) -> dict[str, str | int]: def _bearer_token(authorization: str | None) -> str: if not authorization: return "" - scheme, _, token = authorization.partition(" ") - if scheme.lower() != "bearer" or not token: + scheme, separator, token = authorization.partition(" ") + if separator != " " or scheme.lower() != "bearer" or not token: + return "" + if token != token.strip() or " " in token: return "" - return token.strip() + return token def validate_edge_client_id(client_id: str) -> str: @@ -252,6 +266,45 @@ def validate_edge_client_id(client_id: str) -> str: return normalized +def _client_id_env_suffix(client_id: str) -> str: + normalized = validate_edge_client_id(client_id) + suffix: list[str] = [] + lowered = normalized.lower() + for index, char in enumerate(normalized): + if char == "-": + suffix.append("_DASH_") + elif char == ".": + suffix.append("_DOT_") + elif char == "_": + if lowered[index:].startswith(("_dash_", "_dot_", "_underscore_")): + suffix.append("_UNDERSCORE_") + else: + suffix.append("_") + else: + suffix.append(char.upper()) + return "".join(suffix) + + +def _client_id_from_env_suffix(env_suffix: str) -> str: + decoded: list[str] = [] + index = 0 + lowered = env_suffix.lower() + while index < len(lowered): + if lowered.startswith("_dash_", index): + decoded.append("-") + index += len("_dash_") + elif lowered.startswith("_dot_", index): + decoded.append(".") + index += len("_dot_") + elif lowered.startswith("_underscore_", index): + decoded.append("_") + index += len("_underscore_") + else: + decoded.append(lowered[index]) + index += 1 + return "".join(decoded) + + def _quote_dotenv_value(value: str) -> str: return f"'{value}'" @@ -276,7 +329,7 @@ def load_edge_user_keys_from_env( if key.startswith(prefix) and value.strip(): suffix = key[len(prefix) :] if suffix: - client_id = suffix.lower() + client_id = _client_id_from_env_suffix(suffix) try: result[validate_edge_client_id(client_id)] = value.strip() except ValueError: @@ -292,13 +345,10 @@ def edge_api_key_from_env( ) -> tuple[str, str]: """Return (env_name, api_key) for one configured client id.""" normalized = validate_edge_client_id(client_id) - # Build env var name: TF_USER_ where suffix uppercases and replaces - and . with _ - env_suffix = normalized.upper().replace("-", "_").replace(".", "_") + env_suffix = _client_id_env_suffix(normalized) env_name = f"{users_env}{env_suffix}" - # Effective id used as key in the users dict (lowercased suffix) - effective_id = env_suffix.lower() users = load_edge_user_keys_from_env(env=env, users_env=users_env) - return env_name, users.get(effective_id, "") + return env_name, users.get(normalized, "") def load_edge_clients_from_env( @@ -309,6 +359,10 @@ def load_edge_clients_from_env( """Load all TF edge clients from TF_USER_ env vars.""" clients: dict[str, EdgeClient] = {} for client_id, api_key in load_edge_user_keys_from_env(env=env, users_env=users_env).items(): + existing = clients.get(api_key) + if existing is not None and existing.client_id != client_id: + msg = f"duplicate API key configured for edge clients: {existing.client_id}, {client_id}" + raise ValueError(msg) clients[api_key] = EdgeClient(client_id=client_id) return clients @@ -374,7 +428,7 @@ def ensure_edge_api_keys( if not normalized or normalized in seen_clients: continue seen_clients.add(normalized) - env_suffix = normalized.upper().replace("-", "_").replace(".", "_") + env_suffix = _client_id_env_suffix(normalized) env_name = f"{users_env}{env_suffix}" existing = _get_env_value_from_lines(lines, env_name) if existing.strip(): @@ -399,10 +453,12 @@ def authenticate_edge_request( ) -> EdgeAuthResult: """Validate a static bearer token and map it to a client identity.""" token = _bearer_token(authorization) - client = clients_by_key.get(token) - if client is None: + if not token: return EdgeAuthResult(allowed=False, status_code=401) - return EdgeAuthResult(allowed=True, status_code=200, client_id=client.client_id) + for configured_key, client in clients_by_key.items(): + if hmac.compare_digest(token, configured_key): + return EdgeAuthResult(allowed=True, status_code=200, client_id=client.client_id) + return EdgeAuthResult(allowed=False, status_code=401) def _nearest_rank_percentile(values: list[int], percentile: float) -> int: @@ -534,6 +590,9 @@ def build_edge_access_log( latency_ms: int, olla_endpoint: str = "", api_key: str = "", + prompt_tokens: int | None = None, + completion_tokens: int | None = None, + total_tokens: int | None = None, ) -> EdgeAccessLog: """Build a secret-free access/accounting log record.""" _ = api_key # Accepted only so callers cannot accidentally include it in the record. @@ -547,6 +606,9 @@ def build_edge_access_log( latency_ms=latency_ms, olla_endpoint=olla_endpoint, node_name=derive_olla_node_name(olla_endpoint), + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, ) @@ -585,6 +647,32 @@ def _extract_model(body: bytes) -> str: return model if isinstance(model, str) else "" +def _as_non_negative_int(raw: object) -> int | None: + if isinstance(raw, bool) or not isinstance(raw, int): + return None + return raw if raw >= 0 else None + + +def _extract_usage_tokens(body: bytes) -> EdgeUsageTokens: + payload = _decode_json_object(body) + if payload is None: + return EdgeUsageTokens() + usage = payload.get("usage") + if not isinstance(usage, dict): + return EdgeUsageTokens() + + prompt_tokens = _as_non_negative_int(usage.get("prompt_tokens")) + completion_tokens = _as_non_negative_int(usage.get("completion_tokens")) + total_tokens = _as_non_negative_int(usage.get("total_tokens")) + if total_tokens is None and (prompt_tokens is not None or completion_tokens is not None): + total_tokens = (prompt_tokens or 0) + (completion_tokens or 0) + return EdgeUsageTokens( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + ) + + def _requests_streaming_response(method: str, body: bytes) -> bool: if method.upper() != "POST": return False @@ -637,20 +725,29 @@ def _edge_models_response( return None started = time.perf_counter() + request_id = _header_value(headers, "X-Request-ID") or uuid4().hex auth = authenticate_edge_request(_header_value(headers, "Authorization"), config.clients_by_key) if not auth.allowed: + _record_edge_access_event( + config, + request_id=request_id, + client_id="unauthenticated", + path=path, + model="", + status_code=auth.status_code, + started=started, + ) return _json_response(401, {"error": "unauthorized"}) - request_id = _header_value(headers, "X-Request-ID") or uuid4().hex - upstream = EdgeProxyUpstreamRequest( - olla_path="", - forwarded_headers={}, + _record_edge_access_event( + config, request_id=request_id, client_id=auth.client_id, + path=path, model="", + status_code=200, started=started, ) - _record_edge_access(config, upstream=upstream, path=path, status_code=200) return EdgeProxyResponse( status_code=200, headers={"Content-Type": "application/json"}, @@ -676,16 +773,47 @@ def _prepare_edge_upstream_request( config: EdgeProxyConfig, ) -> EdgeProxyUpstreamRequest | EdgeProxyResponse: started = time.perf_counter() + request_id = _header_value(headers, "X-Request-ID") or uuid4().hex + model = _extract_model(body) auth = authenticate_edge_request(_header_value(headers, "Authorization"), config.clients_by_key) if not auth.allowed: + _record_edge_access_event( + config, + request_id=request_id, + client_id="unauthenticated", + path=path, + model=model, + status_code=auth.status_code, + started=started, + ) return _json_response(401, {"error": "unauthorized"}) try: olla_path = rewrite_openai_path(path) except ValueError: + _record_edge_access_event( + config, + request_id=request_id, + client_id=auth.client_id, + path=path, + model=model, + status_code=404, + started=started, + ) return _json_response(404, {"error": "not_found"}) - request_id = _header_value(headers, "X-Request-ID") or uuid4().hex + if config.max_body_bytes > 0 and len(body) > config.max_body_bytes: + _record_edge_access_event( + config, + request_id=request_id, + client_id=auth.client_id, + path=path, + model=model, + status_code=413, + started=started, + ) + return _json_response(413, {"error": "request_too_large"}) + session = ensure_olla_session_id(headers, request_id=request_id, client_id=auth.client_id) forwarded_headers = { "X-Olla-Session-ID": session.value, @@ -700,32 +828,66 @@ def _prepare_edge_upstream_request( forwarded_headers=forwarded_headers, request_id=request_id, client_id=auth.client_id, - model=_extract_model(body), + model=model, started=started, ) -def _record_edge_access( +def _record_edge_access_event( config: EdgeProxyConfig, *, - upstream: EdgeProxyUpstreamRequest, + request_id: str, + client_id: str, path: str, + model: str, status_code: int, + started: float, olla_endpoint: str = "", + prompt_tokens: int | None = None, + completion_tokens: int | None = None, + total_tokens: int | None = None, ) -> None: if config.access_log_sink is None: return - latency_ms = int((time.perf_counter() - upstream.started) * 1000) + latency_ms = int((time.perf_counter() - started) * 1000) log_record = build_edge_access_log( + request_id=request_id, + client_id=client_id, + path=path, + model=model, + status_code=status_code, + latency_ms=latency_ms, + olla_endpoint=olla_endpoint, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + ) + config.access_log_sink(json.dumps(log_record.to_json_dict(), separators=(",", ":"))) + + +def _record_edge_access( + config: EdgeProxyConfig, + *, + upstream: EdgeProxyUpstreamRequest, + path: str, + status_code: int, + olla_endpoint: str = "", + usage_tokens: EdgeUsageTokens | None = None, +) -> None: + tokens = usage_tokens or EdgeUsageTokens() + _record_edge_access_event( + config, request_id=upstream.request_id, client_id=upstream.client_id, path=path, model=upstream.model, status_code=status_code, - latency_ms=latency_ms, + started=upstream.started, olla_endpoint=olla_endpoint, + prompt_tokens=tokens.prompt_tokens, + completion_tokens=tokens.completion_tokens, + total_tokens=tokens.total_tokens, ) - config.access_log_sink(json.dumps(log_record.to_json_dict(), separators=(",", ":"))) def proxy_edge_request( @@ -762,6 +924,12 @@ def proxy_edge_request( try: response = client.request(method, upstream.olla_path, headers=upstream.forwarded_headers, content=body) except httpx.HTTPError as exc: + _record_edge_access( + config, + upstream=upstream, + path=path, + status_code=502, + ) return _json_response(502, {"error": f"upstream_failed: {exc}"}) _record_edge_access( @@ -770,6 +938,7 @@ def proxy_edge_request( path=path, status_code=response.status_code, olla_endpoint=response.headers.get("X-Olla-Endpoint", ""), + usage_tokens=_extract_usage_tokens(response.content), ) return EdgeProxyResponse( @@ -787,17 +956,43 @@ class Handler(BaseHTTPRequestHandler): def _is_client_disconnect(exc: BaseException) -> bool: return isinstance(exc, (BrokenPipeError, ConnectionResetError)) - def do_GET(self) -> None: # noqa: N802 + def do_GET(self) -> None: self._handle_edge_request() - def do_POST(self) -> None: # noqa: N802 + def do_POST(self) -> None: self._handle_edge_request() def log_message(self, format: str, *args: object) -> None: return def _handle_edge_request(self) -> None: - length = int(self.headers.get("Content-Length", "0") or "0") + headers = dict(self.headers.items()) + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + self._send_edge_response(_json_response(400, {"error": "invalid_content_length"})) + return + if length < 0: + self._send_edge_response(_json_response(400, {"error": "invalid_content_length"})) + return + if config.max_body_bytes > 0 and length > config.max_body_bytes: + started = time.perf_counter() + request_id = _header_value(headers, "X-Request-ID") or uuid4().hex + auth = authenticate_edge_request(_header_value(headers, "Authorization"), config.clients_by_key) + client_id = auth.client_id if auth.allowed else "unauthenticated" + status_code = 413 if auth.allowed else auth.status_code + _record_edge_access_event( + config, + request_id=request_id, + client_id=client_id, + path=self.path, + model="", + status_code=status_code, + started=started, + ) + error = "request_too_large" if auth.allowed else "unauthorized" + self._send_edge_response(_json_response(status_code, {"error": error})) + return body = self.rfile.read(length) if length else b"" if _requests_streaming_response(self.command, body): self._handle_streaming_edge_request(body) @@ -805,7 +1000,7 @@ def _handle_edge_request(self) -> None: result = proxy_edge_request( method=self.command, path=self.path, - headers=dict(self.headers.items()), + headers=headers, body=body, config=config, ) @@ -832,6 +1027,21 @@ def _handle_streaming_edge_request(self, body: bytes) -> None: self._send_edge_response(upstream) return + logged = False + + def record_stream(status_code: int, *, olla_endpoint: str = "") -> None: + nonlocal logged + if logged: + return + _record_edge_access( + config, + upstream=upstream, + path=self.path, + status_code=status_code, + olla_endpoint=olla_endpoint, + ) + logged = True + normalized_base_url = config.olla_base_url.rstrip("/") try: with httpx.Client(base_url=normalized_base_url, timeout=config.timeout, trust_env=False) as client: @@ -841,6 +1051,7 @@ def _handle_streaming_edge_request(self, body: bytes) -> None: headers=upstream.forwarded_headers, content=body, ) as response: + olla_endpoint = response.headers.get("X-Olla-Endpoint", "") self.send_response(response.status_code) response_headers = _proxy_response_headers(response.headers) response_headers.setdefault("Content-Type", "text/event-stream") @@ -850,6 +1061,7 @@ def _handle_streaming_edge_request(self, body: bytes) -> None: self.end_headers() except OSError as exc: if self._is_client_disconnect(exc): + record_stream(response.status_code, olla_endpoint=olla_endpoint) return raise for chunk in response.iter_bytes(): @@ -859,16 +1071,12 @@ def _handle_streaming_edge_request(self, body: bytes) -> None: self.wfile.flush() except OSError as exc: if self._is_client_disconnect(exc): + record_stream(response.status_code, olla_endpoint=olla_endpoint) return raise - _record_edge_access( - config, - upstream=upstream, - path=self.path, - status_code=response.status_code, - olla_endpoint=response.headers.get("X-Olla-Endpoint", ""), - ) + record_stream(response.status_code, olla_endpoint=olla_endpoint) except httpx.HTTPError as exc: + record_stream(502) self._send_edge_response(_json_response(502, {"error": f"upstream_failed: {exc}"})) def _send_edge_response(self, result: EdgeProxyResponse) -> None: @@ -893,6 +1101,15 @@ def _thunder_forge_command(repo_root: Path) -> list[str]: return [shutil.which("uv") or "/opt/homebrew/bin/uv", "run", "thunder-forge"] +def _service_home_for_user(user: str) -> Path: + try: + import pwd + + return Path(pwd.getpwnam(user).pw_dir) + except (ImportError, KeyError): + return Path.home() + + def _edge_service_spec( *, repo_root: Path, @@ -904,6 +1121,7 @@ def _edge_service_spec( user: str, ) -> LaunchdServiceSpec: log_dir = repo_root / "logs" + user_home = _service_home_for_user(user) resolved_access_log_path = access_log_path.expanduser() if not resolved_access_log_path.is_absolute(): resolved_access_log_path = repo_root / resolved_access_log_path @@ -931,12 +1149,12 @@ def _edge_service_spec( stdout_log=str(log_dir / f"edge-{port}.stdout.log"), stderr_log=str(log_dir / f"edge-{port}.stderr.log"), environment={ - "HOME": str(Path.home()), + "HOME": str(user_home), "USER": user, "PATH": ":".join( [ f"{repo_root}/.venv/bin", - f"{Path.home()}/.local/bin", + f"{user_home}/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", @@ -1216,26 +1434,40 @@ def smoke_edge_contract( result.errors.append(f"GET /v1/models failed: {exc}") started = time.perf_counter() + chat_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 16, + "temperature": 0, + "stream": False, + } try: response = client.post( "/v1/chat/completions", headers=auth_headers, - json={ - "model": model, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": 16, - "temperature": 0, - "stream": False, - }, + json=chat_payload, ) result.latency_ms = int((time.perf_counter() - started) * 1000) result.chat_ok = response.is_success - result.session_ok = response.request.headers.get("X-Olla-Session-ID") == fixed_session_id result.olla_endpoint = response.headers.get("X-Olla-Endpoint", "") if not result.chat_ok: result.errors.append(f"POST /v1/chat/completions returned {response.status_code}: {response.text}") - if not result.session_ok: - result.errors.append("X-Olla-Session-ID was not preserved on chat request") + elif not result.olla_endpoint: + result.errors.append("chat response did not include X-Olla-Endpoint for session check") + else: + repeat = client.post( + "/v1/chat/completions", + headers=auth_headers, + json=chat_payload, + ) + repeat_endpoint = repeat.headers.get("X-Olla-Endpoint", "") + result.session_ok = repeat.is_success and repeat_endpoint == result.olla_endpoint + if not repeat.is_success: + result.errors.append( + f"repeat POST /v1/chat/completions returned {repeat.status_code}: {repeat.text}" + ) + elif repeat_endpoint != result.olla_endpoint: + result.errors.append("same session routed to different endpoints") except httpx.HTTPError as exc: result.latency_ms = int((time.perf_counter() - started) * 1000) result.errors.append(f"POST /v1/chat/completions failed: {exc}") diff --git a/src/thunder_forge/cluster/olla.py b/src/thunder_forge/cluster/olla.py index 0a67b82..fb41c2d 100644 --- a/src/thunder_forge/cluster/olla.py +++ b/src/thunder_forge/cluster/olla.py @@ -232,26 +232,40 @@ def smoke_olla_router( auth_headers = {"X-Olla-Session-ID": fixed_session_id} started = time.perf_counter() + backend_chat_payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 16, + "temperature": 0, + "stream": False, + } try: response = client.post( f"{OLLA_OPENAI_PREFIX}/chat/completions", headers=auth_headers, - json={ - "model": model, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": 16, - "temperature": 0, - "stream": False, - }, + json=backend_chat_payload, ) result.latency_ms = int((time.perf_counter() - started) * 1000) result.chat_ok = response.is_success - result.session_ok = response.request.headers.get("X-Olla-Session-ID") == fixed_session_id result.olla_endpoint = response.headers.get("X-Olla-Endpoint", "") if not result.chat_ok: result.errors.append(f"POST {OLLA_OPENAI_PREFIX}/chat/completions returned {response.status_code}") - if not result.session_ok: - result.errors.append("X-Olla-Session-ID was not preserved on backend-model request") + elif not result.olla_endpoint: + result.errors.append("backend-model response did not include X-Olla-Endpoint for session check") + else: + repeat = client.post( + f"{OLLA_OPENAI_PREFIX}/chat/completions", + headers=auth_headers, + json=backend_chat_payload, + ) + repeat_endpoint = repeat.headers.get("X-Olla-Endpoint", "") + result.session_ok = repeat.is_success and repeat_endpoint == result.olla_endpoint + if not repeat.is_success: + result.errors.append( + f"repeat POST {OLLA_OPENAI_PREFIX}/chat/completions returned {repeat.status_code}" + ) + elif repeat_endpoint != result.olla_endpoint: + result.errors.append("same session routed to different endpoints") except httpx.HTTPError as exc: result.latency_ms = int((time.perf_counter() - started) * 1000) result.errors.append(f"POST {OLLA_OPENAI_PREFIX}/chat/completions failed: {exc}") @@ -367,6 +381,7 @@ def _olla_service_spec( user: str, ) -> LaunchdServiceSpec: log_dir = repo_root / "logs" + user_home = _service_home_for_user(user) binary_path = binary.expanduser() config_file = config_path.expanduser() if not binary_path.is_absolute(): @@ -382,14 +397,23 @@ def _olla_service_spec( stdout_log=str(log_dir / f"olla-{port}.stdout.log"), stderr_log=str(log_dir / f"olla-{port}.stderr.log"), environment={ - "HOME": str(Path.home()), - "PATH": f"{Path.home()}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": str(user_home), + "PATH": f"{user_home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", }, process_pattern=process_pattern, user=user, ) +def _service_home_for_user(user: str) -> Path: + try: + import pwd + + return Path(pwd.getpwnam(user).pw_dir) + except (ImportError, KeyError): + return Path.home() + + def _build_olla_launchd_result( *, repo_root: Path, diff --git a/src/thunder_forge/cluster/omlx.py b/src/thunder_forge/cluster/omlx.py index 5e08a79..30ced72 100644 --- a/src/thunder_forge/cluster/omlx.py +++ b/src/thunder_forge/cluster/omlx.py @@ -2,8 +2,8 @@ from __future__ import annotations +import re import shlex -import subprocess import time from collections.abc import Callable from dataclasses import dataclass, field @@ -32,6 +32,10 @@ DEFAULT_OMLX_TOOL_SPEC = "git+https://github.com/jundot/omlx.git" +def _omlx_process_pattern(*, bind_host: str, port: int) -> str: + return f"^.*omlx serve --host {re.escape(bind_host)} --port {port}.*$" + + def launchd_label_for_node(node: Node) -> str: """Build the launchd service label for a node runtime.""" if node.runtime is None: @@ -154,21 +158,7 @@ def run_omlx_runtime_start(node: Node, *, timeout: int = 30) -> OmlxStartResult: command = build_omlx_serve_command(node) log_path = f"/tmp/thunder-forge-omlx-{node.runtime.port}.log" remote_command = f"nohup {command} > {shlex.quote(log_path)} 2>&1 & echo $!" - completed = subprocess.run( - [ - "ssh", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=8", - f"{node.user}@{node.host}", - remote_command, - ], - check=False, - text=True, - capture_output=True, - timeout=timeout, - ) + completed = ssh_run(node.user, node.host, remote_command, timeout=timeout, shell=node.shell) return OmlxStartResult( returncode=completed.returncode, pid=completed.stdout.strip(), @@ -605,15 +595,15 @@ def generate_launchd_plist(node: Node, *, system_daemon: bool = False) -> str: return generate_service_launchd_plist(spec, system_daemon=system_daemon) -def _install_commands(label: str, plist_path: str, port: int) -> list[str]: +def _install_commands(label: str, plist_path: str, port: int, bind_host: str) -> list[str]: """The ordered command sequence for a safe launchd install.""" - process_pattern = f"^.*omlx serve --host 0\\.0\\.0\\.0 --port {port}.*$" + process_pattern = _omlx_process_pattern(bind_host=bind_host, port=port) return user_launchd_commands(label=label, plist_path=plist_path, process_pattern=process_pattern) -def _daemon_commands(label: str, staging_plist_path: str, plist_path: str, port: int) -> list[str]: +def _daemon_commands(label: str, staging_plist_path: str, plist_path: str, port: int, bind_host: str) -> list[str]: """The ordered command sequence for a sudo-backed system LaunchDaemon.""" - process_pattern = f"^.*omlx serve --host 0\\.0\\.0\\.0 --port {port}.*$" + process_pattern = _omlx_process_pattern(bind_host=bind_host, port=port) return system_launchd_commands( label=label, staging_plist_path=staging_plist_path, @@ -668,7 +658,7 @@ def _process_restart_commands(node: Node) -> tuple[OmlxProcessResult, int | None pid_path = f"{node.home_dir}/.omlx/run/omlx-{port}.pid" stdout_log = f"{node.home_dir}/Library/Logs/omlx-{port}.stdout.log" stderr_log = f"{node.home_dir}/Library/Logs/omlx-{port}.stderr.log" - process_pattern = f"^.*omlx serve --host 0\\.0\\.0\\.0 --port {port}.*$" + process_pattern = _omlx_process_pattern(bind_host=node.runtime.bind_host, port=port) stop_command = " ; ".join( [ @@ -749,7 +739,7 @@ def _build_omlx_launchd_result(node: Node) -> tuple[OmlxInstallResult, int | Non result.errors.append(str(exc)) return result, None - result.commands = _install_commands(label, plist_path, runtime.port) + result.commands = _install_commands(label, plist_path, runtime.port, runtime.bind_host) return result, runtime.port @@ -776,7 +766,7 @@ def _build_omlx_daemon_result(node: Node) -> tuple[OmlxInstallResult, int | None result.errors.append(str(exc)) return result, None - result.commands = _daemon_commands(label, staging_plist_path, plist_path, runtime.port) + result.commands = _daemon_commands(label, staging_plist_path, plist_path, runtime.port, runtime.bind_host) return result, runtime.port @@ -797,7 +787,7 @@ def generate_daemon_setup_script(node: Node, *, sudoers_path: str | None = None) label = daemon_result.label sudoers_content = generate_daemon_sudoers(node).rstrip() sudoers_path = sudoers_path or daemon_sudoers_path_for_node(node) - process_pattern = f"^.*omlx serve --host 0\\.0\\.0\\.0 --port {node.runtime.port}.*$" + process_pattern = _omlx_process_pattern(bind_host=node.runtime.bind_host, port=node.runtime.port) return f"""#!/bin/zsh set -euo pipefail @@ -1013,7 +1003,7 @@ def _apply_omlx_launchd_update( result.health_ok = health.health_ok if not result.health_ok: result.errors.extend(health.errors) - except Exception as exc: # noqa: BLE001 + except Exception as exc: result.errors.append(f"Health check failed: {exc}") result.applied = True @@ -1063,7 +1053,7 @@ def _apply_omlx_daemon_update( result.health_ok = health.health_ok if not result.health_ok: result.errors.extend(health.errors) - except Exception as exc: # noqa: BLE001 + except Exception as exc: result.errors.append(f"Health check failed: {exc}") result.applied = True @@ -1190,7 +1180,7 @@ def run_omlx_daemon_setup( result.health_ok = health.health_ok if not result.health_ok: result.errors.extend(health.errors) - except Exception as exc: # noqa: BLE001 + except Exception as exc: result.errors.append(f"Health check failed: {exc}") return result @@ -1222,7 +1212,7 @@ def run_omlx_process_restart(node: Node, *, apply: bool = True, timeout: int = 6 result.health_ok = health.health_ok if not result.health_ok: result.errors.extend(health.errors) - except Exception as exc: # noqa: BLE001 + except Exception as exc: result.errors.append(f"Health check failed: {exc}") result.applied = True diff --git a/src/thunder_forge/cluster/remote_cache.py b/src/thunder_forge/cluster/remote_cache.py new file mode 100644 index 0000000..edea6ac --- /dev/null +++ b/src/thunder_forge/cluster/remote_cache.py @@ -0,0 +1,454 @@ +"""Remote cache command builders for artifact workflows.""" + +from __future__ import annotations + +import base64 +import json +import shlex + +from thunder_forge.cluster.artifacts import build_artifact_identity +from thunder_forge.cluster.config import Node + + +def cache_hub_setup_command() -> str: + return ( + "set -euo pipefail; " + 'CACHE_DIR="${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}"; ' + '/bin/mkdir -p "$CACHE_DIR"; ' + 'echo "cache: oMLX model hub ready at $CACHE_DIR"' + ) + + +def remote_artifact_download_command(*, repo_id: str, model_dir_name: str, timeout: int) -> str: + payload_b64 = base64.b64encode( + json.dumps( + { + "repo_id": repo_id, + "model_dir_name": model_dir_name, + "timeout": timeout, + } + ).encode("utf-8") + ).decode("ascii") + script = """python3 -u - <<'PY' +import base64 +import json +import os +import shutil +import secrets +import subprocess +import sys +import time +import urllib.error +import urllib.request +from http.cookiejar import CookieJar +payload = json.loads(base64.b64decode(__PAYLOAD_B64__).decode()) +repo_id = str(payload['repo_id']) +model_dir_name = str(payload['model_dir_name']) +timeout_seconds = int(payload['timeout']) +cache_root = os.environ.get('TF_CACHE_OMLX_MODELS_DIR') or os.path.expanduser('~/.omlx/models') +model_dir = os.path.join(cache_root, model_dir_name) +base_url = 'http://127.0.0.1:8020' +env = dict(os.environ) +env.pop('ALL_PROXY', None) +env.pop('all_proxy', None) +def _model_complete(path): + if not os.path.isdir(path): + return False + if not os.path.isfile(os.path.join(path, 'config.json')): + return False + if os.path.exists(os.path.join(path, '.rsync-partial')): + return False + has_weights = False + for root, _, files in os.walk(path): + for name in files: + if name.endswith('.incomplete'): + return False + if name.endswith('.safetensors') or name.endswith('.bin'): + has_weights = True + return has_weights +def _request(method, path, payload=None, opener=None): + req = urllib.request.Request(base_url + path, method=method) + req.add_header('Content-Type', 'application/json') + data = None + if payload is not None: + data = json.dumps(payload).encode('utf-8') + client = opener if opener is not None else urllib.request + return client.open(req, data=data, timeout=30) +def _health_ready(): + try: + with urllib.request.urlopen(base_url + '/health', timeout=2) as response: + return 200 <= response.status < 300 + except Exception: + return False +def _resolve_omlx_bin(): + if env.get('OMLX_BIN'): + candidate = os.path.expanduser(env['OMLX_BIN']) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + candidate = shutil.which('omlx', path=env.get('PATH')) + if candidate: + return candidate + shell = env.get('SHELL') or '/bin/zsh' + try: + probe = subprocess.run( + [shell, '-lc', 'command -v omlx'], + check=False, + capture_output=True, + text=True, + timeout=8, + env=env, + ) + resolved = (probe.stdout or '').strip() + if probe.returncode == 0 and resolved: + return resolved + except Exception: + pass + tool_dirs = [ + os.path.expanduser('~/.local/bin'), + os.path.expanduser('~/.cargo/bin'), + os.path.expanduser('~/.local/share/uv/tools/omlx/bin'), + ] + for directory in tool_dirs: + candidate = os.path.join(directory, 'omlx') + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None +if _model_complete(model_dir): + print('download_status: already_ready') + raise SystemExit(0) +downloader_api_key = env.get('TF_OMLX_DOWNLOADER_API_KEY') or env.get('OMLX_API_KEY') +server_proc = None +server_started = False +server_stderr = '' +try: + if not _health_ready(): + downloader_api_key = secrets.token_urlsafe(24) + omlx_bin = _resolve_omlx_bin() + if not omlx_bin: + raise RuntimeError( + 'oMLX CLI is not on PATH for non-interactive shell; ' + 'set OMLX_BIN or ensure command -v omlx works in login shell' + ) + serve_variants = [ + [ + omlx_bin, 'serve', '--host', '127.0.0.1', '--port', '8020', + '--model-dir', cache_root, '--max-model-memory', 'disabled', + '--api-key', downloader_api_key, + ], + [ + omlx_bin, 'serve', '--host', '127.0.0.1', '--port', '8020', + '--model-dir', cache_root, '--api-key', downloader_api_key, + ], + ] + for idx, serve_args in enumerate(serve_variants): + server_proc = subprocess.Popen( + serve_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + server_started = True + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if server_proc.poll() is not None: + _, stderr_text = server_proc.communicate() + server_stderr = (stderr_text or '').strip() + break + if _health_ready(): + break + time.sleep(1) + if _health_ready(): + break + if server_proc.poll() is None: + server_proc.terminate() + try: + _, stderr_text = server_proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + server_proc.kill() + _, stderr_text = server_proc.communicate(timeout=10) + server_stderr = (stderr_text or '').strip() + if idx == len(serve_variants) - 1: + detail = ( + f': {server_stderr}' if server_stderr else '' + ) + raise RuntimeError( + f'oMLX downloader server exited with code {server_proc.returncode}{detail}' + ) + else: + raise TimeoutError('oMLX downloader server did not become ready: ' + base_url) + opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(CookieJar()) + ) + if downloader_api_key: + with _request( + 'POST', + '/admin/api/login', + {'api_key': downloader_api_key, 'remember': False}, + opener=opener, + ) as response: + if response.status >= 400: + raise RuntimeError('admin login failed') + hf_token = env.get('HF_TOKEN', '') + try: + with _request( + 'POST', + '/admin/api/hf/download', + {'repo_id': repo_id, 'hf_token': hf_token}, + opener=opener, + ) as response: + task = json.loads(response.read().decode('utf-8')).get('task') + except urllib.error.HTTPError as exc: + body = exc.read().decode('utf-8', errors='replace') + if exc.code == 400 and 'already in progress' in body: + with _request('GET', '/admin/api/hf/tasks', opener=opener) as response: + tasks = json.loads(response.read().decode('utf-8')).get('tasks', []) + task = next( + ( + item + for item in tasks + if item.get('repo_id') == repo_id + and item.get('status') in {'pending', 'downloading'} + ), + None, + ) + if task is None: + raise RuntimeError('No active oMLX download task found for ' + repo_id) + elif exc.code == 401: + raise RuntimeError('oMLX downloader admin API requires authentication') + else: + raise RuntimeError(body or str(exc)) + if not isinstance(task, dict) or not task.get('task_id'): + raise RuntimeError('oMLX downloader returned no task for ' + repo_id) + task_id = task['task_id'] + deadline = time.monotonic() + timeout_seconds + last_bucket = -1 + while time.monotonic() < deadline: + with _request('GET', '/admin/api/hf/tasks', opener=opener) as response: + tasks = json.loads(response.read().decode('utf-8')).get('tasks', []) + current = next((item for item in tasks if item.get('task_id') == task_id), None) + if current is None: + time.sleep(2) + continue + status = str(current.get('status') or 'unknown') + progress = float(current.get('progress') or 0.0) + bucket = int(progress) + if bucket != last_bucket: + print(f'download_progress: {status} {progress:.1f}%') + last_bucket = bucket + if status == 'completed': + print('download_status: completed') + raise SystemExit(0) + if status in {'failed', 'cancelled'}: + error = current.get('error') or ('download ' + status) + raise RuntimeError('oMLX download failed for ' + repo_id + ': ' + str(error)) + time.sleep(2) + raise TimeoutError('Timed out waiting for oMLX download ' + repo_id) +finally: + if server_started and server_proc is not None: + if server_proc.poll() is None: + server_proc.terminate() + try: + server_proc.wait(timeout=10) + except subprocess.TimeoutExpired: + server_proc.kill() + server_proc.wait(timeout=10) +PY""" + return script.replace("__PAYLOAD_B64__", repr(payload_b64)) + + +def remote_cache_sync_command( + *, + repo_id: str, + runtime_node: Node, + node_home_dir: str, + transport_host: str, + ssh_host_key_alias: str | None, +) -> tuple[str, str, str]: + identity = build_artifact_identity(repo_id) + source_root = '${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}' + source_path = f"{source_root}/{identity.model_dir_name}/" + remote_omlx_models_dir = f"{node_home_dir}/.omlx/models" + remote_model_parent_dir = f"{remote_omlx_models_dir}/{identity.namespace}" + destination = f"{runtime_node.user}@{transport_host}:{remote_omlx_models_dir}/{identity.model_dir_name}/" + + ssh_options = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"] + if ssh_host_key_alias: + ssh_options.extend(["-o", f"HostKeyAlias={ssh_host_key_alias}"]) + + ssh_mkdir_cmd = " ".join( + shlex.quote(arg) + for arg in [ + "ssh", + *ssh_options, + f"{runtime_node.user}@{transport_host}", + "mkdir", + "-p", + remote_model_parent_dir, + ] + ) + rsync_cmd = " ".join( + shlex.quote(arg) + for arg in [ + "rsync", + "-a", + "--progress", + "--partial-dir=.rsync-partial", + "--exclude", + ".cache/", + "-e", + "ssh " + " ".join(shlex.quote(option) for option in ssh_options), + "__TF_SOURCE_PATH__", + destination, + ] + ).replace(shlex.quote("__TF_SOURCE_PATH__"), '"$SOURCE_PATH"') + + command = ( + "set -euo pipefail; " + 'CACHE_ROOT="${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}"; ' + f'SOURCE_PATH="$CACHE_ROOT/{identity.model_dir_name}/"; ' + 'if [[ ! -d "$SOURCE_PATH" ]]; then ' + 'echo "missing cache oMLX model dir: $SOURCE_PATH" >&2; ' + "exit 2; " + "fi; " + 'test -f "$SOURCE_PATH/config.json" || { ' + 'echo "incomplete cache oMLX model dir: missing config.json: $SOURCE_PATH" >&2; ' + "exit 2; " + "}; " + 'test -z "$(find "$SOURCE_PATH" -name \'*.incomplete\' -print -quit)" || { ' + 'echo "incomplete cache oMLX model dir: partial artifact present: $SOURCE_PATH" >&2; ' + "exit 2; " + "}; " + 'test ! -e "$SOURCE_PATH/.rsync-partial" || { ' + 'echo "incomplete cache oMLX model dir: rsync partial dir present: $SOURCE_PATH" >&2; ' + "exit 2; " + "}; " + 'test -n "$(find "$SOURCE_PATH" \\( -name \'*.safetensors\' -o -name \'*.bin\' \\) -type f -print -quit)" || { ' + 'echo "incomplete cache oMLX model dir: no weight files found: $SOURCE_PATH" >&2; ' + "exit 2; " + "}; " + f"{ssh_mkdir_cmd}; " + f"{rsync_cmd}" + ) + return source_path, destination, command + + +def remote_transport_plan_probe_command(*, payload_b64: str) -> str: + script = """python3 - <<'PY' +import base64 +import ipaddress +import json +import platform +import re +import subprocess +payload = json.loads(base64.b64decode(__PAYLOAD_B64__).decode()) +requested_transport = payload['requested_transport'] +management_host = payload['management_host'] +node_user = payload['node_user'] +fabric_host = payload['fabric_host'] +timeout = payload['timeout'] +plan = { + 'requested_transport': requested_transport, + 'management_host': management_host, + 'transport_host': management_host, + 'resolved_transport_host': management_host, + 'fabric_fallback': '', + 'error': '', +} +if requested_transport not in {'auto', 'fabric', 'management'}: + plan['error'] = '--transport must be one of: auto, fabric, management' +elif requested_transport == 'management': + pass +elif not fabric_host: + if requested_transport == 'fabric': + plan['error'] = 'fabric probe disabled for node' +else: + def _run(cmd, extra_timeout=0): + try: + return subprocess.run( + cmd, + check=False, + capture_output=True, + text=True, + timeout=timeout + extra_timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + def _extract_thunderbolt_devices(text): + devices = set() + for block in re.split(r'\\n\\s*\\n', text): + if 'Thunderbolt' not in block: + continue + match = re.search(r'^Device:\\s*(\\S+)\\s*$', block, flags=re.MULTILINE) + if match: + devices.add(match.group(1)) + return devices + def _acceptable(address): + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return False + return parsed.version == 4 and parsed.is_link_local + def _extract_link_local_ipv4(text, allowed_interfaces): + addresses = [] + current_interface = '' + for line in text.splitlines(): + interface_match = re.match(r'^([A-Za-z0-9._-]+):', line) + if interface_match: + current_interface = interface_match.group(1) + if current_interface not in allowed_interfaces: + continue + match = re.search(r'\\binet\\s+(169\\.254\\.\\d{1,3}\\.\\d{1,3})\\b', line) + if match: + address = match.group(1) + if _acceptable(address) and address not in addresses: + addresses.append(address) + return addresses + def _route_uses_allowed_interface(address, allowed_interfaces): + result = _run(['route', '-n', 'get', address]) + if result is None or result.returncode != 0: + return False + match = re.search(r'^\\s*interface:\\s*(\\S+)\\s*$', result.stdout, flags=re.MULTILINE) + return bool(match and match.group(1) in allowed_interfaces) + def _ssh_hostname_check(address): + result = _run([ + 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', + '-o', f'HostKeyAlias={management_host}', + f'{node_user}@{address}', 'hostname' + ], extra_timeout=8) + return result is not None and result.returncode == 0 + resolved = None + if platform.system() == 'Darwin': + local_inventory = _run(['networksetup', '-listallhardwareports']) + if local_inventory is not None and local_inventory.returncode == 0: + local_devices = _extract_thunderbolt_devices(local_inventory.stdout) + if local_devices: + remote_inventory = _run([ + 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', + f'{node_user}@{management_host}', + ( + "networksetup -listallhardwareports 2>/dev/null; " + "printf '\\n__TF_IFCONFIG__\\n'; ifconfig" + ), + ], extra_timeout=8) + if remote_inventory is not None and remote_inventory.returncode == 0: + inventory, _, ifconfig_text = remote_inventory.stdout.partition('__TF_IFCONFIG__') + remote_devices = _extract_thunderbolt_devices(inventory) + for address in _extract_link_local_ipv4( + ifconfig_text, remote_devices + ): + if _route_uses_allowed_interface( + address, local_devices + ) and _ssh_hostname_check(address): + resolved = address + break + if resolved is not None: + plan['transport_host'] = resolved + plan['resolved_transport_host'] = resolved + elif requested_transport == 'fabric': + plan['error'] = 'no reachable fabric address discovered' + else: + plan['fabric_fallback'] = 'dynamic probe unresolved' +print('__TF_TRANSPORT_PLAN__' + json.dumps(plan, sort_keys=True)) +PY""" + return script.replace("__PAYLOAD_B64__", repr(payload_b64)) diff --git a/src/thunder_forge/cluster/ssh.py b/src/thunder_forge/cluster/ssh.py index c52f58c..22caa58 100644 --- a/src/thunder_forge/cluster/ssh.py +++ b/src/thunder_forge/cluster/ssh.py @@ -3,11 +3,46 @@ from __future__ import annotations import functools +import json import os import platform import shlex import socket import subprocess +from dataclasses import dataclass + +from thunder_forge.cluster.config import Node + +SSH_STRICT_HOST_KEY_CHECKING_ENV = "TF_SSH_STRICT_HOST_KEY_CHECKING" +DEFAULT_SSH_STRICT_HOST_KEY_CHECKING = "yes" +_ALLOWED_STRICT_HOST_KEY_VALUES = {"yes", "no", "ask", "accept-new"} +REMOTE_NODE_FACTS_COMMAND = ( + "python3 - <<'PY'\n" + "import json\n" + "import os\n" + "import platform\n" + "import shutil\n" + "shell = os.environ.get('SHELL', '')\n" + "brew = shutil.which('brew') or ''\n" + "homebrew_prefix = os.path.dirname(os.path.dirname(brew)) if brew else ''\n" + "print(json.dumps({\n" + " 'platform': platform.system(),\n" + " 'shell': os.path.basename(shell) if shell else '',\n" + " 'home_dir': os.path.expanduser('~'),\n" + " 'homebrew_prefix': homebrew_prefix,\n" + "}, separators=(',', ':')))\n" + "PY" +) + + +@dataclass(frozen=True) +class RemoteNodeFacts: + """Resolved execution facts for a remote cluster node.""" + + platform: str | None + shell: str | None + home_dir: str | None + homebrew_prefix: str | None def _login_shell() -> str: @@ -15,6 +50,17 @@ def _login_shell() -> str: return "zsh" if platform.system() == "Darwin" else "bash" +def _remote_shell_wrapper(cmd: str, shell: str | None) -> str: + if shell: + return f"{shell} -lc {shlex.quote(cmd)}" + quoted_cmd = shlex.quote(cmd) + return ( + f"if command -v zsh >/dev/null 2>&1; then exec zsh -lc {quoted_cmd}; " + f"elif command -v bash >/dev/null 2>&1; then exec bash -lc {quoted_cmd}; " + f"else exec sh -lc {quoted_cmd}; fi" + ) + + @functools.lru_cache(maxsize=1) def _ssh_key_args() -> tuple[str, ...]: """Return -i args if GATEWAY_SSH_KEY is set, otherwise empty.""" @@ -40,6 +86,11 @@ def _is_local(ip: str) -> bool: return False +def _strict_host_key_checking() -> str: + value = os.environ.get(SSH_STRICT_HOST_KEY_CHECKING_ENV, DEFAULT_SSH_STRICT_HOST_KEY_CHECKING).strip() + return value if value in _ALLOWED_STRICT_HOST_KEY_VALUES else DEFAULT_SSH_STRICT_HOST_KEY_CHECKING + + def ssh_run( user: str, ip: str, @@ -53,22 +104,24 @@ def ssh_run( ) -> subprocess.CompletedProcess[str]: """Run a command on a remote node via SSH, or locally if the target is this machine.""" capture = not stream - effective_shell = shell or _login_shell() if _is_local(ip): + effective_shell = shell or _login_shell() return subprocess.run( [effective_shell, "-lc", cmd], capture_output=capture, text=True, timeout=timeout, ) - wrapped = f"{effective_shell} -lc {shlex.quote(cmd)}" + wrapped = _remote_shell_wrapper(cmd, shell) ssh_cmd = [ "ssh", *_ssh_key_args(), "-o", "ConnectTimeout=10", "-o", - "StrictHostKeyChecking=no", + f"StrictHostKeyChecking={_strict_host_key_checking()}", + "-o", + "BatchMode=yes", ] if tty: ssh_cmd.append("-tt") @@ -81,6 +134,63 @@ def ssh_run( ) +def _string_or_none(raw: object) -> str | None: + if not isinstance(raw, str): + return None + value = raw.strip() + return value or None + + +def resolve_remote_node_facts(node: Node, *, timeout: int = 30) -> RemoteNodeFacts: + """Populate unset remote execution facts on a node via a lightweight SSH probe.""" + result = ssh_run( + node.user, + node.host, + REMOTE_NODE_FACTS_COMMAND, + timeout=timeout, + shell=node.shell or "sh", + ) + if result.returncode != 0: + stderr = (result.stderr or "").strip() + detail = f": {stderr}" if stderr else "" + msg = f"failed to resolve remote node facts for {node.host}{detail}" + raise RuntimeError(msg) + + lines = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] + if not lines: + msg = f"failed to resolve remote node facts for {node.host}: empty response" + raise RuntimeError(msg) + try: + payload = json.loads(lines[-1]) + except json.JSONDecodeError as exc: + msg = f"failed to parse remote node facts for {node.host}: {exc}" + raise RuntimeError(msg) from exc + if not isinstance(payload, dict): + msg = f"failed to parse remote node facts for {node.host}: expected object" + raise RuntimeError(msg) + + remote_platform = _string_or_none(payload.get("platform")) + remote_shell = _string_or_none(payload.get("shell")) + remote_home_dir = _string_or_none(payload.get("home_dir")) + remote_homebrew_prefix = _string_or_none(payload.get("homebrew_prefix")) + + if node.platform is None: + node.platform = remote_platform + if node.shell is None: + node.shell = remote_shell + if node.home_dir is None: + node.home_dir = remote_home_dir + if node.homebrew_prefix is None: + node.homebrew_prefix = remote_homebrew_prefix + + return RemoteNodeFacts( + platform=node.platform, + shell=node.shell, + home_dir=node.home_dir, + homebrew_prefix=node.homebrew_prefix, + ) + + def run_local( cmd: list[str], *, @@ -102,7 +212,7 @@ def scp_content( effective_shell = shell or _login_shell() if _is_local(ip): return subprocess.run( - [effective_shell, "-lc", f"cat > {remote_path}"], + [effective_shell, "-lc", f"cat > {shlex.quote(remote_path)}"], input=content, capture_output=True, text=True, @@ -114,11 +224,11 @@ def scp_content( "-o", "ConnectTimeout=10", "-o", - "StrictHostKeyChecking=no", + f"StrictHostKeyChecking={_strict_host_key_checking()}", "-o", "BatchMode=yes", f"{user}@{ip}", - f"cat > {remote_path}", + f"cat > {shlex.quote(remote_path)}", ] return subprocess.run( ssh_cmd, diff --git a/src/thunder_forge/cluster/usage.py b/src/thunder_forge/cluster/usage.py index fd47661..947e543 100644 --- a/src/thunder_forge/cluster/usage.py +++ b/src/thunder_forge/cluster/usage.py @@ -75,6 +75,28 @@ def _ordered_nested_counter(counter: dict[str, Counter[str]]) -> dict[str, dict[ return {key: dict(sorted(value.items())) for key, value in sorted(counter.items())} +def _as_non_negative_int(raw: object) -> int | None: + if isinstance(raw, bool) or not isinstance(raw, int): + return None + return raw if raw >= 0 else None + + +def _extract_total_tokens(payload: dict[str, object]) -> int | None: + total_tokens = _as_non_negative_int(payload.get("total_tokens")) + if total_tokens is not None: + return total_tokens + + prompt_tokens = _as_non_negative_int(payload.get("prompt_tokens")) + completion_tokens = _as_non_negative_int(payload.get("completion_tokens")) + if prompt_tokens is None and completion_tokens is None: + return None + return (prompt_tokens or 0) + (completion_tokens or 0) + + +def _ordered_list_map(values: dict[str, list[str]]) -> dict[str, list[str]]: + return {key: values[key] for key in sorted(values)} + + def extract_hot_loaded_models(model_statuses: dict[str, dict[str, object]]) -> list[str]: """Return hot-loaded model ids sorted by latest access first.""" @@ -114,9 +136,18 @@ class DailyUsageSummary: requests_by_model: dict[str, int] = field(default_factory=dict) consumed_ms_by_model: dict[str, int] = field(default_factory=dict) requests_by_hour: dict[str, int] = field(default_factory=dict) + requests_by_node_hour: dict[str, dict[str, int]] = field(default_factory=dict) + tokens_present: bool = False + tokens_total: int = 0 + tokens_by_user: dict[str, int] = field(default_factory=dict) + tokens_by_node: dict[str, int] = field(default_factory=dict) + tokens_by_model: dict[str, int] = field(default_factory=dict) + node_metrics_present: bool = False + node_hot_loaded_models: dict[str, list[str]] = field(default_factory=dict) + node_hot_loaded_count: dict[str, int] = field(default_factory=dict) def to_json_dict(self) -> dict[str, object]: - return { + payload: dict[str, object] = { "period": self.period, "generated_at": self.generated_at, "access_log_path": self.access_log_path, @@ -129,6 +160,7 @@ def to_json_dict(self) -> dict[str, object]: "by_node_model": self.requests_by_node_model, "by_model": self.requests_by_model, "by_hour": self.requests_by_hour, + "by_node_hour": self.requests_by_node_hour, }, "consumed_ms": { "total": self.consumed_ms_total, @@ -138,6 +170,19 @@ def to_json_dict(self) -> dict[str, object]: }, "invalid_lines": self.invalid_lines, } + if self.tokens_present: + payload["tokens"] = { + "total": self.tokens_total, + "by_user": self.tokens_by_user, + "by_node": self.tokens_by_node, + "by_model": self.tokens_by_model, + } + if self.node_metrics_present: + payload["node_metrics"] = { + "hot_loaded_models": self.node_hot_loaded_models, + "hot_loaded_count": self.node_hot_loaded_count, + } + return payload def summarize_daily_usage( @@ -156,10 +201,16 @@ def summarize_daily_usage( consumed_ms_by_model: Counter[str] = Counter() requests_by_hour: Counter[str] = Counter() requests_by_node_model: dict[str, Counter[str]] = defaultdict(Counter) + requests_by_node_hour: dict[str, Counter[str]] = defaultdict(Counter) + tokens_by_user: Counter[str] = Counter() + tokens_by_node: Counter[str] = Counter() + tokens_by_model: Counter[str] = Counter() requests_total = 0 invalid_lines = 0 consumed_ms_total = 0 + tokens_total = 0 + tokens_present = False records, invalid = _load_jsonl_lines(access_log_path) invalid_lines += invalid @@ -200,6 +251,8 @@ def summarize_daily_usage( consumed_ms_by_node[node_name] += latency_ms if model_name: requests_by_node_model[node_name][model_name] += 1 + if timestamp is not None: + requests_by_node_hour[node_name][hour_bucket] += 1 if model_name: requests_by_model[model_name] += 1 @@ -208,11 +261,55 @@ def summarize_daily_usage( if timestamp is not None: requests_by_hour[hour_bucket] += 1 + record_tokens = _extract_total_tokens(payload) + if record_tokens is not None: + tokens_present = True + tokens_total += record_tokens + tokens_by_user[client_id] += record_tokens + if node_name: + tokens_by_node[node_name] += record_tokens + if model_name: + tokens_by_model[model_name] += record_tokens + node_metrics_path_str = "" + node_metrics_present = False + node_hot_loaded_models: dict[str, list[str]] = {} + node_hot_loaded_count: dict[str, int] = {} + latest_node_metric_timestamp: dict[str, datetime | None] = {} if node_metrics_path is not None: node_metrics_path_str = str(node_metrics_path) - _, invalid_node_lines = _load_jsonl_lines(node_metrics_path) + node_records, invalid_node_lines = _load_jsonl_lines(node_metrics_path) invalid_lines += invalid_node_lines + for payload in node_records: + timestamp = _parse_timestamp(payload.get("timestamp")) + if period is not None and (timestamp is None or timestamp.date().isoformat() != period): + continue + node_name = payload.get("node_name") + if not isinstance(node_name, str) or not node_name.strip(): + invalid_lines += 1 + continue + node_name = node_name.strip() + + raw_models = payload.get("hot_loaded_models") + hot_loaded_models: list[str] = [] + if isinstance(raw_models, list): + hot_loaded_models = [item.strip() for item in raw_models if isinstance(item, str) and item.strip()] + hot_loaded_count = _as_non_negative_int(payload.get("hot_loaded_count")) + if hot_loaded_count is None and isinstance(raw_models, list): + hot_loaded_count = len(hot_loaded_models) + if hot_loaded_count is None and not hot_loaded_models: + continue + + previous_timestamp = latest_node_metric_timestamp.get(node_name) + if previous_timestamp is not None and timestamp is not None and timestamp < previous_timestamp: + continue + + node_metrics_present = True + latest_node_metric_timestamp[node_name] = timestamp + if hot_loaded_models: + node_hot_loaded_models[node_name] = hot_loaded_models + if hot_loaded_count is not None: + node_hot_loaded_count[node_name] = hot_loaded_count return DailyUsageSummary( period=period or "all", @@ -231,4 +328,13 @@ def summarize_daily_usage( requests_by_model=_ordered_counter(requests_by_model), consumed_ms_by_model=_ordered_counter(consumed_ms_by_model), requests_by_hour=_ordered_counter(requests_by_hour), - ) \ No newline at end of file + requests_by_node_hour=_ordered_nested_counter(requests_by_node_hour), + tokens_present=tokens_present, + tokens_total=tokens_total, + tokens_by_user=_ordered_counter(tokens_by_user), + tokens_by_node=_ordered_counter(tokens_by_node), + tokens_by_model=_ordered_counter(tokens_by_model), + node_metrics_present=node_metrics_present, + node_hot_loaded_models=_ordered_list_map(node_hot_loaded_models), + node_hot_loaded_count=dict(sorted(node_hot_loaded_count.items())), + ) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index a7cb5f8..131766d 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -11,6 +11,7 @@ build_artifact_sync_plan, is_local_artifact_complete, omlx_model_dir_name, + probe_artifact_presence, ) @@ -234,6 +235,51 @@ def test_local_artifact_complete_rejects_rsync_partial_dir(tmp_path) -> None: assert is_local_artifact_complete(model_dir) is False +def test_probe_artifact_presence_checks_node_as_configured_user(tmp_path, monkeypatch) -> None: + import subprocess + + import thunder_forge.cluster.artifacts as artifacts_module + + cache_root = tmp_path / "cache" + cache_model_dir = cache_root / "mlx-community" / "gpt-oss-20b-MXFP4-Q8" + cache_model_dir.mkdir(parents=True) + (cache_model_dir / "config.json").write_text("{}") + (cache_model_dir / "model.safetensors").write_text("weights") + calls: list[tuple[str, str, str]] = [] + + def fake_ssh_run(user, ip, cmd, **kwargs): + calls.append((user, ip, cmd)) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(artifacts_module, "ssh_run", fake_ssh_run) + + presence = probe_artifact_presence( + repo_id="mlx-community/gpt-oss-20b-MXFP4-Q8", + node_user="operator", + node_host="infer-01.lan", + node_home_dir="/Users/operator", + cache_omlx_models_dir=str(cache_root), + ) + + assert presence.cache_omlx_model_dir is True + assert presence.node_omlx_model_dir is True + assert calls == [ + ( + "operator", + "infer-01.lan", + ( + "test -d /Users/operator/.omlx/models/mlx-community/gpt-oss-20b-MXFP4-Q8 && " + "test -f /Users/operator/.omlx/models/mlx-community/gpt-oss-20b-MXFP4-Q8/config.json && " + "test -z \"$(find /Users/operator/.omlx/models/mlx-community/gpt-oss-20b-MXFP4-Q8 " + "-name '*.incomplete' -print -quit)\" && " + "test ! -e /Users/operator/.omlx/models/mlx-community/gpt-oss-20b-MXFP4-Q8/.rsync-partial && " + "test -n \"$(find /Users/operator/.omlx/models/mlx-community/gpt-oss-20b-MXFP4-Q8 " + "\\( -name '*.safetensors' -o -name '*.bin' \\) -type f -print -quit)\"" + ), + ) + ] + + def test_artifact_sync_plan_can_use_fabric_host() -> None: plan = build_artifact_sync_plan( repo_id="BAAI/bge-small-en-v1.5", diff --git a/tests/test_cli_artifact.py b/tests/test_cli_artifact.py index c842057..0b106f0 100644 --- a/tests/test_cli_artifact.py +++ b/tests/test_cli_artifact.py @@ -142,7 +142,7 @@ def test_artifact_status_prints_readiness_plan(tmp_path: Path, monkeypatch) -> N monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -290,7 +290,7 @@ def test_artifact_status_checks_remote_cache_host_over_ssh(tmp_path: Path, monke monkeypatch.setattr( artifacts_module, "_remote_artifact_complete", - lambda host, path: True, + lambda *, user, host, path: True, ) def fake_ssh_run(user, ip, cmd, **kwargs): @@ -405,6 +405,10 @@ def fake_ssh_run(user, ip, cmd, **kwargs): assert "TF_CACHE_REMOTE_EXEC=1" not in calls[0][2] assert "mkdir -p" in calls[0][2] assert "missing cache oMLX model dir" in calls[0][2] + assert 'test -f "$SOURCE_PATH/config.json"' in calls[0][2] + assert "find \"$SOURCE_PATH\" -name '*.incomplete'" in calls[0][2] + assert 'test ! -e "$SOURCE_PATH/.rsync-partial"' in calls[0][2] + assert "find \"$SOURCE_PATH\" \\( -name '*.safetensors' -o -name '*.bin' \\)" in calls[0][2] assert '"$SOURCE_PATH"' in calls[0][2] assert "'$SOURCE_PATH'" not in calls[0][2] assert calls[0][3]["timeout"] == 123 @@ -483,7 +487,7 @@ def test_artifact_sync_dry_run_prints_cache_to_node_plan(tmp_path: Path, monkeyp monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -528,7 +532,7 @@ def test_artifact_sync_without_model_syncs_all_node_models(tmp_path: Path, monke monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -567,7 +571,7 @@ def test_artifact_sync_dry_run_uses_cache_omlx_dir_env(tmp_path: Path, monkeypat monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -602,7 +606,7 @@ def test_artifact_sync_uses_dynamic_fabric_by_default_when_enabled(tmp_path: Pat monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -648,7 +652,7 @@ def test_artifact_sync_falls_back_to_management_host_when_fabric_is_unresolved( monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -689,7 +693,7 @@ def test_artifact_sync_can_force_management_host_when_fabric_host_is_configured( monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -730,7 +734,7 @@ def test_artifact_sync_without_fabric_host_uses_management_even_when_probe_would monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -774,7 +778,7 @@ def test_artifact_sync_apply_invokes_runner(tmp_path: Path, monkeypatch) -> None monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -823,7 +827,7 @@ def test_artifact_sync_apply_without_model_invokes_runner_for_each_node_model( monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -894,7 +898,7 @@ def test_cluster_sync_uses_config_defaults_and_restarts_runtime(tmp_path: Path, monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -951,7 +955,7 @@ def test_cluster_sync_with_prune_removes_unassigned_node_cache_models(tmp_path: monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), @@ -1070,7 +1074,7 @@ def test_artifact_sync_apply_propagates_runner_failure(tmp_path: Path, monkeypat monkeypatch.setattr( cli_module, "probe_artifact_presence", - lambda *, repo_id, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( + lambda *, repo_id, node_user, node_host, node_home_dir, cache_omlx_models_dir=None: ArtifactPresence( cache_omlx_model_dir=True, node_omlx_model_dir=False, ), diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index 0a97d5b..96d9c0e 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -20,6 +20,11 @@ def test_config_lint_reports_errors_and_warnings(tmp_path: Path, monkeypatch) -> benchmark_only: true runtime_model_id: gpt-oss-20b-mxfp4-bf16 nodes: + gateway: + host: gateway.lan + ram_gb: 32 + user: shag + roles: [gateway] infer-03: host: infer-03.lan ram_gb: 128 @@ -56,6 +61,11 @@ def test_config_lint_passes_clean_config(tmp_path: Path, monkeypatch) -> None: source: { repo: mlx-community/gpt-oss-20b-MXFP4-Q8 } runtime_model_id: gpt-oss-20b-MXFP4-Q8 nodes: + gateway: + host: gateway.lan + ram_gb: 32 + user: shag + roles: [gateway] infer-03: host: infer-03.lan ram_gb: 128 diff --git a/tests/test_cli_edge.py b/tests/test_cli_edge.py index 92bc5ff..5858e3d 100644 --- a/tests/test_cli_edge.py +++ b/tests/test_cli_edge.py @@ -2,6 +2,7 @@ import json import os +import threading from pathlib import Path from typer.testing import CliRunner @@ -73,7 +74,7 @@ def _cluster_config( def test_edge_smoke_cli_reads_api_key_from_env_and_prints_summary(monkeypatch) -> None: import thunder_forge.cli as cli_module - monkeypatch.setenv("TF_USER_CLIENT_A", "dev-secret") + monkeypatch.setenv("TF_USER_CLIENT_DASH_A", "dev-secret") monkeypatch.setattr(cli_module, "_load_config", lambda: (_cluster_config(), Path.cwd()), raising=False) monkeypatch.setattr( cli_module, @@ -121,7 +122,7 @@ def test_edge_smoke_cli_reads_api_key_from_env_and_prints_summary(monkeypatch) - def test_edge_smoke_cli_fails_when_client_api_key_is_missing(monkeypatch) -> None: import thunder_forge.cli as cli_module - monkeypatch.delenv("TF_USER_CLIENT_A", raising=False) + monkeypatch.delenv("TF_USER_CLIENT_DASH_A", raising=False) monkeypatch.setattr(cli_module, "_load_config", lambda: (_cluster_config(), Path.cwd()), raising=False) result = runner.invoke( @@ -139,7 +140,7 @@ def test_edge_smoke_cli_fails_when_client_api_key_is_missing(monkeypatch) -> Non ) assert result.exit_code == 1 - assert "Error: TF_USER_CLIENT_A is not set" in result.stderr + assert "Error: TF_USER_CLIENT_DASH_A is not set" in result.stderr def test_edge_serve_cli_builds_proxy_config_from_env_without_printing_key(monkeypatch) -> None: @@ -270,6 +271,29 @@ def fake_serve_edge_proxy(*, host: str, port: int, config: EdgeProxyConfig) -> N assert config.model_catalog[0].runtime_model_id == "Qwen3-Coder-Next-4bit" +def test_edge_serve_cli_reports_node_metrics_collector_errors(monkeypatch) -> None: + import thunder_forge.cli as cli_module + + observed = threading.Event() + monkeypatch.setenv("TF_USER_CLIENT_A", "secret-a") + monkeypatch.setattr(cli_module, "_load_config", lambda: (_cluster_config(), Path.cwd()), raising=False) + + def fake_collect_node_metric_snapshots(*args, **kwargs): + observed.set() + raise RuntimeError("metrics unavailable") + + def fake_serve_edge_proxy(*, host: str, port: int, config: EdgeProxyConfig) -> None: + assert observed.wait(timeout=1.0) + + monkeypatch.setattr(cli_module, "_collect_node_metric_snapshots", fake_collect_node_metric_snapshots) + monkeypatch.setattr(cli_module, "serve_edge_proxy", fake_serve_edge_proxy, raising=False) + + result = runner.invoke(app, ["edge", "serve", "--metrics-interval-seconds", "60"]) + + assert result.exit_code == 0 + assert "node_metrics_collector_error: metrics unavailable" in result.stdout + + def test_edge_client_config_opencode_prints_assigned_aliases(monkeypatch) -> None: import thunder_forge.cli as cli_module @@ -738,7 +762,7 @@ def test_edge_smoke_cli_uses_config_default(monkeypatch) -> None: import thunder_forge.cli as cli_module captured: dict[str, str] = {} - monkeypatch.setenv("TF_USER_CLIENT_A", "dev-secret") + monkeypatch.setenv("TF_USER_CLIENT_DASH_A", "dev-secret") monkeypatch.setattr( cli_module, "_load_config", @@ -781,8 +805,8 @@ def test_edge_keys_cli_generates_user_hash_without_printing_secrets(tmp_path, mo assert "client: client-b" in result.stdout assert "secrets_printed: no" in result.stdout content = env_file.read_text() - assert "TF_USER_CLIENT_A=" in content - assert "TF_USER_CLIENT_B=" in content + assert "TF_USER_CLIENT_DASH_A=" in content + assert "TF_USER_CLIENT_DASH_B=" in content for line in content.splitlines(): if line.startswith("TF_USER_"): _, _, api_key = line.partition("=") diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py index d53d72b..571a1ad 100644 --- a/tests/test_cli_usage.py +++ b/tests/test_cli_usage.py @@ -46,6 +46,22 @@ def test_usage_report_cli_emits_json_summary(tmp_path: Path, monkeypatch) -> Non "model": "coder", "latency_ms": 100, "olla_endpoint": "msm1-omlx-live", + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20, + } + ) + + "\n" + ) + (repo / "logs" / "tf-node-metrics.jsonl").write_text( + json.dumps( + { + "timestamp": "2026-06-02T08:16:00+00:00", + "node_name": "msm1", + "health_ok": True, + "models_ok": True, + "hot_loaded_models": ["coder", "agent"], + "hot_loaded_count": 2, } ) + "\n" @@ -64,10 +80,18 @@ def test_usage_report_cli_emits_json_summary(tmp_path: Path, monkeypatch) -> Non assert payload["requests"]["by_user"] == {"alice": 1} assert payload["requests"]["by_user_model"] == {"alice": {"coder": 1}} assert payload["requests"]["by_node"] == {"msm1": 1} + assert payload["requests"]["by_node_hour"] == {"msm1": {"08": 1}} assert payload["requests"]["by_model"] == {"coder": 1} assert payload["requests"]["by_hour"] == {"08": 1} assert payload["consumed_ms"]["total"] == 100 - assert "tokens" not in payload + assert payload["tokens"] == { + "total": 20, + "by_user": {"alice": 20}, + "by_node": {"msm1": 20}, + "by_model": {"coder": 20}, + } + assert payload["node_metrics"]["hot_loaded_models"] == {"msm1": ["coder", "agent"]} + assert payload["node_metrics"]["hot_loaded_count"] == {"msm1": 2} def test_usage_collect_node_metrics_writes_snapshot_jsonl(tmp_path: Path, monkeypatch) -> None: @@ -185,4 +209,4 @@ def test_usage_collect_node_metrics_maps_hot_loaded_runtime_ids_to_aliases(tmp_p assert len(lines) == 1 payload = json.loads(lines[0]) assert payload["hot_loaded_models"] == ["agent", "agent-better"] - assert payload["hot_loaded_count"] == 2 \ No newline at end of file + assert payload["hot_loaded_count"] == 2 diff --git a/tests/test_config.py b/tests/test_config.py index cc615a2..173a985 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -310,6 +310,82 @@ def test_parse_node_runtime_options_for_omlx_serve(tmp_path: Path) -> None: assert runtime.trusted_network is True +def test_parse_node_runtime_requires_type() -> None: + with pytest.raises(ValueError, match=r"nodes\.infer-03\.runtime\.type is required"): + parse_cluster_config( + { + "models": {}, + "nodes": { + "infer-03": { + "host": "infer-03.lan", + "ram_gb": 128, + "roles": ["inference"], + "runtime": {"port": 8018}, + } + }, + } + ) + + +@pytest.mark.parametrize("field", ["no_cache", "trusted_network"]) +def test_parse_node_runtime_rejects_string_booleans(field: str) -> None: + with pytest.raises(ValueError, match=rf"nodes\.infer-03\.runtime\.{field} must be boolean true/false"): + parse_cluster_config( + { + "models": {}, + "nodes": { + "infer-03": { + "host": "infer-03.lan", + "ram_gb": 128, + "roles": ["inference"], + "runtime": {"type": "omlx", field: "false"}, + } + }, + } + ) + + +@pytest.mark.parametrize("value", [0, "many"]) +def test_parse_node_runtime_rejects_invalid_max_concurrent_requests(value: object) -> None: + with pytest.raises(ValueError, match=r"nodes\.infer-03\.runtime\.max_concurrent_requests must"): + parse_cluster_config( + { + "models": {}, + "nodes": { + "infer-03": { + "host": "infer-03.lan", + "ram_gb": 128, + "roles": ["inference"], + "runtime": { + "type": "omlx", + "max_concurrent_requests": value, + }, + } + }, + } + ) + + +def test_parse_node_runtime_rejects_empty_string_options() -> None: + with pytest.raises(ValueError, match=r"nodes\.infer-03\.runtime\.mcp_config must not be empty"): + parse_cluster_config( + { + "models": {}, + "nodes": { + "infer-03": { + "host": "infer-03.lan", + "ram_gb": 128, + "roles": ["inference"], + "runtime": { + "type": "omlx", + "mcp_config": " ", + }, + } + }, + } + ) + + def test_example_config_keeps_active_omlx_runtime_blocks_consistent() -> None: example_path = Path(__file__).parents[1] / "tfconfig.example.yaml" raw = yaml_lib.safe_load(example_path.read_text()) @@ -545,6 +621,49 @@ def test_lint_cluster_config_reports_unknown_models_and_exposure_warnings(tmp_pa ] +def test_lint_cluster_config_reports_unusable_topology() -> None: + empty_config = parse_cluster_config({"models": {}, "nodes": {}}) + + empty_issues = lint_cluster_config(empty_config) + + assert any(issue.severity == "error" and issue.path == "nodes" for issue in empty_issues) + assert any("gateway" in issue.message for issue in empty_issues) + assert any("inference" in issue.message for issue in empty_issues) + + no_route_config = parse_cluster_config( + { + "models": { + "memory": { + "source": {"repo": "mlx-community/gpt-oss-20b-MXFP4-Q8"}, + "runtime_model_id": "gpt-oss-20b-MXFP4-Q8", + } + }, + "nodes": { + "gateway-cache-01": { + "host": "gateway-cache-01.lan", + "ram_gb": 64, + "roles": ["gateway"], + }, + "infer-03": { + "host": "infer-03.lan", + "ram_gb": 128, + "roles": ["inference"], + "runtime": {"type": "omlx", "port": 8018}, + }, + }, + } + ) + + no_route_issues = lint_cluster_config(no_route_config) + + assert any( + issue.severity == "error" + and issue.path == "nodes" + and "no routable model placements" in issue.message + for issue in no_route_issues + ) + + def test_load_cluster_config_loads_dotenv(cluster_yaml: Path, monkeypatch: pytest.MonkeyPatch) -> None: """load_cluster_config loads .env from repo root.""" import thunder_forge.cluster.config as config_module @@ -572,8 +691,9 @@ def test_find_repo_root_ignores_node_assignments_without_tfconfig(monkeypatch, t (config_dir / "node-assignments.yaml").write_text("models: {}\nnodes: {}\n") monkeypatch.chdir(tmp_path) - with pytest.raises(FileNotFoundError, match="tfconfig.yaml"): + with pytest.raises(FileNotFoundError) as exc_info: find_repo_root() + assert "tfconfig.yaml" in str(exc_info.value) def test_parse_model_info() -> None: @@ -623,3 +743,18 @@ def test_parse_model_info_absent() -> None: } config = parse_cluster_config(raw) assert config.models["coder"].model_info is None + + +def test_parse_model_rejects_obsolete_serving_key() -> None: + with pytest.raises(ValueError, match=r"models\.coder\.serving is not supported"): + parse_cluster_config( + { + "models": { + "coder": { + "source": {"type": "huggingface", "repo": "test/coder"}, + "serving": "chat", + } + }, + "nodes": {}, + } + ) diff --git a/tests/test_edge.py b/tests/test_edge.py index d539da7..3194406 100644 --- a/tests/test_edge.py +++ b/tests/test_edge.py @@ -3,6 +3,7 @@ import json import httpx +import pytest from thunder_forge.cluster.edge import ( EdgeAccessLog, @@ -19,6 +20,7 @@ load_edge_clients_from_env, proxy_edge_request, rewrite_openai_path, + smoke_edge_contract, summarize_edge_usage, ) @@ -48,6 +50,15 @@ def test_edge_auth_maps_valid_bearer_token_to_client_id_without_exposing_secret( assert "dev-secret" not in repr(result) +def test_edge_auth_rejects_non_canonical_bearer_header_spacing() -> None: + clients = {"dev-secret": EdgeClient(client_id="client-a")} + + result = authenticate_edge_request("Bearer dev-secret", clients) + + assert result.allowed is False + assert result.status_code == 401 + + def test_edge_users_and_multi_client_loader() -> None: env = { "TF_USER_CLIENT_A": "secret-a", @@ -64,6 +75,52 @@ def test_edge_users_and_multi_client_loader() -> None: assert "" not in clients +def test_edge_client_loader_rejects_duplicate_api_keys() -> None: + env = { + "TF_USER_CLIENT_A": "shared-secret", + "TF_USER_CLIENT_B": "shared-secret", + } + + with pytest.raises(ValueError, match="duplicate API key"): + load_edge_clients_from_env(env=env) + + +def test_edge_client_env_names_encode_dash_and_dot_without_collisions() -> None: + env = { + "TF_USER_CLIENT_A": "underscore-secret", + "TF_USER_CLIENT_DASH_A": "dash-secret", + "TF_USER_CLIENT_DOT_A": "dot-secret", + "TF_USER_CLIENT_UNDERSCORE_DASH_A": "literal-dash-word-secret", + "TF_USER_CLIENT_UNDERSCORE_DOT_A": "literal-dot-word-secret", + "TF_USER_CLIENT_UNDERSCORE_UNDERSCORE_A": "literal-underscore-word-secret", + } + + assert edge_api_key_from_env(env=env, client_id="client_a") == ("TF_USER_CLIENT_A", "underscore-secret") + assert edge_api_key_from_env(env=env, client_id="client-a") == ("TF_USER_CLIENT_DASH_A", "dash-secret") + assert edge_api_key_from_env(env=env, client_id="client.a") == ("TF_USER_CLIENT_DOT_A", "dot-secret") + assert edge_api_key_from_env(env=env, client_id="client_dash_a") == ( + "TF_USER_CLIENT_UNDERSCORE_DASH_A", + "literal-dash-word-secret", + ) + assert edge_api_key_from_env(env=env, client_id="client_dot_a") == ( + "TF_USER_CLIENT_UNDERSCORE_DOT_A", + "literal-dot-word-secret", + ) + assert edge_api_key_from_env(env=env, client_id="client_underscore_a") == ( + "TF_USER_CLIENT_UNDERSCORE_UNDERSCORE_A", + "literal-underscore-word-secret", + ) + + clients = load_edge_clients_from_env(env=env) + + assert clients["underscore-secret"].client_id == "client_a" + assert clients["dash-secret"].client_id == "client-a" + assert clients["dot-secret"].client_id == "client.a" + assert clients["literal-dash-word-secret"].client_id == "client_dash_a" + assert clients["literal-dot-word-secret"].client_id == "client_dot_a" + assert clients["literal-underscore-word-secret"].client_id == "client_underscore_a" + + def test_build_edge_clients_reads_only_user_prefixed_keys() -> None: clients = build_edge_clients_from_env( env={"TF_USER_CLIENT_A": "secret-a", "OTHER_KEY": "ignored-secret"}, @@ -208,6 +265,71 @@ def handler(request: httpx.Request) -> httpx.Response: assert invalid.body == b'{"error":"unauthorized"}' +def test_proxy_edge_request_logs_auth_failures_without_calling_olla() -> None: + requests: list[httpx.Request] = [] + logs: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"unexpected": True}) + + config = EdgeProxyConfig( + olla_base_url="http://olla.local:40115", + clients_by_key={"dev-secret": EdgeClient(client_id="client-a")}, + access_log_sink=logs.append, + ) + + result = proxy_edge_request( + method="GET", + path="/v1/models", + headers={"Authorization": "Bearer wrong-secret"}, + body=b"", + config=config, + transport=httpx.MockTransport(handler), + ) + + assert result.status_code == 401 + assert requests == [] + assert len(logs) == 1 + logged = json.loads(logs[0]) + assert logged["client_id"] == "unauthenticated" + assert logged["path"] == "/v1/models" + assert logged["status_code"] == 401 + assert "wrong-secret" not in logs[0] + + +def test_proxy_edge_request_rejects_oversized_body_without_calling_olla() -> None: + requests: list[httpx.Request] = [] + logs: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"unexpected": True}) + + config = EdgeProxyConfig( + olla_base_url="http://olla.local:40115", + clients_by_key={"dev-secret": EdgeClient(client_id="client-a")}, + access_log_sink=logs.append, + max_body_bytes=4, + ) + + result = proxy_edge_request( + method="POST", + path="/v1/chat/completions", + headers={"Authorization": "Bearer dev-secret", "Content-Type": "application/json"}, + body=b"12345", + config=config, + transport=httpx.MockTransport(handler), + ) + + assert result.status_code == 413 + assert requests == [] + assert result.body == b'{"error":"request_too_large"}' + logged = json.loads(logs[0]) + assert logged["client_id"] == "client-a" + assert logged["status_code"] == 413 + + def test_proxy_edge_request_serves_tf_alias_model_catalog_without_calling_olla() -> None: requests: list[httpx.Request] = [] logs: list[str] = [] @@ -347,6 +469,110 @@ def handler(request: httpx.Request) -> httpx.Response: assert "dev-secret" not in logs[0] +def test_proxy_edge_request_records_upstream_usage_tokens() -> None: + logs: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + headers={"X-Olla-Endpoint": "infer-03-omlx-live"}, + json={ + "id": "chatcmpl-1", + "choices": [{"message": {"content": "pong"}}], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 8, + "total_tokens": 20, + }, + }, + ) + + config = EdgeProxyConfig( + olla_base_url="http://olla.local:40115", + clients_by_key={"dev-secret": EdgeClient(client_id="client-a")}, + access_log_sink=logs.append, + ) + + result = proxy_edge_request( + method="POST", + path="/v1/chat/completions", + headers={"Authorization": "Bearer dev-secret", "Content-Type": "application/json"}, + body=json.dumps({"model": "memory", "messages": []}).encode(), + config=config, + transport=httpx.MockTransport(handler), + ) + + assert result.status_code == 200 + logged = json.loads(logs[0]) + assert logged["prompt_tokens"] == 12 + assert logged["completion_tokens"] == 8 + assert logged["total_tokens"] == 20 + + +def test_proxy_edge_request_logs_upstream_failures_without_secret() -> None: + logs: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + config = EdgeProxyConfig( + olla_base_url="http://olla.local:40115", + clients_by_key={"dev-secret": EdgeClient(client_id="client-a")}, + access_log_sink=logs.append, + ) + + result = proxy_edge_request( + method="POST", + path="/v1/chat/completions", + headers={"Authorization": "Bearer dev-secret", "Content-Type": "application/json"}, + body=json.dumps({"model": "memory", "messages": []}).encode(), + config=config, + transport=httpx.MockTransport(handler), + ) + + assert result.status_code == 502 + assert len(logs) == 1 + logged = json.loads(logs[0]) + assert logged["client_id"] == "client-a" + assert logged["model"] == "memory" + assert logged["status_code"] == 502 + assert "dev-secret" not in logs[0] + + +def test_smoke_edge_contract_fails_when_same_session_routes_to_different_endpoints() -> None: + chat_endpoints = ["infer-01-omlx-live", "infer-02-omlx-live"] + chat_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal chat_count + if request.method == "GET" and request.url.path == "/v1/models": + if request.headers.get("Authorization") == "Bearer dev-secret": + return httpx.Response(200, json={"object": "list", "data": [{"id": "memory"}]}) + return httpx.Response(401, json={"error": "unauthorized"}) + if request.method == "POST" and request.url.path == "/v1/chat/completions": + assert request.headers.get("X-Olla-Session-ID") == "tf-smoke-session" + endpoint = chat_endpoints[min(chat_count, len(chat_endpoints) - 1)] + chat_count += 1 + return httpx.Response( + 200, + headers={"X-Olla-Endpoint": endpoint}, + json={"id": "chatcmpl-1", "choices": [{"message": {"content": "pong"}}]}, + ) + return httpx.Response(500, text=f"unexpected path: {request.url.path}") + + result = smoke_edge_contract( + base_url="http://edge.local:40116", + api_key="dev-secret", + model="memory", + transport=httpx.MockTransport(handler), + ) + + assert result.chat_ok is True + assert result.session_ok is False + assert chat_count == 2 + assert "same session routed to different endpoints" in result.errors + + def test_summarize_edge_usage_groups_requests_by_client(tmp_path) -> None: log_path = tmp_path / "tf-edge-access.jsonl" log_path.write_text( diff --git a/tests/test_olla_smoke.py b/tests/test_olla_smoke.py index 1dac878..6806c73 100644 --- a/tests/test_olla_smoke.py +++ b/tests/test_olla_smoke.py @@ -113,3 +113,56 @@ def handler(request: httpx.Request) -> httpx.Response: assert result.alias_endpoint == "infer-03-omlx-live" assert result.alias_ok is True assert result.errors == [] + + +def test_smoke_olla_router_fails_when_same_session_routes_to_different_endpoints() -> None: + backend_endpoints = ["infer-01-omlx-live", "infer-02-omlx-live"] + backend_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal backend_count + if request.url.path == "/internal/health": + return httpx.Response(200, json={"status": "healthy"}) + if request.url.path == "/internal/status/endpoints": + return httpx.Response( + 200, + json={ + "endpoints": [ + {"name": "infer-01-omlx-live", "status": "healthy"}, + {"name": "infer-02-omlx-live", "status": "healthy"}, + ] + }, + ) + if request.url.path == "/olla/openai-compatible/v1/models": + return httpx.Response( + 200, + json={"object": "list", "data": [{"id": "Qwen3-1.7B-4bit", "object": "model"}]}, + ) + if request.url.path == "/olla/openai-compatible/v1/chat/completions": + payload = json.loads(request.read().decode()) + if payload["model"] == "Qwen3-1.7B-4bit": + assert request.headers.get("X-Olla-Session-ID") == "tf-olla-smoke-session" + endpoint = backend_endpoints[min(backend_count, len(backend_endpoints) - 1)] + backend_count += 1 + else: + endpoint = "infer-01-omlx-live" + return httpx.Response( + 200, + headers={"X-Olla-Endpoint": endpoint}, + json={"id": "chatcmpl-1", "choices": [{"message": {"content": "pong"}}]}, + ) + if request.url.path == "/v1/models": + return httpx.Response(404, text="not found") + return httpx.Response(500, text=f"unexpected path: {request.url.path}") + + result = smoke_olla_router( + base_url="http://127.0.0.1:40115", + model="Qwen3-1.7B-4bit", + alias="memory", + transport=httpx.MockTransport(handler), + ) + + assert result.chat_ok is True + assert result.session_ok is False + assert backend_count == 2 + assert "same session routed to different endpoints" in result.errors diff --git a/tests/test_omlx_runtime.py b/tests/test_omlx_runtime.py index 14a2dc2..ba3c8fe 100644 --- a/tests/test_omlx_runtime.py +++ b/tests/test_omlx_runtime.py @@ -11,13 +11,13 @@ def test_run_omlx_runtime_start_executes_remote_nohup_command(monkeypatch) -> None: calls = [] - def fake_run(args, **kwargs): - calls.append((args, kwargs)) - return subprocess.CompletedProcess(args=args, returncode=0, stdout="4242\n", stderr="") + def fake_ssh_run(user, ip, cmd, **kwargs): + calls.append((user, ip, cmd, kwargs)) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="4242\n", stderr="") import thunder_forge.cluster.omlx as omlx_module - monkeypatch.setattr(omlx_module.subprocess, "run", fake_run) + monkeypatch.setattr(omlx_module, "ssh_run", fake_ssh_run) node = Node( host="infer-03.lan", user="shag", @@ -30,21 +30,17 @@ def fake_run(args, **kwargs): assert result.returncode == 0 assert result.pid == "4242" - assert calls[0][0] == [ - "ssh", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=8", - "shag@infer-03.lan", + assert calls == [ ( - "nohup /Users/shag/.local/bin/omlx serve --host 0.0.0.0 --port 8018 " - "> /tmp/thunder-forge-omlx-8018.log 2>&1 & echo $!" - ), + "shag", + "infer-03.lan", + ( + "nohup /Users/shag/.local/bin/omlx serve --host 0.0.0.0 --port 8018 " + "> /tmp/thunder-forge-omlx-8018.log 2>&1 & echo $!" + ), + {"timeout": 30, "shell": None}, + ) ] - assert calls[0][1]["check"] is False - assert calls[0][1]["text"] is True - assert calls[0][1]["timeout"] == 30 def test_smoke_omlx_chat_passes_when_model_is_visible_and_chat_answers() -> None: diff --git a/tests/test_operator_docs.py b/tests/test_operator_docs.py new file mode 100644 index 0000000..01a9ec7 --- /dev/null +++ b/tests/test_operator_docs.py @@ -0,0 +1,37 @@ +"""Tests for operator-facing README and Makefile consistency.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +def _makefile_targets(makefile: str) -> set[str]: + return { + match.group(1) + for match in re.finditer(r"^([A-Za-z][A-Za-z0-9_.-]*):(?:\s|$)", makefile, flags=re.MULTILINE) + } + + +def _documented_make_targets(text: str) -> set[str]: + return set(re.findall(r"(? None: + repo_root = Path(__file__).parents[1] + makefile = (repo_root / "Makefile").read_text() + target_names = _makefile_targets(makefile) + + doc_paths = [ + repo_root / "README.md", + repo_root / ".github" / "skills" / "thunder-forge" / "SKILL.md", + *sorted((repo_root / "docs" / "operations").glob("*.md")), + ] + missing = { + f"{path.relative_to(repo_root)}: make {target}" + for path in doc_paths + for target in _documented_make_targets(path.read_text()) + if target not in target_names + } + + assert missing == set() diff --git a/tests/test_remote_cache.py b/tests/test_remote_cache.py new file mode 100644 index 0000000..4e1aa3e --- /dev/null +++ b/tests/test_remote_cache.py @@ -0,0 +1,71 @@ +"""Tests for remote cache command builders used by CLI workflows.""" + +from __future__ import annotations + +from thunder_forge.cluster.config import Node +from thunder_forge.cluster.remote_cache import ( + cache_hub_setup_command, + remote_artifact_download_command, + remote_cache_sync_command, + remote_transport_plan_probe_command, +) + + +def test_cache_hub_setup_command_uses_omlx_models_dir_env_fallback() -> None: + command = cache_hub_setup_command() + + assert 'CACHE_DIR="${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}"' in command + assert "mkdir -p" in command + + +def test_remote_artifact_download_command_runs_downloader_helper() -> None: + command = remote_artifact_download_command( + repo_id="mlx-community/Qwen3-1.7B-4bit", + model_dir_name="mlx-community/Qwen3-1.7B-4bit", + timeout=7200, + ) + + assert command.startswith("python3 -u - <<'PY'") + assert "base64.b64decode" in command + assert "/admin/api/hf/download" in command + + +def test_remote_artifact_download_command_keeps_progress_bucket_update_inside_print_branch() -> None: + command = remote_artifact_download_command( + repo_id="mlx-community/Qwen3-1.7B-4bit", + model_dir_name="mlx-community/Qwen3-1.7B-4bit", + timeout=7200, + ) + + assert ( + " if bucket != last_bucket:\n" + " print(f'download_progress: {status} {progress:.1f}%')\n" + " last_bucket = bucket\n" + " if status == 'completed':" + ) in command + + +def test_remote_cache_sync_command_builds_batchmode_rsync_plan() -> None: + node = Node(host="infer-01.lan", ram_gb=128, user="shag") + + source_path, destination, command = remote_cache_sync_command( + repo_id="BAAI/bge-small-en-v1.5", + runtime_node=node, + node_home_dir="/Users/shag", + transport_host="169.254.1.2", + ssh_host_key_alias="infer-01.lan", + ) + + assert source_path == "${TF_CACHE_OMLX_MODELS_DIR:-$HOME/.omlx/models}/BAAI/bge-small-en-v1.5/" + assert destination == "shag@169.254.1.2:/Users/shag/.omlx/models/BAAI/bge-small-en-v1.5/" + assert "BatchMode=yes" in command + assert "--exclude .cache/" in command + assert "HostKeyAlias=infer-01.lan" in command + + +def test_remote_transport_plan_probe_command_decodes_payload() -> None: + command = remote_transport_plan_probe_command(payload_b64="eyJmb28iOiJiYXIifQ==") + + assert command.startswith("python3 - <<'PY'") + assert "payload = json.loads(base64.b64decode('eyJmb28iOiJiYXIifQ==').decode())" in command + assert "__TF_TRANSPORT_PLAN__" in command diff --git a/tests/test_runtime_install.py b/tests/test_runtime_install.py index 6d48dc9..a4e8893 100644 --- a/tests/test_runtime_install.py +++ b/tests/test_runtime_install.py @@ -21,10 +21,10 @@ ) -def _make_runtime_node(home_dir="/Users/shag", port=8018, model_dir=None): +def _make_runtime_node(home_dir="/Users/shag", port=8018, model_dir=None, bind_host="0.0.0.0"): from thunder_forge.cluster.config import NodeRuntime - runtime = NodeRuntime(type=RuntimeType.OMLX, port=port, model_dir=model_dir) + runtime = NodeRuntime(type=RuntimeType.OMLX, port=port, model_dir=model_dir, bind_host=bind_host) return Node( host="infer-03.lan", fabric_host=False, @@ -392,6 +392,23 @@ def test_run_omlx_process_restart_dry_run_describes_rootless_commands() -> None: assert any("nohup" in c for c in result.commands) +def test_omlx_stop_patterns_follow_configured_bind_host() -> None: + node = _make_runtime_node(bind_host="127.0.0.1") + + launchd_result = run_omlx_runtime_restart(node, apply=False) + daemon_result = run_omlx_daemon_restart(node, apply=False) + process_result = run_omlx_process_restart(node, apply=False) + setup_script = generate_daemon_setup_script(node) + + assert any(r"--host 127\.0\.0\.1 --port 8018" in command for command in launchd_result.commands) + assert any(r"--host 127\.0\.0\.1 --port 8018" in command for command in daemon_result.commands) + assert any(r"--host 127\.0\.0\.1 --port 8018" in command for command in process_result.commands) + assert r"--host 127\.0\.0\.1 --port 8018" in setup_script + assert r"--host 0\.0\.0\.0 --port 8018" not in "\n".join( + [*launchd_result.commands, *daemon_result.commands, *process_result.commands, setup_script] + ) + + def test_run_omlx_process_restart_apply_records_pid_and_health(monkeypatch) -> None: node = _make_runtime_node() calls: list[str] = [] diff --git a/tests/test_services.py b/tests/test_services.py index 537deaa..cd7b072 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -351,6 +351,18 @@ def test_run_olla_service_restart_dry_run_describes_frontend_launch_agent(tmp_pa assert not result.applied +def test_olla_service_restart_uses_requested_user_home(tmp_path: Path, monkeypatch) -> None: + import thunder_forge.cluster.olla as olla_module + + monkeypatch.setattr(olla_module, "_service_home_for_user", lambda user: Path(f"/Users/{user}")) + + result = run_olla_service_restart(repo_root=tmp_path, apply=False, user="shag") + + assert "HOME" in result.plist_content + assert "/Users/shag" in result.plist_content + assert "/Users/shag/.local/bin" in result.plist_content + + def test_run_olla_service_restart_apply_writes_plist_then_starts(tmp_path: Path, monkeypatch) -> None: import thunder_forge.cluster.olla as olla_module @@ -440,6 +452,19 @@ def test_run_edge_service_restart_dry_run_describes_frontend_launch_daemon(tmp_p assert not result.applied +def test_edge_service_restart_uses_requested_user_home(tmp_path: Path, monkeypatch) -> None: + import thunder_forge.cluster.edge as edge_module + + monkeypatch.setattr(edge_module.platform, "system", lambda: "Darwin") + monkeypatch.setattr(edge_module, "_service_home_for_user", lambda user: Path(f"/Users/{user}")) + + result = run_edge_service_restart(repo_root=tmp_path, manager="daemon", apply=False, user="shag") + + assert "HOME" in result.plist_content + assert "/Users/shag" in result.plist_content + assert "/Users/shag/.local/bin" in result.plist_content + + def test_run_edge_service_restart_daemon_apply_reinstalls_every_time(tmp_path: Path, monkeypatch) -> None: import thunder_forge.cluster.edge as edge_module diff --git a/tests/test_ssh.py b/tests/test_ssh.py new file mode 100644 index 0000000..8939406 --- /dev/null +++ b/tests/test_ssh.py @@ -0,0 +1,147 @@ +"""Tests for shared SSH command construction.""" + +from __future__ import annotations + +import subprocess + +import thunder_forge.cluster.ssh as ssh_module +from thunder_forge.cluster.config import Node + + +def test_ssh_run_uses_strict_host_key_checking_by_default(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(ssh_module, "_is_local", lambda ip: False) + monkeypatch.setattr(ssh_module.subprocess, "run", fake_run) + monkeypatch.delenv("TF_SSH_STRICT_HOST_KEY_CHECKING", raising=False) + + result = ssh_module.ssh_run("shag", "infer-01.lan", "true") + + assert result.returncode == 0 + assert "-o" in calls[0] + assert "StrictHostKeyChecking=yes" in calls[0] + assert "StrictHostKeyChecking=no" not in calls[0] + + +def test_ssh_run_uses_batch_mode_for_remote_hosts(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(ssh_module, "_is_local", lambda ip: False) + monkeypatch.setattr(ssh_module.subprocess, "run", fake_run) + + ssh_module.ssh_run("shag", "infer-01.lan", "true") + + assert "BatchMode=yes" in calls[0] + + +def test_ssh_run_allows_host_key_policy_override(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(ssh_module, "_is_local", lambda ip: False) + monkeypatch.setattr(ssh_module.subprocess, "run", fake_run) + monkeypatch.setenv("TF_SSH_STRICT_HOST_KEY_CHECKING", "accept-new") + + ssh_module.ssh_run("shag", "infer-01.lan", "true") + + assert "StrictHostKeyChecking=accept-new" in calls[0] + + +def test_ssh_run_without_shell_probes_remote_shell(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(ssh_module, "_is_local", lambda ip: False) + monkeypatch.setattr(ssh_module.subprocess, "run", fake_run) + + ssh_module.ssh_run("shag", "infer-01.lan", "true") + + remote_command = calls[0][-1] + assert "command -v zsh" in remote_command + assert "exec zsh -lc true" in remote_command + assert "exec bash -lc true" in remote_command + assert "exec sh -lc true" in remote_command + + +def test_scp_content_quotes_remote_path(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(ssh_module, "_is_local", lambda ip: False) + monkeypatch.setattr(ssh_module.subprocess, "run", fake_run) + + ssh_module.scp_content("shag", "infer-01.lan", "payload", "/tmp/path with spaces/setup.sh") + + assert calls[0][-1] == "cat > '/tmp/path with spaces/setup.sh'" + + +def test_resolve_remote_node_facts_populates_shell_home_and_platform(monkeypatch) -> None: + calls: list[tuple[str, str, str]] = [] + + def fake_ssh_run(user, ip, cmd, **kwargs): + calls.append((user, ip, cmd)) + return subprocess.CompletedProcess( + args=["ssh"], + returncode=0, + stdout='{"platform":"Darwin","shell":"zsh","home_dir":"/Users/shag","homebrew_prefix":"/opt/homebrew"}\n', + stderr="", + ) + + node = Node(host="infer-01.lan", ram_gb=128, user="shag") + monkeypatch.setattr(ssh_module, "ssh_run", fake_ssh_run) + + result = ssh_module.resolve_remote_node_facts(node) + + assert result.platform == "Darwin" + assert result.shell == "zsh" + assert result.home_dir == "/Users/shag" + assert result.homebrew_prefix == "/opt/homebrew" + assert node.platform == "Darwin" + assert node.shell == "zsh" + assert node.home_dir == "/Users/shag" + assert node.homebrew_prefix == "/opt/homebrew" + assert calls == [("shag", "infer-01.lan", ssh_module.REMOTE_NODE_FACTS_COMMAND)] + + +def test_resolve_remote_node_facts_leaves_existing_configured_values(monkeypatch) -> None: + def fake_ssh_run(user, ip, cmd, **kwargs): + return subprocess.CompletedProcess( + args=["ssh"], + returncode=0, + stdout='{"platform":"Linux","shell":"bash","home_dir":"/home/shag","homebrew_prefix":""}\n', + stderr="", + ) + + node = Node( + host="rock.lan", + ram_gb=64, + user="serpo", + shell="/bin/zsh", + home_dir="/srv/serpo", + ) + monkeypatch.setattr(ssh_module, "ssh_run", fake_ssh_run) + + result = ssh_module.resolve_remote_node_facts(node) + + assert result.platform == "Linux" + assert result.shell == "/bin/zsh" + assert result.home_dir == "/srv/serpo" + assert node.shell == "/bin/zsh" + assert node.home_dir == "/srv/serpo" diff --git a/uv.lock b/uv.lock index 9fb1c7d..3af860c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,13 @@ version = 1 -revision = 3 requires-python = ">=3.12" [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, ] [[package]] @@ -19,212 +18,36 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, - { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, - { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, - { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, - { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, - { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, - { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, - { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, - { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, - { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] [[package]] name = "certifi" version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422 } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "48.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] @@ -235,9 +58,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[package]] @@ -250,36 +73,27 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [[package]] name = "idna" -version = "3.17" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "invoke" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] @@ -289,162 +103,45 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "paramiko" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bcrypt" }, - { name = "cryptography" }, - { name = "invoke" }, - { name = "pynacl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "psycopg" -version = "3.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, -] - -[package.optional-dependencies] -binary = [ - { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, -] - -[[package]] -name = "psycopg-binary" -version = "3.3.4" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, - { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, - { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, - { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, - { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, - { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, - { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, - { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, - { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, - { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, - { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, - { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, - { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, - { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, - { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, - { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pynacl" -version = "1.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, - { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, - { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, - { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, - { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, - { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, - { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, - { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] [[package]] @@ -458,64 +155,64 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] [[package]] @@ -526,43 +223,43 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, ] [[package]] name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025 }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550 }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345 }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217 }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035 }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291 }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922 }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186 }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541 }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014 }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467 }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910 }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190 }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014 }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541 }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403 }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555 }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -578,61 +275,44 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "bcrypt" }, - { name = "httpx" }, - { name = "paramiko" }, - { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.27" }, - { name = "python-dotenv", specifier = ">=1.0" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "python-dotenv", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "typer", specifier = ">=0.15" }, + { name = "typer", specifier = ">=0.26" }, ] [package.metadata.requires-dev] dev = [ - { name = "bcrypt", specifier = ">=4.2" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "paramiko", specifier = ">=5.0.0" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "ruff", specifier = ">=0.11" }, + { name = "pytest", specifier = ">=9.0" }, + { name = "ruff", specifier = ">=0.15" }, ] [[package]] name = "typer" -version = "0.26.5" +version = "0.26.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/1a/2cf40b65b1d9c254fe5814bb0519f9b8f2ac38059df0810f9b866300c04a/typer-0.26.5.tar.gz", hash = "sha256:9b9b39e35c3afc9e1e51a06f21155246e457c0911279b09b35d8210ca74b935c", size = 201494, upload-time = "2026-06-01T14:42:49.744Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/d6/baac76fc04a6532883de3d8722c7f921dae94d10965e7ffba9e38e42a251/typer-0.26.5-py3-none-any.whl", hash = "sha256:4bfd901d564e41608920134aa5d4481200f4ba76d98e982d9f9d32dcb7b84da0", size = 122451, upload-time = "2026-06-01T14:42:51.021Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456 }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "tzdata" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ]