diff --git a/.github/workflows/release-kubectl-us-net.yaml b/.github/workflows/release-kubectl-us-net.yaml new file mode 100644 index 000000000..f924ca2bd --- /dev/null +++ b/.github/workflows/release-kubectl-us-net.yaml @@ -0,0 +1,45 @@ +name: Releases +on: + push: + tags: + - "kubectl-us-net/v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + packages: write + id-token: write + issues: write + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + packages: write + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version-file: python/kubectl-us-net/pyproject.toml + + - name: Build wheel + working-directory: python/kubectl-us-net + run: uv build --wheel + + - name: Upload release artifacts + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + make_latest: false + files: | + python/kubectl-us-net/dist/*.whl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/operator-guide/kubectl-us-net.md b/docs/operator-guide/kubectl-us-net.md new file mode 100644 index 000000000..c16b7f1ad --- /dev/null +++ b/docs/operator-guide/kubectl-us-net.md @@ -0,0 +1,208 @@ +# kubectl-us-net + +`kubectl-us-net` is a `kubectl` plugin for troubleshooting UnderStack's +Neutron/OVN data plane. It wraps `kubectl exec` into the OVN NB/SB pods +(and, for `ovs-vsctl`/`ovs-appctl`, whichever pod is running on a given +node) alongside OpenStack API calls, so you don't have to remember pod +names, container names, or the `neutron-` naming convention OVN uses +for objects it syncs from Neutron. + +It's a companion to the manual debugging steps in +[OVN / Open vSwitch](ovs-ovn.md) -- for example, the walkthrough in +[Verifying a router port is bound to an HA_Chassis_Group](ovs-ovn.md#verifying-a-router-port-is-bound-to-an-ha_chassis_group) +is exactly what `router show` (below) automates in one command. + +This is intentionally a plain Python CLI for now (no compiled binary, no +krew packaging) so the command surface and output can be iterated on +quickly. Distributing it via krew is planned once the behavior settles. + +## Installation + +```bash +cd python/kubectl-us-net +uv sync +export PATH="$PWD/.venv/bin:$PATH" # so `kubectl us-net ...` finds it +``` + +## Prerequisites + +- `kubectl`, pointed at the target cluster (via `--context` or your current + context) +- OpenStack credentials available on your machine -- `OS_CLOUD` env var / + `clouds.yaml`, or pass `--os-cloud` explicitly + +This tool doesn't manage either of those; it only reads them. + +## Global options + +| Option | Default | Description | +|---|---|---| +| `--context` | current kubectl context | kubectl context to target | +| `--namespace`, `-n` | `openstack` | Namespace hosting the OVN NB/SB pods | +| `--nb-pod` | `ovn-ovsdb-nb-0` | Northbound OVSDB pod name | +| `--sb-pod` | `ovn-ovsdb-sb-0` | Southbound OVSDB pod name | +| `--os-cloud` | `OS_CLOUD` env / clouds.yaml default | OpenStack cloud name | + +Every command starts by printing a banner showing the kube context, OVN +namespace/pod names, and (for OpenStack-backed commands) the OpenStack cloud +target, so it's always clear what you're actually talking to. + +## Commands + +### Raw passthrough + +```bash +kubectl us-net nbctl -- show +kubectl us-net sbctl -- list Chassis +kubectl us-net vsctl --node -- show +kubectl us-net appctl --node -- version +``` + +Thin wrappers around `ovn-nbctl`, `ovn-sbctl`, `ovs-vsctl`, and +`ovs-appctl` that resolve the right pod for you and stream native output +straight through -- use these for anything not covered by a higher-level +command below. + +`vsctl`/`appctl` resolve the target pod by node name. On UnderStack's OVN +deployment, OVS is co-located inside the `ovn-controller` DaemonSet pod (no +separate `openvswitch` pod), so both commands default to the +`ovn-controller` name-prefix. If your cluster's pod naming differs, pass +`--pod ` (and, if needed, `--container `) to target it +directly, or `--target ` on `appctl` to change the discovery +prefix. + +### router list + +```bash +kubectl us-net router list +``` + +A table of every router seen in OpenStack and/or OVN, so you can spot +mismatches (present on only one side) before drilling into one with +`router show`. Flavored routers (e.g. VRF) are handled by a different L3 +backend and never get an OVN `Logical_Router`, so they're marked +`n/a (flavored)` in the OVN column rather than a false "NO". + +```console +$ kubectl us-net router list +NAME ID OPENSTACK OVN +--------------------------------------------------------------------- +tenant-router 769be712-d084-4846-bd21-a85f6494f3b6 yes yes +patch-router 94c6e0ee-3959-49e2-a9f2-e5f3a304bb77 yes yes +vrf-router df9746b3-d1da-484c-bf4c-86248279dddb yes n/a (flavored) +vrf-router-2 8f3492e8-d4d9-4f27-817c-b53a19b91f67 yes n/a (flavored) +``` + +### router show + +```bash +kubectl us-net router show +kubectl us-net router show --flows # also dump SB logical flows +``` + +Resolves the router in OpenStack, maps it to its OVN `Logical_Router` +(`neutron-`), and prints: + +- **Router ports** -- each `Logical_Router_Port` (gateway vs. internal), its + Neutron port ID, OVN-side networks and Neutron-side fixed IPs, the VLAN + tag(s) of its network's localnet/uplink port(s), and its chassis binding: + - a linked `HA_Chassis_Group`, with each chassis's liveness and physical + networks (via `ovn-bridge-mappings`), highest priority first; + - or, for a VLAN/FLAT distributed gateway, its `Gateway_Chassis` binding + (OVN's own L3-scheduler mechanism, distinct from `ha_chassis_group`); + - or, for a centralized router, the chassis it's pinned to via + `options:chassis`; + - and only flags a port as "likely bug" when *none* of the above apply -- + the exact bug class `scripts/cleanup_dead_ovn_ha_chassis.py` repairs. +- **NAT rules** -- each rule's type/external IP/logical IP, and the + OpenStack port it resolves to (if any). +- **Ports** -- for each NAT-resolved port: its fixed IPs, owner (server name + for compute-owned ports), a cross-check against its OVN `Logical_Switch_Port` + (type/up/addresses), and that port's own `HA_Chassis_Group` binding (the + per-network unified HCG referenced by external/baremetal ports, distinct + from the router-port-level HCG shown above). +- Optionally, southbound logical flows (`ovn-sbctl lflow-list`), with + `--flows`. + +!!! note + As of this writing, every router in UnderStack is **centralized** -- + there are no VLAN/FLAT distributed-gateway routers deployed, so the + `Gateway_Chassis` path above is exercised only by unit tests, not live + traffic. A router port should show up as **"Pinned chassis"** + (`options:chassis`), not a router-port-level `HA_Chassis_Group`. The + per-network `HA_Chassis_Group` is expected on **external ports bound to + physical baremetal servers** instead (the `Ports` section below) -- that + is a different, per-network HCG used to steer traffic to the chassis + wired to that server's physical network, and is unrelated to router + centralization. If a router port ever shows a linked HCG of its own, or + a baremetal external port has none, treat it as a red flag worth + investigating with `router show`'s "likely bug" diagnostics. + +Example output (IPs and hostnames below are illustrative, not real): + +```console +$ kubectl us-net --context my-cluster-dev --os-cloud example-cloud router show patch-router +================================================================ +kubectl-us-net -- target environment +================================================================ + Kubernetes context : my-cluster-dev + OVN namespace/pods : openstack (nb=ovn-ovsdb-nb-0, sb=ovn-ovsdb-sb-0) + OpenStack cloud : example-cloud + auth URL : https://keystone.dev.undercloud.example.com/v3 + project : baremetal +================================================================ + + +Router patch-router (94c6e0ee-3959-49e2-a9f2-e5f3a304bb77) +OVN Logical_Router: neutron-94c6e0ee-3959-49e2-a9f2-e5f3a304bb77 +Type: centralized (options:chassis=a2172b59-5558-4018-858d-88947e2d9adf) + +Router ports: + - lrp-6aeee225-e410-4a7a-891d-edc2a3f3831f [gateway] + Neutron port : 6aeee225-e410-4a7a-891d-edc2a3f3831f + OVN networks : 203.0.113.41/26 + Neutron fixed IPs : 203.0.113.41 + Network VLAN tag : 1804, 1800 + Pinned chassis : a2172b59-5558-4018-858d-88947e2d9adf (alive, physnets=physnet1) + - lrp-c0a41495-4cb5-4913-a7e7-9a2047f79e7d [internal] + Neutron port : c0a41495-4cb5-4913-a7e7-9a2047f79e7d + OVN networks : 192.0.2.1/24 + Neutron fixed IPs : 192.0.2.1 + Network VLAN tag : 1802 + Pinned chassis : a2172b59-5558-4018-858d-88947e2d9adf (alive, physnets=physnet1) + +NAT rules: + dnat_and_snat external=203.0.113.19 logical=192.0.2.156 -> port d0075095-6232-43a6-b0c0-7fd7a07716a3 + snat external=203.0.113.41 logical=192.0.2.0/24 + +Ports: + d0075095-6232-43a6-b0c0-7fd7a07716a3 ((unnamed)) + Fixed IPs : 192.0.2.156 + Owner : server 14d4e13f-fef5-4676-a8e2-a5bdf63c2b6f (web-server-01) + OVN LSP : type=external, up, addresses=d4:04:e6:4f:7e:cc 192.0.2.156 + HA_Chassis_Group : neutron-5af67e57-7052-49fc-9bad-253275b39986 -> a2172b59-5558-4018-858d-88947e2d9adf (priority=32767, alive, physnets=physnet1) +``` + +Here the router port shows a **pinned chassis** (expected for a centralized +router), while the baremetal server's external port shows its own +**`HA_Chassis_Group`** -- exactly the split described in the note above. + +## Development + +Each command is a self-contained module under `us_net/commands/` that +registers into the top-level `typer` app (`us_net/cli.py`). Low-level OVSDB +access lives in `us_net/ovn.py` (JSON unwrap ported from +`scripts/cleanup_dead_ovn_ha_chassis.py`), `kubectl exec` plumbing in +`us_net/kube.py`, and OpenStack SDK connection setup in +`us_net/osclient.py`. Run tests and linting from `python/kubectl-us-net/`: + +```bash +uv run pytest +uv run ruff check +uv run ruff format +``` + +## Contributing + +If you find any issues or have suggestions for improvements, please open an +issue on the [GitHub repository](https://github.com/rackerlabs/understack). diff --git a/properdocs.yml b/properdocs.yml index 23b373a76..80a6fa711 100644 --- a/properdocs.yml +++ b/properdocs.yml @@ -232,6 +232,7 @@ nav: - 'Networking': - operator-guide/openstack-neutron.md - operator-guide/ovs-ovn.md + - operator-guide/kubectl-us-net.md - 'Infrastructure': - operator-guide/argocd-helm-chart.md - operator-guide/workflows.md diff --git a/python/kubectl-us-net/README.md b/python/kubectl-us-net/README.md new file mode 100644 index 000000000..5bc508b87 --- /dev/null +++ b/python/kubectl-us-net/README.md @@ -0,0 +1,95 @@ +# kubectl-us-net + +A `kubectl` plugin for troubleshooting UnderStack's Neutron/OVN data plane. +Wraps `kubectl exec` into the OVN NB/SB pods (and, for `ovs-vsctl`/`ovs-appctl`, +whichever pod is running on a given node) alongside OpenStack API calls, so +you don't have to remember pod names, container names, or the +`neutron-` naming convention OVN uses for objects it syncs from +Neutron. + +This is intentionally a plain Python CLI for now (no compiled binary, no +krew packaging) so the command surface and output can be iterated on +quickly. Distributing it via krew is planned once the behavior settles. + +## Setup + +``` +cd python/kubectl-us-net +uv sync +export PATH="$PWD/.venv/bin:$PATH" # so `kubectl us-net ...` finds it +``` + +Requires `kubectl` (pointed at the target cluster) and OpenStack credentials +(`OS_CLOUD` env var / `clouds.yaml`, or pass `--os-cloud` explicitly) already +available on your machine -- this tool doesn't manage either. + +## Usage + +Every command starts by printing a banner showing the kube context, OVN +namespace/pod names, and (for OpenStack-backed commands) the OpenStack cloud +target -- so it's always clear what you're actually talking to. + +### Raw passthrough + +``` +kubectl us-net nbctl -- show +kubectl us-net sbctl -- list Chassis +kubectl us-net vsctl --node -- show +kubectl us-net appctl --node -- version +``` + +`vsctl`/`appctl` resolve the target pod by node name. On UnderStack's OVN +deployment, OVS is co-located inside the `ovn-controller` DaemonSet pod (no +separate `openvswitch` pod), so both commands default to the +`ovn-controller` name-prefix. If your cluster's pod naming differs, pass +`--pod ` (and, if needed, `--container `) to target it directly, +or `--target ` on `appctl` to change the discovery prefix. + +### `router list` + +``` +kubectl us-net router list +``` + +A table of every router seen in OpenStack and/or OVN, so you can spot +mismatches (present on only one side) before drilling into one with +`router show`. Flavored routers (e.g. VRF) are handled by a different L3 +backend and never get an OVN `Logical_Router`, so they're marked +`n/a (flavored)` in the OVN column rather than a false "NO". + +### `router show` + +``` +kubectl us-net router show +kubectl us-net router show --flows # also dump SB logical flows +``` + +Resolves the router in OpenStack, maps it to its OVN `Logical_Router` +(`neutron-`), and prints: + +- **Router ports** -- each `Logical_Router_Port` (gateway vs. internal), its + Neutron port ID, OVN-side networks and Neutron-side fixed IPs, the VLAN + tag(s) of its network's localnet/uplink port(s), and its chassis binding: + - a linked `HA_Chassis_Group`, with each chassis's liveness and physical + networks (via `ovn-bridge-mappings`), highest priority first; + - or, for a VLAN/FLAT distributed gateway, its `Gateway_Chassis` binding + (OVN's own L3-scheduler mechanism, distinct from `ha_chassis_group`); + - or, for a centralized router, the chassis it's pinned to via + `options:chassis`; + - and only flags a port as "likely bug" when *none* of the above apply -- + the exact bug class `scripts/cleanup_dead_ovn_ha_chassis.py` repairs. +- **NAT rules** -- each rule's type/external IP/logical IP, and the + OpenStack port it resolves to (if any). +- **Ports** -- for each NAT-resolved port: its fixed IPs, owner (server name + for compute-owned ports), a cross-check against its OVN `Logical_Switch_Port` + (type/up/addresses), and that port's own `HA_Chassis_Group` binding (the + per-network unified HCG referenced by external/baremetal ports, distinct + from the router-port-level HCG shown above). +- Optionally, southbound logical flows (`ovn-sbctl lflow-list`). + +## Global options + +- `--context` -- kubectl context (default: current context) +- `--namespace` / `-n` -- namespace hosting the OVN NB/SB pods (default: `openstack`) +- `--nb-pod` / `--sb-pod` -- NB/SB pod names (default: `ovn-ovsdb-nb-0` / `ovn-ovsdb-sb-0`) +- `--os-cloud` -- OpenStack cloud name (default: `OS_CLOUD` env / clouds.yaml default) diff --git a/python/kubectl-us-net/pyproject.toml b/python/kubectl-us-net/pyproject.toml new file mode 100644 index 000000000..41f0ef1ec --- /dev/null +++ b/python/kubectl-us-net/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "kubectl-us-net" +description = "kubectl plugin for troubleshooting UnderStack's Neutron/OVN data plane" +authors = [{ name = "Understack Developers" }] +requires-python = ">=3.12" +readme = "README.md" +dependencies = [ + "openstacksdk>=4.2.0,<5", + "keystoneauth-websso>=0.2.0,<0.3", + "typer>=0.21,<0.22", +] +license = { text = "Apache-2.0" } +dynamic = ["version"] + +[project.scripts] +kubectl-us-net = "us_net.cli:app" + +[project.urls] +Homepage = "https://github.com/rackerlabs/understack" +Issues = "https://github.com/rackerlabs/understack/issues" + +[dependency-groups] +test = [ + "pytest<10", + "pytest-github-actions-annotate-failures", + "pytest-cov>=6.1.0", +] +[tool.uv] +default-groups = ["test"] + +[tool.hatch.build.targets.sdist] +include = ["us_net"] + +[tool.hatch.build.targets.wheel] +include = ["us_net"] + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.version.raw-options] +root = "../../" +tag_regex = "^kubectl-us-net/v(?P.*)$" + +[tool.ruff] +target-version = "py312" +fix = true + +[tool.ruff.lint] +select = [ + "D", # pydocstyle + "E", # pycodestyle (error) + "F", # pyflakes + "B", # flake8-bugbear + "I", # isort + "S", # flake8-bandit + "UP", # pyupgrade + "ASYNC", # flake8-async +] + +ignore = [ + "D100", # don't require docs for every module + "D101", # don't require docs for every class + "D102", # don't require docs for every class method + "D103", # don't require docs for every function + "D104", # don't require docs for every package + "D106", # don't require docs for every nested class + "D107", # don't require docs for __init__ + "D417", # don't require docs for every function parameter + "S404", # subprocess module import is intentional here (kubectl/openstack wrapper) + "S603", # subprocess calls use internally-built argv lists, never shell=True + "S607", # kubectl/openstack/ovn-nbctl etc. are intentionally resolved via PATH +] + +[tool.ruff.lint.isort] +force-single-line = true + +[tool.ruff.lint.pydocstyle] +# enable the google doc style rules by default +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] # assert is the point in tests diff --git a/python/kubectl-us-net/tests/__init__.py b/python/kubectl-us-net/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/kubectl-us-net/tests/test_cli.py b/python/kubectl-us-net/tests/test_cli.py new file mode 100644 index 000000000..4ce9d5ce9 --- /dev/null +++ b/python/kubectl-us-net/tests/test_cli.py @@ -0,0 +1,60 @@ +from typer.testing import CliRunner + +from us_net.cli import app + +runner = CliRunner() + + +def test_help_lists_all_commands(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + for command in ("nbctl", "sbctl", "vsctl", "appctl", "router"): + assert command in result.output + + +def test_bare_invocation_shows_usage_instead_of_missing_command_error(): + result = runner.invoke(app, []) + assert "Usage:" in result.output + for command in ("nbctl", "sbctl", "vsctl", "appctl", "router"): + assert command in result.output + assert "Missing command" not in result.output + + +def test_bare_router_shows_usage_instead_of_missing_command_error(): + result = runner.invoke(app, ["router"]) + assert "Usage:" in result.output + assert "show" in result.output + assert "Missing command" not in result.output + + +def test_callback_builds_connection_context(monkeypatch): + import us_net.commands.raw as raw + + captured = {} + monkeypatch.setattr( + raw.kube, + "stream_exec_in_pod", + lambda ctx, pod, container, argv: captured.setdefault("ctx", ctx) and 0, + ) + result = runner.invoke( + app, + [ + "--context", + "my-ctx", + "--namespace", + "my-ns", + "--nb-pod", + "my-nb-pod", + "--os-cloud", + "my-cloud", + "nbctl", + "--", + "show", + ], + ) + assert result.exit_code == 0 + ctx = captured["ctx"] + assert ctx.kube_context == "my-ctx" + assert ctx.namespace == "my-ns" + assert ctx.nb_pod == "my-nb-pod" + assert ctx.os_cloud == "my-cloud" diff --git a/python/kubectl-us-net/tests/test_connection.py b/python/kubectl-us-net/tests/test_connection.py new file mode 100644 index 000000000..fe84c5393 --- /dev/null +++ b/python/kubectl-us-net/tests/test_connection.py @@ -0,0 +1,80 @@ +import subprocess + +from us_net import connection +from us_net.connection import ConnectionContext +from us_net.connection import print_connection_banner +from us_net.connection import resolve_kube_context + + +def make_ctx(**overrides): + defaults = dict( + kube_context=None, + namespace="openstack", + nb_pod="ovn-ovsdb-nb-0", + sb_pod="ovn-ovsdb-sb-0", + os_cloud=None, + ) + defaults.update(overrides) + return ConnectionContext(**defaults) + + +def test_kubectl_base_without_context(): + assert make_ctx().kubectl_base() == ["kubectl"] + + +def test_kubectl_base_with_context(): + ctx = make_ctx(kube_context="my-ctx") + assert ctx.kubectl_base() == ["kubectl", "--context", "my-ctx"] + + +def test_resolve_kube_context_prefers_explicit_value(): + assert resolve_kube_context("explicit") == "explicit" + + +def test_resolve_kube_context_falls_back_to_kubectl(monkeypatch): + monkeypatch.setattr( + connection.subprocess, + "run", + lambda cmd, **kwargs: subprocess.CompletedProcess( + cmd, 0, stdout="my-context\n", stderr="" + ), + ) + assert resolve_kube_context(None) == "my-context" + + +def test_print_connection_banner_without_openstack(monkeypatch, capsys): + monkeypatch.setattr( + connection, "resolve_kube_context", lambda kube_context: "ctx-1" + ) + print_connection_banner(make_ctx(), include_openstack=False) + out = capsys.readouterr().out + assert "Kubernetes context : ctx-1" in out + assert "OpenStack cloud" not in out + + +def test_print_connection_banner_with_openstack(monkeypatch, capsys): + monkeypatch.setattr( + connection, "resolve_kube_context", lambda kube_context: "ctx-1" + ) + monkeypatch.setattr( + connection.osclient, + "describe_target", + lambda os_cloud: [("auth URL", "https://example")], + ) + ctx = make_ctx(os_cloud="dev-cloud") + print_connection_banner(ctx, include_openstack=True) + out = capsys.readouterr().out + assert "OpenStack cloud : dev-cloud" in out + assert "auth URL" in out + + +def test_print_connection_banner_shows_default_label_when_cloud_unset( + monkeypatch, capsys +): + monkeypatch.setattr( + connection, "resolve_kube_context", lambda kube_context: "ctx-1" + ) + monkeypatch.setattr(connection.osclient, "describe_target", lambda os_cloud: []) + print_connection_banner(make_ctx(), include_openstack=True) + out = capsys.readouterr().out + assert "(default via OS_CLOUD / clouds.yaml)" in out diff --git a/python/kubectl-us-net/tests/test_kube.py b/python/kubectl-us-net/tests/test_kube.py new file mode 100644 index 000000000..612c97fce --- /dev/null +++ b/python/kubectl-us-net/tests/test_kube.py @@ -0,0 +1,141 @@ +import json +import subprocess + +import pytest + +from us_net import kube +from us_net.connection import ConnectionContext + + +def make_ctx(**overrides): + defaults = dict( + kube_context=None, + namespace="openstack", + nb_pod="ovn-ovsdb-nb-0", + sb_pod="ovn-ovsdb-sb-0", + os_cloud=None, + ) + defaults.update(overrides) + return ConnectionContext(**defaults) + + +def test_exec_argv_without_context_or_container(): + argv = kube._exec_argv(make_ctx(), "mypod", None, ["ovn-nbctl", "show"]) + assert argv == [ + "kubectl", + "exec", + "-n", + "openstack", + "mypod", + "--", + "ovn-nbctl", + "show", + ] + + +def test_exec_argv_with_context_and_container(): + ctx = make_ctx(kube_context="my-ctx") + argv = kube._exec_argv(ctx, "mypod", "ovsdb", ["ovn-nbctl", "show"]) + assert argv == [ + "kubectl", + "--context", + "my-ctx", + "exec", + "-n", + "openstack", + "mypod", + "-c", + "ovsdb", + "--", + "ovn-nbctl", + "show", + ] + + +def test_exec_in_pod_runs_subprocess_and_captures_output(monkeypatch): + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + monkeypatch.setattr(kube.subprocess, "run", fake_run) + result = kube.exec_in_pod(make_ctx(), "mypod", "ovsdb", ["ovn-nbctl", "show"]) + assert result.stdout == "ok" + assert captured["kwargs"] == {"capture_output": True, "text": True} + assert captured["cmd"][-2:] == ["ovn-nbctl", "show"] + + +def test_stream_exec_in_pod_returns_returncode(monkeypatch): + monkeypatch.setattr( + kube.subprocess, "run", lambda cmd: subprocess.CompletedProcess(cmd, 3) + ) + assert kube.stream_exec_in_pod(make_ctx(), "mypod", None, ["ovn-nbctl"]) == 3 + + +def test_pods_on_node_filters_by_node_and_prefix(monkeypatch): + payload = { + "items": [ + { + "metadata": {"name": "ovn-controller-default-abc"}, + "spec": {"nodeName": "node-1"}, + }, + { + "metadata": {"name": "ovn-controller-default-xyz"}, + "spec": {"nodeName": "node-2"}, + }, + { + "metadata": {"name": "neutron-server-abc"}, + "spec": {"nodeName": "node-1"}, + }, + ] + } + monkeypatch.setattr( + kube.subprocess, + "run", + lambda cmd, **kwargs: subprocess.CompletedProcess( + cmd, 0, stdout=json.dumps(payload), stderr="" + ), + ) + pods = kube.pods_on_node(make_ctx(), "node-1", "ovn-controller") + assert pods == ["ovn-controller-default-abc"] + + +def test_pods_on_node_exits_on_kubectl_failure(monkeypatch): + monkeypatch.setattr( + kube.subprocess, + "run", + lambda cmd, **kwargs: subprocess.CompletedProcess( + cmd, 1, stdout="", stderr="boom" + ), + ) + with pytest.raises(SystemExit): + kube.pods_on_node(make_ctx(), "node-1", "ovn-controller") + + +def test_resolve_node_pod_returns_explicit_pod_without_lookup(monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("should not call pods_on_node when --pod is given") + + monkeypatch.setattr(kube, "pods_on_node", fail) + pod = kube.resolve_node_pod(make_ctx(), "node-1", "ovn-controller", "explicit-pod") + assert pod == "explicit-pod" + + +def test_resolve_node_pod_returns_single_candidate(monkeypatch): + monkeypatch.setattr(kube, "pods_on_node", lambda ctx, node, prefix: ["only-one"]) + pod = kube.resolve_node_pod(make_ctx(), "node-1", "ovn-controller", None) + assert pod == "only-one" + + +def test_resolve_node_pod_exits_on_no_candidates(monkeypatch): + monkeypatch.setattr(kube, "pods_on_node", lambda ctx, node, prefix: []) + with pytest.raises(SystemExit): + kube.resolve_node_pod(make_ctx(), "node-1", "ovn-controller", None) + + +def test_resolve_node_pod_exits_on_multiple_candidates(monkeypatch): + monkeypatch.setattr(kube, "pods_on_node", lambda ctx, node, prefix: ["a", "b"]) + with pytest.raises(SystemExit): + kube.resolve_node_pod(make_ctx(), "node-1", "ovn-controller", None) diff --git a/python/kubectl-us-net/tests/test_osclient.py b/python/kubectl-us-net/tests/test_osclient.py new file mode 100644 index 000000000..196166688 --- /dev/null +++ b/python/kubectl-us-net/tests/test_osclient.py @@ -0,0 +1,74 @@ +import pytest + +from us_net import osclient + + +class FakeConfig: + def __init__(self, data): + self.config = data + + +class FakeConnection: + def __init__(self, data): + self.config = FakeConfig(data) + + +def test_describe_target_returns_auth_details(monkeypatch): + fake_conn = FakeConnection( + { + "auth": { + "auth_url": "https://example/v3", + "project_name": "baremetal", + "username": "doug", + }, + "region_name": "dfw3", + } + ) + monkeypatch.setattr(osclient, "get_connection", lambda os_cloud: fake_conn) + pairs = dict(osclient.describe_target("dev-cloud")) + assert pairs["auth URL"] == "https://example/v3" + assert pairs["region"] == "dfw3" + assert pairs["project"] == "baremetal" + assert pairs["username"] == "doug" + + +def test_describe_target_handles_connect_failure(monkeypatch): + def boom(os_cloud): + raise RuntimeError("no cloud configured") + + monkeypatch.setattr(osclient, "get_connection", boom) + pairs = dict(osclient.describe_target("dev-cloud")) + assert "unavailable" in pairs["status"] + + +def test_describe_target_handles_empty_auth(monkeypatch): + monkeypatch.setattr(osclient, "get_connection", lambda os_cloud: FakeConnection({})) + pairs = dict(osclient.describe_target("dev-cloud")) + assert "no auth details" in pairs["status"] + + +def test_resolve_router_found(): + class FakeRouter: + id = "abc" + + class FakeNetwork: + def find_router(self, name_or_id): + return FakeRouter() + + class FakeConn: + network = FakeNetwork() + + router = osclient.resolve_router(FakeConn(), "my-router") + assert router.id == "abc" + + +def test_resolve_router_not_found_raises(): + class FakeNetwork: + def find_router(self, name_or_id): + return None + + class FakeConn: + network = FakeNetwork() + + with pytest.raises(LookupError): + osclient.resolve_router(FakeConn(), "missing-router") diff --git a/python/kubectl-us-net/tests/test_ovn.py b/python/kubectl-us-net/tests/test_ovn.py new file mode 100644 index 000000000..2f754fe00 --- /dev/null +++ b/python/kubectl-us-net/tests/test_ovn.py @@ -0,0 +1,28 @@ +from us_net.ovn import as_list +from us_net.ovn import parse_ovn_json + + +def test_parse_ovn_json_unwraps_uuid_and_set(): + raw = ( + '{"headings": ["_uuid", "ports"], ' + '"data": [[["uuid", "abc-123"], ' + '["set", [["uuid", "p1"], ["uuid", "p2"]]]]]}' + ) + assert parse_ovn_json(raw) == [{"_uuid": "abc-123", "ports": ["p1", "p2"]}] + + +def test_parse_ovn_json_unwraps_map(): + raw = '{"headings": ["options"], "data": [[["map", [["chassis", "abc"]]]]]}' + assert parse_ovn_json(raw) == [{"options": {"chassis": "abc"}}] + + +def test_parse_ovn_json_empty_set_stays_a_list(): + raw = '{"headings": ["ports"], "data": [[["set", []]]]}' + assert parse_ovn_json(raw) == [{"ports": []}] + + +def test_as_list_normalizes_single_value_and_empty(): + assert as_list(None) == [] + assert as_list("") == [] + assert as_list("solo") == ["solo"] + assert as_list(["a", "b"]) == ["a", "b"] diff --git a/python/kubectl-us-net/tests/test_raw.py b/python/kubectl-us-net/tests/test_raw.py new file mode 100644 index 000000000..6ac755726 --- /dev/null +++ b/python/kubectl-us-net/tests/test_raw.py @@ -0,0 +1,153 @@ +import typer +from typer.testing import CliRunner + +from us_net.commands import raw +from us_net.connection import ConnectionContext + +runner = CliRunner() + + +def make_app(): + app = typer.Typer() + + @app.callback() + def main(ctx: typer.Context) -> None: + ctx.obj = ConnectionContext( + kube_context=None, + namespace="openstack", + nb_pod="ovn-ovsdb-nb-0", + sb_pod="ovn-ovsdb-sb-0", + os_cloud=None, + ) + + raw.register(app) + return app + + +def test_nbctl_execs_into_nb_pod_via_ovsdb_container(monkeypatch): + calls = [] + + def fake_stream_exec_in_pod(ctx, pod, container, argv): + calls.append((pod, container, argv)) + return 0 + + monkeypatch.setattr(raw.kube, "stream_exec_in_pod", fake_stream_exec_in_pod) + result = runner.invoke(make_app(), ["nbctl", "--", "show"]) + assert result.exit_code == 0 + assert calls == [("ovn-ovsdb-nb-0", "ovsdb", ["ovn-nbctl", "show"])] + + +def test_nbctl_passes_through_flag_like_args_untouched(monkeypatch): + calls = [] + monkeypatch.setattr( + raw.kube, + "stream_exec_in_pod", + lambda ctx, pod, container, argv: calls.append(argv) or 0, + ) + result = runner.invoke( + make_app(), ["nbctl", "--", "--format=json", "list", "Chassis"] + ) + assert result.exit_code == 0 + assert calls == [["ovn-nbctl", "--format=json", "list", "Chassis"]] + + +def test_nbctl_propagates_nonzero_exit_code(monkeypatch): + monkeypatch.setattr(raw.kube, "stream_exec_in_pod", lambda *a, **k: 7) + result = runner.invoke(make_app(), ["nbctl", "--", "show"]) + assert result.exit_code == 7 + + +def test_sbctl_execs_into_sb_pod_via_ovsdb_container(monkeypatch): + calls = [] + monkeypatch.setattr( + raw.kube, + "stream_exec_in_pod", + lambda ctx, pod, container, argv: calls.append((pod, container)) or 0, + ) + result = runner.invoke(make_app(), ["sbctl", "--", "list", "Chassis"]) + assert result.exit_code == 0 + assert calls == [("ovn-ovsdb-sb-0", "ovsdb")] + + +def test_vsctl_resolves_pod_by_node_with_ovn_controller_prefix(monkeypatch): + resolve_calls = [] + exec_calls = [] + monkeypatch.setattr( + raw.kube, + "resolve_node_pod", + lambda ctx, node, prefix, pod: ( + resolve_calls.append((node, prefix, pod)) or "resolved-pod" + ), + ) + monkeypatch.setattr( + raw.kube, + "stream_exec_in_pod", + lambda ctx, pod, container, argv: ( + exec_calls.append((pod, container, argv)) or 0 + ), + ) + result = runner.invoke(make_app(), ["vsctl", "--node", "node-1", "--", "show"]) + assert result.exit_code == 0 + assert resolve_calls == [("node-1", "ovn-controller", None)] + assert exec_calls == [("resolved-pod", None, ["ovs-vsctl", "show"])] + + +def test_vsctl_explicit_pod_and_container_bypass_defaults(monkeypatch): + resolve_calls = [] + exec_calls = [] + monkeypatch.setattr( + raw.kube, + "resolve_node_pod", + lambda ctx, node, prefix, pod: resolve_calls.append(pod) or pod, + ) + monkeypatch.setattr( + raw.kube, + "stream_exec_in_pod", + lambda ctx, pod, container, argv: exec_calls.append((pod, container)) or 0, + ) + result = runner.invoke( + make_app(), + [ + "vsctl", + "--node", + "node-1", + "--pod", + "my-pod", + "--container", + "my-c", + "--", + "show", + ], + ) + assert result.exit_code == 0 + assert resolve_calls == ["my-pod"] + assert exec_calls == [("my-pod", "my-c")] + + +def test_appctl_default_target_prefix_is_ovn_controller(monkeypatch): + resolve_calls = [] + monkeypatch.setattr( + raw.kube, + "resolve_node_pod", + lambda ctx, node, prefix, pod: resolve_calls.append(prefix) or "pod-x", + ) + monkeypatch.setattr(raw.kube, "stream_exec_in_pod", lambda *a, **k: 0) + result = runner.invoke(make_app(), ["appctl", "--node", "node-1", "--", "version"]) + assert result.exit_code == 0 + assert resolve_calls == ["ovn-controller"] + + +def test_appctl_custom_target_prefix(monkeypatch): + resolve_calls = [] + monkeypatch.setattr( + raw.kube, + "resolve_node_pod", + lambda ctx, node, prefix, pod: resolve_calls.append(prefix) or "pod-x", + ) + monkeypatch.setattr(raw.kube, "stream_exec_in_pod", lambda *a, **k: 0) + result = runner.invoke( + make_app(), + ["appctl", "--node", "node-1", "--target", "openvswitch", "--", "version"], + ) + assert result.exit_code == 0 + assert resolve_calls == ["openvswitch"] diff --git a/python/kubectl-us-net/tests/test_router.py b/python/kubectl-us-net/tests/test_router.py new file mode 100644 index 000000000..71af8bdb0 --- /dev/null +++ b/python/kubectl-us-net/tests/test_router.py @@ -0,0 +1,982 @@ +import types + +import typer +from openstack import exceptions as os_exc +from typer.testing import CliRunner + +from us_net.commands import router +from us_net.connection import ConnectionContext + +runner = CliRunner() + + +def make_app(): + app = typer.Typer() + + @app.callback() + def main(ctx: typer.Context) -> None: + ctx.obj = ConnectionContext( + kube_context=None, + namespace="openstack", + nb_pod="ovn-ovsdb-nb-0", + sb_pod="ovn-ovsdb-sb-0", + os_cloud="dev-cloud", + ) + + app.add_typer(router.app, name="router") + return app + + +class FakeRouter: + id = "rtr-1" + name = "test-router" + + +class PortNotFound(os_exc.ResourceNotFound): + pass + + +class ServerNotFound(Exception): + pass + + +class FakeNetworkAPI: + def __init__(self, router_ports, all_ports=None, routers_list=None): + self._router_ports = router_ports + self._all_ports = {p.id: p for p in (all_ports or router_ports)} + self._routers_list = routers_list or [] + + def ports(self, device_id=None, fixed_ips=None): + if fixed_ips is not None: + ip = fixed_ips.split("=", 1)[1] + return [ + p + for p in self._all_ports.values() + if any(fip["ip_address"] == ip for fip in p.fixed_ips) + ] + return self._router_ports + + def get_port(self, port_id): + if port_id not in self._all_ports: + raise PortNotFound(f"port {port_id} not found") + return self._all_ports[port_id] + + def routers(self): + return self._routers_list + + +class FakeComputeAPI: + def __init__(self, servers=None): + self._servers = servers or {} + + def get_server(self, server_id): + if server_id not in self._servers: + raise ServerNotFound(f"server {server_id} not found") + return self._servers[server_id] + + +class FakeConfig: + config = {} + + +class FakeConnection: + def __init__(self, ports, all_ports=None, servers=None, routers_list=None): + self.network = FakeNetworkAPI(ports, all_ports, routers_list) + self.compute = FakeComputeAPI(servers) + self.config = FakeConfig() + + +def make_ports(): + return [ + types.SimpleNamespace( + id="gw-1", + name=None, + device_owner="network:router_gateway", + device_id=None, + fixed_ips=[{"ip_address": "203.0.113.5"}], + ), + types.SimpleNamespace( + id="int-1", + name=None, + device_owner="network:router_interface", + device_id=None, + fixed_ips=[{"ip_address": "192.168.0.1"}], + ), + ] + + +def make_bound_port(): + """A tenant instance port that a floating-IP NAT rule points to.""" + return types.SimpleNamespace( + id="vm-port-1", + name="vm-port", + device_owner="compute:nova", + device_id="server-1", + fixed_ips=[{"ip_address": "192.168.0.32"}], + ) + + +def patch_common(monkeypatch, *, hcg_linked=True, chassis_alive=True, centralized=True): + monkeypatch.setattr( + router.osclient, + "get_connection", + lambda os_cloud: FakeConnection( + make_ports(), + all_ports=[*make_ports(), make_bound_port()], + servers={"server-1": types.SimpleNamespace(name="my-server")}, + ), + ) + monkeypatch.setattr( + router.osclient, "resolve_router", lambda conn, name_or_id: FakeRouter() + ) + + lr_row = { + "_uuid": "lr-uuid", + "name": "neutron-rtr-1", + "options": {"chassis": "chassis-a"} if centralized else {}, + "ports": ["lrp-uuid-gw", "lrp-uuid-int"], + "nat": ["nat-uuid-1", "nat-uuid-2"], + } + lrp_rows = [ + { + "_uuid": "lrp-uuid-gw", + "name": "lrp-gw-1", + "networks": ["203.0.113.5/24"], + "ha_chassis_group": [], + }, + { + "_uuid": "lrp-uuid-int", + "name": "lrp-int-1", + "networks": ["192.168.0.1/24"], + "ha_chassis_group": "hcg-uuid-1" if hcg_linked else [], + }, + { + "_uuid": "lrp-uuid-unrelated", + "name": "lrp-other", + "networks": ["10.0.0.1/24"], + "ha_chassis_group": [], + }, + ] + hcg_rows = [ + {"_uuid": "hcg-uuid-1", "name": "neutron-net-1", "ha_chassis": ["hac-uuid-1"]} + ] + ha_chassis_rows = [ + {"_uuid": "hac-uuid-1", "chassis_name": "chassis-a", "priority": 32767} + ] + chassis_rows = ( + [ + { + "name": "chassis-a", + "other_config": {"ovn-bridge-mappings": "f20-1-network:br-ex"}, + } + ] + if chassis_alive + else [] + ) + nat_rows = [ + { + "_uuid": "nat-uuid-1", + "type": "dnat_and_snat", + "external_ip": "204.232.163.17", + "logical_ip": "192.168.0.32", + "logical_port": "vm-port-1", + "external_ids": {"neutron:fip_port_id": "vm-port-1"}, + }, + { + "_uuid": "nat-uuid-2", + "type": "snat", + "external_ip": "204.232.163.55", + "logical_ip": "192.168.0.0/24", + "logical_port": "", + "external_ids": {}, + }, + ] + + localnet_lsp_ext = { + "_uuid": "lsp-uuid-localnet-ext", + "name": "uplink-ext-1", + "type": "localnet", + "tag": 1804, + } + localnet_lsp_int = { + "_uuid": "lsp-uuid-localnet-int", + "name": "uplink-int-1", + "type": "localnet", + "tag": 1802, + } + peer_lsp_gw = { + "_uuid": "lsp-uuid-gw", + "name": "gw-1", + "type": "router", + "addresses": "router", + "up": True, + "external_ids": {"neutron:network_name": "neutron-net-ext"}, + "ha_chassis_group": [], + } + peer_lsp_int = { + "_uuid": "lsp-uuid-int", + "name": "int-1", + "type": "router", + "addresses": "router", + "up": True, + "external_ids": {"neutron:network_name": "neutron-net-int"}, + "ha_chassis_group": [], + } + peer_lsp_vm = { + "_uuid": "lsp-uuid-vm", + "name": "vm-port-1", + "type": "", + "addresses": "fa:16:3e:00:00:01 192.168.0.32", + "up": True, + "ha_chassis_group": [], + } + all_lsp_rows = [ + localnet_lsp_ext, + localnet_lsp_int, + peer_lsp_gw, + peer_lsp_int, + peer_lsp_vm, + ] + lsp_rows_by_name = {row["name"]: row for row in all_lsp_rows} + switch_rows_by_name = { + "neutron-net-ext": { + "_uuid": "switch-ext", + "name": "neutron-net-ext", + "ports": ["lsp-uuid-localnet-ext", "lsp-uuid-gw"], + }, + "neutron-net-int": { + "_uuid": "switch-int", + "name": "neutron-net-int", + "ports": ["lsp-uuid-localnet-int", "lsp-uuid-int"], + }, + } + + def fake_nbctl_find(ctx, table, condition): + if table == "Logical_Router": + assert condition == "name=neutron-rtr-1" + return [lr_row] + if table == "Logical_Switch_Port": + port_id = condition.removeprefix("name=") + row = lsp_rows_by_name.get(port_id) + return [row] if row else [] + if table == "Logical_Switch": + name = condition.removeprefix("name=") + row = switch_rows_by_name.get(name) + return [row] if row else [] + raise AssertionError(f"unexpected nbctl_find table {table!r}") + + def fake_nbctl_list(ctx, table): + return { + "Logical_Router_Port": lrp_rows, + "HA_Chassis_Group": hcg_rows, + "HA_Chassis": ha_chassis_rows, + "Gateway_Chassis": [], + "NAT": nat_rows, + "Logical_Switch_Port": all_lsp_rows, + }[table] + + def fake_sbctl_list(ctx, table): + assert table == "Chassis" + return chassis_rows + + monkeypatch.setattr(router.ovn, "nbctl_find", fake_nbctl_find) + monkeypatch.setattr(router.ovn, "nbctl_list", fake_nbctl_list) + monkeypatch.setattr(router.ovn, "sbctl_list", fake_sbctl_list) + monkeypatch.setattr( + router.ovn, "sbctl_lflow_list", lambda ctx, name: "FLOW_TABLE_OUTPUT\n" + ) + + +def test_localnet_tags_returns_unknown_network_for_missing_switch_name(): + assert router._localnet_tags(None, None, [], {}) == "(unknown network)" + + +def test_localnet_tags_returns_switch_not_found(monkeypatch): + monkeypatch.setattr(router.ovn, "nbctl_find", lambda ctx, table, condition: []) + assert router._localnet_tags(None, "neutron-missing", [], {}) == ( + "(switch not found)" + ) + + +def test_localnet_tags_returns_no_localnet_port(monkeypatch): + monkeypatch.setattr( + router.ovn, + "nbctl_find", + lambda ctx, table, condition: [{"_uuid": "sw-1", "ports": ["lsp-1"]}], + ) + all_lsp_rows = [{"_uuid": "lsp-1", "type": "", "tag": []}] + assert router._localnet_tags(None, "neutron-net-1", all_lsp_rows, {}) == ( + "(no localnet port)" + ) + + +def test_localnet_tags_joins_multiple_tags(monkeypatch): + monkeypatch.setattr( + router.ovn, + "nbctl_find", + lambda ctx, table, condition: [{"_uuid": "sw-1", "ports": ["lsp-1", "lsp-2"]}], + ) + all_lsp_rows = [ + {"_uuid": "lsp-1", "type": "localnet", "tag": 1800}, + {"_uuid": "lsp-2", "type": "localnet", "tag": 1801}, + {"_uuid": "lsp-3", "type": "router", "tag": []}, + ] + assert router._localnet_tags(None, "neutron-net-1", all_lsp_rows, {}) == ( + "1800, 1801" + ) + + +def test_localnet_tags_caches_switch_lookup_across_calls(monkeypatch): + calls = [] + + def fake_nbctl_find(ctx, table, condition): + calls.append(condition) + return [{"_uuid": "sw-1", "ports": []}] + + monkeypatch.setattr(router.ovn, "nbctl_find", fake_nbctl_find) + cache: dict = {} + router._localnet_tags(None, "neutron-net-1", [], cache) + router._localnet_tags(None, "neutron-net-1", [], cache) + assert calls == ["name=neutron-net-1"] # only looked up once, then cached + + +def test_router_show_reports_gateway_and_internal_ports(monkeypatch): + patch_common(monkeypatch) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "lrp-gw-1 [gateway]" in result.output + assert "203.0.113.5/24" in result.output + assert "203.0.113.5" in result.output # Neutron fixed IP + assert "lrp-int-1 [internal]" in result.output + assert "lrp-other" not in result.output # unrelated LRP filtered out + assert "Network VLAN tag : 1804" in result.output # gateway's uplink tag + assert "Network VLAN tag : 1802" in result.output # internal's uplink tag + # gateway HCG is unlinked but the router is centralized, so that's expected + assert ( + "Pinned chassis : chassis-a (alive, physnets=f20-1-network)" in result.output + ) + assert ( + "neutron-net-1 -> chassis-a (priority=32767, alive, physnets=f20-1-network)" + in result.output + ) + assert "dnat_and_snat" in result.output + assert "external=204.232.163.17" in result.output + assert "logical=192.168.0.32" in result.output + assert "-> port vm-port-1" in result.output + assert "snat" in result.output + assert "logical=192.168.0.0/24" in result.output + assert "FLOW_TABLE_OUTPUT" not in result.output # --flows not passed + + +def test_router_show_ports_section_lists_resolved_nat_ports(monkeypatch): + patch_common(monkeypatch) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "\nPorts:\n" in result.output + assert "vm-port-1 (vm-port)" in result.output + assert "Fixed IPs : 192.168.0.32" in result.output + assert "Owner : server server-1 (my-server)" in result.output + assert ( + "OVN LSP : type=(normal), up, addresses=fa:16:3e:00:00:01 192.168.0.32" + in result.output + ) + # vm-port-1's LSP has no ha_chassis_group of its own in the base fixture + assert "HA_Chassis_Group : NOT LINKED" in result.output + + +def test_router_show_ports_section_shows_lsp_level_hcg(monkeypatch): + patch_common(monkeypatch) + monkeypatch.setattr( + router, + "_find_lsp", + lambda conn_ctx, port_id: ( + { + "type": "", + "addresses": "fa:16:3e:00:00:01 192.168.0.32", + "up": True, + "ha_chassis_group": "hcg-uuid-1", + } + if port_id == "vm-port-1" + else None + ), + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + ports_section = result.output.split("\nPorts:\n", 1)[1] + assert ( + "HA_Chassis_Group : neutron-net-1 -> " + "chassis-a (priority=32767, alive, physnets=f20-1-network)" in ports_section + ) + + +def test_router_show_router_ports_show_neutron_port_id_not_in_ports_section( + monkeypatch, +): + patch_common(monkeypatch) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + router_ports_section = result.output.split("\nRouter ports:\n", 1)[1].split( + "\nNAT rules:\n", 1 + )[0] + assert "Neutron port : gw-1" in router_ports_section + assert "Neutron port : int-1" in router_ports_section + # the router's own ports restate nothing new in Ports: -- only NAT-target + # ports (e.g. floating-IP-bound VMs) belong there + ports_section = result.output.split("\nPorts:\n", 1)[1] + assert "gw-1 ((unnamed))" not in ports_section + assert "int-1 ((unnamed))" not in ports_section + + +def test_describe_lsp_flags_dangling_reference(): + assert router._describe_lsp(None) == "NOT FOUND (dangling?)" + + +def make_chassis_tables(**overrides) -> router.ChassisTables: + defaults = dict( + hcg_rows={}, ha_chassis_rows={}, gateway_chassis_rows={}, sb_chassis_by_name={} + ) + defaults.update(overrides) + return router.ChassisTables(**defaults) + + +def test_describe_hcg_not_linked_when_uuid_missing(): + assert router._describe_hcg(None, make_chassis_tables()) == "NOT LINKED" + + +def test_describe_hcg_flags_dangling_hcg_row(): + result = router._describe_hcg("missing-hcg", make_chassis_tables()) + assert result == "missing-hcg (row not found!)" + + +def test_describe_hcg_resolves_via_bundled_tables(): + tables = make_chassis_tables( + hcg_rows={"hcg-1": {"name": "neutron-net-1", "ha_chassis": ["hac-1"]}}, + ha_chassis_rows={"hac-1": {"chassis_name": "chassis-a", "priority": 32767}}, + sb_chassis_by_name={"chassis-a": {"name": "chassis-a"}}, + ) + result = router._describe_hcg("hcg-1", tables) + expected = ( + "neutron-net-1 -> chassis-a (priority=32767, alive, " + "physnets=(no ovn-bridge-mappings configured))" + ) + assert result == expected + + +def test_describe_lsp_summarizes_found_row(): + row = {"type": "router", "addresses": "router", "up": True} + assert router._describe_lsp(row) == "type=router, up, addresses=router" + + +def test_describe_lsp_defaults_missing_type_and_down_state(): + row = {"addresses": ["fa:16:3e:00:00:01", "192.168.0.32"], "up": False} + assert ( + router._describe_lsp(row) + == "type=(normal), down, addresses=fa:16:3e:00:00:01, 192.168.0.32" + ) + + +def test_router_show_ports_section_flags_dangling_lsp(monkeypatch): + patch_common(monkeypatch) + monkeypatch.setattr(router, "_find_lsp", lambda conn_ctx, port_id: None) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + ports_section = result.output.split("\nPorts:\n", 1)[1] + assert ports_section.count("OVN LSP : NOT FOUND (dangling?)") == 1 + # with no LSP row at all, there's no ha_chassis_group to resolve either + assert ports_section.count("HA_Chassis_Group : NOT LINKED") == 1 + + +def test_router_show_ports_section_is_empty_with_no_nat_rules(monkeypatch): + patch_common(monkeypatch) + monkeypatch.setattr( + router.ovn, + "nbctl_list", + lambda ctx, table: { + "Logical_Router_Port": [ + { + "_uuid": "lrp-uuid-gw", + "name": "lrp-gw-1", + "networks": ["203.0.113.5/24"], + "ha_chassis_group": [], + }, + { + "_uuid": "lrp-uuid-int", + "name": "lrp-int-1", + "networks": ["192.168.0.1/24"], + "ha_chassis_group": "hcg-uuid-1", + }, + ], + "HA_Chassis_Group": [ + { + "_uuid": "hcg-uuid-1", + "name": "neutron-net-1", + "ha_chassis": ["hac-uuid-1"], + } + ], + "HA_Chassis": [ + {"_uuid": "hac-uuid-1", "chassis_name": "chassis-a", "priority": 32767} + ], + "Gateway_Chassis": [], + "NAT": [], + "Logical_Switch_Port": [], + }[table], + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + # the router's own ports still appear in Router ports:, by Neutron port id + router_ports_section = result.output.split("\nRouter ports:\n", 1)[1].split( + "\nNAT rules:\n", 1 + )[0] + assert "Neutron port : gw-1" in router_ports_section + assert "Neutron port : int-1" in router_ports_section + assert "\nPorts:\n (none)" in result.output + + +def test_router_show_nat_snat_rule_has_no_port_link(monkeypatch): + patch_common(monkeypatch) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + snat_line = next( + line for line in result.output.splitlines() if line.strip().startswith("snat") + ) + assert "-> port" not in snat_line + + +def test_router_show_nat_dangling_port_reference_is_flagged(monkeypatch): + patch_common(monkeypatch) + monkeypatch.setattr( + router.ovn, + "nbctl_list", + lambda ctx, table: { + "Logical_Router_Port": [], + "HA_Chassis_Group": [], + "HA_Chassis": [], + "Gateway_Chassis": [], + "NAT": [ + { + "_uuid": "nat-uuid-1", + "type": "dnat_and_snat", + "external_ip": "204.232.163.99", + "logical_ip": "192.168.0.99", + "logical_port": "deleted-port-id", + "external_ids": {"neutron:fip_port_id": "deleted-port-id"}, + } + ], + "Logical_Switch_Port": [], + }[table], + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "port deleted-port-id NOT FOUND (dangling NAT rule?)" in result.output + + +def test_router_show_nat_transient_error_is_not_reported_as_dangling(monkeypatch): + patch_common(monkeypatch) + + def boom(*args, **kwargs): + raise TimeoutError("connection timed out") + + monkeypatch.setattr( + router.osclient, + "get_connection", + lambda os_cloud: FakeConnection( + make_ports(), all_ports=[*make_ports(), make_bound_port()], servers={} + ), + ) + monkeypatch.setattr(router, "_resolve_nat_port", lambda conn, nat: boom()) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "ERROR resolving port: connection timed out" in result.output + assert "dangling NAT rule" not in result.output + + +def test_describe_chassis_refs_sorts_by_priority_descending(): + chassis_rows = { + "hac-low": {"chassis_name": "chassis-backup", "priority": 100}, + "hac-high": {"chassis_name": "chassis-primary", "priority": 32767}, + } + sb_chassis_by_name = { + "chassis-backup": {"name": "chassis-backup"}, + "chassis-primary": {"name": "chassis-primary"}, + } + summary = router._describe_chassis_refs( + ["hac-low", "hac-high"], chassis_rows, sb_chassis_by_name + ) + primary_pos = summary.index("chassis-primary") + backup_pos = summary.index("chassis-backup") + assert primary_pos < backup_pos # higher priority (32767) printed first + + +def test_router_show_flags_dead_chassis(monkeypatch): + patch_common(monkeypatch, chassis_alive=False) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "DEAD" in result.output + + +def test_router_show_flows_flag_dumps_sb_flows(monkeypatch): + patch_common(monkeypatch) + result = runner.invoke(make_app(), ["router", "show", "test-router", "--flows"]) + assert result.exit_code == 0 + assert "FLOW_TABLE_OUTPUT" in result.output + + +def test_router_show_missing_router_errors_cleanly(monkeypatch): + monkeypatch.setattr( + router.osclient, "get_connection", lambda os_cloud: FakeConnection([]) + ) + + def raise_lookup(conn, name_or_id): + raise LookupError(f"router {name_or_id!r} not found") + + monkeypatch.setattr(router.osclient, "resolve_router", raise_lookup) + result = runner.invoke(make_app(), ["router", "show", "missing"]) + assert result.exit_code == 1 + assert "not found" in result.output + + +def test_router_show_missing_ovn_logical_router_errors(monkeypatch): + monkeypatch.setattr( + router.osclient, "get_connection", lambda os_cloud: FakeConnection(make_ports()) + ) + monkeypatch.setattr( + router.osclient, "resolve_router", lambda conn, name_or_id: FakeRouter() + ) + monkeypatch.setattr(router.ovn, "nbctl_find", lambda ctx, table, condition: []) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 1 + + +def test_lrp_role_returns_unknown_for_unrecognized_port(): + role = router._lrp_role("lrp-stray", "gw-1", {"int-1"}) + assert role == "unknown" + + +def test_rows_by_uuid_filters_to_matching_rows_only(): + rows = [ + {"_uuid": "a", "name": "keep-a"}, + {"_uuid": "b", "name": "drop-b"}, + {"_uuid": "c", "name": "keep-c"}, + ] + result = router._rows_by_uuid(rows, {"a", "c"}) + assert result == [ + {"_uuid": "a", "name": "keep-a"}, + {"_uuid": "c", "name": "keep-c"}, + ] + + +def test_rows_by_uuid_ignores_rows_missing_uuid_key(): + rows = [{"name": "no-uuid-field"}, {"_uuid": "a", "name": "keep-a"}] + assert router._rows_by_uuid(rows, {"a"}) == [{"_uuid": "a", "name": "keep-a"}] + + +def test_rows_by_uuid_returns_empty_for_no_matches(): + rows = [{"_uuid": "a"}, {"_uuid": "b"}] + assert router._rows_by_uuid(rows, {"z"}) == [] + + +def test_router_show_flags_dangling_hcg_reference(monkeypatch): + patch_common(monkeypatch) + # Point the internal LRP's ha_chassis_group at a uuid with no matching + # HA_Chassis_Group row -- a dangling reference, the exact referential + # integrity gap cleanup_dead_ovn_ha_chassis.py exists to repair. + monkeypatch.setattr( + router.ovn, + "nbctl_list", + lambda ctx, table: { + "Logical_Router_Port": [ + { + "_uuid": "lrp-uuid-gw", + "name": "lrp-gw-1", + "networks": ["203.0.113.5/24"], + "ha_chassis_group": [], + }, + { + "_uuid": "lrp-uuid-int", + "name": "lrp-int-1", + "networks": ["192.168.0.1/24"], + "ha_chassis_group": "missing-hcg-uuid", + }, + ], + "HA_Chassis_Group": [], + "HA_Chassis": [], + "Gateway_Chassis": [], + "NAT": [], + "Logical_Switch_Port": [], + }[table], + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "missing-hcg-uuid (row not found!)" in result.output + + +def test_router_show_skips_dangling_ha_chassis_reference(monkeypatch): + patch_common(monkeypatch) + # The HCG references a HA_Chassis uuid that no longer has a row -- + # should be silently skipped rather than crashing. + monkeypatch.setattr( + router.ovn, + "nbctl_list", + lambda ctx, table: { + "Logical_Router_Port": [ + { + "_uuid": "lrp-uuid-int", + "name": "lrp-int-1", + "networks": ["192.168.0.1/24"], + "ha_chassis_group": "hcg-uuid-1", + }, + ], + "HA_Chassis_Group": [ + { + "_uuid": "hcg-uuid-1", + "name": "neutron-net-1", + "ha_chassis": ["missing-hac"], + } + ], + "HA_Chassis": [], + "Gateway_Chassis": [], + "NAT": [], + "Logical_Switch_Port": [], + }[table], + ) + monkeypatch.setattr( + router.ovn, + "nbctl_find", + lambda ctx, table, condition: [ + { + "_uuid": "lr-uuid", + "name": "neutron-rtr-1", + "options": {"chassis": "chassis-a"}, + "ports": ["lrp-uuid-int"], + } + ], + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "neutron-net-1 -> (empty)" in result.output + + +def test_router_show_unlinked_hcg_is_expected_on_centralized_router_both_ports( + monkeypatch, +): + patch_common(monkeypatch, hcg_linked=False, centralized=True) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + router_ports_section = result.output.split("\nRouter ports:\n", 1)[1].split( + "\nNAT rules:\n", 1 + )[0] + assert "lrp-gw-1 [gateway]" in router_ports_section + assert "lrp-int-1 [internal]" in router_ports_section + # options:chassis pins every port on a centralized router -- gateway + # included -- so an unlinked HCG on either port is expected, not a bug + assert router_ports_section.count("Pinned chassis : chassis-a (alive") == 2 + assert "NOT LINKED" not in router_ports_section + + +def test_router_show_on_distributed_router_only_flags_the_gateway_port( + monkeypatch, +): + patch_common(monkeypatch, hcg_linked=False, centralized=False) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "Type: distributed" in result.output + assert "Pinned chassis" not in result.output + # the gateway port has neither ha_chassis_group nor gateway_chassis -- + # that's a real bug (no scheduling info at all) + assert result.output.count("NOT LINKED (likely bug") == 1 + # upstream OVN never sets ha_chassis_group on internal LRPs by default, + # so its absence there is expected, not a bug + assert "HA_Chassis_Group : (none) -- not set by default on internal ports" in ( + result.output + ) + + +def test_router_show_vlan_flat_distributed_gateway_uses_gateway_chassis( + monkeypatch, +): + # VLAN/FLAT distributed gateways are scheduled by OVN's own L3 scheduler + # via gateway_chassis, not ha_chassis_group -- a healthy such router + # should not be flagged as "likely bug". + patch_common(monkeypatch, hcg_linked=False, centralized=False) + monkeypatch.setattr( + router.ovn, + "nbctl_list", + lambda ctx, table: { + "Logical_Router_Port": [ + { + "_uuid": "lrp-uuid-gw", + "name": "lrp-gw-1", + "networks": ["203.0.113.5/24"], + "ha_chassis_group": [], + "gateway_chassis": "gwc-uuid-1", + }, + { + "_uuid": "lrp-uuid-int", + "name": "lrp-int-1", + "networks": ["192.168.0.1/24"], + "ha_chassis_group": [], + }, + ], + "HA_Chassis_Group": [], + "HA_Chassis": [], + "Gateway_Chassis": [ + { + "_uuid": "gwc-uuid-1", + "chassis_name": "chassis-a", + "priority": 32767, + } + ], + "NAT": [], + "Logical_Switch_Port": [], + }[table], + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert "Type: distributed" in result.output + assert "NOT LINKED (likely bug" not in result.output + assert ( + "Gateway_Chassis : chassis-a (priority=32767, alive, " + "physnets=f20-1-network)" in result.output + ) + + +def test_router_show_pinned_chassis_flags_dead_chassis(monkeypatch): + patch_common(monkeypatch, hcg_linked=False, centralized=True, chassis_alive=False) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert ( + "Pinned chassis : chassis-a (DEAD, physnets=(chassis not in SB))" + in result.output + ) + + +def test_router_show_pinned_chassis_flags_missing_bridge_mappings(monkeypatch): + patch_common(monkeypatch, hcg_linked=False, centralized=True) + monkeypatch.setattr( + router.ovn, "sbctl_list", lambda ctx, table: [{"name": "chassis-a"}] + ) + result = runner.invoke(make_app(), ["router", "show", "test-router"]) + assert result.exit_code == 0 + assert ( + "Pinned chassis : chassis-a (alive, " + "physnets=(no ovn-bridge-mappings configured))" in result.output + ) + + +class FakeOsRouter: + def __init__(self, id, name, flavor_id=None): + self.id = id + self.name = name + self.flavor_id = flavor_id + + +def patch_list_common(monkeypatch, openstack_routers, ovn_lr_names): + monkeypatch.setattr( + router.osclient, + "get_connection", + lambda os_cloud: FakeConnection([], routers_list=openstack_routers), + ) + monkeypatch.setattr( + router.ovn, + "nbctl_list", + lambda ctx, table: [{"name": name} for name in ovn_lr_names], + ) + + +def test_router_list_shows_a_row_per_router_matched_on_both_sides(monkeypatch): + patch_list_common( + monkeypatch, + [FakeOsRouter("rtr-1", "router-one"), FakeOsRouter("rtr-2", "router-two")], + ["neutron-rtr-1", "neutron-rtr-2"], + ) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 0 + assert "NAME" in result.output + assert "ID" in result.output + assert "OPENSTACK" in result.output + assert "OVN" in result.output + lines = result.output.splitlines() + row_one = next(line for line in lines if line.startswith("router-one")) + row_two = next(line for line in lines if line.startswith("router-two")) + assert "rtr-1" in row_one and "yes" in row_one + assert "rtr-2" in row_two and "yes" in row_two + # rows are sorted by name + assert lines.index(row_one) < lines.index(row_two) + + +def test_router_list_flags_router_missing_in_ovn(monkeypatch): + patch_list_common( + monkeypatch, + [FakeOsRouter("rtr-1", "router-one"), FakeOsRouter("rtr-2", "router-two")], + ["neutron-rtr-1"], + ) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 0 + row_two = next( + line for line in result.output.splitlines() if line.startswith("router-two") + ) + assert "NO" in row_two + + +def test_router_list_flags_router_missing_in_openstack(monkeypatch): + patch_list_common( + monkeypatch, + [FakeOsRouter("rtr-1", "router-one")], + ["neutron-rtr-1", "neutron-rtr-orphan"], + ) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 0 + row_orphan = next( + line for line in result.output.splitlines() if line.startswith("(unknown)") + ) + assert "rtr-orphan" in row_orphan + assert "NO" in row_orphan # not in OpenStack + assert row_orphan.rstrip().endswith("yes") # is in OVN + + +def test_router_list_ignores_non_neutron_ovn_routers(monkeypatch): + patch_list_common( + monkeypatch, + [FakeOsRouter("rtr-1", "router-one")], + ["neutron-rtr-1", "some-other-router"], + ) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 0 + assert "some-other-router" not in result.output + # only one data row (plus header + separator) + assert len(result.output.strip().splitlines()[-3:]) == 3 + + +def test_router_list_marks_flavored_routers_as_not_applicable_for_ovn(monkeypatch): + # Flavored (e.g. VRF) routers are handled by a different L3 backend and + # never get an OVN Logical_Router -- "NO" there would look like a bug + # when it's actually expected. + patch_list_common( + monkeypatch, + [FakeOsRouter("rtr-vrf", "vrf-router", flavor_id="flavor-uuid")], + [], + ) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 0 + row = next( + line for line in result.output.splitlines() if line.startswith("vrf-router") + ) + assert "yes" in row # present in OpenStack + assert "n/a (flavored)" in row + + +def test_router_list_reports_no_routers_found(monkeypatch): + patch_list_common(monkeypatch, [], []) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 0 + assert "(no routers found)" in result.output + + +def test_router_list_reports_connection_error_cleanly(monkeypatch): + def boom(os_cloud): + raise RuntimeError("no cloud configured") + + monkeypatch.setattr(router.osclient, "get_connection", boom) + result = runner.invoke(make_app(), ["router", "list"]) + assert result.exit_code == 1 + assert "ERROR: no cloud configured" in result.output diff --git a/python/kubectl-us-net/us_net/__init__.py b/python/kubectl-us-net/us_net/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/kubectl-us-net/us_net/cli.py b/python/kubectl-us-net/us_net/cli.py new file mode 100644 index 000000000..a31a870bd --- /dev/null +++ b/python/kubectl-us-net/us_net/cli.py @@ -0,0 +1,55 @@ +"""kubectl-us-net: kubectl plugin for troubleshooting UnderStack's Neutron/OVN.""" + +from __future__ import annotations + +import typer + +from us_net.commands import raw +from us_net.commands import router +from us_net.connection import ConnectionContext + +app = typer.Typer( + name="kubectl-us-net", + add_completion=False, + no_args_is_help=True, + help="Troubleshoot UnderStack's Neutron/OVN data plane.", +) + + +@app.callback() +def main( + ctx: typer.Context, + context: str = typer.Option( + None, "--context", help="kubectl context to use (default: current-context)" + ), + namespace: str = typer.Option( + "openstack", "--namespace", "-n", help="Namespace hosting the OVN NB/SB pods" + ), + nb_pod: str = typer.Option( + "ovn-ovsdb-nb-0", "--nb-pod", help="Northbound OVSDB pod name" + ), + sb_pod: str = typer.Option( + "ovn-ovsdb-sb-0", "--sb-pod", help="Southbound OVSDB pod name" + ), + os_cloud: str = typer.Option( + None, + "--os-cloud", + help="OpenStack cloud name (default: OS_CLOUD env / clouds.yaml default)", + ), +) -> None: + """Set up the shared connection context used by every subcommand.""" + ctx.obj = ConnectionContext( + kube_context=context, + namespace=namespace, + nb_pod=nb_pod, + sb_pod=sb_pod, + os_cloud=os_cloud, + ) + + +raw.register(app) +app.add_typer(router.app, name="router") + + +if __name__ == "__main__": + app() diff --git a/python/kubectl-us-net/us_net/commands/__init__.py b/python/kubectl-us-net/us_net/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/kubectl-us-net/us_net/commands/raw.py b/python/kubectl-us-net/us_net/commands/raw.py new file mode 100644 index 000000000..bd4dffa03 --- /dev/null +++ b/python/kubectl-us-net/us_net/commands/raw.py @@ -0,0 +1,90 @@ +"""Raw passthrough commands: nbctl, sbctl, vsctl, appctl.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from us_net import kube +from us_net.connection import ConnectionContext +from us_net.connection import print_connection_banner +from us_net.ovn import OVSDB_CONTAINER + +PASSTHROUGH_SETTINGS = {"ignore_unknown_options": True, "allow_extra_args": True} + +# typer.Argument() would trip ruff's B008 (function call in a default) if used +# as the default value directly, so the catch-all args param is declared via +# Annotated instead -- keep the actual default (None) on the function param. +PassthroughArgs = Annotated[list[str] | None, typer.Argument()] + + +def nbctl(ctx: typer.Context, args: PassthroughArgs = None) -> None: + """Run ovn-nbctl against the Northbound DB pod.""" + conn_ctx: ConnectionContext = ctx.obj + print_connection_banner(conn_ctx) + rc = kube.stream_exec_in_pod( + conn_ctx, conn_ctx.nb_pod, OVSDB_CONTAINER, ["ovn-nbctl", *(args or [])] + ) + raise typer.Exit(rc) + + +def sbctl(ctx: typer.Context, args: PassthroughArgs = None) -> None: + """Run ovn-sbctl against the Southbound DB pod.""" + conn_ctx: ConnectionContext = ctx.obj + print_connection_banner(conn_ctx) + rc = kube.stream_exec_in_pod( + conn_ctx, conn_ctx.sb_pod, OVSDB_CONTAINER, ["ovn-sbctl", *(args or [])] + ) + raise typer.Exit(rc) + + +def vsctl( + ctx: typer.Context, + node: str = typer.Option(..., "--node", help="Node to run ovs-vsctl on"), + pod: str = typer.Option(None, "--pod", help="Exact pod, bypassing node discovery"), + container: str = typer.Option( + None, "--container", help="Container to exec into (default: pod's default)" + ), + args: PassthroughArgs = None, +) -> None: + """Run ovs-vsctl on --node's ovn-controller pod (OVS is co-located there).""" + conn_ctx: ConnectionContext = ctx.obj + print_connection_banner(conn_ctx) + target_pod = kube.resolve_node_pod(conn_ctx, node, "ovn-controller", pod) + rc = kube.stream_exec_in_pod( + conn_ctx, target_pod, container, ["ovs-vsctl", *(args or [])] + ) + raise typer.Exit(rc) + + +def appctl( + ctx: typer.Context, + node: str = typer.Option(..., "--node", help="Node to run ovs-appctl on"), + target: str = typer.Option( + "ovn-controller", + "--target", + help="Pod name-prefix to target; OVS is usually co-located with ovn-controller", + ), + pod: str = typer.Option(None, "--pod", help="Exact pod, bypassing node discovery"), + container: str = typer.Option( + None, "--container", help="Container to exec into (default: pod's default)" + ), + args: PassthroughArgs = None, +) -> None: + """Run ovs-appctl against the ovn-controller pod on --node.""" + conn_ctx: ConnectionContext = ctx.obj + print_connection_banner(conn_ctx) + target_pod = kube.resolve_node_pod(conn_ctx, node, target, pod) + rc = kube.stream_exec_in_pod( + conn_ctx, target_pod, container, ["ovs-appctl", *(args or [])] + ) + raise typer.Exit(rc) + + +def register(app: typer.Typer) -> None: + """Attach nbctl/sbctl/vsctl/appctl directly onto the root app (no extra nesting).""" + app.command("nbctl", context_settings=PASSTHROUGH_SETTINGS)(nbctl) + app.command("sbctl", context_settings=PASSTHROUGH_SETTINGS)(sbctl) + app.command("vsctl", context_settings=PASSTHROUGH_SETTINGS)(vsctl) + app.command("appctl", context_settings=PASSTHROUGH_SETTINGS)(appctl) diff --git a/python/kubectl-us-net/us_net/commands/router.py b/python/kubectl-us-net/us_net/commands/router.py new file mode 100644 index 000000000..0dae50dc9 --- /dev/null +++ b/python/kubectl-us-net/us_net/commands/router.py @@ -0,0 +1,432 @@ +"""`router show`/`router list`: cross-check Neutron routers against OVN state.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import typer +from openstack import exceptions as os_exc + +from us_net import osclient +from us_net import ovn +from us_net.connection import ConnectionContext +from us_net.connection import print_connection_banner + +app = typer.Typer(no_args_is_help=True, help="Inspect a Neutron router's OVN state.") + +NEUTRON_PREFIX = "neutron-" + +GATEWAY_DEVICE_OWNER = "network:router_gateway" +INTERFACE_DEVICE_OWNER = "network:router_interface" + + +def _lrp_role( + lrp_name: str, gateway_port_id: str | None, interface_port_ids: set[str] +) -> str: + port_id = lrp_name.removeprefix("lrp-") + if gateway_port_id and port_id == gateway_port_id: + return "gateway" + if port_id in interface_port_ids: + return "internal" + return "unknown" + + +def _rows_by_uuid(rows: list[dict], uuids: set[str]) -> list[dict]: + """Filter a full OVN table dump down to rows referenced by a parent's uuid set.""" + return [row for row in rows if row.get("_uuid") in uuids] + + +def _chassis_physical_networks(chassis_row: dict | None) -> str: + """Physical networks a chassis is wired for, via its ovn-bridge-mappings.""" + if chassis_row is None: + return "(chassis not in SB)" + mappings = (chassis_row.get("other_config") or {}).get("ovn-bridge-mappings") + if not mappings: + return "(no ovn-bridge-mappings configured)" + return ", ".join(pair.split(":", 1)[0] for pair in mappings.split(",") if pair) + + +def _resolve_nat_port(conn, nat_row: dict) -> tuple[str | None, Any | None]: + """Resolve the OpenStack port a NAT rule is bound to, if any. + + Prefers external_ids["neutron:fip_port_id"] (populated by neutron for + floating-IP dnat_and_snat rules), then the logical_port column, then + falls back to matching logical_ip against a port's fixed IPs -- only + for single-host IPs, never for whole-subnet snat rules. + """ + external_ids = nat_row.get("external_ids") or {} + port_id = ( + external_ids.get("neutron:fip_port_id") or nat_row.get("logical_port") or None + ) + if port_id: + try: + return port_id, conn.network.get_port(port_id) + except os_exc.ResourceNotFound: + return port_id, None + + logical_ip = nat_row.get("logical_ip") + if logical_ip and "/" not in logical_ip: + matches = list(conn.network.ports(fixed_ips=f"ip_address={logical_ip}")) + if matches: + return matches[0].id, matches[0] + return None, None + + +def _describe_port_owner(conn, port) -> str: + """Describe what a port is bound to: a server, or another device owner.""" + device_owner = getattr(port, "device_owner", None) + device_id = getattr(port, "device_id", None) + if device_owner and device_owner.startswith("compute:") and device_id: + try: + server = conn.compute.get_server(device_id) + return f"server {device_id} ({server.name})" + except Exception: + return f"server {device_id}" + if device_owner: + return f"device_owner={device_owner}" + return "unbound" + + +def _find_lsp(lsp_by_name: dict[str, dict], port_id: str) -> dict | None: + """The OVN Logical_Switch_Port for a Neutron port, if it has one.""" + return lsp_by_name.get(port_id) + + +def _describe_lsp(lsp_row: dict | None) -> str: + """Summarize an OVN Logical_Switch_Port row for cross-checking against Neutron.""" + if lsp_row is None: + return "NOT FOUND (dangling?)" + lsp_type = lsp_row.get("type") or "(normal)" + addresses = ", ".join(ovn.as_list(lsp_row.get("addresses"))) or "(none)" + up = "up" if lsp_row.get("up") else "down" + return f"type={lsp_type}, {up}, addresses={addresses}" + + +def _describe_chassis_refs( + chassis_uuids: list[str], + chassis_rows: dict[str, dict], + sb_chassis_by_name: dict[str, dict], +) -> str: + """Format HA_Chassis/Gateway_Chassis rows, highest priority first. + + Both tables share the same chassis_name/priority shape, and priority + order is OVN's actual failover preference. + """ + rows = [chassis_rows[uuid] for uuid in chassis_uuids if uuid in chassis_rows] + rows.sort(key=lambda row: row["priority"], reverse=True) + chassis_descr = [] + for row in rows: + chassis_name = row["chassis_name"] + sb_row = sb_chassis_by_name.get(chassis_name) + live = "alive" if sb_row is not None else "DEAD" + physnets = _chassis_physical_networks(sb_row) + chassis_descr.append( + f"{chassis_name} (priority={row['priority']}, {live}, physnets={physnets})" + ) + return "; ".join(chassis_descr) or "(empty)" + + +@dataclass +class ChassisTables: + """OVN chassis-related tables, fetched once per `show()` invocation. + + Bundled into one object rather than passed as separate same-shaped + dict[str, dict] parameters -- those couldn't be told apart by a type + checker, so a transposed argument would silently look up the wrong + table instead of raising. + """ + + hcg_rows: dict[str, dict] + ha_chassis_rows: dict[str, dict] + gateway_chassis_rows: dict[str, dict] + sb_chassis_by_name: dict[str, dict] + + +def _describe_hcg(hcg_uuid: str | None, tables: ChassisTables) -> str: + """Resolve an ha_chassis_group uuid into a human-readable chassis summary.""" + if not hcg_uuid: + return "NOT LINKED" + hcg = tables.hcg_rows.get(hcg_uuid) + if hcg is None: + return f"{hcg_uuid} (row not found!)" + chassis_summary = _describe_chassis_refs( + ovn.as_list(hcg.get("ha_chassis")), + tables.ha_chassis_rows, + tables.sb_chassis_by_name, + ) + return f"{hcg['name']} -> {chassis_summary}" + + +def _localnet_tags( + conn_ctx: ConnectionContext, + switch_name: str | None, + all_lsp_rows: list[dict], + switch_row_cache: dict[str, dict | None], +) -> str: + """VLAN tag(s) of a network's localnet/uplink port(s), via its OVN Logical_Switch. + + A network can have more than one (e.g. one per leaf-switch-pair segment). + `all_lsp_rows` is the whole fleet's Logical_Switch_Port table, fetched + once by the caller rather than per port. `switch_row_cache` memoizes the + (cheap, scoped) Logical_Switch lookup across ports that share a network. + """ + if not switch_name: + return "(unknown network)" + if switch_name not in switch_row_cache: + rows = ovn.nbctl_find(conn_ctx, "Logical_Switch", f"name={switch_name}") + switch_row_cache[switch_name] = rows[0] if rows else None + switch_row = switch_row_cache[switch_name] + if switch_row is None: + return "(switch not found)" + lsp_uuids = set(ovn.as_list(switch_row.get("ports"))) + candidate_lsps = _rows_by_uuid(all_lsp_rows, lsp_uuids) + tags = [ + tag + for lsp in candidate_lsps + if lsp.get("type") == "localnet" + for tag in ovn.as_list(lsp.get("tag")) + ] + return ", ".join(str(tag) for tag in tags) if tags else "(no localnet port)" + + +@app.command("show") +def show( + ctx: typer.Context, + name_or_id: str = typer.Argument(..., help="Neutron router name or ID"), + flows: bool = typer.Option( + False, + "--flows", + help="Also dump southbound logical flows for this router (can be large)", + ), +) -> None: + """Show a router's gateway/internal IPs, HCG state, NAT rules, and SB flows.""" + conn_ctx: ConnectionContext = ctx.obj + print_connection_banner(conn_ctx, include_openstack=True) + + try: + conn = osclient.get_connection(conn_ctx.os_cloud) + router = osclient.resolve_router(conn, name_or_id) + except Exception as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(1) from exc + + ports = list(conn.network.ports(device_id=router.id)) + gateway_port_id: str | None = None + interface_port_ids: set[str] = set() + port_fixed_ips: dict[str, list[dict]] = {} + for port in ports: + port_fixed_ips[port.id] = port.fixed_ips + if port.device_owner == GATEWAY_DEVICE_OWNER: + gateway_port_id = port.id + elif port.device_owner == INTERFACE_DEVICE_OWNER: + interface_port_ids.add(port.id) + + ovn_name = f"{NEUTRON_PREFIX}{router.id}" + print(f"\nRouter {router.name} ({router.id})") + print(f"OVN Logical_Router: {ovn_name}") + + lr_rows = ovn.nbctl_find(conn_ctx, "Logical_Router", f"name={ovn_name}") + if not lr_rows: + typer.echo( + f"\nERROR: no OVN Logical_Router named {ovn_name} " + "-- router may not be scheduled in OVN yet.", + err=True, + ) + raise typer.Exit(1) + lr = lr_rows[0] + chassis_option = lr.get("options", {}).get("chassis") + router_is_centralized = bool(chassis_option) + router_type = ( + f"centralized (options:chassis={chassis_option})" + if router_is_centralized + else "distributed" + ) + print(f"Type: {router_type}") + + lrp_uuids = set(ovn.as_list(lr.get("ports"))) + all_lrps = ovn.nbctl_list(conn_ctx, "Logical_Router_Port") + lrps = _rows_by_uuid(all_lrps, lrp_uuids) + + hcg_rows = { + row["_uuid"]: row for row in ovn.nbctl_list(conn_ctx, "HA_Chassis_Group") + } + ha_chassis_rows = { + row["_uuid"]: row for row in ovn.nbctl_list(conn_ctx, "HA_Chassis") + } + gateway_chassis_rows = { + row["_uuid"]: row for row in ovn.nbctl_list(conn_ctx, "Gateway_Chassis") + } + sb_chassis_by_name = { + row["name"]: row for row in ovn.sbctl_list(conn_ctx, "Chassis") + } + tables = ChassisTables( + hcg_rows=hcg_rows, + ha_chassis_rows=ha_chassis_rows, + gateway_chassis_rows=gateway_chassis_rows, + sb_chassis_by_name=sb_chassis_by_name, + ) + router_chassis_live = "alive" if chassis_option in sb_chassis_by_name else "DEAD" + router_chassis_physnets = _chassis_physical_networks( + sb_chassis_by_name.get(chassis_option) + ) + # Fetched once and reused for every port below -- this is a fleet-wide + # table, so refetching it per port would scale with cluster size. + all_lsp_rows = ovn.nbctl_list(conn_ctx, "Logical_Switch_Port") + lsp_by_name = {row["name"]: row for row in all_lsp_rows} + switch_row_cache: dict[str, dict | None] = {} + + print("\nRouter ports:") + for lrp in lrps: + port_id = lrp["name"].removeprefix("lrp-") + role = _lrp_role(lrp["name"], gateway_port_id, interface_port_ids) + networks = ", ".join(ovn.as_list(lrp.get("networks"))) + neutron_ips = ", ".join( + fip["ip_address"] for fip in port_fixed_ips.get(port_id, []) + ) + print(f" - {lrp['name']} [{role}]") + print(f" Neutron port : {port_id}") + print(f" OVN networks : {networks or '(none)'}") + print(f" Neutron fixed IPs : {neutron_ips or '(none)'}") + + peer_lsp = _find_lsp(lsp_by_name, port_id) + switch_name = ( + (peer_lsp or {}).get("external_ids", {}).get("neutron:network_name") + ) + tags = _localnet_tags(conn_ctx, switch_name, all_lsp_rows, switch_row_cache) + print(f" Network VLAN tag : {tags}") + + hcg_uuid = lrp.get("ha_chassis_group") + gw_chassis_uuids = ovn.as_list(lrp.get("gateway_chassis")) + if hcg_uuid: + summary = _describe_hcg(hcg_uuid, tables) + print(f" HA_Chassis_Group : {summary}") + elif gw_chassis_uuids: + # VLAN/FLAT distributed gateways are scheduled by OVN's own L3 + # scheduler via gateway_chassis, not ha_chassis_group. + summary = _describe_chassis_refs( + gw_chassis_uuids, tables.gateway_chassis_rows, tables.sb_chassis_by_name + ) + print(f" Gateway_Chassis : {summary}") + elif router_is_centralized: + # options:chassis alone pins every port on a centralized router + # (gateway included) -- ovn-northd ignores/warns on + # ha_chassis_group here regardless of port role, so an unlinked + # HCG is expected, not a bug. + pin = ( + f"{chassis_option} ({router_chassis_live}, " + f"physnets={router_chassis_physnets})" + ) + print(f" Pinned chassis : {pin}") + elif role == "internal": + # Upstream OVN never sets ha_chassis_group on an internal + # router-interface LRP by default (only understack's + # vxlan-specific workaround does, and only for genuinely + # distributed routers) -- unset here is the normal state. + print( + " HA_Chassis_Group : (none) -- not set by default " + "on internal ports" + ) + else: + print( + " HA_Chassis_Group : NOT LINKED (likely bug -- no " + "ha_chassis_group or gateway_chassis found; see " + "scripts/cleanup_dead_ovn_ha_chassis.py)" + ) + + print("\nNAT rules:") + nat_uuids = set(ovn.as_list(lr.get("nat"))) + all_nat_rows = ovn.nbctl_list(conn_ctx, "NAT") + nat_rows = _rows_by_uuid(all_nat_rows, nat_uuids) + resolved_ports: dict[str, Any] = {} + if not nat_rows: + print(" (none)") + for nat in nat_rows: + nat_type = nat.get("type", "?") + external_ip = nat.get("external_ip") or "-" + logical_ip = nat.get("logical_ip") or "-" + line = f" {nat_type:<14} external={external_ip:<16} logical={logical_ip}" + try: + port_id, port = _resolve_nat_port(conn, nat) + except Exception as exc: + print(f"{line} -> ERROR resolving port: {exc}") + continue + if port_id and port is None: + line += f" -> port {port_id} NOT FOUND (dangling NAT rule?)" + elif port is not None: + line += f" -> port {port.id}" + resolved_ports[port.id] = port + print(line) + + print("\nPorts:") + if not resolved_ports: + print(" (none)") + for port in resolved_ports.values(): + fixed_ips = ", ".join(fip["ip_address"] for fip in (port.fixed_ips or [])) + print(f" {port.id} ({port.name or '(unnamed)'})") + print(f" Fixed IPs : {fixed_ips or '(none)'}") + print(f" Owner : {_describe_port_owner(conn, port)}") + lsp_row = _find_lsp(lsp_by_name, port.id) + print(f" OVN LSP : {_describe_lsp(lsp_row)}") + lsp_hcg_uuid = (lsp_row or {}).get("ha_chassis_group") + hcg_summary = _describe_hcg(lsp_hcg_uuid, tables) + print(f" HA_Chassis_Group : {hcg_summary}") + + if flows: + print("\nSouthbound logical flows (ovn-sbctl lflow-list):") + print(ovn.sbctl_lflow_list(conn_ctx, ovn_name).rstrip()) + + +@app.command("list") +def list_routers(ctx: typer.Context) -> None: + """List routers, cross-referencing presence in OpenStack (Neutron) and OVN.""" + conn_ctx: ConnectionContext = ctx.obj + print_connection_banner(conn_ctx, include_openstack=True) + + try: + conn = osclient.get_connection(conn_ctx.os_cloud) + except Exception as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(1) from exc + + openstack_routers = {r.id: r for r in conn.network.routers()} + + ovn_router_names: dict[str, str] = {} + for row in ovn.nbctl_list(conn_ctx, "Logical_Router"): + name = row.get("name") or "" + if name.startswith(NEUTRON_PREFIX): + ovn_router_names[name.removeprefix(NEUTRON_PREFIX)] = name + + all_ids = set(openstack_routers) | set(ovn_router_names) + table_rows = [] + for router_id in all_ids: + os_router = openstack_routers.get(router_id) + # Flavored routers (e.g. VRF) are handled by a different L3 backend + # entirely and never get an OVN Logical_Router -- "NO" there would + # look like a bug when it's actually expected. + flavored = bool(os_router and getattr(os_router, "flavor_id", None)) + name = os_router.name if os_router else "(unknown)" + in_openstack = "yes" if os_router else "NO" + in_ovn = ( + "n/a (flavored)" + if flavored + else ("yes" if router_id in ovn_router_names else "NO") + ) + table_rows.append((name, router_id, in_openstack, in_ovn)) + table_rows.sort(key=lambda row: row[0].lower()) + + print() + if not table_rows: + print("(no routers found)") + return + + name_width = max(len("NAME"), *(len(row[0]) for row in table_rows)) + id_width = max(len("ID"), *(len(row[1]) for row in table_rows)) + header = f"{'NAME':<{name_width}} {'ID':<{id_width}} {'OPENSTACK':<9} OVN" + print(header) + print("-" * len(header)) + for name, router_id, in_openstack, in_ovn in table_rows: + name_col = f"{name:<{name_width}}" + id_col = f"{router_id:<{id_width}}" + os_col = f"{in_openstack:<9}" + print(f"{name_col} {id_col} {os_col} {in_ovn}") diff --git a/python/kubectl-us-net/us_net/connection.py b/python/kubectl-us-net/us_net/connection.py new file mode 100644 index 000000000..d18b19006 --- /dev/null +++ b/python/kubectl-us-net/us_net/connection.py @@ -0,0 +1,55 @@ +"""Shared connection context: kube context/namespace/pods and OpenStack cloud target. + +Every subcommand prints a banner describing what it's actually talking to, +modeled on print_connection_banner() in scripts/cleanup_dead_ovn_ha_chassis.py. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +from us_net import osclient + + +@dataclass +class ConnectionContext: + kube_context: str | None + namespace: str + nb_pod: str + sb_pod: str + os_cloud: str | None + + def kubectl_base(self) -> list[str]: + cmd = ["kubectl"] + if self.kube_context: + cmd += ["--context", self.kube_context] + return cmd + + +def resolve_kube_context(kube_context: str | None) -> str: + """Return the kube context that will actually be used.""" + if kube_context: + return kube_context + result = subprocess.run( + ["kubectl", "config", "current-context"], capture_output=True, text=True + ) + return result.stdout.strip() or "(unknown)" + + +def print_connection_banner( + ctx: ConnectionContext, include_openstack: bool = False +) -> None: + """Print what cluster/namespace/pods/cloud this invocation is targeting.""" + print("=" * 64) + print("kubectl-us-net -- target environment") + print("=" * 64) + print(f" Kubernetes context : {resolve_kube_context(ctx.kube_context)}") + print(f" OVN namespace/pods : {ctx.namespace} (nb={ctx.nb_pod}, sb={ctx.sb_pod})") + if include_openstack: + cloud_label = ctx.os_cloud or "(default via OS_CLOUD / clouds.yaml)" + print(f" OpenStack cloud : {cloud_label}") + for label, val in osclient.describe_target(ctx.os_cloud): + print(f" {label:<16} : {val}") + print("=" * 64) + print(flush=True) diff --git a/python/kubectl-us-net/us_net/kube.py b/python/kubectl-us-net/us_net/kube.py new file mode 100644 index 000000000..f8644ade0 --- /dev/null +++ b/python/kubectl-us-net/us_net/kube.py @@ -0,0 +1,83 @@ +"""kubectl exec/get wrappers used by the raw passthrough and higher-level commands.""" + +from __future__ import annotations + +import json +import subprocess +import sys + +from us_net.connection import ConnectionContext + + +def _exec_argv( + ctx: ConnectionContext, pod: str, container: str | None, argv: list[str] +) -> list[str]: + cmd = ctx.kubectl_base() + ["exec", "-n", ctx.namespace, pod] + if container: + cmd += ["-c", container] + cmd += ["--", *argv] + return cmd + + +def exec_in_pod( + ctx: ConnectionContext, pod: str, container: str | None, argv: list[str] +) -> subprocess.CompletedProcess: + """Run `kubectl exec` in a pod and capture its output.""" + return subprocess.run( + _exec_argv(ctx, pod, container, argv), capture_output=True, text=True + ) + + +def stream_exec_in_pod( + ctx: ConnectionContext, pod: str, container: str | None, argv: list[str] +) -> int: + """Run `kubectl exec` with stdout/stderr inherited, for raw passthrough commands.""" + result = subprocess.run(_exec_argv(ctx, pod, container, argv)) + return result.returncode + + +def pods_on_node(ctx: ConnectionContext, node_name: str, name_prefix: str) -> list[str]: + """Pod names in ctx.namespace on node_name whose name starts with name_prefix.""" + cmd = ctx.kubectl_base() + ["get", "pods", "-n", ctx.namespace, "-o", "json"] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print( + f"ERROR: kubectl get pods failed:\n{result.stderr.strip()}", file=sys.stderr + ) + sys.exit(1) + pods = json.loads(result.stdout) + return [ + item["metadata"]["name"] + for item in pods.get("items", []) + if item.get("spec", {}).get("nodeName") == node_name + and item["metadata"]["name"].startswith(name_prefix) + ] + + +def resolve_node_pod( + ctx: ConnectionContext, node_name: str, name_prefix: str, explicit_pod: str | None +) -> str: + """Resolve the single pod to exec into for a node-targeted command. + + The DaemonSet pod naming comes from the upstream openstack-helm chart, + not anything vendored in this repo, so name_prefix may not match every + deployment -- explicit_pod (--pod) is the escape hatch when it's wrong. + """ + if explicit_pod: + return explicit_pod + candidates = pods_on_node(ctx, node_name, name_prefix) + if len(candidates) == 1: + return candidates[0] + if not candidates: + print( + f"ERROR: no pod named '{name_prefix}*' found on node {node_name!r} " + f"in namespace {ctx.namespace!r}. Pass --pod to target one explicitly.", + file=sys.stderr, + ) + else: + print( + f"ERROR: multiple candidate pods on node {node_name!r}: {candidates}. " + "Pass --pod to disambiguate.", + file=sys.stderr, + ) + sys.exit(1) diff --git a/python/kubectl-us-net/us_net/osclient.py b/python/kubectl-us-net/us_net/osclient.py new file mode 100644 index 000000000..abf12d904 --- /dev/null +++ b/python/kubectl-us-net/us_net/osclient.py @@ -0,0 +1,40 @@ +"""OpenStack SDK connection + router resolution helpers.""" + +from __future__ import annotations + +import openstack +from openstack.connection import Connection + + +def get_connection(os_cloud: str | None) -> Connection: + """Connect to OpenStack, falling back to OS_CLOUD/clouds.yaml when unset.""" + return openstack.connect(cloud=os_cloud) + + +def describe_target(os_cloud: str | None) -> list[tuple[str, str]]: + """(label, value) pairs describing the OpenStack target; never includes secrets.""" + try: + conn = get_connection(os_cloud) + except Exception as exc: + return [("status", f"unavailable ({exc})")] + config = conn.config.config + auth = config.get("auth", {}) + pairs = [ + (label, value) + for label, value in ( + ("auth URL", auth.get("auth_url")), + ("region", config.get("region_name")), + ("project", auth.get("project_name")), + ("username", auth.get("username")), + ) + if value + ] + return pairs or [("status", "(no auth details in configuration)")] + + +def resolve_router(conn: Connection, name_or_id: str): + """Resolve a Neutron router by name or ID, raising LookupError if not found.""" + router = conn.network.find_router(name_or_id) + if router is None: + raise LookupError(f"router {name_or_id!r} not found") + return router diff --git a/python/kubectl-us-net/us_net/ovn.py b/python/kubectl-us-net/us_net/ovn.py new file mode 100644 index 000000000..d01d2fd9c --- /dev/null +++ b/python/kubectl-us-net/us_net/ovn.py @@ -0,0 +1,88 @@ +"""ovn-nbctl / ovn-sbctl helpers: exec wrappers + OVSDB JSON unwrapping. + +The unwrap logic is ported from scripts/cleanup_dead_ovn_ha_chassis.py, which +already solved the problem of turning `--format=json` OVSDB output into plain +Python values. +""" + +from __future__ import annotations + +import json +import sys + +from us_net import kube +from us_net.connection import ConnectionContext + +OVSDB_CONTAINER = "ovsdb" + + +def _unwrap_ovn_value(val): + """Recursively unwrap an OVN JSON-encoded value.""" + if not isinstance(val, list) or len(val) < 2: + return val + tag = val[0] + if tag == "uuid": + return val[1] + if tag == "set": + return [_unwrap_ovn_value(v) for v in val[1]] + if tag == "map": + return {_unwrap_ovn_value(k): _unwrap_ovn_value(v) for k, v in val[1]} + return val + + +def parse_ovn_json(raw: str) -> list[dict]: + """Parse OVN --format=json list/find output into a list of row dicts.""" + obj = json.loads(raw) + headings = obj["headings"] + return [ + {h: _unwrap_ovn_value(v) for h, v in zip(headings, row, strict=True)} + for row in obj["data"] + ] + + +def as_list(val) -> list: + """OVSDB unwraps single-element sets to a bare value instead of a list.""" + if val in (None, ""): + return [] + return val if isinstance(val, list) else [val] + + +def _run(ctx: ConnectionContext, pod: str, ctl: str, args: list[str]) -> str: + result = kube.exec_in_pod(ctx, pod, OVSDB_CONTAINER, [ctl, *args]) + if result.returncode != 0: + print( + f"ERROR: {ctl} {' '.join(args)} failed:\n{result.stderr.strip()}", + file=sys.stderr, + ) + sys.exit(1) + return result.stdout + + +def nbctl_raw(ctx: ConnectionContext, args: list[str]) -> str: + """Run ovn-nbctl against the Northbound pod, returning raw stdout.""" + return _run(ctx, ctx.nb_pod, "ovn-nbctl", args) + + +def sbctl_raw(ctx: ConnectionContext, args: list[str]) -> str: + """Run ovn-sbctl against the Southbound pod, returning raw stdout.""" + return _run(ctx, ctx.sb_pod, "ovn-sbctl", args) + + +def nbctl_list(ctx: ConnectionContext, table: str) -> list[dict]: + """`ovn-nbctl list ` as parsed JSON rows.""" + return parse_ovn_json(nbctl_raw(ctx, ["--format=json", "list", table])) + + +def sbctl_list(ctx: ConnectionContext, table: str) -> list[dict]: + """`ovn-sbctl list
` as parsed JSON rows.""" + return parse_ovn_json(sbctl_raw(ctx, ["--format=json", "list", table])) + + +def nbctl_find(ctx: ConnectionContext, table: str, condition: str) -> list[dict]: + """`ovn-nbctl find
` as parsed JSON rows.""" + return parse_ovn_json(nbctl_raw(ctx, ["--format=json", "find", table, condition])) + + +def sbctl_lflow_list(ctx: ConnectionContext, datapath_name: str) -> str: + """`ovn-sbctl lflow-list ` raw table output.""" + return sbctl_raw(ctx, ["lflow-list", datapath_name]) diff --git a/python/kubectl-us-net/uv.lock b/python/kubectl-us-net/uv.lock new file mode 100644 index 000000000..43743ae7d --- /dev/null +++ b/python/kubectl-us-net/uv.lock @@ -0,0 +1,773 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "keystoneauth-websso" +version = "0.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "keystoneauth1" }, + { name = "multipart" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/87/6362ba7b9e48926aa0d81733af3b604ac2063a32a86594ea69ea3743e496/keystoneauth_websso-0.2.5.tar.gz", hash = "sha256:a30289dd4ae70ba56387bb8defe8da6e3eb7f9e6d289692d3cb5b0c7460b071c", size = 9761, upload-time = "2025-08-21T17:28:11.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f9/a97ba7d7c658f2216f378d1c9b8c093dd3bf0a53edf959051089a271192a/keystoneauth_websso-0.2.5-py3-none-any.whl", hash = "sha256:aac9f193546ca462b69025a6fcbca494630000d372608b5da0b345db5f8a5b5a", size = 12281, upload-time = "2025-08-21T17:28:09.627Z" }, +] + +[[package]] +name = "keystoneauth1" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso8601" }, + { name = "os-service-types" }, + { name = "requests" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/627b01cde69d0ece2fd552b8c7c34af06acf13a0a77d1829ff0b46a3b45f/keystoneauth1-5.15.0.tar.gz", hash = "sha256:ce2cacdfd028e65bd23ff403d6572ebfab3b006d6d2dde3aa85c263675a9fbb5", size = 288544, upload-time = "2026-07-03T16:21:31.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f6/6552fed59449604d982ffe9fe202b82f0724d333319812d1f544a95d3a09/keystoneauth1-5.15.0-py3-none-any.whl", hash = "sha256:9e2a4cf45cc2e2164093157091bc073eda1f334f755e8fab9fd25420c6f6acef", size = 342364, upload-time = "2026-07-03T16:21:29.866Z" }, +] + +[[package]] +name = "kubectl-us-net" +source = { editable = "." } +dependencies = [ + { name = "keystoneauth-websso" }, + { name = "openstacksdk" }, + { name = "typer" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-github-actions-annotate-failures" }, +] + +[package.metadata] +requires-dist = [ + { name = "keystoneauth-websso", specifier = ">=0.2.0,<0.3" }, + { name = "openstacksdk", specifier = ">=4.2.0,<5" }, + { name = "typer", specifier = ">=0.21,<0.22" }, +] + +[package.metadata.requires-dev] +test = [ + { name = "pytest", specifier = "<10" }, + { name = "pytest-cov", specifier = ">=6.1.0" }, + { name = "pytest-github-actions-annotate-failures" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +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" } +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" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "multipart" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/c4/f3f0c57ec3e845bae038d3849af0e2b9d3f62a046b2a09af3f697ad882d6/multipart-2.0.0.tar.gz", hash = "sha256:d6076567b489270271d9ec103d404745da1501ad5b70e3a7f1f07f5623d82850", size = 42606, upload-time = "2026-07-18T16:03:23.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/dd/feb9c23d5c5a382194a9f86b02e98b8724d745e1a1cf4ecb1a5c0f884aa1/multipart-2.0.0-py3-none-any.whl", hash = "sha256:b3d4d473c4b96bb43e0d54b76fcc3b89b904df28387daf54e08405eb2123a081", size = 16418, upload-time = "2026-07-18T16:03:22.599Z" }, +] + +[[package]] +name = "openstacksdk" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "decorator" }, + { name = "dogpile-cache" }, + { name = "iso8601" }, + { name = "jmespath" }, + { name = "jsonpatch" }, + { name = "keystoneauth1" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/1d/0238c57f7eb64170be6ff1703c3f87afa44a648f0672c91de7f66a58a11e/openstacksdk-4.17.0.tar.gz", hash = "sha256:827e1ade488db6116f59af1da6c97dbdfeeb879d3fc96bca99b19351f15df8ba", size = 1399124, upload-time = "2026-06-24T09:25:09.839Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/b8/7c48faf004219cba8cd160800f11fd8440a5ae18e621679da2d4607fa1fd/openstacksdk-4.17.0-py3-none-any.whl", hash = "sha256:3c968f8b97b38b7d73ddb88d16c6a7d80bcc554c5fbfcc12638a7b51bdae144e", size = 1988252, upload-time = "2026-06-24T09:25:08.325Z" }, +] + +[[package]] +name = "os-service-types" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/62/31e39aa8f2ac5bff0b061ce053f0610c9fe659e12aeca20bfb26d1665024/os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575", size = 27476, upload-time = "2025-11-21T13:55:47.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/26/0937af7b4383f1eba5bca789b8d191c0e09e59bb64962b18f4a14534ce41/os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e", size = 24876, upload-time = "2025-11-21T13:55:46.093Z" }, +] + +[[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" } +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 = "pbr" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[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 = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-github-actions-annotate-failures" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/a0/bdb91581b03c41016c78e16b8ec36c34d8508206fcb30f1951c9cdff2e97/pytest_github_actions_annotate_failures-0.4.2.tar.gz", hash = "sha256:5dd18304512361788bc7b5c5c805db853f03f4950c6be09b088de6bab8e2e6c9", size = 12158, upload-time = "2026-06-19T15:59:17.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/09/c44e658f3a27c588c2017d858c8b0fa962612af9b74326beabbf010c839c/pytest_github_actions_annotate_failures-0.4.2-py3-none-any.whl", hash = "sha256:02911cd3b55f235328a334f8ca6037a89944398b8e8b028c82de97111eff4071", size = 6151, upload-time = "2026-06-19T15:59:16.486Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +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" } +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" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[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" } +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" }, +] + +[[package]] +name = "stevedore" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/dd/04d56c2a5232358df41f3d0f0e31833d378b6c8ed7803a6b1b7867b0eba6/stevedore-5.9.0.tar.gz", hash = "sha256:abbd0af7a38a8bbb1d6adea2e35b17609cf004eaac323e88a8d8963640dd2b3c", size = 514850, upload-time = "2026-07-02T11:38:08.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/8d/008761f6e1000600e5303db30d05724bdcf3d2d186cbb59fac79b52e39ed/stevedore-5.9.0-py3-none-any.whl", hash = "sha256:e520945d4c257700eddc1eb1d79df04b2ea578eef185e0e3fa5b442fc848d3f7", size = 54463, upload-time = "2026-07-02T11:38:07.43Z" }, +] + +[[package]] +name = "typer" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/1e/a27cc02a0cd715118c71fa2aef2c687fdefc3c28d90fd0dd789c5118154c/typer-0.21.2.tar.gz", hash = "sha256:1abd95a3b675e17ff61b0838ac637fe9478d446d62ad17fa4bb81ea57cc54028", size = 120426, upload-time = "2026-02-10T19:33:46.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/cc/d59f893fbdfb5f58770c05febfc4086a46875f1084453621c35605cec946/typer-0.21.2-py3-none-any.whl", hash = "sha256:c3d8de54d00347ef90b82131ca946274f017cffb46683ae3883c360fa958f55c", size = 56728, upload-time = "2026-02-10T19:33:48.01Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]