commit bc36073036aab6c41a7c70d21f7156cd2a80dbbb Author: Dan Guido Date: Thu Jan 29 21:50:14 2026 -0500 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c4adab7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(uv sync:*)", + "Bash(uv run pytest:*)", + "Bash(uv add:*)", + "Bash(uv run ruff:*)", + "Bash(uv run mypy:*)", + "Bash(./lint.sh:*)", + "Bash(uv run tobcloud --help:*)", + "Bash(uv run tobcloud on --help:*)", + "Bash(uv run tobcloud off --help:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.claude/skills/git-worktrees/SKILL.md b/.claude/skills/git-worktrees/SKILL.md new file mode 100644 index 0000000..35aa61f --- /dev/null +++ b/.claude/skills/git-worktrees/SKILL.md @@ -0,0 +1,83 @@ +--- +name: git-worktrees +description: Use git worktrees when running multiple Claude Code instances in parallel for different features - creates isolated workspaces with separate branches and virtual environments +--- + +# Parallel Development with Git Worktrees + +## Overview + +Git worktrees create isolated workspaces sharing the same repository, allowing work on multiple branches simultaneously without switching. This is essential when running multiple Claude Code instances in parallel. + +**Core principle:** One worktree per Claude Code instance ensures isolation and prevents conflicts. + +**Announce at start:** "I'm using the git-worktrees skill to set up an isolated workspace." + +## When to Use + +- Running multiple Claude Code instances for different features +- Need to work on a feature branch while keeping main branch accessible +- Parallel development on separate tasks + +## Setup + +```bash +# Create worktrees for parallel development +git worktree add ../dropkit-feature-a feature-a +git worktree add ../dropkit-feature-b feature-b + +# Each worktree gets its own directory with full codebase +# Run Claude Code in each directory independently +``` + +## Guidelines for Parallel Instances + +1. **One worktree per Claude Code instance** - Never run multiple instances in the same directory +2. **Separate branches** - Each worktree should be on its own feature branch +3. **Independent `uv sync`** - Run `uv sync` in each worktree (creates separate `.venv`) +4. **Tests run independently** - Each worktree can run its own test suite without conflicts +5. **Merge via main branch** - When features are complete, merge branches to main + +## Managing Worktrees + +```bash +# List all worktrees +git worktree list + +# Remove a worktree when done +git worktree remove ../dropkit-feature-a + +# Prune stale worktree entries +git worktree prune +``` + +## Potential Conflicts to Avoid + +| Resource | Risk | Mitigation | +|----------|------|------------| +| `~/.config/dropkit/` | User config is shared | Don't modify during parallel dev | +| `~/.ssh/config` | SSH config is shared | Coordinate droplet names | +| DigitalOcean API | Creating droplets with same name | Use unique droplet names per worktree | + +## Quick Reference + +| Situation | Action | +|-----------|--------| +| Starting parallel work | Create new worktree with feature branch | +| New worktree created | Run `uv sync` to create isolated venv | +| Feature complete | Merge to main, remove worktree | +| Stale worktrees | Run `git worktree prune` | + +## Example Workflow + +``` +You: I'm using the git-worktrees skill to set up an isolated workspace. + +[Create worktree: git worktree add ../dropkit-auth feature/auth] +[Run uv sync] +[Run uv run pytest - all passing] + +Worktree ready at ../dropkit-auth +Tests passing +Ready to implement auth feature +``` diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..20756cc Binary files /dev/null and b/.coverage differ diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..3540a01 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,29 @@ +FROM mcr.microsoft.com/devcontainers/base:debian + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +# Switch to non-root user +USER vscode +WORKDIR /home/vscode + +# Add ~/.local/bin to PATH for Claude Code +ENV PATH="/home/vscode/.local/bin:$PATH" + +# Install Claude Code (installs to ~/.local/bin/claude) +RUN curl -fsSL https://claude.ai/install.sh | bash && \ + claude plugin marketplace add trailofbits/skills && \ + claude plugin marketplace add anthropics/skills && \ + claude plugin marketplace add obra/superpowers + +# Set environment variables +ENV DEVCONTAINER=true +ENV EDITOR=nano + +# Configure zsh and add aliases +RUN echo "export HISTFILE=\"\$HOME/.zsh_history_data/.zsh_history\"" >> ~/.zshrc && \ + echo "alias yolo=\"claude --dangerously-skip-permissions\"" >> ~/.zshrc + +WORKDIR /workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e1e2710 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json", + "name": "dropkit", + "build": { + "dockerfile": "Dockerfile" + }, + "runArgs": ["--hostname=dropkit-dev"], + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.13" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/tailscale/codespace/tailscale:1": {} + }, + "remoteUser": "vscode", + "workspaceFolder": "/workspace", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", + "mounts": [ + "source=dropkit-zsh-history,target=/home/vscode/.zsh_history_data,type=volume", + "source=dropkit-claude,target=/home/vscode/.claude_data,type=volume", + "source=dropkit-gh,target=/home/vscode/.gh_data,type=volume", + "source=dropkit-config,target=/home/vscode/.config/dropkit,type=volume", + "source=dropkit-ssh,target=/home/vscode/.ssh,type=volume" + ], + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "eamodio.gitlens", + "tamasfe.even-better-toml" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + "python.defaultInterpreterPath": "/workspace/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" + } + } + } + } + }, + "onCreateCommand": "uv sync", + "updateContentCommand": "uv sync", + "postCreateCommand": "sudo chown -R vscode:vscode /home/vscode/.config/dropkit /home/vscode/.claude_data /home/vscode/.gh_data /home/vscode/.zsh_history_data /home/vscode/.ssh", + "containerEnv": { + "UV_LINK_MODE": "copy", + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude_data", + "GH_CONFIG_DIR": "/home/vscode/.gh_data" + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..efa610a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 # Supply chain protection: wait for community vetting + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 # Supply chain protection: wait for community vetting + groups: + python-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7a7ad0c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run ruff check + run: uv run ruff check . + + - name: Run ruff format check + run: uv run ruff format --check . + + - name: Run type check + run: uv run ty check dropkit/ + + - name: Run tests + run: uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e37173b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Build-generated version file +dropkit/_version.txt + +# Virtual environments +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a4d3c20 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +# Pre-commit configuration +# Run with: prek run (or prek run --all-files) + +default_language_version: + python: python3.11 + +repos: + # Ruff - official pre-commit hooks (faster than local) + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.14 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + # prek builtin hooks - fast, no external deps + - repo: builtin + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + exclude: ^dropkit/templates/ # Jinja2 templates aren't valid YAML + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + + # ty type checking (no official pre-commit hook, use local) + - repo: local + hooks: + - id: ty-check + name: ty type check + entry: uv run ty check dropkit/ + language: system + types: [python] + pass_filenames: false + + # Shell script linting + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck + args: [--severity=error] diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..42e5564 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,171 @@ +# dropkit + +CLI tool for managing DigitalOcean droplets for Trail of Bits engineers. +Pre-configured cloud-init, Tailscale VPN (enabled by default), and SSH config management. + +## Critical Rules ⚠️ + +- **Never use `pip`** — always use `uv` for all Python operations +- **Always run `prek run`** before committing (or `prek install` to auto-run on commit) +- **Keep README.md in sync** when adding commands or features + +## Quick Commands + +```bash +uv sync # Install dependencies +prek install # Set up pre-commit hooks (one-time) +prek run # Run all checks (ruff, ty, shellcheck, etc.) +uv run pytest # Run tests +uv run dropkit --help # CLI help +``` + +## Project Structure + +``` +dropkit/ +├── pyproject.toml # Dependencies and metadata +├── CLAUDE.md # This file +├── README.md # User documentation +├── dropkit/ +│ ├── main.py # Typer CLI entry point +│ ├── config.py # Config with SSH key validation +│ ├── api.py # DigitalOcean REST API (see docstring for endpoints) +│ ├── cloudinit.py # Cloud-init template rendering +│ ├── ssh_config.py # SSH config manipulation +│ └── templates/ +│ └── default-cloud-init.yaml +└── tests/ + ├── test_api.py + ├── test_config.py + ├── test_main_helpers.py + └── test_ssh_config.py +``` + +## Technology Stack + +- **Python 3.11+** with `uv` (NOT pip) +- **CLI**: Typer + Rich +- **API**: Direct REST calls (requests library, no SDK) +- **Config**: YAML + Pydantic 2.x validation +- **Templating**: Jinja2 for cloud-init +- **Code Quality**: Ruff (linter + formatter), ty (types) + +## Key Conventions + +### Username +- **Derived from DigitalOcean account email**, not configured +- Fetched via `/v2/account`, sanitized for Linux compatibility +- `john.doe@trailofbits.com` → `john_doe` + +### SSH Hostname +- All SSH entries use `dropkit.` format +- Centralized in `get_ssh_hostname()` helper + +### Tags +- Default tags: `owner:` and `firewall` +- Additional tags **extend** defaults (never replace) +- Used for filtering, billing, and security + +### SSH Keys +- Only public keys (*.pub) accepted +- Strict validation rejects private keys +- Auto-detects id_ed25519.pub, id_rsa.pub, id_ecdsa.pub + +### Tailscale VPN +- **Enabled by default** for new droplets +- Locks down UFW to only allow tailscale0 interface +- Disable with `--no-tailscale` flag + +## Architecture Decisions + +1. **Username from email** — Not stored in config; fetched from DO API on demand. + Ensures consistency across machines. + +2. **SSH key validation** — Prevents accidental private key upload. + Validates format (ssh-rsa, ssh-ed25519, ecdsa-sha2-*, etc.). + +3. **Tags extend defaults** — `--tags` adds to defaults, never replaces. + Ensures owner tag always present. + +4. **Direct REST API** — No python-digitalocean library. + Simpler, fewer dependencies, full control. + +5. **Tailscale by default** — Secure VPN access without public SSH. + Local Tailscale required; keeps public IP if local Tailscale not running. + +6. **Pydantic validation** — Runtime type safety for config files. + Clear errors for invalid configurations. + +7. **SSH config backups** — Created at `~/.ssh/config.bak` before modifications. + Each backup overwrites previous. + +## Gotchas & Troubleshooting + +### Cloud-init JSON parsing (CRITICAL) +`cloud-init status --format=json` may return **non-zero exit code but valid JSON**. +**Always parse JSON regardless of subprocess return code.** + +```python +# CORRECT: Parse JSON even on error +result = subprocess.run([...], capture_output=True) +data = json.loads(result.stdout) # Don't check returncode first + +# WRONG: Checking returncode before parsing +if result.returncode == 0: # May skip valid JSON with error status + data = json.loads(result.stdout) +``` + +Status values: `"done"` (success), `"error"` (failed), `"running"` (in progress). + +### Disk resize is permanent +Cannot be undone. Use `--no-disk` to resize only CPU/memory. + +### SSH config backups overwrite +Only keeps one backup at `~/.ssh/config.bak`. + +## Development + +### Package Management +```bash +uv add # Add dependency +uv add --dev # Add dev dependency +uv sync # Install all +uv run # Run in venv +``` + +### Linting +```bash +prek run # Run all checks (required before commit) +prek run --all-files # Check all files, not just staged +uv run ruff check --fix . # Lint + autofix only +uv run ty check dropkit/ # Type check only +``` + +**Ruff config**: Python 3.11+, 100-char lines, modern syntax (`str | None`, `list[str]`). + +### Testing +```bash +uv run pytest # All tests +uv run pytest tests/test_api.py # Specific file +uv run pytest -k "validate_ssh" # Pattern match +uv run pytest -v # Verbose +``` + +## Pydantic Models + +- **`DropkitConfig`** — Root config with `extra='forbid'` +- **`DigitalOceanConfig`** — API token validation +- **`DefaultsConfig`** — Region, size, image slugs +- **`CloudInitConfig`** — Template path, SSH keys (min 1) +- **`SSHConfig`** — SSH config path, identity file +- **`TailscaleConfig`** — VPN settings (enabled, lock_down_firewall, auth_timeout) + +Config files: `~/.config/dropkit/config.yaml`, `~/.config/dropkit/cloud-init.yaml` + +## Shell Completion + +```bash +dropkit --install-completion zsh # Enable tab completion +``` + +Provides dynamic completion for droplet names (filtered by owner tag). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd81383 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Trail of Bits, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f60e10 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: dev lint format test audit + +dev: + uv sync --all-groups + +lint: + uv run ruff format --check . && uv run ruff check . && uv run ty check dropkit/ + +format: + uv run ruff format . + +test: + uv run pytest + +audit: + uv run pip-audit diff --git a/README.md b/README.md new file mode 100644 index 0000000..786bbe4 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# dropkit + +A command-line tool for managing DigitalOcean droplets with automated setup, SSH configuration, and lifecycle management. + +## Features + +- 🚀 **Quick droplet creation** with cloud-init automation +- 🔑 **Automatic SSH configuration** - just run `ssh dropkit.` +- 🔐 **Tailscale VPN** - secure access via Tailscale (enabled by default) +- 👤 **User management** - automatically creates your user account on droplets +- 🏷️ **Smart tagging** - organizes droplets by owner for easy filtering +- 🔒 **Security-first** - SSH key validation, confirmation prompts for destructive operations +- 📊 **Rich CLI** - beautiful tables, progress indicators, and helpful error messages +- 🔄 **Complete lifecycle** - create, list, resize, destroy droplets with ease +- 💤 **Hibernate/Wake** - snapshot and destroy to save costs, restore later with one command + +## Prerequisites + +- **Python 3.11+** +- **DigitalOcean account** with an API token ([create one here](https://cloud.digitalocean.com/account/api/tokens)) +- **SSH key pair** (usually `~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`) +- **uv** package manager ([install instructions](https://github.com/astral-sh/uv)) +- **Tailscale** (optional but recommended) - install from [tailscale.com/download](https://tailscale.com/download) + +## Installation + +```bash +# HTTPS or SSH depending on your GitHub setup +uv tool install git+https://github.com/trailofbits/dropkit.git +uv tool install git+ssh://git@github.com/trailofbits/dropkit.git + +# Upgrade +uv tool upgrade dropkit +``` + +## Quick Start + +### 1. Initialize Configuration + +Run the initialization wizard: + +```bash +dropkit init +``` + +This will validate your DigitalOcean API token, detect SSH keys, register them with DigitalOcean, and let you choose defaults (type `?` for help to see available options). + +### 2. Create Your First Droplet + +```bash +# Interactive mode - prompts for name, region, size, image (type ? for help) +dropkit create + +# Or specify the name and use defaults +dropkit create my-first-droplet + +# Assign to a specific project (by name or ID) +dropkit create my-droplet --project "My Project" + +# Create without Tailscale VPN +dropkit create my-droplet --no-tailscale +``` + +The tool will: +1. Create the droplet and wait for it to become active +2. Add SSH configuration automatically +3. Wait for cloud-init to complete +4. **Tailscale setup** (enabled by default): + - Display an auth URL for you to authenticate in your browser + - Update SSH config with your Tailscale IP + - Lock down the firewall to only allow Tailscale traffic + +### 3. Connect via SSH + +```bash +ssh dropkit.my-first-droplet +``` + +Your user account is already set up with your SSH keys. + +## Available Commands + +``` +Usage: dropkit [OPTIONS] COMMAND [ARGS]... + +Manage DigitalOcean droplets + +Commands: + init Initialize dropkit configuration. + create Create a new DigitalOcean droplet with cloud-init configuration. + list List droplets and hibernated snapshots tagged with owner:. + config-ssh Configure SSH for an existing droplet. + info Show detailed information about a droplet. + rename Rename a droplet (requires confirmation). + destroy Destroy a droplet or hibernated snapshot (DESTRUCTIVE). + resize Resize a droplet (causes downtime - requires power off). + on Power on a droplet. + off Power off a droplet (requires confirmation). + hibernate Hibernate a droplet (snapshot and destroy to save costs). + wake Wake a hibernated droplet (restore from snapshot). + enable-tailscale Enable Tailscale VPN on an existing droplet. + list-ssh-keys List SSH keys registered via dropkit. + add-ssh-key Add or import an SSH public key to DigitalOcean. + delete-ssh-key Delete an SSH key registered via dropkit. + version Show the version of dropkit. +``` + +Use `dropkit --help` for detailed help on any command. + +## Configuration + +Configuration files are stored in `~/.config/dropkit/`: + +- **`config.yaml`** - Main configuration (API token, defaults, SSH keys) +- **`cloud-init.yaml`** - Cloud-init template (customizable) + +### Default Tags + +All droplets are automatically tagged with: + +- `owner:` - Your DigitalOcean account username (derived from email) +- `firewall` - For security group identification + +### Projects + +- **Set default project** during `dropkit init` (type `?` to see available projects) +- **Override per-droplet** using `--project ` with `dropkit create` +- Specify by name or UUID; tab completion available + +### SSH Hostname Convention + +All SSH config entries use the prefix `dropkit.`: + +- Connect with: `ssh dropkit.my-droplet` + +### Shell Completion + +Enable tab completion for droplet names in your shell: + +**Zsh (recommended):** +```bash +dropkit --install-completion zsh +``` + +**Bash:** +```bash +dropkit --install-completion bash +``` + +After installation, restart your shell. Tab completion dynamically fetches your droplets from DigitalOcean: + +```bash +dropkit info # Shows your droplets +dropkit destroy # Shows your droplets +dropkit resize # Shows your droplets +dropkit on # Shows your droplets +dropkit off # Shows your droplets +``` + +### Hibernate and Wake (Cost Saving) + +DigitalOcean charges for stopped droplets at the full hourly rate. To avoid this, use hibernate/wake: + +```bash +# Hibernate: snapshot the droplet and destroy it (stops billing) +dropkit hibernate my-droplet + +# Wake: restore the droplet from the snapshot +dropkit wake my-droplet + +# Delete a hibernated snapshot without restoring +dropkit destroy my-droplet +``` + +**How it works:** +1. `hibernate` powers off the droplet, creates a snapshot (`dropkit-`), then destroys the droplet +2. `wake` creates a new droplet from the snapshot with the same region and size +3. Snapshots are tagged with `owner:` and `size:` for tracking +4. After waking, you're prompted to delete the snapshot (default: yes) + +**Note:** Snapshots are billed at $0.06/GB/month, which is typically much cheaper than keeping a droplet running. + +### Cloud-Init Customization + +Edit `~/.config/dropkit/cloud-init.yaml` to customize user setup, package installation, firewall rules, and shell configuration. The template uses Jinja2 syntax with variables `{{ username }}` and `{{ ssh_keys }}`. + +## Troubleshooting + +### "Config not found. Run 'dropkit init' first" + +Initialize the configuration: + +```bash +dropkit init +``` + +### Cloud-init failed or timeout + +Check cloud-init status manually: + +```bash +ssh dropkit.my-droplet 'sudo cloud-init status' +ssh dropkit.my-droplet 'sudo cat /var/log/cloud-init.log' +``` + +Use `--verbose` flag to see detailed output: + +```bash +dropkit create my-droplet --verbose +``` + +### "Droplet not found with tag owner:" + +The droplet might belong to someone else. List your droplets: + +```bash +dropkit list +``` + +## Technology Stack + +- **CLI Framework**: [Typer](https://typer.tiangolo.com/) - Modern CLI framework +- **UI/Display**: [Rich](https://rich.readthedocs.io/) - Terminal formatting +- **API Client**: [requests](https://requests.readthedocs.io/) - HTTP library +- **Configuration**: [Pydantic](https://docs.pydantic.dev/) - Data validation +- **Templating**: [Jinja2](https://jinja.palletsprojects.com/) - Cloud-init templates +- **Package Manager**: [uv](https://github.com/astral-sh/uv) - Fast Python package manager +- **Code Quality**: Ruff (linter/formatter) + ty (type checker) + +## Appendix: API Token Permissions + +### Creating Your Token + +1. Go to [DigitalOcean API Tokens](https://cloud.digitalocean.com/account/api/tokens) +2. Click **Generate New Token** with name "dropkit-cli" +3. Select **Custom Scopes** (recommended) or **Full Access** (simpler) +4. For custom scopes, enable the 23 scopes listed below + +### Required Scopes (23 total) + +`account:read`, `actions:read`, `droplet:create`, `droplet:read`, `droplet:update`, `droplet:delete`, `image:create`, `image:read`, `image:update`, `image:delete`, `project:read`, `project:update`, `regions:read`, `sizes:read`, `snapshot:read`, `snapshot:delete`, `ssh_key:create`, `ssh_key:read`, `ssh_key:update`, `ssh_key:delete`, `tag:read`, `tag:create`, `vpc:read` + +### Scope Reference by Feature + +| Feature | Required Scopes | +|---------|----------------| +| **Initialize config** | `account:read`, `regions:read`, `sizes:read`, `image:read`, `ssh_key:read`, `ssh_key:create`, `project:read` | +| **Create droplets** | `droplet:create`, `project:update`, `actions:read`, `tag:create` | +| **List droplets** | `droplet:read`, `snapshot:read`, `tag:read` | +| **Show droplet info** | `droplet:read` | +| **Destroy droplets** | `droplet:delete`, `snapshot:delete` | +| **Rename droplets** | `droplet:update` | +| **Resize droplets** | `droplet:update`, `sizes:read`, `actions:read` | +| **Power on/off** | `droplet:update`, `actions:read` | +| **Hibernate** | `droplet:update`, `droplet:delete`, `snapshot:create`, `actions:read`, `tag:create` | +| **Wake** | `droplet:create`, `snapshot:read`, `snapshot:delete` | +| **Manage SSH keys** | `ssh_key:read`, `ssh_key:create`, `ssh_key:update`, `ssh_key:delete` | + +For more information, see the [DigitalOcean API Token Scopes documentation](https://docs.digitalocean.com/reference/api/scopes/). diff --git a/dropkit/__init__.py b/dropkit/__init__.py new file mode 100644 index 0000000..acf012f --- /dev/null +++ b/dropkit/__init__.py @@ -0,0 +1,54 @@ +"""dropkit - Manage DigitalOcean droplets for ToB engineers.""" + +import subprocess +from pathlib import Path + + +def _get_version() -> str: + """ + Get version string. + + Returns: + - "dev" if running from git repository (development mode) + - "0.1.0+git." if installed (commit hash embedded at build time) + - "0.1.0" fallback if version cannot be determined + """ + # Check if we're in a git repository (development mode) + try: + repo_root = Path(__file__).parent.parent + if (repo_root / ".git").exists(): + return "dev" + except Exception: + pass + + # Installed mode - check for embedded version file (created at build time) + try: + version_file = Path(__file__).parent / "_version.txt" + if version_file.exists(): + commit = version_file.read_text().strip() + if commit: + return f"0.1.0+git.{commit}" + except Exception: + pass + + # Fallback: try to get commit from git (in case running from source) + try: + result = subprocess.run( + ["git", "rev-parse", "--short=7", "HEAD"], + capture_output=True, + text=True, + timeout=1, + cwd=Path(__file__).parent, + ) + if result.returncode == 0: + commit = result.stdout.strip() + if commit: + return f"0.1.0+git.{commit}" + except Exception: + pass + + # Final fallback to base version + return "0.1.0" + + +__version__ = _get_version() diff --git a/dropkit/api.py b/dropkit/api.py new file mode 100644 index 0000000..eb43c18 --- /dev/null +++ b/dropkit/api.py @@ -0,0 +1,954 @@ +"""DigitalOcean API client using raw REST API calls. + +API Reference +============= + +Base URL: https://api.digitalocean.com/v2 +Auth: Authorization: Bearer + +Key Endpoints +------------- + +Account & SSH Keys: + GET /account Account info (includes email for username) + GET /account/keys List SSH keys (paginated) + GET /account/keys/{fingerprint} Get SSH key by fingerprint + POST /account/keys Add new SSH key + PUT /account/keys/{id} Update SSH key name + DELETE /account/keys/{id} Delete SSH key + +Droplets: + POST /droplets Create droplet + GET /droplets List droplets + GET /droplets?tag_name=X Filter by tag + GET /droplets/{id} Get droplet info + DELETE /droplets/{id} Delete droplet + POST /droplets/{id}/actions Perform action (resize, power_on, power_off, snapshot) + +Metadata: + GET /regions List regions (paginated) + GET /sizes List droplet sizes (paginated) + GET /images List images (paginated) + +Actions: + GET /actions/{id} Check action status + +Projects: + GET /projects List projects (paginated) + GET /projects/{project_id} Get project by UUID + GET /projects/default Get default project + POST /projects/{project_id}/resources Assign resources (body: {"resources": ["do:droplet:123"]}) + +Snapshots: + GET /snapshots List snapshots (filter: resource_type=droplet) + GET /snapshots/{id} Get snapshot + DELETE /snapshots/{id} Delete snapshot + +Tags: + POST /tags Create tag + POST /tags/{name}/resources Tag a resource + +Pagination +---------- +Uses `page` and `per_page` query params (max 200/page). +This module auto-handles pagination by following `links.pages.next` URLs. +""" + +import re +from typing import Any + +import requests + + +class DigitalOceanAPIError(Exception): + """Exception raised for DigitalOcean API errors.""" + + def __init__(self, message: str, status_code: int | None = None): + self.status_code = status_code + super().__init__(message) + + +class DigitalOceanAPI: + """Client for DigitalOcean REST API.""" + + def __init__(self, token: str): + """Initialize API client with authentication token.""" + self.token = token + self.base_url = "https://api.digitalocean.com/v2" + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + ) + + def get_account(self) -> dict[str, Any]: + """ + Get account information. + + Returns: + Account object with details like email, droplet_limit, status, etc. + """ + response = self._request("GET", "/account") + return response.get("account", {}) + + def get_username(self) -> str: + """ + Get username from DigitalOcean account email. + + Fetches the account information and sanitizes the email address + to create a valid Linux username. + + Returns: + Sanitized username from DigitalOcean account + + Raises: + DigitalOceanAPIError: If account email cannot be fetched or is empty + """ + account = self.get_account() + email = account.get("email", "") + + if not email: + raise DigitalOceanAPIError("No email found in DigitalOcean account") + + return self._sanitize_email_for_username(email) + + @staticmethod + def _sanitize_email_for_username(email: str) -> str: + """ + Sanitize email address to create a valid username. + + Removes @trailofbits.com suffix and replaces special characters. + + Args: + email: Email address from DigitalOcean account + + Returns: + Sanitized username suitable for Linux user creation + """ + # Remove @trailofbits.com suffix (case insensitive) + username = re.sub(r"@trailofbits\.com$", "", email, flags=re.IGNORECASE) + + # If no @trailofbits.com, just take the part before @ + if "@" in username: + username = username.split("@")[0] + + # Replace dots, hyphens, and other special characters with underscores + username = re.sub(r"[^a-z0-9_]", "_", username.lower()) + + # Remove leading/trailing underscores + username = username.strip("_") + + # Ensure it starts with a letter (Linux username requirement) + if username and not username[0].isalpha(): + username = "u" + username + + # Fallback if sanitization results in empty string + if not username: + username = "user" + + return username + + @staticmethod + def _validate_positive_int(value: int, name: str) -> None: + """ + Validate that an integer ID is positive. + + Args: + value: The integer to validate + name: Name of the parameter (for error message) + + Raises: + ValueError: If the value is not positive + """ + if value <= 0: + raise ValueError(f"{name} must be a positive integer, got: {value}") + + def _request( + self, + method: str, + endpoint: str, + **kwargs, + ) -> dict[str, Any]: + """Make an API request.""" + url = f"{self.base_url}{endpoint}" + + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + if response.status_code == 204 and response.text == "": + return {} + + return response.json() + except requests.exceptions.HTTPError as e: + error_msg = f"API request failed: {e}" + if e.response is not None: + try: + error_data = e.response.json() + if "message" in error_data: + error_msg = f"API error: {error_data['message']}" + except ValueError: + pass + raise DigitalOceanAPIError(error_msg, e.response.status_code) + raise DigitalOceanAPIError(error_msg) from e + except requests.exceptions.RequestException as e: + raise DigitalOceanAPIError(f"Network error: {e}") from e + + def _get_paginated( + self, + endpoint: str, + key: str, + per_page: int = 200, + extra_params: dict[str, str] | None = None, + max_pages: int = 1000, + ) -> list[dict[str, Any]]: + """ + Fetch all pages of a paginated API endpoint. + + Args: + endpoint: API endpoint to fetch + key: Key in response containing the items (e.g., 'regions', 'sizes') + per_page: Number of items per page (default 200, max 200) + extra_params: Additional query parameters to include (safely encoded) + max_pages: Maximum number of pages to fetch (default 1000, prevents DoS) + + Returns: + List of all items across all pages + + Raises: + DigitalOceanAPIError: If max_pages limit is reached + """ + all_items = [] + page = 1 + + while True: + # Build params dict safely + params: dict[str, str | int] = {"page": page, "per_page": per_page} + if extra_params: + params.update(extra_params) + + response = self._request("GET", endpoint, params=params) + + items = response.get(key, []) + all_items.extend(items) + + # Check if there are more pages + links = response.get("links", {}) + pages = links.get("pages", {}) + + # If there's no "next" link, we're done + if "next" not in pages: + break + + # Safety: prevent infinite pagination + if page >= max_pages: + raise DigitalOceanAPIError( + f"Pagination limit reached: {max_pages} pages. " + "This may indicate an API issue or misconfiguration." + ) + + page += 1 + + return all_items + + def get_regions(self) -> list[dict[str, Any]]: + """ + Fetch all available regions (handles pagination). + + Returns: + List of region objects with slug, name, available, features, etc. + """ + return self._get_paginated("/regions", "regions") + + def get_sizes(self) -> list[dict[str, Any]]: + """ + Fetch all available droplet sizes (handles pagination). + + Returns: + List of size objects with slug, memory, vcpus, disk, price, etc. + """ + return self._get_paginated("/sizes", "sizes") + + def get_available_regions(self) -> list[dict[str, Any]]: + """Get only available regions.""" + regions = self.get_regions() + return [r for r in regions if r.get("available", False)] + + def get_available_sizes(self) -> list[dict[str, Any]]: + """Get only available sizes.""" + sizes = self.get_sizes() + return [s for s in sizes if s.get("available", False)] + + def get_images(self, image_type: str = "distribution") -> list[dict[str, Any]]: + """ + Fetch all available images (handles pagination). + + Args: + image_type: Filter by image type ('distribution', 'application', or 'all') + + Returns: + List of image objects with slug, name, distribution, etc. + """ + if image_type == "all": + return self._get_paginated("/images", "images") + else: + # Use extra_params to safely pass query parameters + return self._get_paginated("/images", "images", extra_params={"type": image_type}) + + def get_available_images(self, image_type: str = "distribution") -> list[dict[str, Any]]: + """ + Get only available distribution images. + + Args: + image_type: Filter by image type ('distribution', 'application', or 'all') + + Returns: + List of available image objects + """ + images = self.get_images(image_type) + # Filter for public images that are available + return [ + img for img in images if img.get("public", False) and img.get("status") == "available" + ] + + def create_droplet( + self, + name: str, + region: str, + size: str, + image: str, + user_data: str, + tags: list[str], + ssh_keys: list[int] | None = None, + ) -> dict[str, Any]: + """ + Create a new droplet. + + Args: + name: Droplet name + region: Region slug (e.g., 'nyc3') + size: Size slug (e.g., 's-2vcpu-4gb') + image: Image slug (e.g., 'ubuntu-25-04-x64') + user_data: Cloud-init user data + tags: List of tags to apply + ssh_keys: List of SSH key IDs for root access (optional) + + Returns: + Droplet object from API response + """ + payload: dict[str, Any] = { + "name": name, + "region": region, + "size": size, + "image": image, + "user_data": user_data, + "tags": tags, + } + + if ssh_keys: + payload["ssh_keys"] = ssh_keys + + response = self._request("POST", "/droplets", json=payload) + return response.get("droplet", {}) + + def get_droplet(self, droplet_id: int) -> dict[str, Any]: + """ + Get droplet information by ID. + + Args: + droplet_id: Droplet ID + + Returns: + Droplet object + + Raises: + ValueError: If droplet_id is not positive + """ + self._validate_positive_int(droplet_id, "droplet_id") + response = self._request("GET", f"/droplets/{droplet_id}") + return response.get("droplet", {}) + + def list_droplets(self, tag_name: str | None = None) -> list[dict[str, Any]]: + """ + List all droplets, optionally filtered by tag. + + Args: + tag_name: Optional tag to filter by (e.g., 'owner:myname') + + Returns: + List of droplet objects + """ + if tag_name: + # Use tag-based filtering with safe parameter encoding + return self._get_paginated("/droplets", "droplets", extra_params={"tag_name": tag_name}) + else: + # Get all droplets + return self._get_paginated("/droplets", "droplets") + + def wait_for_droplet_active( + self, + droplet_id: int, + timeout: int = 300, + poll_interval: int = 5, + ) -> dict[str, Any]: + """ + Wait for droplet to become active. + + Args: + droplet_id: Droplet ID + timeout: Maximum time to wait in seconds (default 300) + poll_interval: Time between polls in seconds (default 5) + + Returns: + Final droplet object + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If timeout is reached or droplet enters error state + """ + import time + + self._validate_positive_int(droplet_id, "droplet_id") + start_time = time.time() + + while True: + droplet = self.get_droplet(droplet_id) + status = droplet.get("status", "") + + if status == "active": + return droplet + elif status == "error": + raise DigitalOceanAPIError( + f"Droplet entered error state: {droplet.get('name', droplet_id)}" + ) + + elapsed = time.time() - start_time + if elapsed > timeout: + raise DigitalOceanAPIError( + f"Timeout waiting for droplet to become active (waited {elapsed:.0f}s)" + ) + + time.sleep(poll_interval) + + def list_ssh_keys(self) -> list[dict[str, Any]]: + """ + List all SSH keys in the account. + + Returns: + List of SSH key objects with id, fingerprint, public_key, name + """ + return self._get_paginated("/account/keys", "ssh_keys") + + def get_ssh_key_by_fingerprint(self, fingerprint: str) -> dict[str, Any] | None: + """ + Get SSH key by fingerprint. + + Args: + fingerprint: SSH key fingerprint (MD5 format: aa:bb:cc:...) + + Returns: + SSH key object if found, None if not found + """ + try: + response = self._request("GET", f"/account/keys/{fingerprint}") + return response.get("ssh_key", {}) + except DigitalOceanAPIError as e: + # 404 means key doesn't exist + if e.status_code == 404: + return None + raise + + def add_ssh_key(self, name: str, public_key: str) -> dict[str, Any]: + """ + Add a new SSH key to the account. + + Args: + name: Name for the SSH key + public_key: The full SSH public key content + + Returns: + SSH key object from API response + """ + payload = { + "name": name, + "public_key": public_key, + } + + response = self._request("POST", "/account/keys", json=payload) + return response.get("ssh_key", {}) + + def update_ssh_key(self, key_id: int, name: str) -> dict[str, Any]: + """ + Update an SSH key name. + + Args: + key_id: The SSH key ID to update + name: New name for the SSH key + + Returns: + Updated SSH key object from API response + + Raises: + ValueError: If key_id is not positive + DigitalOceanAPIError: If update fails + """ + self._validate_positive_int(key_id, "key_id") + payload = {"name": name} + response = self._request("PUT", f"/account/keys/{key_id}", json=payload) + return response.get("ssh_key", {}) + + def delete_ssh_key(self, key_id: int) -> None: + """ + Delete an SSH key from the account. + + Args: + key_id: The SSH key ID to delete + + Raises: + ValueError: If key_id is not positive + DigitalOceanAPIError: If deletion fails + """ + self._validate_positive_int(key_id, "key_id") + self._request("DELETE", f"/account/keys/{key_id}") + + def delete_droplet(self, droplet_id: int) -> None: + """ + Delete a droplet by ID. + + Args: + droplet_id: Droplet ID to delete + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If deletion fails + """ + self._validate_positive_int(droplet_id, "droplet_id") + self._request("DELETE", f"/droplets/{droplet_id}") + + def rename_droplet(self, droplet_id: int, new_name: str) -> dict[str, Any]: + """ + Rename a droplet. + + Args: + droplet_id: Droplet ID + new_name: New name for the droplet + + Returns: + Action object with id, status, etc. + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If rename fails + """ + self._validate_positive_int(droplet_id, "droplet_id") + payload = {"type": "rename", "name": new_name} + response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload) + return response.get("action", {}) + + def resize_droplet(self, droplet_id: int, size: str, disk: bool = True) -> dict[str, Any]: + """ + Resize a droplet (requires power off, causes downtime). + + Args: + droplet_id: Droplet ID + size: New size slug (e.g., 's-4vcpu-8gb') + disk: Whether to resize disk (permanent, cannot be undone). Default: True + + Returns: + Action object with id, status, etc. + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If resize fails + """ + self._validate_positive_int(droplet_id, "droplet_id") + payload = { + "type": "resize", + "size": size, + "disk": disk, + } + + response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload) + return response.get("action", {}) + + def power_on_droplet(self, droplet_id: int) -> dict[str, Any]: + """ + Power on a droplet. + + Args: + droplet_id: Droplet ID + + Returns: + Action object with id, status, etc. + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If power on fails + """ + self._validate_positive_int(droplet_id, "droplet_id") + payload = {"type": "power_on"} + response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload) + return response.get("action", {}) + + def power_off_droplet(self, droplet_id: int) -> dict[str, Any]: + """ + Power off a droplet. + + Args: + droplet_id: Droplet ID + + Returns: + Action object with id, status, etc. + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If power off fails + """ + self._validate_positive_int(droplet_id, "droplet_id") + payload = {"type": "power_off"} + response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload) + return response.get("action", {}) + + def get_action(self, action_id: int) -> dict[str, Any]: + """ + Get action status by ID. + + Args: + action_id: Action ID + + Returns: + Action object with id, status, type, etc. + + Raises: + ValueError: If action_id is not positive + DigitalOceanAPIError: If request fails + """ + self._validate_positive_int(action_id, "action_id") + response = self._request("GET", f"/actions/{action_id}") + return response.get("action", {}) + + def list_droplet_actions(self, droplet_id: int) -> list[dict[str, Any]]: + """ + List all actions for a droplet. + + Args: + droplet_id: Droplet ID + + Returns: + List of action objects (most recent first) + + Raises: + ValueError: If droplet_id is not positive + """ + self._validate_positive_int(droplet_id, "droplet_id") + return self._get_paginated(f"/droplets/{droplet_id}/actions", "actions") + + def wait_for_action_complete( + self, + action_id: int, + timeout: int = 300, + poll_interval: int = 5, + ) -> dict[str, Any]: + """ + Wait for action to complete. + + Args: + action_id: Action ID + timeout: Maximum time to wait in seconds (default 300) + poll_interval: Time between polls in seconds (default 5) + + Returns: + Final action object + + Raises: + ValueError: If action_id is not positive + DigitalOceanAPIError: If timeout is reached or action enters error state + """ + import time + + self._validate_positive_int(action_id, "action_id") + start_time = time.time() + + while True: + action = self.get_action(action_id) + status = action.get("status", "") + + if status == "completed": + return action + elif status == "errored": + raise DigitalOceanAPIError( + f"Action failed: {action.get('type', 'unknown')} (ID: {action_id})" + ) + + elapsed = time.time() - start_time + if elapsed > timeout: + raise DigitalOceanAPIError( + f"Timeout waiting for action to complete (waited {elapsed:.0f}s)" + ) + + time.sleep(poll_interval) + + def list_projects(self) -> list[dict[str, Any]]: + """ + List all projects in the account. + + Returns: + List of project objects with id, name, description, purpose, etc. + """ + return self._get_paginated("/projects", "projects") + + def get_project(self, project_id: str) -> dict[str, Any] | None: + """ + Get a specific project by ID. + + Args: + project_id: Project UUID + + Returns: + Project object if found, None if not found (404) + + Raises: + DigitalOceanAPIError: If request fails (non-404 errors) + """ + try: + response = self._request("GET", f"/projects/{project_id}") + return response.get("project", {}) + except DigitalOceanAPIError as e: + # 404 means project doesn't exist + if e.status_code == 404: + return None + raise + + def get_default_project(self) -> dict[str, Any] | None: + """ + Get the default project for the account. + + Returns: + Project object if default project exists, None if not found + + Raises: + DigitalOceanAPIError: If request fails (non-404 errors) + """ + try: + response = self._request("GET", "/projects/default") + return response.get("project", {}) + except DigitalOceanAPIError as e: + # 404 means no default project + if e.status_code == 404: + return None + raise + + def assign_resources_to_project( + self, project_id: str, resource_urns: list[str] + ) -> dict[str, Any]: + """ + Assign resources to a project. + + Args: + project_id: Project UUID + resource_urns: List of resource URNs (e.g., ["do:droplet:12345"]) + + Returns: + Response with assigned resources + + Raises: + DigitalOceanAPIError: If assignment fails + """ + payload = {"resources": resource_urns} + response = self._request("POST", f"/projects/{project_id}/resources", json=payload) + return response + + @staticmethod + def get_droplet_urn(droplet_id: int) -> str: + """ + Get the URN (Uniform Resource Name) for a droplet. + + Args: + droplet_id: Droplet ID + + Returns: + URN string in format "do:droplet:{id}" + """ + return f"do:droplet:{droplet_id}" + + # Snapshot methods + + def create_snapshot(self, droplet_id: int, name: str) -> dict[str, Any]: + """ + Create a snapshot of a droplet. + + Args: + droplet_id: Droplet ID to snapshot + name: Name for the snapshot + + Returns: + Action object with id, status, etc. + + Raises: + ValueError: If droplet_id is not positive + DigitalOceanAPIError: If snapshot creation fails + """ + self._validate_positive_int(droplet_id, "droplet_id") + payload = { + "type": "snapshot", + "name": name, + } + response = self._request("POST", f"/droplets/{droplet_id}/actions", json=payload) + return response.get("action", {}) + + def list_snapshots(self, tag: str | None = None) -> list[dict[str, Any]]: + """ + List snapshots, optionally filtered by tag. + + Args: + tag: Optional tag to filter by (e.g., 'owner:myname') + + Returns: + List of snapshot objects + """ + extra_params: dict[str, str] = {"resource_type": "droplet"} + if tag: + extra_params["tag_name"] = tag + return self._get_paginated("/snapshots", "snapshots", extra_params=extra_params) + + def get_snapshot(self, snapshot_id: int) -> dict[str, Any] | None: + """ + Get a snapshot by ID. + + Args: + snapshot_id: Snapshot ID + + Returns: + Snapshot object if found, None if not found (404) + + Raises: + ValueError: If snapshot_id is not positive + DigitalOceanAPIError: If request fails (non-404 errors) + """ + self._validate_positive_int(snapshot_id, "snapshot_id") + try: + response = self._request("GET", f"/snapshots/{snapshot_id}") + return response.get("snapshot", {}) + except DigitalOceanAPIError as e: + if e.status_code == 404: + return None + raise + + def get_snapshot_by_name(self, name: str, tag: str | None = None) -> dict[str, Any] | None: + """ + Find a snapshot by exact name. + + Args: + name: Exact snapshot name to search for + tag: Optional tag to filter by + + Returns: + Snapshot object if found, None if not found + """ + snapshots = self.list_snapshots(tag=tag) + for snapshot in snapshots: + if snapshot.get("name") == name: + return snapshot + return None + + def delete_snapshot(self, snapshot_id: int) -> None: + """ + Delete a snapshot by ID. + + Args: + snapshot_id: Snapshot ID to delete + + Raises: + ValueError: If snapshot_id is not positive + DigitalOceanAPIError: If deletion fails + """ + self._validate_positive_int(snapshot_id, "snapshot_id") + self._request("DELETE", f"/snapshots/{snapshot_id}") + + def tag_resource(self, tag_name: str, resource_id: str, resource_type: str) -> None: + """ + Add a tag to a resource. + + Args: + tag_name: Tag name to apply + resource_id: Resource ID (string for snapshots/images) + resource_type: Resource type ('image' for snapshots, 'droplet', etc.) + + Raises: + DigitalOceanAPIError: If tagging fails + """ + payload = { + "resources": [ + { + "resource_id": resource_id, + "resource_type": resource_type, + } + ] + } + self._request("POST", f"/tags/{tag_name}/resources", json=payload) + + def create_tag(self, tag_name: str) -> dict[str, Any]: + """ + Create a tag if it doesn't exist. + + Args: + tag_name: Tag name to create + + Returns: + Tag object from API response + + Raises: + DigitalOceanAPIError: If creation fails (ignores 422 for existing tags) + """ + payload = {"name": tag_name} + try: + response = self._request("POST", "/tags", json=payload) + return response.get("tag", {}) + except DigitalOceanAPIError as e: + # 422 means tag already exists, which is fine + if e.status_code == 422: + return {"name": tag_name} + raise + + def create_droplet_from_snapshot( + self, + name: str, + region: str, + size: str, + snapshot_id: int, + tags: list[str], + ssh_keys: list[int] | None = None, + ) -> dict[str, Any]: + """ + Create a new droplet from a snapshot image. + + Args: + name: Droplet name + region: Region slug (e.g., 'nyc3') + size: Size slug (e.g., 's-2vcpu-4gb') + snapshot_id: Snapshot ID to restore from + tags: List of tags to apply + ssh_keys: List of SSH key IDs for root access (optional) + + Returns: + Droplet object from API response + + Raises: + ValueError: If snapshot_id is not positive + DigitalOceanAPIError: If droplet creation fails + """ + self._validate_positive_int(snapshot_id, "snapshot_id") + payload: dict[str, Any] = { + "name": name, + "region": region, + "size": size, + "image": snapshot_id, # Snapshot ID as the image + "tags": tags, + } + + if ssh_keys: + payload["ssh_keys"] = ssh_keys + + response = self._request("POST", "/droplets", json=payload) + return response.get("droplet", {}) diff --git a/dropkit/cloudinit.py b/dropkit/cloudinit.py new file mode 100644 index 0000000..d11c280 --- /dev/null +++ b/dropkit/cloudinit.py @@ -0,0 +1,59 @@ +"""Cloud-init template rendering.""" + +from pathlib import Path + +from jinja2 import Template + +from dropkit.config import Config + + +def render_cloud_init( + template_path: str, + username: str, + full_name: str, + email: str, + ssh_keys: list[str], + tailscale_enabled: bool = True, +) -> str: + """ + Render cloud-init template with user data. + + Args: + template_path: Path to the cloud-init template file + username: Username to create in the droplet + full_name: Full name extracted from email (for git user.name) + email: Email address from DigitalOcean account (for git user.email) + ssh_keys: List of SSH public key file paths + tailscale_enabled: Whether to install Tailscale VPN (default: True) + + Returns: + Rendered cloud-init configuration as string + """ + # Read template + template_file = Path(template_path).expanduser() + if not template_file.exists(): + raise FileNotFoundError(f"Cloud-init template not found: {template_path}") + + with open(template_file) as f: + template_content = f.read() + + # Validate and read SSH key contents + ssh_key_contents = [] + for key_path in ssh_keys: + # Validate it's a public key + Config.validate_ssh_public_key(key_path) + # Read the content + content = Config.read_ssh_key_content(key_path) + ssh_key_contents.append(content) + + # Render template with Jinja2 + template = Template(template_content) + rendered = template.render( + username=username, + full_name=full_name, + email=email, + ssh_keys=ssh_key_contents, + tailscale_enabled=tailscale_enabled, + ) + + return rendered diff --git a/dropkit/config.py b/dropkit/config.py new file mode 100644 index 0000000..8916f29 --- /dev/null +++ b/dropkit/config.py @@ -0,0 +1,347 @@ +"""Configuration management for dropkit.""" + +import base64 +import hashlib +import os +from pathlib import Path + +import yaml +from cryptography.hazmat.primitives import serialization +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class DigitalOceanConfig(BaseModel): + """DigitalOcean API configuration.""" + + token: str = Field(..., min_length=1, description="DigitalOcean API token") + api_base: str = Field(default="https://api.digitalocean.com/v2", description="API base URL") + + @field_validator("token") + @classmethod + def token_not_empty(cls, v: str) -> str: + """Validate token is not empty or whitespace.""" + if not v or not v.strip(): + raise ValueError("Token cannot be empty") + return v.strip() + + +class DefaultsConfig(BaseModel): + """Default settings for droplet creation.""" + + region: str = Field(..., min_length=1, description="Default region slug") + size: str = Field(..., min_length=1, description="Default droplet size slug") + image: str = Field(..., min_length=1, description="Default image slug") + extra_tags: list[str] = Field( + default_factory=list, + description="Extra tags (in addition to mandatory owner: and firewall tags)", + ) + project_id: str | None = Field( + default=None, + description="Default project ID (UUID) for new droplets", + ) + + +class CloudInitConfig(BaseModel): + """Cloud-init configuration.""" + + template_path: str | None = Field( + default=None, description="Path to cloud-init template (None = use package default)" + ) + ssh_keys: list[str] = Field(..., min_length=1, description="SSH public key paths") + ssh_key_ids: list[int] = Field( + ..., min_length=1, description="DigitalOcean SSH key IDs for root access" + ) + + @field_validator("ssh_keys") + @classmethod + def ssh_keys_not_empty(cls, v: list[str]) -> list[str]: + """Validate at least one SSH key is provided.""" + if not v: + raise ValueError("At least one SSH key must be configured") + return v + + @field_validator("ssh_key_ids") + @classmethod + def ssh_key_ids_not_empty(cls, v: list[int]) -> list[int]: + """Validate at least one SSH key ID is provided.""" + if not v: + raise ValueError("At least one SSH key ID must be configured") + return v + + +class SSHConfig(BaseModel): + """SSH configuration.""" + + config_path: str = Field(..., description="Path to SSH config file") + auto_update: bool = Field(default=True, description="Auto-update SSH config") + identity_file: str = Field(..., description="SSH identity file path") + + +class TailscaleConfig(BaseModel): + """Tailscale VPN configuration.""" + + enabled: bool = Field(default=True, description="Enable Tailscale by default for new droplets") + lock_down_firewall: bool = Field( + default=True, description="Reset UFW to only allow traffic on tailscale0 interface" + ) + auth_timeout: int = Field( + default=300, ge=30, description="Timeout in seconds for Tailscale authentication (min: 30)" + ) + + +class DropkitConfig(BaseModel): + """Main dropkit configuration.""" + + model_config = ConfigDict(extra="forbid") + + digitalocean: DigitalOceanConfig + defaults: DefaultsConfig + cloudinit: CloudInitConfig + ssh: SSHConfig + tailscale: TailscaleConfig = Field(default_factory=TailscaleConfig) + + +class Config: + """Manages dropkit configuration with Pydantic validation.""" + + CONFIG_DIR = Path.home() / ".config" / "dropkit" + CONFIG_FILE = CONFIG_DIR / "config.yaml" + CLOUD_INIT_FILE = CONFIG_DIR / "cloud-init.yaml" + + def __init__(self): + """Initialize config manager.""" + self._config: DropkitConfig | None = None + + @property + def config(self) -> DropkitConfig: + """Get the validated configuration.""" + if self._config is None: + raise ValueError("Configuration not loaded. Call load() first.") + return self._config + + @classmethod + def exists(cls) -> bool: + """Check if config file exists.""" + return cls.CONFIG_FILE.exists() + + @classmethod + def get_config_dir(cls) -> Path: + """Get the config directory path.""" + return cls.CONFIG_DIR + + @staticmethod + def get_default_template_path() -> Path: + """Get the default cloud-init template path from package.""" + return Path(__file__).parent / "templates" / "default-cloud-init.yaml" + + @classmethod + def ensure_config_dir(cls) -> None: + """Create config directory if it doesn't exist.""" + cls.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + # Set restrictive permissions on config directory + cls.CONFIG_DIR.chmod(0o700) + + def load(self) -> None: + """Load and validate configuration from file.""" + if not self.CONFIG_FILE.exists(): + raise FileNotFoundError( + f"Config file not found at {self.CONFIG_FILE}. Run 'dropkit init' first." + ) + + with open(self.CONFIG_FILE) as f: + data = yaml.safe_load(f) or {} + + # Validate with Pydantic + self._config = DropkitConfig(**data) + + def save(self) -> None: + """Save configuration to file.""" + if self._config is None: + raise ValueError("No configuration to save") + + self.ensure_config_dir() + + # Convert Pydantic model to dict for YAML serialization + data = self._config.model_dump(mode="python") + + with open(self.CONFIG_FILE, "w") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Set restrictive permissions on config file (contains API token) + self.CONFIG_FILE.chmod(0o600) + + @staticmethod + def get_system_username() -> str: + """ + Get current system username from environment. + + This is used for tagging droplets (owner:) to track + who created them. + + Returns: + Current system username from $USER environment variable + """ + return os.environ.get("USER", "user") + + @staticmethod + def detect_ssh_keys() -> list[str]: + """Auto-detect all SSH public keys (*.pub) in ~/.ssh/.""" + ssh_dir = Path.home() / ".ssh" + if not ssh_dir.exists(): + return [] + + # Find all .pub files in the SSH directory + found_keys = [str(key_path) for key_path in ssh_dir.glob("*.pub")] + + # Sort by modification time (most recently modified first) + found_keys.sort(key=lambda p: Path(p).stat().st_mtime, reverse=True) + + return found_keys + + @staticmethod + def validate_ssh_public_key(key_path: str) -> None: + """ + Validate that a file is a valid SSH public key. + + Args: + key_path: Path to the SSH key file + + Raises: + ValueError: If the file is not a valid SSH public key + FileNotFoundError: If the file doesn't exist + """ + path = Path(key_path).expanduser() + + if not path.exists(): + raise FileNotFoundError(f"SSH key not found: {key_path}") + + # Check if filename ends with .pub + if not path.name.endswith(".pub"): + raise ValueError( + f"SSH key file must be a public key (*.pub): {key_path}\n" + f"Private keys should NEVER be uploaded to DigitalOcean." + ) + + # Read and validate content + try: + with open(path) as f: + content = f.read().strip() + except Exception as e: + raise ValueError(f"Cannot read SSH key file: {e}") + + if not content: + raise ValueError(f"SSH key file is empty: {key_path}") + + # Valid public key prefixes + valid_prefixes = ( + "ssh-rsa ", + "ssh-ed25519 ", + "ecdsa-sha2-nistp256 ", + "ecdsa-sha2-nistp384 ", + "ecdsa-sha2-nistp521 ", + "sk-ssh-ed25519@openssh.com ", + "sk-ecdsa-sha2-nistp256@openssh.com ", + ) + + if not content.startswith(valid_prefixes): + raise ValueError( + f"File does not appear to be a valid SSH public key: {key_path}\n" + f"Public keys should start with: {', '.join(p.strip() for p in valid_prefixes)}" + ) + + @staticmethod + def read_ssh_key_content(key_path: str) -> str: + """Read SSH public key content.""" + path = Path(key_path).expanduser() + if not path.exists(): + raise FileNotFoundError(f"SSH key not found: {key_path}") + + with open(path) as f: + return f.read().strip() + + @staticmethod + def compute_ssh_key_fingerprint(public_key: str) -> str: + """ + Compute MD5 fingerprint of an SSH public key. + + Args: + public_key: SSH public key content + + Returns: + MD5 fingerprint in format: aa:bb:cc:dd:... + + Raises: + ValueError: If key format is invalid + """ + try: + # Validate and load SSH public key using cryptography library + serialization.load_ssh_public_key(public_key.encode()) + + # Parse SSH public key format: "ssh-rsa AAAAB3... comment" + # Extract the base64 key material (second field) + parts = public_key.strip().split() + if len(parts) < 2: + raise ValueError("Invalid SSH public key format") + + # Decode the base64 key material and compute MD5 + key_data = base64.b64decode(parts[1]) + fingerprint = hashlib.md5(key_data).hexdigest() + + # Format as colon-separated hex pairs + return ":".join(fingerprint[i : i + 2] for i in range(0, len(fingerprint), 2)) + except Exception as e: + raise ValueError(f"Failed to compute SSH key fingerprint: {e}") + + def create_default_config( + self, + token: str, + username: str, # noqa: ARG002 - kept for API compatibility, username derived from DO API + region: str = "nyc3", + size: str = "s-2vcpu-4gb", + image: str = "ubuntu-25-04-x64", + ssh_keys: list[str] | None = None, + ssh_key_ids: list[int] | None = None, + extra_tags: list[str] | None = None, + project_id: str | None = None, + ) -> None: + """Create a default configuration with validation. + + Note: The mandatory tags owner: and firewall are NOT stored in config. + They are always added at runtime when creating droplets. + """ + if ssh_keys is None: + ssh_keys = self.detect_ssh_keys() + + if not ssh_keys: + raise ValueError("No SSH keys found. Please specify SSH key paths.") + + if ssh_key_ids is None: + raise ValueError("SSH key IDs must be provided from DigitalOcean.") + + # Store only extra tags (mandatory tags added at runtime) + if extra_tags is None: + extra_tags = [] + + # Create validated Pydantic model + self._config = DropkitConfig( + digitalocean=DigitalOceanConfig( + token=token, + api_base="https://api.digitalocean.com/v2", + ), + defaults=DefaultsConfig( + region=region, + size=size, + image=image, + extra_tags=extra_tags, + project_id=project_id, + ), + cloudinit=CloudInitConfig( + ssh_keys=ssh_keys, + ssh_key_ids=ssh_key_ids, + ), + ssh=SSHConfig( + config_path=str(Path.home() / ".ssh" / "config"), + auto_update=True, + identity_file=ssh_keys[0].replace(".pub", "") if ssh_keys else "~/.ssh/id_ed25519", + ), + ) diff --git a/dropkit/lock.py b/dropkit/lock.py new file mode 100644 index 0000000..576b858 --- /dev/null +++ b/dropkit/lock.py @@ -0,0 +1,186 @@ +"""File-based locking to prevent concurrent dropkit write operations.""" + +import fcntl +import json +import os +import time +from collections.abc import Generator +from contextlib import contextmanager, suppress +from functools import wraps +from pathlib import Path + +import typer +from rich.console import Console + +# Lock file location +LOCK_FILE = Path("/tmp/dropkit.lock") + +# Default timeout for acquiring lock (seconds) +DEFAULT_TIMEOUT = 30.0 + +# Polling interval when waiting for lock (seconds) +POLL_INTERVAL = 0.5 + +console = Console() + + +class LockError(Exception): + """Raised when lock acquisition fails.""" + + +class LockInfo: + """Information about the current lock holder.""" + + def __init__(self, pid: int, command: str): + self.pid = pid + self.command = command + + @classmethod + def from_file(cls, lock_file: Path) -> "LockInfo | None": + """Read lock info from file, returns None if unreadable.""" + try: + if lock_file.exists(): + content = lock_file.read_text().strip() + if content: + data = json.loads(content) + return cls(pid=data.get("pid", 0), command=data.get("command", "unknown")) + except (json.JSONDecodeError, OSError, KeyError): + pass + return None + + def to_json(self) -> str: + """Serialize lock info to JSON.""" + return json.dumps({"pid": self.pid, "command": self.command}) + + def is_process_alive(self) -> bool: + """Check if the lock holder process is still running.""" + try: + os.kill(self.pid, 0) # Signal 0 checks existence without killing + return True + except OSError: + return False + + +def _write_lock_info(lock_fd: int, command: str) -> None: + """Write lock holder information to the lock file.""" + info = LockInfo(pid=os.getpid(), command=command) + content = info.to_json().encode() + os.ftruncate(lock_fd, 0) + os.lseek(lock_fd, 0, os.SEEK_SET) + os.write(lock_fd, content) + + +def _clear_lock_info(lock_fd: int) -> None: + """Clear lock holder information (on release).""" + with suppress(OSError): + os.ftruncate(lock_fd, 0) + + +@contextmanager +def operation_lock( + command: str, + timeout: float | None = None, +) -> Generator[None, None, None]: + """ + Context manager that acquires an exclusive lock for dropkit operations. + + Uses fcntl.flock() which automatically releases the lock when: + - The context manager exits normally + - An exception is raised + - The process crashes or is killed + + Args: + command: Name of the command acquiring the lock (for debugging) + timeout: Maximum seconds to wait for lock (default: 30) + + Raises: + LockError: If lock cannot be acquired within timeout + + Example: + with operation_lock("create"): + # Exclusive operation here + pass + """ + # Resolve default timeout at runtime (allows patching in tests) + if timeout is None: + timeout = DEFAULT_TIMEOUT + + # Ensure parent directory exists (should always exist for /tmp) + LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Open file for reading and writing (creates if doesn't exist) + lock_fd = os.open(str(LOCK_FILE), os.O_RDWR | os.O_CREAT, 0o600) + + try: + start_time = time.monotonic() + acquired = False + + while time.monotonic() - start_time < timeout: + try: + # Try non-blocking exclusive lock + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + break + except OSError: + # Lock is held by another process, wait and retry + time.sleep(POLL_INTERVAL) + + if not acquired: + # Timeout - read lock info for error message + lock_info = LockInfo.from_file(LOCK_FILE) + if lock_info and lock_info.is_process_alive(): + raise LockError( + f"Another dropkit operation is in progress.\n" + f" Command: {lock_info.command}\n" + f" PID: {lock_info.pid}\n" + f"Wait for it to complete or check if it's stuck." + ) + else: + # Stale lock - try one more time (blocking briefly) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + acquired = True + except OSError: + raise LockError( + f"Could not acquire lock after {timeout} seconds.\nLock file: {LOCK_FILE}" + ) + + # Write lock holder info + _write_lock_info(lock_fd, command) + + yield # Execute the protected code + + finally: + # Clear lock info and release lock + _clear_lock_info(lock_fd) + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + +def requires_lock(command_name: str): + """ + Decorator that wraps a function with operation_lock and handles errors. + + Args: + command_name: Name of the command (for lock info) + + Example: + @app.command() + @requires_lock("create") + def create(...): + pass + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + with operation_lock(command_name): + return func(*args, **kwargs) + except LockError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + return wrapper + + return decorator diff --git a/dropkit/main.py b/dropkit/main.py new file mode 100644 index 0000000..e7b0563 --- /dev/null +++ b/dropkit/main.py @@ -0,0 +1,4154 @@ +"""Main CLI application for dropkit.""" + +import json +import re +import subprocess +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm, Prompt +from rich.table import Table + +from dropkit.api import DigitalOceanAPI, DigitalOceanAPIError +from dropkit.cloudinit import render_cloud_init +from dropkit.config import Config, DropkitConfig +from dropkit.lock import requires_lock +from dropkit.ssh_config import ( + add_ssh_host, + get_ssh_host_ip, + host_exists, + remove_known_hosts_entry, + remove_ssh_host, +) +from dropkit.ui import ( + display_images, + display_projects, + display_regions, + display_sizes, + prompt_with_help, +) +from dropkit.version_check import check_for_updates + +app = typer.Typer( + name="dropkit", + help="Manage DigitalOcean droplets for ToB engineers", +) +console = Console() + + +@app.callback() +def main_callback(): + """Run before any command - checks for updates once per day.""" + check_for_updates() + + +# Helper functions + + +def complete_droplet_name(incomplete: str) -> list[str]: + """ + Autocompletion function for droplet names. + + Fetches droplet names from DigitalOcean for the current user. + This is used by Typer for shell completion (e.g., bash, zsh). + + Args: + incomplete: Partial text entered by the user + + Returns: + List of matching droplet names + """ + try: + # Check if config exists + if not Config.exists(): + return [] + + # Load config and create API client + config_manager = Config() + config_manager.load() + config = config_manager.config + api = DigitalOceanAPI(config.digitalocean.token) + + # Get username and fetch droplets with user tag + username = api.get_username() + tag_name = get_user_tag(username) + droplets = api.list_droplets(tag_name=tag_name) + + # Extract droplet names + droplet_names = [d.get("name", "") for d in droplets if d.get("name")] + + # Filter by incomplete text (case-insensitive) + if incomplete: + droplet_names = [ + name for name in droplet_names if name.lower().startswith(incomplete.lower()) + ] + + return droplet_names + except Exception: + # Silently fail on errors - completion should never break the CLI + return [] + + +def complete_project_name(incomplete: str) -> list[str]: + """ + Autocompletion function for project names. + + Fetches project names from DigitalOcean. + This is used by Typer for shell completion (e.g., bash, zsh). + + Args: + incomplete: Partial text entered by the user + + Returns: + List of matching project names + """ + try: + # Check if config exists + if not Config.exists(): + return [] + + # Load config and create API client + config_manager = Config() + config_manager.load() + config = config_manager.config + api = DigitalOceanAPI(config.digitalocean.token) + + # Fetch all projects + projects = api.list_projects() + + # Extract project names + project_names = [p.get("name", "") for p in projects if p.get("name")] + + # Filter by incomplete text (case-insensitive) + if incomplete: + project_names = [ + name for name in project_names if name.lower().startswith(incomplete.lower()) + ] + + return project_names + except Exception: + # Silently fail on errors - completion should never break the CLI + return [] + + +def complete_snapshot_name(incomplete: str) -> list[str]: + """ + Autocompletion function for hibernated snapshot names. + + Fetches dropkit snapshots from DigitalOcean for the current user + and extracts the droplet name from the snapshot name. + This is used by Typer for shell completion (e.g., bash, zsh). + + Args: + incomplete: Partial text entered by the user + + Returns: + List of matching droplet names (without dropkit- prefix) + """ + try: + if not Config.exists(): + return [] + + config_manager = Config() + config_manager.load() + config = config_manager.config + api = DigitalOceanAPI(config.digitalocean.token) + + username = api.get_username() + user_tag = get_user_tag(username) + snapshots = get_user_hibernated_snapshots(api, user_tag) + + # Extract droplet names from snapshots + names = [] + for snapshot in snapshots: + droplet_name = get_droplet_name_from_snapshot(snapshot.get("name", "")) + if droplet_name: + names.append(droplet_name) + + # Filter by incomplete text (case-insensitive) + if incomplete: + names = [n for n in names if n.lower().startswith(incomplete.lower())] + + return names + except Exception: + # Silently fail on errors - completion should never break the CLI + return [] + + +def load_config_and_api() -> tuple[Config, DigitalOceanAPI]: + """ + Load configuration and create API client. + + Returns: + Tuple of (Config instance, DigitalOceanAPI instance) + + Raises: + typer.Exit: If config doesn't exist or fails to load + """ + if not Config.exists(): + console.print("[red]Error: Config not found. Run 'dropkit init' first.[/red]") + raise typer.Exit(1) + + config_manager = Config() + try: + config_manager.load() + except Exception as e: + console.print(f"[red]Error loading config: {e}[/red]") + console.print( + "[yellow]Config file may be invalid. Try running[/yellow] [cyan]dropkit init --force[/cyan]" + ) + raise typer.Exit(1) + + config = config_manager.config + api = DigitalOceanAPI(config.digitalocean.token) + + return config_manager, api + + +def get_ssh_hostname(droplet_name: str) -> str: + """ + Convert droplet name to SSH config hostname. + + Args: + droplet_name: Name of the droplet + + Returns: + SSH hostname with dropkit prefix (e.g., "dropkit.my-droplet") + """ + return f"dropkit.{droplet_name}" + + +def get_snapshot_name(droplet_name: str) -> str: + """ + Convert droplet name to snapshot name. + + Args: + droplet_name: Name of the droplet + + Returns: + Snapshot name with dropkit prefix (e.g., "dropkit-my-droplet") + """ + return f"dropkit-{droplet_name}" + + +def get_droplet_name_from_snapshot(snapshot_name: str) -> str | None: + """ + Extract droplet name from a dropkit snapshot name. + + Args: + snapshot_name: Snapshot name (e.g., "dropkit-my-droplet") + + Returns: + Droplet name if snapshot name is in dropkit format, None otherwise + """ + prefix = "dropkit-" + if snapshot_name.startswith(prefix): + return snapshot_name[len(prefix) :] + return None + + +def get_user_hibernated_snapshots(api: DigitalOceanAPI, user_tag: str) -> list[dict[str, Any]]: + """ + Get hibernated dropkit snapshots owned by the user. + + Note: DO API doesn't support tag_name filter for snapshots, so we filter client-side. + + Args: + api: DigitalOcean API client + user_tag: User's owner tag (e.g., "owner:username") + + Returns: + List of snapshot objects that are dropkit hibernations owned by this user + """ + snapshots = api.list_snapshots() + return [ + s + for s in snapshots + if s.get("name", "").startswith("dropkit-") and user_tag in s.get("tags", []) + ] + + +def find_snapshot_action(api: DigitalOceanAPI, droplet_id: int) -> dict[str, Any] | None: + """ + Find the most recent snapshot action for a droplet. + + Args: + api: DigitalOcean API client + droplet_id: Droplet ID + + Returns: + Most recent snapshot action dict, or None if not found + """ + actions = api.list_droplet_actions(droplet_id) + for action in actions: + if action.get("type") == "snapshot": + return action + return None + + +def ensure_ssh_config( + droplet: dict, + droplet_name: str, + username: str, + config: DropkitConfig, +) -> str: + """ + Ensure SSH config entry exists for a droplet. + + Checks if the SSH config already has an entry for this droplet. + If not, extracts the public IP and adds an SSH config entry. + + Args: + droplet: Droplet dict from DigitalOcean API + droplet_name: Name of the droplet + username: Username for SSH connection + config: DropkitConfig instance with SSH settings + + Returns: + SSH hostname (e.g., "dropkit.my-droplet") + + Raises: + ValueError: If droplet has no public IP + """ + ssh_hostname = get_ssh_hostname(droplet_name) + + if not host_exists(config.ssh.config_path, ssh_hostname): + # Get public IP from droplet networks + networks = droplet.get("networks", {}) + public_ip = None + for network in networks.get("v4", []): + if network.get("type") == "public": + public_ip = network.get("ip_address") + break + + if not public_ip: + raise ValueError("Could not find public IP for droplet") + + console.print(f"[dim]Adding SSH config for {ssh_hostname}...[/dim]") + add_ssh_host( + config_path=config.ssh.config_path, + host_name=ssh_hostname, + hostname=public_ip, + user=username, + identity_file=config.ssh.identity_file, + ) + console.print(f"[green]✓[/green] Added SSH config: {ssh_hostname} -> {public_ip}") + + return ssh_hostname + + +def cleanup_ssh_entries( + config: DropkitConfig, droplet_name: str, prompt_known_hosts: bool = True +) -> None: + """ + Remove SSH config entry and optionally clean up known_hosts for a droplet. + + Args: + config: DropkitConfig instance with SSH settings. + droplet_name: Name of the droplet being removed. + prompt_known_hosts: If True, prompt user before removing known_hosts entries. + """ + ssh_hostname = get_ssh_hostname(droplet_name) + + # Get IP BEFORE removing SSH config (needed for known_hosts cleanup) + ssh_ip = get_ssh_host_ip(config.ssh.config_path, ssh_hostname) + console.print(f"[dim]Found IP: {ssh_ip}[/dim]") + + # Remove SSH config entry + if host_exists(config.ssh.config_path, ssh_hostname): + try: + remove_ssh_host(config.ssh.config_path, ssh_hostname) + console.print( + f"[green]✓[/green] Removed SSH config entry for [cyan]{ssh_hostname}[/cyan]" + ) + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Could not remove SSH config entry: {e}") + else: + console.print(f"[dim]SSH config entry for {ssh_hostname} not found (skipped)[/dim]") + + # Clean up known_hosts + should_remove = not prompt_known_hosts or Confirm.ask( + "Remove SSH fingerprint from known_hosts?", default=True + ) + + if should_remove: + known_hosts_path = str(Path(config.ssh.config_path).parent / "known_hosts") + hostnames_to_remove = [ssh_hostname] + if ssh_ip: + hostnames_to_remove.append(ssh_ip) + console.print(f"[dim]Hostnames to find: {hostnames_to_remove}[/dim]") + try: + removed = remove_known_hosts_entry(known_hosts_path, hostnames_to_remove) + if removed: + console.print( + f"[green]✓[/green] Removed {removed} known_hosts " + f"{'entry' if removed == 1 else 'entries'}" + ) + else: + console.print("[dim]No matching entries in known_hosts[/dim]") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Could not remove known_hosts entry: {e}") + + +def get_user_tag(username: str) -> str: + """ + Get the user tag for filtering droplets. + + Args: + username: Username from DigitalOcean account + + Returns: + Tag string for filtering (e.g., "owner:username") + """ + return f"owner:{username}" + + +def build_droplet_tags(username: str, extra_tags: list[str] | None = None) -> list[str]: + """ + Build complete list of tags for a droplet. + + Combines mandatory tags (owner:, firewall) with extra tags, + avoiding duplicates. + + Args: + username: Username from DigitalOcean account + extra_tags: Optional list of additional tags + + Returns: + Complete list of tags with mandatory tags first + """ + # Mandatory tags + tags = [get_user_tag(username), "firewall"] + + # Add extra tags, avoiding duplicates + if extra_tags: + for tag in extra_tags: + if tag not in tags: + tags.append(tag) + + return tags + + +def register_ssh_keys_with_do(api: DigitalOceanAPI) -> tuple[list[str], list[int], str]: + """ + Detect, select, validate, and register SSH keys with DigitalOcean. + + This function handles the entire SSH key workflow: + - Detects SSH keys in ~/.ssh/ + - Prompts user to select which keys to use + - Validates keys are proper public keys + - Gets username from DigitalOcean for key naming + - Registers keys with DO (or reuses existing ones) + + Args: + api: DigitalOceanAPI instance + + Returns: + Tuple of (ssh_key_paths, ssh_key_ids, username) + + Raises: + typer.Exit: If any step fails + """ + # Auto-detect SSH keys + console.print("\n[bold]SSH Keys[/bold]") + detected_keys = Config.detect_ssh_keys() + + if not detected_keys: + console.print("[yellow]⚠[/yellow] No SSH keys detected in ~/.ssh/") + console.print("[dim]Please add SSH keys to ~/.ssh/ and run init again[/dim]") + raise typer.Exit(1) + + # Show detected keys + console.print("[green]✓[/green] Detected SSH public keys:") + for i, key in enumerate(detected_keys, 1): + console.print(f" {i}. [cyan]{key}[/cyan]") + + # Let user select which keys to use + if len(detected_keys) == 1: + ssh_keys = detected_keys + console.print(f"\n[dim]Using SSH key: {ssh_keys[0]}[/dim]") + else: + selection = Prompt.ask( + "\n[cyan]Select SSH keys to use (comma-separated numbers, or 'all')[/cyan]", + default="all", + ) + + if selection.lower() == "all": + ssh_keys = detected_keys + else: + try: + indices = [int(s.strip()) - 1 for s in selection.split(",") if s.strip()] + ssh_keys = [detected_keys[i] for i in indices if 0 <= i < len(detected_keys)] + if not ssh_keys: + console.print("[red]Error: No valid keys selected[/red]") + raise typer.Exit(1) + except (ValueError, IndexError): + console.print("[red]Error: Invalid selection[/red]") + raise typer.Exit(1) + + # Validate SSH keys are valid public keys + for key_path in ssh_keys: + try: + Config.validate_ssh_public_key(key_path) + except FileNotFoundError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + # Get username from DigitalOcean account (needed for SSH key naming) + try: + username = api.get_username() + console.print( + f"\n[green]✓[/green] Using username from DigitalOcean: [cyan]{username}[/cyan]" + ) + except DigitalOceanAPIError: + # Fallback to system username if API call fails + username = Config.get_system_username() + console.print( + f"\n[yellow]⚠[/yellow] Could not fetch username from DigitalOcean, " + f"using system username: [cyan]{username}[/cyan]" + ) + + # Register SSH keys with DigitalOcean + console.print("\n[dim]Checking SSH keys in DigitalOcean...[/dim]") + ssh_key_ids = [] + + try: + for key_path in ssh_keys: + key_content = Config.read_ssh_key_content(key_path) + + # Compute fingerprint + try: + fingerprint = Config.compute_ssh_key_fingerprint(key_content) + except ValueError as e: + console.print( + f"[red]Error computing fingerprint for {Path(key_path).name}: {e}[/red]" + ) + raise typer.Exit(1) + + # Check if key already exists by fingerprint + existing_key = api.get_ssh_key_by_fingerprint(fingerprint) + + if existing_key: + ssh_key_ids.append(existing_key["id"]) + console.print(f"[green]✓[/green] Key already registered: {Path(key_path).name}") + else: + # Register new key with format: dropkit-{username}-{fingerprint_prefix} + # Use first 8 characters of fingerprint (without colons) + fingerprint_prefix = fingerprint.replace(":", "")[:8] + key_name = f"dropkit-{username}-{fingerprint_prefix}" + console.print(f"[dim]Registering new key: {Path(key_path).name}...[/dim]") + new_key = api.add_ssh_key(key_name, key_content) + ssh_key_ids.append(new_key["id"]) + console.print(f"[green]✓[/green] Registered new key: {key_name}") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error managing SSH keys in DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + return ssh_keys, ssh_key_ids, username + + +def wait_for_cloud_init(ssh_hostname: str, verbose: bool = False) -> tuple[bool, bool]: + """ + Wait for cloud-init to complete on a droplet via SSH. + + This function polls the droplet via SSH to check cloud-init status. + It waits up to 10 minutes for cloud-init to complete. + + Args: + ssh_hostname: SSH hostname to connect to (e.g., "dropkit.my-droplet") + verbose: If True, show debug output + + Returns: + Tuple of (cloud_init_done, cloud_init_error) where: + - cloud_init_done: True if cloud-init completed successfully + - cloud_init_error: True if cloud-init completed with errors + - Both False if timeout occurred + + Raises: + None - errors are handled internally and reported to console + """ + console.print("[dim]Waiting for cloud-init to complete...[/dim]") + console.print( + "[dim]This may take several minutes for packages to install and setup to finish[/dim]" + ) + + max_attempts = 60 # 10 minutes max (60 * 10 seconds) + attempt = 0 + cloud_init_done = False + cloud_init_error = False + + if verbose: + console.print( + f"[dim][DEBUG] Will poll cloud-init status up to {max_attempts} times (10 minutes)[/dim]" + ) + + # Wait for SSH to be ready and user to be created + if verbose: + console.print( + "[dim][DEBUG] Sleeping 30 seconds to allow SSH service to start and user to be created...[/dim]" + ) + time.sleep(30) + + while attempt < max_attempts and not cloud_init_done: + try: + # Use the SSH config hostname alias we just created + if verbose: + console.print( + f"[dim][DEBUG] Attempt {attempt + 1}/{max_attempts}: Checking cloud-init status via SSH...[/dim]" + ) + + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=5", + "-o", + "BatchMode=yes", + ssh_hostname, # Use hostname alias from SSH config + "sudo cloud-init status --format=json --wait", + ], + capture_output=True, + timeout=30, + ) + + if verbose: + console.print(f"[dim][DEBUG] SSH return code: {result.returncode}[/dim]") + if result.stdout: + console.print( + f"[dim][DEBUG] SSH stdout: {result.stdout.decode('utf-8', errors='ignore')}[/dim]" + ) + if result.stderr: + console.print( + f"[dim][DEBUG] SSH stderr: {result.stderr.decode('utf-8', errors='ignore')}[/dim]" + ) + + # Always try to parse JSON output, regardless of return code + # (cloud-init may return non-zero even with valid JSON on error status) + try: + stdout = result.stdout.decode("utf-8", errors="ignore").strip() + if not stdout: + raise json.JSONDecodeError("Empty output", "", 0) + + status_data = json.loads(stdout) + status = status_data.get("status", "") + + if verbose: + console.print(f"[dim][DEBUG] Cloud-init status from JSON: {status}[/dim]") + if status_data.get("errors"): + console.print( + f"[dim][DEBUG] Cloud-init errors: {status_data.get('errors')}[/dim]" + ) + + if status == "done": + cloud_init_done = True + console.print("[green]✓[/green] Cloud-init completed successfully") + elif status == "error": + # Cloud-init failed + cloud_init_error = True + console.print("[red]✗[/red] Cloud-init completed with errors") + + # Show error details if available + errors = status_data.get("errors", []) + if errors: + console.print(f"[red]Cloud-init errors:[/red] {', '.join(errors)}") + + # Show recovery messages if available + recoverable_errors = status_data.get("recoverable_errors", {}) + if recoverable_errors: + console.print(f"[yellow]Recoverable errors:[/yellow] {recoverable_errors}") + + console.print( + "\n[yellow]The droplet is running but cloud-init failed.[/yellow]" + ) + console.print("[yellow]You can investigate by running:[/yellow]") + console.print(f" [cyan]ssh {ssh_hostname} 'sudo cloud-init status'[/cyan]") + console.print( + f" [cyan]ssh {ssh_hostname} 'sudo cat /var/log/cloud-init.log'[/cyan]" + ) + console.print( + f" [cyan]ssh {ssh_hostname} 'sudo cat /var/log/cloud-init-output.log'[/cyan]" + ) + + # Break out of the loop - no point continuing + break + else: + # Status exists but not "done" or "error" yet (e.g., "running") + attempt += 1 + if verbose: + console.print( + f"[dim][DEBUG] Cloud-init status is '{status}', not 'done' yet. Sleeping 10 seconds...[/dim]" + ) + time.sleep(10) + except json.JSONDecodeError as e: + # Failed to parse JSON or empty output - SSH might not be ready yet + attempt += 1 + if verbose: + console.print( + f"[dim][DEBUG] Failed to parse JSON output: {e}. SSH may not be ready. Sleeping 10 seconds...[/dim]" + ) + time.sleep(10) + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + if verbose: + console.print( + f"[dim][DEBUG] SSH attempt failed with exception: {type(e).__name__}[/dim]" + ) + attempt += 1 + time.sleep(10) + + if not cloud_init_done and not cloud_init_error: + console.print( + "[yellow]⚠[/yellow] Cloud-init status check timed out, but droplet may still be initializing" + ) + console.print( + f"[yellow]⚠[/yellow] You can check status manually with: ssh {ssh_hostname} 'cloud-init status'" + ) + + return cloud_init_done, cloud_init_error + + +# Tailscale helper functions + + +def check_local_tailscale() -> bool: + """ + Check if Tailscale is running and connected on the local machine. + + Returns: + True if Tailscale is running and the user is connected to a tailnet + """ + try: + result = subprocess.run( + ["tailscale", "status", "--json"], + capture_output=True, + timeout=5, + ) + if result.returncode != 0: + return False + + # Parse JSON to check if we're connected + status = json.loads(result.stdout.decode("utf-8", errors="ignore")) + # BackendState should be "Running" if connected + return status.get("BackendState") == "Running" + except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError): + return False + + +def run_tailscale_up(ssh_hostname: str, verbose: bool = False) -> str | None: + """ + Run 'tailscale up' on the droplet and extract the auth URL. + + Args: + ssh_hostname: SSH hostname to connect to + verbose: Show debug output + + Returns: + Auth URL if found, None if tailscale up failed + """ + if verbose: + console.print("[dim][DEBUG] Running tailscale up on droplet...[/dim]") + + try: + # Run tailscale up with a timeout on the remote side. + # tailscale up blocks waiting for authentication, so we use `timeout` + # to kill it after 5 seconds - the auth URL is printed immediately. + # The `|| true` ensures we don't fail due to timeout's exit code. + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + ssh_hostname, + "timeout 5 sudo tailscale up 2>&1 || true", + ], + capture_output=True, + timeout=30, + ) + + output = result.stdout.decode("utf-8", errors="ignore") + + if verbose: + console.print(f"[dim][DEBUG] tailscale up output: {output}[/dim]") + + # Parse auth URL from output + # Format: "To authenticate, visit:\n\n\thttps://login.tailscale.com/a/..." + for line in output.split("\n"): + # Match URL and strip trailing punctuation that's unlikely to be part of URL + url_match = re.search(r"https://[^\s]+", line) + if url_match: + url = url_match.group(0).rstrip(".,;:!?'\")>]}") + # Validate domain to prevent URL substring attacks + # e.g., reject "https://evil.com/login.tailscale.com" + parsed = urlparse(url) + if parsed.netloc.endswith(".tailscale.com") or parsed.netloc == "tailscale.com": + return url + + return None + + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + if verbose: + console.print(f"[dim][DEBUG] tailscale up failed: {e}[/dim]") + return None + + +def tailscale_logout(ssh_hostname: str, verbose: bool = False) -> bool: + """ + Logout from Tailscale on a droplet before destroy/hibernate. + + This removes the device from the Tailscale admin console, preventing + stale entries from accumulating. + + Args: + ssh_hostname: SSH hostname to connect to + verbose: Show debug output + + Returns: + True if logout succeeded, False if it failed + """ + if verbose: + console.print("[dim][DEBUG] Running tailscale logout on droplet...[/dim]") + + try: + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + ssh_hostname, + "sudo tailscale logout", + ], + capture_output=True, + timeout=30, + ) + + if result.returncode == 0: + if verbose: + console.print("[dim][DEBUG] tailscale logout succeeded[/dim]") + return True + else: + stderr = result.stderr.decode("utf-8", errors="ignore") + if verbose: + console.print(f"[dim][DEBUG] tailscale logout failed: {stderr}[/dim]") + return False + + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + if verbose: + console.print(f"[dim][DEBUG] tailscale logout failed: {e}[/dim]") + return False + + +def is_tailscale_ip(ip: str) -> bool: + """ + Check if an IP address is in the Tailscale CGNAT range. + + Tailscale uses the CGNAT IP range 100.64.0.0/10 (100.64.0.0 - 100.127.255.255). + + Args: + ip: IP address string to validate + + Returns: + True if the IP is in the Tailscale CGNAT range + """ + try: + parts = ip.split(".") + if len(parts) != 4: + return False + + octets = [int(p) for p in parts] + + # Check all octets are valid (0-255) + if not all(0 <= o <= 255 for o in octets): + return False + + # Tailscale CGNAT range: 100.64.0.0/10 + # First octet must be 100 + # Second octet must be 64-127 (the /10 covers 64 addresses in second octet) + return octets[0] == 100 and 64 <= octets[1] <= 127 + + except (ValueError, AttributeError): + return False + + +def is_droplet_tailscale_locked(config: DropkitConfig, droplet_name: str) -> bool: + """ + Check if droplet is under Tailscale lockdown by examining SSH config IP. + + A droplet is considered locked if its SSH config entry points to a + Tailscale IP (100.64.0.0/10 range) rather than a public IP. + + Args: + config: DropkitConfig instance with SSH settings + droplet_name: Name of the droplet to check + + Returns: + True if droplet SSH config points to Tailscale IP, False otherwise + """ + ssh_hostname = get_ssh_hostname(droplet_name) + current_ip = get_ssh_host_ip(config.ssh.config_path, ssh_hostname) + + if not current_ip: + return False + + return is_tailscale_ip(current_ip) + + +def add_temporary_ssh_rule(ssh_hostname: str, verbose: bool = False) -> bool: + """ + Add temporary UFW rule to allow SSH on public interface (eth0). + + This allows SSH access via public IP even when firewall is locked + to Tailscale only. Used before hibernate to ensure droplet can be + accessed after wake (before Tailscale re-authentication). + + Args: + ssh_hostname: SSH hostname to connect to + verbose: Show debug output + + Returns: + True on success, False on failure + """ + cmd = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + ssh_hostname, + "sudo ufw allow in on eth0 to any port 22", + ] + + if verbose: + console.print(f"[dim]Running: {' '.join(cmd)}[/dim]") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + + if verbose: + if result.stdout: + console.print(f"[dim]stdout: {result.stdout}[/dim]") + if result.stderr: + console.print(f"[dim]stderr: {result.stderr}[/dim]") + + return result.returncode == 0 + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + if verbose: + console.print(f"[dim]SSH command failed: {e}[/dim]") + return False + + +def prepare_for_hibernate( + config: DropkitConfig, + api: DigitalOceanAPI, + droplet: dict, + droplet_name: str, + verbose: bool = False, +) -> bool: + """ + Prepare droplet for hibernation, handling Tailscale lockdown if present. + + If droplet is under Tailscale lockdown: + 1. Add temporary UFW rule to allow SSH on eth0 + 2. Update SSH config to use public IP instead of Tailscale IP + + Args: + config: DropkitConfig instance + api: DigitalOceanAPI instance + droplet: Droplet dict from API + droplet_name: Name of the droplet + verbose: Show debug output + + Returns: + True if droplet was under Tailscale lockdown, False otherwise + """ + if not is_droplet_tailscale_locked(config, droplet_name): + return False + + console.print("[dim]Detected Tailscale lockdown - preparing for hibernate...[/dim]") + + ssh_hostname = get_ssh_hostname(droplet_name) + + # Add temporary SSH rule via Tailscale connection (must succeed before logout) + console.print("[dim]Adding temporary SSH rule for eth0...[/dim]") + if not add_temporary_ssh_rule(ssh_hostname, verbose): + # Temp rule failed - abort logout to keep Tailscale connectivity + console.print( + "[yellow]⚠[/yellow] Could not add temporary SSH rule - " + "skipping Tailscale logout to maintain connectivity" + ) + # Continue without logout - snapshot will preserve Tailscale state + return True + + console.print("[green]✓[/green] Temporary SSH rule added") + + # Now safe to logout from Tailscale (we have public IP fallback) + console.print("[dim]Logging out from Tailscale...[/dim]") + if tailscale_logout(ssh_hostname, verbose): + console.print("[green]✓[/green] Logged out from Tailscale") + else: + console.print( + "[yellow]⚠[/yellow] Could not logout from Tailscale " + "(device may remain in Tailscale admin console)" + ) + + # Get public IP from droplet + networks = droplet.get("networks", {}) + v4_networks = networks.get("v4", []) + public_ip = None + + for network in v4_networks: + if network.get("type") == "public": + public_ip = network.get("ip_address") + break + + if public_ip: + # Update SSH config to use public IP + console.print(f"[dim]Updating SSH config to public IP: {public_ip}[/dim]") + try: + # Get username from API + username = api.get_username() + add_ssh_host( + config_path=config.ssh.config_path, + host_name=ssh_hostname, + hostname=public_ip, + user=username, + identity_file=config.ssh.identity_file, + ) + console.print("[green]✓[/green] SSH config updated to public IP") + except Exception as e: + if verbose: + console.print(f"[dim]Could not update SSH config: {e}[/dim]") + + return True + + +def wait_for_tailscale_ip( + ssh_hostname: str, + timeout: int = 300, + poll_interval: int = 5, + verbose: bool = False, +) -> str | None: + """ + Poll for Tailscale IP address after user authenticates. + + Args: + ssh_hostname: SSH hostname to connect to + timeout: Maximum time to wait in seconds + poll_interval: Time between polls in seconds + verbose: Show debug output + + Returns: + Tailscale IP address if found, None if timeout + """ + start_time = time.time() + + if verbose: + console.print(f"[dim][DEBUG] Polling for Tailscale IP (timeout: {timeout}s)...[/dim]") + + while time.time() - start_time < timeout: + try: + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=5", + "-o", + "BatchMode=yes", + ssh_hostname, + "tailscale ip -4 2>/dev/null", + ], + capture_output=True, + timeout=15, + ) + + output = result.stdout.decode("utf-8", errors="ignore").strip() + + # Validate it's a valid Tailscale CGNAT IP (100.64.0.0/10 range) + if output and is_tailscale_ip(output): + if verbose: + console.print(f"[dim][DEBUG] Got Tailscale IP: {output}[/dim]") + return output + + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + if verbose: + elapsed = time.time() - start_time + console.print( + f"[dim][DEBUG] Waiting for Tailscale auth ({elapsed:.0f}s elapsed)...[/dim]" + ) + + time.sleep(poll_interval) + + return None + + +def lock_down_to_tailscale(ssh_hostname: str, verbose: bool = False) -> bool: + """ + Lock down UFW to only allow traffic on the tailscale0 interface. + + This resets UFW and configures it to only allow inbound traffic + on the Tailscale interface, effectively blocking all public access. + + Args: + ssh_hostname: SSH hostname to connect to (should use Tailscale IP now) + verbose: Show debug output + + Returns: + True if all UFW commands succeeded, False if any command failed + """ + if verbose: + console.print("[dim][DEBUG] Locking down UFW to tailscale0 only...[/dim]") + + try: + # UFW commands to lock down to Tailscale only + # These must all succeed for proper firewall lockdown + commands = [ + ("sudo ufw --force reset", "reset UFW"), + ("sudo ufw allow in on tailscale0", "allow tailscale0 traffic"), + ("sudo ufw default deny incoming", "deny incoming by default"), + ("sudo ufw default allow outgoing", "allow outgoing by default"), + ('echo "y" | sudo ufw enable', "enable UFW"), + ] + + for cmd, description in commands: + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + ssh_hostname, + cmd, + ], + capture_output=True, + timeout=30, + ) + + if verbose: + console.print(f"[dim][DEBUG] {cmd}: returncode={result.returncode}[/dim]") + + # Check return code - any failure means firewall is in unknown state + if result.returncode != 0: + stderr = result.stderr.decode("utf-8", errors="ignore").strip() + if verbose: + console.print( + f"[dim][DEBUG] UFW command failed to {description}: {stderr}[/dim]" + ) + return False + + return True + + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + if verbose: + console.print(f"[dim][DEBUG] Failed to lock down UFW: {e}[/dim]") + return False + + +def verify_tailscale_ssh( + tailscale_ip: str, + username: str, + identity_file: str, + verbose: bool = False, +) -> bool: + """ + Verify SSH access works via Tailscale IP. + + Args: + tailscale_ip: Tailscale IP address to connect to + username: SSH username + identity_file: SSH identity file path + verbose: Show debug output + + Returns: + True if SSH works, False otherwise + """ + try: + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + "-o", + "BatchMode=yes", + "-i", + identity_file, + f"{username}@{tailscale_ip}", + "echo 'SSH via Tailscale working'", + ], + capture_output=True, + timeout=15, + ) + + if verbose: + console.print( + f"[dim][DEBUG] Tailscale SSH verify: returncode={result.returncode}[/dim]" + ) + + return result.returncode == 0 + + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return False + + +def check_tailscale_installed(ssh_hostname: str, verbose: bool = False) -> bool: + """ + Check if Tailscale is installed on a droplet. + + Args: + ssh_hostname: SSH hostname to connect to + verbose: Show debug output + + Returns: + True if Tailscale is installed, False otherwise + """ + try: + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + "-o", + "BatchMode=yes", + ssh_hostname, + "which tailscale", + ], + capture_output=True, + timeout=15, + ) + + if verbose: + console.print(f"[dim][DEBUG] which tailscale: returncode={result.returncode}[/dim]") + + return result.returncode == 0 + + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + return False + + +def install_tailscale_on_droplet(ssh_hostname: str, verbose: bool = False) -> bool: + """ + Install Tailscale on a droplet via SSH. + + Uses the official Tailscale install script. + + Args: + ssh_hostname: SSH hostname to connect to + verbose: Show debug output + + Returns: + True if installation succeeded, False otherwise + """ + if verbose: + console.print("[dim][DEBUG] Installing Tailscale on droplet...[/dim]") + + try: + result = subprocess.run( + [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "ConnectTimeout=10", + ssh_hostname, + "curl -fsSL https://tailscale.com/install.sh | sudo sh", + ], + capture_output=True, + timeout=120, # Installation may take a while + ) + + if verbose: + stdout = result.stdout.decode("utf-8", errors="ignore") + stderr = result.stderr.decode("utf-8", errors="ignore") + console.print(f"[dim][DEBUG] Install stdout: {stdout[:500]}...[/dim]") + if stderr: + console.print(f"[dim][DEBUG] Install stderr: {stderr[:500]}[/dim]") + console.print(f"[dim][DEBUG] Install returncode: {result.returncode}[/dim]") + + return result.returncode == 0 + + except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: + if verbose: + console.print(f"[dim][DEBUG] Install failed: {e}[/dim]") + return False + + +def setup_tailscale( + ssh_hostname: str, + username: str, + config: DropkitConfig, + verbose: bool = False, +) -> str | None: + """ + Set up Tailscale VPN on a droplet after cloud-init completes. + + This function handles the full Tailscale setup flow: + 1. Run `tailscale up` and extract auth URL + 2. Display auth URL to user for browser authentication + 3. Poll for Tailscale IP after authentication + 4. Update SSH config with Tailscale IP + 5. Optionally lock down firewall to Tailscale only + + Args: + ssh_hostname: SSH hostname to connect to (e.g., "dropkit.my-droplet") + username: Username for SSH config + config: DropkitConfig instance with tailscale and ssh settings + verbose: Show debug output + + Returns: + Tailscale IP address if setup succeeded, None otherwise + """ + console.print("\n[bold]Setting up Tailscale VPN[/bold]") + + # Run tailscale up and get auth URL + auth_url = run_tailscale_up(ssh_hostname, verbose) + + if not auth_url: + # No auth URL - could mean already authenticated or an error + # Check if Tailscale is already connected by trying to get IP + console.print("[dim]No authentication URL received, checking if already connected...[/dim]") + + tailscale_ip = wait_for_tailscale_ip( + ssh_hostname, timeout=10, poll_interval=2, verbose=verbose + ) + + if tailscale_ip: + console.print( + f"[green]✓[/green] Tailscale already connected: [cyan]{tailscale_ip}[/cyan]" + ) + else: + console.print("[yellow]⚠[/yellow] Could not connect to Tailscale") + console.print("[dim]Tailscale is installed but not connected.[/dim]") + console.print(f"[dim]Connect later with: ssh {ssh_hostname} 'sudo tailscale up'[/dim]") + return None + else: + # Normal flow: display auth URL and wait for user to authenticate + console.print("\n[bold yellow]Tailscale Authentication Required[/bold yellow]") + console.print("\nOpen this URL in your browser to authenticate:") + console.print(f" [cyan]{auth_url}[/cyan]\n") + console.print("[dim]Waiting for you to complete authentication...[/dim]") + + # Poll for Tailscale IP + tailscale_ip = wait_for_tailscale_ip( + ssh_hostname, + timeout=config.tailscale.auth_timeout, + verbose=verbose, + ) + + if not tailscale_ip: + console.print("[yellow]⚠[/yellow] Tailscale authentication timed out") + console.print("[dim]You can authenticate later with:[/dim]") + console.print(f"[dim] ssh {ssh_hostname} 'sudo tailscale up'[/dim]") + return None + + console.print(f"[green]✓[/green] Tailscale connected: [cyan]{tailscale_ip}[/cyan]") + + # Update SSH config with Tailscale IP + console.print("[dim]Updating SSH config with Tailscale IP...[/dim]") + try: + add_ssh_host( + config_path=config.ssh.config_path, + host_name=ssh_hostname, + hostname=tailscale_ip, + user=username, + identity_file=config.ssh.identity_file, + ) + console.print( + f"[green]✓[/green] Updated SSH config: [cyan]{ssh_hostname}[/cyan] -> {tailscale_ip}" + ) + except OSError as e: + console.print(f"[yellow]⚠[/yellow] Could not update SSH config: {e}") + console.print( + f"[dim]You can connect manually: ssh -i {config.ssh.identity_file} " + f"{username}@{tailscale_ip}[/dim]" + ) + + # Lock down firewall if configured + if config.tailscale.lock_down_firewall: + if check_local_tailscale(): + console.print("[dim]Locking down firewall to Tailscale only...[/dim]") + if lock_down_to_tailscale(ssh_hostname, verbose): + console.print("[green]✓[/green] Firewall locked down to Tailscale") + else: + console.print("[yellow]⚠[/yellow] Could not lock down firewall") + + # Verify SSH via Tailscale + if verify_tailscale_ssh(tailscale_ip, username, config.ssh.identity_file, verbose): + console.print("[green]✓[/green] Verified SSH access via Tailscale") + else: + console.print( + "[yellow]⚠[/yellow] SSH verification failed - you may need to wait a moment" + ) + else: + console.print( + "[yellow]⚠[/yellow] Local Tailscale not running - skipping firewall lockdown" + ) + console.print( + "[dim]Public SSH access remains available. Start Tailscale locally and run:[/dim]" + ) + console.print( + f"[dim] ssh {ssh_hostname} " + "'sudo ufw --force reset && " + "sudo ufw allow in on tailscale0 && " + "sudo ufw default deny incoming && " + "sudo ufw default allow outgoing && " + "sudo ufw --force enable'[/dim]" + ) + + return tailscale_ip + + +def find_user_droplet(api: DigitalOceanAPI, droplet_name: str) -> tuple[dict | None, str | None]: + """ + Find a droplet by name, filtered by current user's tag. + + Args: + api: DigitalOceanAPI instance + droplet_name: Name of the droplet to find + + Returns: + Droplet dict if found, None otherwise + + Raises: + typer.Exit: If username cannot be fetched + """ + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Get droplets tagged with this user + try: + tag_name = get_user_tag(username) + droplets = api.list_droplets(tag_name=tag_name) + except DigitalOceanAPIError as e: + console.print(f"[red]Error listing droplets: {e}[/red]") + raise typer.Exit(1) + + # Find droplet by name + for droplet in droplets: + if droplet.get("name") == droplet_name: + return droplet, username + + return None, None + + +def find_project_by_name_or_id( + api: DigitalOceanAPI, name_or_id: str +) -> tuple[str | None, str | None]: + """ + Find a project by name or UUID. + + Optimized to use direct API call for UUIDs, only lists all projects for name search. + + Args: + api: DigitalOceanAPI instance + name_or_id: Project name or UUID + + Returns: + Tuple of (project_id, project_name) if found, (None, None) otherwise + """ + # Check if input looks like a UUID (36 chars with hyphens at positions 8, 13, 18, 23) + if len(name_or_id) == 36 and name_or_id.count("-") == 4: + # Try direct API call for UUID (more efficient than listing all) + try: + project = api.get_project(name_or_id) + if project: + return project.get("id"), project.get("name") + except DigitalOceanAPIError: + # If direct lookup fails, fall through to name search + pass + + # Not a UUID or UUID lookup failed - search by name + try: + projects = api.list_projects() + except DigitalOceanAPIError: + return None, None + + # Try exact name match (case-insensitive) + for project in projects: + if project.get("name", "").lower() == name_or_id.lower(): + return project.get("id"), project.get("name") + + return None, None + + +@app.command() +@requires_lock("init") +def init( + force: bool = typer.Option( + False, + "--force", + "-f", + help="Overwrite existing configuration", + ), +) -> None: + """ + Initialize dropkit configuration. + + This will create a config directory at ~/.config/dropkit/ and prompt + you for your DigitalOcean API token and default settings. + """ + # Check if config already exists + if Config.exists() and not force: + console.print( + f"[yellow]Configuration already exists at[/yellow] [cyan]{Config.CONFIG_FILE}[/cyan]" + ) + console.print("[yellow]Use[/yellow] [cyan]--force[/cyan] [yellow]to overwrite[/yellow]") + raise typer.Exit(1) + + console.print( + Panel.fit( + "[bold cyan]dropkit initialization[/bold cyan]\n\n" + "This will set up your dropkit configuration.", + border_style="cyan", + ) + ) + + # Create config directory + Config.ensure_config_dir() + console.print(f"[green]✓[/green] Created config directory: [cyan]{Config.CONFIG_DIR}[/cyan]") + + # Prompt for DigitalOcean API token + console.print("\n[bold]DigitalOcean API Token[/bold]") + console.print("Get your token from: https://cloud.digitalocean.com/account/api/tokens") + token = Prompt.ask("[cyan]Enter your DO API token[/cyan]", password=True) + + if not token or not token.strip(): + console.print("[red]Error: API token is required[/red]") + raise typer.Exit(1) + + # Initialize API client to fetch regions and sizes + console.print("\n[dim]Validating token and fetching available options...[/dim]") + api = DigitalOceanAPI(token.strip()) + + # Try to fetch regions, sizes, and images + regions = None + sizes = None + images = None + try: + regions = api.get_available_regions() + sizes = api.get_available_sizes() + images = api.get_available_images() + console.print("[green]✓[/green] Token validated successfully") + except DigitalOceanAPIError as e: + console.print(f"[yellow]⚠[/yellow] Could not fetch regions/sizes/images: {e}") + console.print("[yellow]⚠[/yellow] You can still continue with manual entry") + + # Register SSH keys with DigitalOcean + ssh_keys, ssh_key_ids, username = register_ssh_keys_with_do(api) + + # Prompt for default region + console.print("\n[bold]Default Settings[/bold]") + + if regions: + region = prompt_with_help( + "Default region", + default="nyc3", + display_func=display_regions, + data=regions, + ) + else: + region = Prompt.ask( + "[cyan]Default region[/cyan]", + default="nyc3", + ) + + # Prompt for default size + if sizes: + size = prompt_with_help( + "Default droplet size", + default="s-2vcpu-4gb", + display_func=display_sizes, + data=sizes, + ) + else: + size = Prompt.ask( + "[cyan]Default droplet size[/cyan]", + default="s-2vcpu-4gb", + ) + + # Prompt for default image + if images: + image = prompt_with_help( + "Default image", + default="ubuntu-25-04-x64", + display_func=display_images, + data=images, + ) + else: + image = Prompt.ask( + "[cyan]Default image[/cyan]", + default="ubuntu-25-04-x64", + ) + + # Prompt for extra tags + console.print("\n[bold]Tags[/bold]") + console.print(f"[dim]Mandatory tags (always added): owner:{username}, firewall[/dim]") + extra_tags_input = Prompt.ask( + "[cyan]Extra tags (comma-separated, optional)[/cyan]", + default="", + ) + + # Parse extra tags + extra_tags = [] + if extra_tags_input.strip(): + extra_tags = [t.strip() for t in extra_tags_input.split(",") if t.strip()] + + # Prompt for default project (optional) + console.print("\n[bold]Default Project (optional)[/bold]") + console.print("[dim]You can assign new droplets to a specific project by default[/dim]") + + project_id = None + default_project_name = None + + try: + # Try to get the default project + default_project = api.get_default_project() + if default_project: + default_project_name = default_project.get("name", "") + console.print( + f"[dim]Your DigitalOcean default project: [cyan]{default_project_name}[/cyan][/dim]" + ) + + # Fetch all projects for help display + projects = api.list_projects() + if projects: + console.print(f"[dim]Found {len(projects)} project(s)[/dim]") + + # Offer default project name as the default choice + prompt_default = default_project_name if default_project_name else "" + + use_project = prompt_with_help( + "Default project name or ID (? for help, or press Enter to skip)", + default=prompt_default, + display_func=display_projects, + data=projects, + ) + + if use_project.strip(): + # Resolve project name or ID to UUID (config stores UUID) + resolved_id, resolved_name = find_project_by_name_or_id(api, use_project) + if resolved_id: + project_id = resolved_id + console.print(f"[green]✓[/green] Default project: [cyan]{resolved_name}[/cyan]") + else: + console.print(f"[yellow]⚠[/yellow] Project '{use_project}' not found[/yellow]") + console.print("[dim]Skipping default project[/dim]") + else: + console.print("[dim]No projects found in your account[/dim]") + except DigitalOceanAPIError as e: + console.print(f"[yellow]⚠[/yellow] Could not fetch projects: {e}") + console.print("[dim]Skipping default project[/dim]") + + # Create config + config = Config() + config.create_default_config( + token=token.strip(), + username=username, + region=region, + size=size, + image=image, + ssh_keys=ssh_keys, + ssh_key_ids=ssh_key_ids, + extra_tags=extra_tags, + project_id=project_id, + ) + config.save() + + console.print(f"\n[green]✓[/green] Saved configuration to [cyan]{Config.CONFIG_FILE}[/cyan]") + + # Show summary + console.print("\n[bold green]Configuration complete![/bold green]") + console.print("\n[bold]Summary:[/bold]") + + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="cyan") + table.add_column() + + table.add_row("Config directory:", str(Config.CONFIG_DIR)) + table.add_row("Default region:", region) + table.add_row("Default size:", size) + table.add_row("Default image:", image) + table.add_row( + "Mandatory tags:", + f"owner:{username}, firewall (always added)", + ) + if config.config.defaults.extra_tags: + table.add_row("Extra tags:", ", ".join(config.config.defaults.extra_tags)) + if project_id: + # Try to get project name for display + try: + projects_list = api.list_projects() + project_obj = next((p for p in projects_list if p.get("id") == project_id), None) + if project_obj: + table.add_row("Default project:", project_obj.get("name", project_id)) + except Exception: + # If we can't get the name, just show the ID + table.add_row("Default project:", project_id) + table.add_row("SSH keys:", f"{len(ssh_keys)} key(s)") + + console.print(table) + + console.print("\n[bold]Next steps:[/bold]") + console.print(" • Create a droplet: [cyan]dropkit create [/cyan]") + console.print( + " • Customize cloud-init template (optional): copy from package and set template_path in config" + ) + + +@app.command() +@requires_lock("create") +def create( + name: str | None = typer.Argument(None, help="Name for the droplet"), + region: str | None = typer.Option(None, "--region", "-r", help="Region slug"), + size: str | None = typer.Option(None, "--size", "-s", help="Droplet size slug"), + image: str | None = typer.Option(None, "--image", "-i", help="Image slug"), + tags: str | None = typer.Option( + None, "--tags", "-t", help="Comma-separated tags (extends default tags)" + ), + user: str | None = typer.Option(None, "--user", "-u", help="Username to create"), + project: str | None = typer.Option( + None, + "--project", + "-p", + help="Project name or ID to assign droplet to", + autocompletion=complete_project_name, + ), + no_tailscale: bool = typer.Option( + False, "--no-tailscale", help="Disable Tailscale VPN setup for this droplet" + ), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show verbose debug output"), +) -> None: + """ + Create a new DigitalOcean droplet with cloud-init configuration. + + This will create a droplet with the specified name, applying your cloud-init + template and automatically adding an SSH config entry. + """ + # Load configuration + config_manager = Config() + try: + config_manager.load() + except FileNotFoundError as e: + console.print(f"[red]Error: {e}[/red]") + console.print("[yellow]Run[/yellow] [cyan]dropkit init[/cyan] [yellow]first[/yellow]") + raise typer.Exit(1) + except Exception as e: + console.print(f"[red]Error loading config: {e}[/red]") + console.print( + "[yellow]Config file may be invalid. Try running[/yellow] [cyan]dropkit init --force[/cyan]" + ) + raise typer.Exit(1) + + # Get validated config + config = config_manager.config + + # Get values from config or use provided values + token = config.digitalocean.token + + # Create API client + api = DigitalOceanAPI(token) + + # Interactive mode: prompt for missing values + if name is None: + console.print("\n[bold cyan]Create New Droplet[/bold cyan]") + name = Prompt.ask("\n[bold]Droplet name[/bold]") + + # Get region (interactive if not provided) + if region is None: + try: + available_regions = api.get_available_regions() + region = prompt_with_help( + "\n[bold]Region[/bold]", + default=config.defaults.region, + display_func=display_regions, + data=available_regions, + ) + except DigitalOceanAPIError as e: + console.print(f"[yellow]Warning: Could not fetch regions: {e}[/yellow]") + console.print(f"[dim]Using default region: {config.defaults.region}[/dim]") + region = config.defaults.region + + # Get size (interactive if not provided) + if size is None: + try: + available_sizes = api.get_available_sizes() + size = prompt_with_help( + "\n[bold]Size[/bold]", + default=config.defaults.size, + display_func=display_sizes, + data=available_sizes, + ) + except DigitalOceanAPIError as e: + console.print(f"[yellow]Warning: Could not fetch sizes: {e}[/yellow]") + console.print(f"[dim]Using default size: {config.defaults.size}[/dim]") + size = config.defaults.size + + # Get image (interactive if not provided) + if image is None: + try: + available_images = api.get_available_images() + image = prompt_with_help( + "\n[bold]Image[/bold]", + default=config.defaults.image, + display_func=display_images, + data=available_images, + ) + except DigitalOceanAPIError as e: + console.print(f"[yellow]Warning: Could not fetch images: {e}[/yellow]") + console.print(f"[dim]Using default image: {config.defaults.image}[/dim]") + image = config.defaults.image + + # Type guard - values are guaranteed non-None after interactive prompts + if name is None or region is None or size is None or image is None: + console.print("[red]Error: Missing required parameters[/red]") + raise typer.Exit(1) + + # Get project (use flag if provided, otherwise use config default or skip) + # The parameter can be a name or UUID; config always stores UUID + project_input = project if project is not None else config.defaults.project_id + project_id = None + project_name = None + + if project_input: + # Resolve project name or UUID to get both ID and name + resolved_id, resolved_name = find_project_by_name_or_id(api, project_input) + if resolved_id: + project_id = resolved_id + project_name = resolved_name + if verbose: + console.print(f"[dim][DEBUG] Project: {project_name} ({project_id})[/dim]") + else: + console.print(f"[yellow]Warning: Project '{project_input}' not found[/yellow]") + console.print("[yellow]Droplet will be created without project assignment[/yellow]") + + # Get username, email, and full name for droplet (use flag if provided, otherwise fetch from DO API) + try: + account = api.get_account() + email = account.get("email", "") + if not email: + console.print("[red]Error: No email found in DigitalOcean account[/red]") + raise typer.Exit(1) + + do_username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching account info from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Determine final username + username = do_username if user is None else user + + # Get full name from account (fallback to username if not available) + full_name = account.get("name", "") or username + + if verbose: + console.print(f"[dim][DEBUG] Username: {username}[/dim]") + console.print(f"[dim][DEBUG] Full name: {full_name}[/dim]") + console.print(f"[dim][DEBUG] Email: {email}[/dim]") + + # Build tags list: mandatory tags + extra_tags from config + command-line tags + extra_tags_list = list(config.defaults.extra_tags) # Start with config tags + + if tags: + additional_tags = [t.strip() for t in tags.split(",") if t.strip()] + extra_tags_list.extend(additional_tags) + + tags_list = build_droplet_tags(do_username, extra_tags_list) + + if verbose: + console.print(f"[dim][DEBUG] Using tags: {tags_list}[/dim]") + + # Check for existing droplet with same name + user_tag = get_user_tag(do_username) + existing_droplet, _ = find_user_droplet(api, name) + if existing_droplet: + console.print(f"[red]Error: A droplet named '{name}' already exists[/red]") + console.print( + f"[yellow]Use [cyan]dropkit destroy {name}[/cyan] to delete it first, " + f"or choose a different name[/yellow]" + ) + raise typer.Exit(1) + + # Check for hibernated snapshot with same name + snapshot_name = get_snapshot_name(name) + try: + snapshot = api.get_snapshot_by_name(snapshot_name, tag=user_tag) + if snapshot: + console.print( + f"[red]Error: A hibernated snapshot '{snapshot_name}' already exists[/red]" + ) + console.print( + f"[yellow]Use [cyan]dropkit wake {name}[/cyan] to restore it, " + f"[cyan]dropkit destroy {name}[/cyan] to delete the snapshot, " + f"or choose a different name[/yellow]" + ) + raise typer.Exit(1) + except DigitalOceanAPIError as e: + if verbose: + console.print(f"[dim][DEBUG] Could not check for existing snapshots: {e}[/dim]") + # Continue anyway - snapshot check is best-effort + + # Get SSH keys + ssh_keys = config.cloudinit.ssh_keys + + if verbose: + console.print(f"[dim][DEBUG] SSH keys: {ssh_keys}[/dim]") + + # Get cloud-init template path (use package default if not specified) + template_path = ( + config.cloudinit.template_path + if config.cloudinit.template_path + else str(config_manager.get_default_template_path()) + ) + + if verbose: + source = "custom" if config.cloudinit.template_path else "package default" + console.print(f"[dim][DEBUG] Cloud-init template ({source}): {template_path}[/dim]") + + console.print( + Panel.fit( + f"[bold cyan]Creating droplet: {name}[/bold cyan]", + border_style="cyan", + ) + ) + + # Show configuration + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style="dim") + table.add_column(style="white") + + table.add_row("Region:", region) + table.add_row("Size:", size) + table.add_row("Image:", image) + table.add_row("User:", username) + table.add_row("Tags:", ", ".join(tags_list)) + if project_id and project_name: + table.add_row("Project:", project_name) + + console.print(table) + console.print() + + # Determine if Tailscale should be enabled + tailscale_enabled = not no_tailscale and config.tailscale.enabled + + # Render cloud-init + try: + console.print("[dim]Rendering cloud-init template...[/dim]") + user_data = render_cloud_init( + template_path, username, full_name, email, ssh_keys, tailscale_enabled + ) + + if verbose: + console.print("\n[dim][DEBUG] Rendered cloud-init template:[/dim]") + console.print("[dim]" + "=" * 60 + "[/dim]") + console.print(f"[dim]{user_data}[/dim]") + console.print("[dim]" + "=" * 60 + "[/dim]\n") + except Exception as e: + console.print(f"[red]Error rendering cloud-init: {e}[/red]") + raise typer.Exit(1) + + # Create droplet + try: + if verbose: + console.print( + f"[dim][DEBUG] Creating droplet with API endpoint: {config.digitalocean.api_base}/droplets[/dim]" + ) + console.print("[dim]Creating droplet via API...[/dim]") + droplet = api.create_droplet( + name=name, + region=region, + size=size, + image=image, + user_data=user_data, + tags=tags_list, + ssh_keys=config.cloudinit.ssh_key_ids, + ) + + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Failed to get droplet ID from API response[/red]") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Droplet created with ID: [cyan]{droplet_id}[/cyan]") + + if verbose: + console.print(f"[dim][DEBUG] Droplet status: {droplet.get('status')}[/dim]") + console.print(f"[dim][DEBUG] Full droplet response: {droplet}[/dim]") + + # Wait for droplet to become active + console.print("[dim]Waiting for droplet to become active...[/dim]") + if verbose: + console.print("[dim][DEBUG] Polling droplet status every 5 seconds...[/dim]") + + with console.status("[cyan]Waiting...[/cyan]"): + active_droplet = api.wait_for_droplet_active(droplet_id) + + console.print("[green]✓[/green] Droplet is now active") + + if verbose: + console.print( + f"[dim][DEBUG] Active droplet networks: {active_droplet.get('networks')}[/dim]" + ) + + # Assign droplet to project if specified + if project_id: + try: + console.print(f"[dim]Assigning droplet to project '{project_name}'...[/dim]") + droplet_urn = api.get_droplet_urn(droplet_id) + api.assign_resources_to_project(project_id, [droplet_urn]) + console.print(f"[green]✓[/green] Assigned to project: [cyan]{project_name}[/cyan]") + except DigitalOceanAPIError as e: + console.print(f"[yellow]⚠[/yellow] Could not assign to project: {e}") + + # Get IP address + networks = active_droplet.get("networks", {}) + v4_networks = networks.get("v4", []) + ip_address = None + + for network in v4_networks: + if network.get("type") == "public": + ip_address = network.get("ip_address") + break + + # Initialize for type safety - ssh_hostname needed for output regardless of path + ssh_hostname = get_ssh_hostname(name) + tailscale_ip: str | None = None + + if not ip_address: + console.print("[yellow]⚠[/yellow] Could not determine IP address") + cloud_init_done = False + cloud_init_error = False + else: + console.print(f"[green]✓[/green] IP address: [cyan]{ip_address}[/cyan]") + if verbose: + console.print(f"[dim][DEBUG] All v4 networks: {v4_networks}[/dim]") + + # Add SSH config entry first so we can use it for cloud-init checks + if config.ssh.auto_update: + try: + console.print("[dim]Adding SSH config entry...[/dim]") + if verbose: + console.print( + f"[dim][DEBUG] SSH config path: {config.ssh.config_path}[/dim]" + ) + console.print( + f"[dim][DEBUG] Adding host '{name}' -> {username}@{ip_address}[/dim]" + ) + console.print( + f"[dim][DEBUG] Identity file: {config.ssh.identity_file}[/dim]" + ) + + add_ssh_host( + config_path=config.ssh.config_path, + host_name=ssh_hostname, + hostname=ip_address, + user=username, + identity_file=config.ssh.identity_file, + ) + console.print( + f"[green]✓[/green] Added SSH config: [cyan]ssh {ssh_hostname}[/cyan]" + ) + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Could not update SSH config: {e}") + + # Wait for cloud-init to complete using helper function + cloud_init_done, cloud_init_error = wait_for_cloud_init(ssh_hostname, verbose) + + # Tailscale setup (if enabled and cloud-init succeeded) + if tailscale_enabled and cloud_init_done: + tailscale_ip = setup_tailscale(ssh_hostname, username, config, verbose) + + # Show summary based on cloud-init and Tailscale status + console.print() + if tailscale_enabled and tailscale_ip: + console.print("[bold green]Droplet ready with Tailscale VPN![/bold green]") + console.print("\n[bold]Connect via Tailscale with:[/bold]") + console.print(f" [cyan]ssh {ssh_hostname}[/cyan]") + if not config.tailscale.lock_down_firewall or not check_local_tailscale(): + console.print("\n[bold]Or via public IP:[/bold]") + console.print(f" [cyan]ssh {username}@{ip_address}[/cyan]") + elif cloud_init_done: + console.print("[bold green]Droplet created successfully![/bold green]") + console.print("\n[bold]Droplet is fully ready! Connect with:[/bold]") + if ip_address: + console.print(f" [cyan]ssh {ssh_hostname}[/cyan]") + console.print(f" or: [cyan]ssh {username}@{ip_address}[/cyan]") + elif cloud_init_error: + console.print("[bold yellow]Droplet created with cloud-init errors[/bold yellow]") + console.print( + "\n[bold]The droplet is running but needs investigation. Connect with:[/bold]" + ) + if ip_address: + console.print(f" [cyan]ssh {ssh_hostname}[/cyan]") + console.print(f" or: [cyan]ssh {username}@{ip_address}[/cyan]") + else: + console.print("[bold green]Droplet created successfully![/bold green]") + console.print("\n[bold]Connect with:[/bold]") + if ip_address: + console.print(f" [cyan]ssh {ssh_hostname}[/cyan]") + console.print(f" or: [cyan]ssh {username}@{ip_address}[/cyan]") + + if ip_address and not cloud_init_done and not cloud_init_error: + console.print( + "\n[dim]Note: Cloud-init may still be running. You can check status with:[/dim]" + ) + console.print(f"[dim] ssh {ssh_hostname} 'cloud-init status'[/dim]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="list") +def list_droplets(): + """List droplets and hibernated snapshots tagged with owner:.""" + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Get username from DigitalOcean for tag filtering + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + tag_name = get_user_tag(username) + + console.print(f"[dim]Fetching resources with tag: [cyan]{tag_name}[/cyan][/dim]\n") + + # List droplets + droplets = api.list_droplets(tag_name=tag_name) + + if droplets: + # Create droplets table + console.print("[bold]Droplets:[/bold]") + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Name", style="white", no_wrap=True) + table.add_column("Status", style="white", no_wrap=True) + table.add_column("IP Address", style="cyan", no_wrap=True) + table.add_column("Region", style="white", no_wrap=True) + table.add_column("Size", style="white", no_wrap=True) + table.add_column("SSH", style="white", no_wrap=True) + + # Add rows + for droplet in droplets: + name = droplet.get("name", "N/A") + status = droplet.get("status", "N/A") + + # Get public IP + ip_address = "N/A" + v4_networks = droplet.get("networks", {}).get("v4", []) + for network in v4_networks: + if network.get("type") == "public": + ip_address = network.get("ip_address", "N/A") + break + + region = droplet.get("region", {}).get("slug", "N/A") + size = droplet.get("size_slug", "N/A") + + # Check if in SSH config + ssh_hostname = get_ssh_hostname(name) + in_ssh_config = "✓" if host_exists(config.ssh.config_path, ssh_hostname) else "✗" + + # Color status + if status == "active": + status_colored = f"[green]{status}[/green]" + elif status == "new": + status_colored = f"[yellow]{status}[/yellow]" + else: + status_colored = f"[red]{status}[/red]" + + table.add_row(name, status_colored, ip_address, region, size, in_ssh_config) + + console.print(table) + + # List hibernated snapshots + hibernated = get_user_hibernated_snapshots(api, tag_name) + + if hibernated: + if droplets: + console.print() # Spacing between tables + console.print("[bold]Hibernated:[/bold]") + snap_table = Table(show_header=True, header_style="bold cyan") + snap_table.add_column("Name", style="white", no_wrap=True) + snap_table.add_column("Size", style="white", no_wrap=True) + snap_table.add_column("Region", style="white", no_wrap=True) + + for snapshot in hibernated: + snapshot_name = snapshot.get("name", "") + # Extract droplet name from snapshot name (remove "dropkit-" prefix) + droplet_name = get_droplet_name_from_snapshot(snapshot_name) or snapshot_name + + size_gb = snapshot.get("size_gigabytes", 0) + regions = snapshot.get("regions", []) + region = regions[0] if regions else "N/A" + + snap_table.add_row(droplet_name, f"{size_gb} GB", region) + + console.print(snap_table) + console.print() + console.print("[dim]Wake with: dropkit wake [/dim]") + + # Summary + if droplets or hibernated: + console.print() + parts = [] + if droplets: + parts.append(f"{len(droplets)} droplet(s)") + if hibernated: + parts.append(f"{len(hibernated)} hibernated") + console.print(f"[dim]Total: {', '.join(parts)}[/dim]") + else: + console.print( + f"[yellow]No droplets or hibernated snapshots found with tag: {tag_name}[/yellow]" + ) + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("config-ssh") +def config_ssh( + droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name), + user: str | None = typer.Option(None, "--user", "-u", help="SSH username"), + identity_file: str | None = typer.Option( + None, "--identity-file", "-i", help="SSH identity file path" + ), +): + """Configure SSH for an existing droplet.""" + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get IP address + ip_address = None + v4_networks = droplet.get("networks", {}).get("v4", []) + for network in v4_networks: + if network.get("type") == "public": + ip_address = network.get("ip_address") + break + + if not ip_address: + console.print( + f"[red]Error: No public IP address found for droplet '{droplet_name}'[/red]" + ) + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Found droplet with IP: [cyan]{ip_address}[/cyan]\n") + + # Check if already in SSH config + ssh_hostname = get_ssh_hostname(droplet_name) + if host_exists(config.ssh.config_path, ssh_hostname): + console.print( + f"[yellow]⚠[/yellow] SSH config entry for '{ssh_hostname}' already exists" + ) + if not Confirm.ask("Do you want to update it?", default=False): + console.print("[dim]Aborted[/dim]") + return + + # Get SSH user (from flag or prompt) + if user is None: + # Use droplet username from DO as default + ssh_user: str = Prompt.ask( + "[cyan]SSH username[/cyan]", + default=username, + ) + else: + ssh_user = user + console.print(f"[dim]Using SSH user: [cyan]{ssh_user}[/cyan][/dim]") + + # Get identity file (from flag or prompt) + if identity_file is None: + default_identity = config.ssh.identity_file + identity_file = Prompt.ask( + "[cyan]SSH identity file path[/cyan]", + default=default_identity, + ) + else: + console.print(f"[dim]Using identity file: [cyan]{identity_file}[/cyan][/dim]") + + # Add to SSH config + console.print("[dim]Adding SSH config entry...[/dim]") + add_ssh_host( + config_path=config.ssh.config_path, + host_name=ssh_hostname, + hostname=ip_address, + user=ssh_user, + identity_file=identity_file, + ) + + console.print(f"[green]✓[/green] SSH config updated for [cyan]{droplet_name}[/cyan]") + console.print("\n[bold]Connect with:[/bold]") + console.print(f" [cyan]ssh {ssh_hostname}[/cyan]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +def info(droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name)): + """Show detailed information about a droplet.""" + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get detailed droplet info + droplet_id = droplet.get("id") + if droplet_id: + droplet = api.get_droplet(droplet_id) + + # Display information in a nice format + console.print( + Panel.fit( + f"[bold cyan]Droplet Information: {droplet_name}[/bold cyan]", + border_style="cyan", + ) + ) + + # Basic info table + basic_table = Table(show_header=False, box=None, padding=(0, 2)) + basic_table.add_column(style="dim") + basic_table.add_column(style="white") + + status = droplet.get("status", "unknown") + status_colored = ( + f"[green]{status}[/green]" if status == "active" else f"[yellow]{status}[/yellow]" + ) + + basic_table.add_row("ID:", str(droplet.get("id", "N/A"))) + basic_table.add_row("Name:", droplet.get("name", "N/A")) + basic_table.add_row("Status:", status_colored) + basic_table.add_row("Created:", droplet.get("created_at", "N/A")) + + console.print("\n[bold]Basic Information:[/bold]") + console.print(basic_table) + + # Network info + networks = droplet.get("networks", {}) + v4_networks = networks.get("v4", []) + v6_networks = networks.get("v6", []) + + console.print("\n[bold]Network:[/bold]") + network_table = Table(show_header=False, box=None, padding=(0, 2)) + network_table.add_column(style="dim") + network_table.add_column(style="cyan") + + # IPv4 addresses + for network in v4_networks: + net_type = network.get("type", "").title() + ip_address = network.get("ip_address", "N/A") + network_table.add_row(f"{net_type} IPv4:", ip_address) + + # IPv6 addresses + for network in v6_networks: + net_type = network.get("type", "").title() + ip_address = network.get("ip_address", "N/A") + network_table.add_row(f"{net_type} IPv6:", ip_address) + + if not v4_networks and not v6_networks: + network_table.add_row("IP Addresses:", "None") + + console.print(network_table) + + # Configuration + console.print("\n[bold]Configuration:[/bold]") + config_table = Table(show_header=False, box=None, padding=(0, 2)) + config_table.add_column(style="dim") + config_table.add_column(style="white") + + region_info = droplet.get("region", {}) + size_info = droplet.get("size", {}) + image_info = droplet.get("image", {}) + + config_table.add_row("Region:", region_info.get("slug", "N/A")) + config_table.add_row("Size:", droplet.get("size_slug", "N/A")) + config_table.add_row("vCPUs:", f"{size_info.get('vcpus', 'N/A')} vCPU(s)") + config_table.add_row("Memory:", f"{size_info.get('memory', 'N/A')} MB") + config_table.add_row("Disk:", f"{size_info.get('disk', 'N/A')} GB") + config_table.add_row("Transfer:", f"{size_info.get('transfer', 'N/A')} TB") + config_table.add_row("Price:", f"${size_info.get('price_monthly', 'N/A')}/month") + + console.print(config_table) + + # Image info + console.print("\n[bold]Image:[/bold]") + image_table = Table(show_header=False, box=None, padding=(0, 2)) + image_table.add_column(style="dim") + image_table.add_column(style="white") + + image_table.add_row("Distribution:", image_info.get("distribution", "N/A")) + image_table.add_row("Name:", image_info.get("name", "N/A")) + image_table.add_row("Slug:", image_info.get("slug", "N/A")) + + console.print(image_table) + + # Tags + tags = droplet.get("tags", []) + console.print("\n[bold]Tags:[/bold]") + if tags: + console.print(f" {', '.join(tags)}") + else: + console.print(" [dim]None[/dim]") + + # Features + features = droplet.get("features", []) + if features: + console.print("\n[bold]Features:[/bold]") + console.print(f" {', '.join(features)}") + + # SSH info + console.print("\n[bold]SSH Access:[/bold]") + ssh_hostname = get_ssh_hostname(droplet_name) + in_ssh_config = host_exists(config.ssh.config_path, ssh_hostname) + if in_ssh_config: + console.print(f" [green]✓[/green] In SSH config: [cyan]ssh {ssh_hostname}[/cyan]") + else: + console.print(" [yellow]✗[/yellow] Not in SSH config") + console.print(f" [dim]Run: [cyan]dropkit config-ssh {droplet_name}[/cyan][/dim]") + + # Public IP for manual SSH + public_ip = None + for network in v4_networks: + if network.get("type") == "public": + public_ip = network.get("ip_address") + break + + if public_ip: + console.print(f" [dim]Manual SSH: ssh root@{public_ip}[/dim]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("destroy") +def destroy(droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name)): + """ + Destroy a droplet or hibernated snapshot (DESTRUCTIVE - requires confirmation). + + This will permanently delete the droplet (or hibernated snapshot) and remove + its SSH config entry. Only resources tagged with owner: can be destroyed. + + If no droplet is found, this command will check for a hibernated snapshot + with the same name and offer to delete that instead. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + # If no droplet found, check for hibernated snapshot + if not droplet: + # Get username if we don't have it + if not username: + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Check for hibernated snapshot + snapshot_name = get_snapshot_name(droplet_name) + user_tag = get_user_tag(username) + snapshot = api.get_snapshot_by_name(snapshot_name, tag=user_tag) + + if snapshot: + # Found a hibernated snapshot - handle deletion + _destroy_hibernated_snapshot(api, snapshot, droplet_name, snapshot_name) + return + + # Neither droplet nor snapshot found + console.print( + f"[red]Error: No droplet or hibernated snapshot found for '{droplet_name}'[/red]" + ) + console.print(f"[dim]Checked for droplet with tag: {user_tag}[/dim]") + console.print(f"[dim]Checked for snapshot named: {snapshot_name}[/dim]") + raise typer.Exit(1) + + # Get detailed droplet info for display + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Could not determine droplet ID[/red]") + raise typer.Exit(1) + + droplet = api.get_droplet(droplet_id) + + # Display droplet information before deletion + console.print( + Panel.fit( + f"[bold red]⚠ DESTROY DROPLET: {droplet_name}[/bold red]", + border_style="red", + ) + ) + + # Show key information + info_table = Table(show_header=False, box=None, padding=(0, 2)) + info_table.add_column(style="dim") + info_table.add_column(style="white") + + status = droplet.get("status", "unknown") + info_table.add_row("Name:", droplet.get("name", "N/A")) + info_table.add_row("ID:", str(droplet.get("id", "N/A"))) + info_table.add_row("Status:", status) + + # Get IP address + networks = droplet.get("networks", {}) + v4_networks = networks.get("v4", []) + for network in v4_networks: + if network.get("type") == "public": + info_table.add_row("IP:", network.get("ip_address", "N/A")) + break + + info_table.add_row("Region:", droplet.get("region", {}).get("slug", "N/A")) + info_table.add_row("Size:", droplet.get("size_slug", "N/A")) + info_table.add_row("Created:", droplet.get("created_at", "N/A")) + + # Show tags + tags = droplet.get("tags", []) + if tags: + info_table.add_row("Tags:", ", ".join(tags)) + + console.print(info_table) + console.print() + + # First confirmation: yes/no + console.print("[bold red]⚠ WARNING: This action cannot be undone![/bold red]") + console.print() + + first_confirm = Prompt.ask( + "[yellow]Are you sure you want to destroy this droplet?[/yellow]", + choices=["yes", "no"], + default="no", + ) + + if first_confirm != "yes": + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + # Second confirmation: type droplet name + console.print() + name_confirm = Prompt.ask( + f"[yellow]Type the droplet name '[cyan]{droplet_name}[/cyan]' to confirm deletion[/yellow]" + ) + + if name_confirm != droplet_name: + console.print( + f"[red]Error: Name mismatch. Expected '{droplet_name}', got '{name_confirm}'[/red]" + ) + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(1) + + # Logout from Tailscale if droplet is Tailscale-locked (best-effort) + if is_droplet_tailscale_locked(config, droplet_name): + ssh_hostname = get_ssh_hostname(droplet_name) + console.print() + console.print("[dim]Logging out from Tailscale...[/dim]") + if tailscale_logout(ssh_hostname): + console.print("[green]✓[/green] Logged out from Tailscale") + else: + console.print( + "[yellow]⚠[/yellow] Could not logout from Tailscale " + "(device may remain in Tailscale admin console)" + ) + + # Proceed with deletion + console.print() + console.print("[dim]Deleting droplet...[/dim]") + + api.delete_droplet(droplet_id) + console.print("[green]✓[/green] Droplet destroyed") + + # Remove SSH config entry and clean up known_hosts + cleanup_ssh_entries(config, droplet_name, prompt_known_hosts=True) + + console.print() + console.print("[bold green]Droplet successfully destroyed[/bold green]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +def _complete_hibernate( + api: DigitalOceanAPI, + config: DropkitConfig, + droplet_id: int, + droplet_name: str, + snapshot_name: str, + username: str, + size_slug: str, + tailscale_locked: bool = False, +) -> None: + """ + Complete hibernate after snapshot is done (tag, destroy, cleanup, success message). + + This handles steps 8-11 of the hibernate flow: + - Tag the snapshot with owner, size, and tailscale-lockdown (if applicable) + - Destroy the droplet + - Remove SSH config entry + - Print success message + + Args: + api: DigitalOceanAPI instance + config: DropkitConfig instance + droplet_id: ID of the droplet to destroy + droplet_name: Name of the droplet + snapshot_name: Name of the snapshot + username: Username for tagging + size_slug: Size slug for tagging + tailscale_locked: If True, tag snapshot with tailscale-lockdown + """ + user_tag = get_user_tag(username) + + # Tag the snapshot + snapshot = api.get_snapshot_by_name(snapshot_name) + if snapshot: + snapshot_id = snapshot.get("id") + if snapshot_id: + try: + api.create_tag(user_tag) + api.tag_resource(user_tag, str(snapshot_id), "image") + size_tag = f"size:{size_slug}" + api.create_tag(size_tag) + api.tag_resource(size_tag, str(snapshot_id), "image") + + # Tag with tailscale-lockdown if droplet was under Tailscale lockdown + if tailscale_locked: + lockdown_tag = "tailscale-lockdown" + api.create_tag(lockdown_tag) + api.tag_resource(lockdown_tag, str(snapshot_id), "image") + except DigitalOceanAPIError as e: + console.print(f"[yellow]⚠[/yellow] Could not tag snapshot (non-critical): {e}") + + # Destroy droplet + console.print("\n[dim]Destroying droplet...[/dim]") + api.delete_droplet(droplet_id) + console.print("[green]✓[/green] Droplet destroyed") + + # Remove SSH config entry and clean up known_hosts + cleanup_ssh_entries(config, droplet_name, prompt_known_hosts=True) + + # Summary + console.print() + console.print(f"[bold green]Droplet '{droplet_name}' is now hibernated.[/bold green]") + if snapshot: + size_gb = snapshot.get("size_gigabytes", 0) + if size_gb: + console.print(f"Snapshot size: {size_gb} GB") + console.print(f"Restore anytime with: [cyan]dropkit wake {droplet_name}[/cyan]") + + +def _destroy_hibernated_snapshot( + api: DigitalOceanAPI, + snapshot: dict, + droplet_name: str, + snapshot_name: str, +) -> None: + """ + Handle destruction of a hibernated snapshot. + + This is called by the destroy command when no droplet is found but a + hibernated snapshot exists. + """ + snapshot_id_str = snapshot.get("id") + if not snapshot_id_str: + console.print("[red]Error: Could not determine snapshot ID[/red]") + raise typer.Exit(1) + snapshot_id = int(snapshot_id_str) # API returns string, convert to int + + size_gb = snapshot.get("size_gigabytes", 0) + regions = snapshot.get("regions", []) + region = regions[0] if regions else "N/A" + created_at = snapshot.get("created_at", "N/A") + + # Display snapshot information + console.print( + Panel.fit( + f"[bold red]⚠ DESTROY HIBERNATED SNAPSHOT: {droplet_name}[/bold red]", + border_style="red", + ) + ) + + console.print("[dim]No active droplet found, but found a hibernated snapshot.[/dim]") + console.print() + + info_table = Table(show_header=False, box=None, padding=(0, 2)) + info_table.add_column(style="dim") + info_table.add_column(style="white") + + info_table.add_row("Snapshot name:", snapshot_name) + info_table.add_row("Snapshot ID:", str(snapshot_id)) + info_table.add_row("Size:", f"{size_gb} GB") + info_table.add_row("Region:", region) + info_table.add_row("Created:", created_at) + + console.print(info_table) + console.print() + + # Warning and confirmation + console.print("[bold red]⚠ WARNING: This action cannot be undone![/bold red]") + console.print("[dim]The hibernated snapshot will be permanently deleted.[/dim]") + console.print() + + confirm = Prompt.ask( + f"[yellow]Delete hibernated snapshot '{snapshot_name}'?[/yellow]", + choices=["yes", "no"], + default="no", + ) + + if confirm != "yes": + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + # Delete snapshot + console.print() + console.print("[dim]Deleting snapshot...[/dim]") + api.delete_snapshot(snapshot_id) + console.print("[green]✓[/green] Snapshot deleted") + + console.print() + console.print( + f"[bold green]Hibernated snapshot '{droplet_name}' successfully destroyed[/bold green]" + ) + + +@app.command() +@requires_lock("rename") +def rename( + old_name: str = typer.Argument(..., autocompletion=complete_droplet_name), + new_name: str = typer.Argument(..., help="New name for the droplet"), +): + """ + Rename a droplet (requires confirmation). + + This will rename the droplet and update the SSH config entry. + Only droplets tagged with owner: can be renamed. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{old_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, old_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{old_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get droplet ID + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Could not determine droplet ID[/red]") + raise typer.Exit(1) + + # Check if new name is same as old name + if old_name == new_name: + console.print(f"[yellow]New name is the same as current name ({old_name})[/yellow]") + console.print("[dim]No rename needed.[/dim]") + raise typer.Exit(0) + + # Check if new name already exists among user's droplets + user_droplets = api.list_droplets(tag_name=get_user_tag(username)) + for d in user_droplets: + if d.get("name") == new_name: + console.print( + f"[red]Error:[/red] A droplet with name '[cyan]{new_name}[/cyan]' already exists." + ) + raise typer.Exit(1) + + # Check droplet status - must be active for rename + status = droplet.get("status", "unknown") + if status != "active": + console.print( + f"[red]Error:[/red] Droplet '[cyan]{old_name}[/cyan]' " + f"is currently [bold]{status}[/bold]." + ) + console.print(f"[dim]Power on the droplet first with: dropkit on {old_name}[/dim]") + raise typer.Exit(1) + + # Get detailed droplet info for display + droplet = api.get_droplet(droplet_id) + + # Get IP address for SSH config update + ip_address = None + v4_networks = droplet.get("networks", {}).get("v4", []) + for network in v4_networks: + if network.get("type") == "public": + ip_address = network.get("ip_address") + break + + # Display droplet information + console.print( + Panel.fit( + "[bold cyan]RENAME DROPLET[/bold cyan]", + border_style="cyan", + ) + ) + + info_table = Table(show_header=False, box=None, padding=(0, 2)) + info_table.add_column(style="dim") + info_table.add_column(style="white") + + info_table.add_row("Current name:", f"[cyan]{old_name}[/cyan]") + info_table.add_row("New name:", f"[green]{new_name}[/green]") + info_table.add_row("ID:", str(droplet_id)) + if ip_address: + info_table.add_row("IP:", ip_address) + info_table.add_row("Status:", droplet.get("status", "unknown")) + + console.print(info_table) + console.print() + + # Show SSH config change + old_ssh_hostname = get_ssh_hostname(old_name) + new_ssh_hostname = get_ssh_hostname(new_name) + console.print(f"[dim]SSH config will change: {old_ssh_hostname} → {new_ssh_hostname}[/dim]") + console.print() + + # Confirmation + confirm = Prompt.ask( + "[yellow]Are you sure you want to rename this droplet?[/yellow]", + choices=["yes", "no"], + default="no", + ) + + if confirm != "yes": + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + # Perform rename via API + console.print() + console.print("[dim]Renaming droplet...[/dim]") + + action = api.rename_droplet(droplet_id, new_name) + + # Wait for rename action to complete + action_id = action.get("id") + if action_id: + api.wait_for_action_complete(action_id, timeout=60, poll_interval=2) + + console.print(f"[green]✓[/green] Droplet renamed to [cyan]{new_name}[/cyan]") + + # Update SSH config + if host_exists(config.ssh.config_path, old_ssh_hostname): + try: + # Remove old SSH entry + remove_ssh_host(config.ssh.config_path, old_ssh_hostname) + console.print( + f"[green]✓[/green] Removed old SSH config entry: [dim]{old_ssh_hostname}[/dim]" + ) + + # Add new SSH entry if we have the IP + if ip_address: + add_ssh_host( + config_path=config.ssh.config_path, + host_name=new_ssh_hostname, + hostname=ip_address, + user=username, + identity_file=config.ssh.identity_file, + ) + console.print( + f"[green]✓[/green] Added new SSH config entry: [cyan]{new_ssh_hostname}[/cyan]" + ) + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Could not update SSH config: {e}") + console.print( + f"[dim]Run 'dropkit config-ssh {new_name}' to configure SSH manually[/dim]" + ) + else: + console.print(f"[dim]SSH config entry for {old_ssh_hostname} not found (skipped)[/dim]") + + console.print() + console.print(f"[bold green]Droplet successfully renamed to {new_name}[/bold green]") + if ip_address and host_exists(config.ssh.config_path, new_ssh_hostname): + console.print("\n[bold]Connect with:[/bold]") + console.print(f" [cyan]ssh {new_ssh_hostname}[/cyan]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("resize") +def resize( + droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name), + size: str | None = typer.Option(None, "--size", "-s", help="New size slug (e.g., s-4vcpu-8gb)"), + disk: bool = typer.Option( + True, "--disk/--no-disk", help="Resize disk (permanent, default: True)" + ), +): + """ + Resize a droplet (causes downtime - requires power off). + + This will change the droplet's vCPUs, memory, and optionally disk size. + Only droplets tagged with owner: can be resized. + """ + try: + # Load config and API + _, api = load_config_and_api() + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get detailed droplet info + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Could not determine droplet ID[/red]") + raise typer.Exit(1) + + droplet = api.get_droplet(droplet_id) + + # Get current size info + current_size_slug = droplet.get("size_slug", "") + current_size_info = droplet.get("size", {}) + + if not current_size_slug: + console.print("[red]Error: Could not determine current droplet size[/red]") + raise typer.Exit(1) + + # Display header + console.print( + Panel.fit( + f"[bold cyan]RESIZE DROPLET: {droplet_name}[/bold cyan]", + border_style="cyan", + ) + ) + + # Display current size + console.print("\n[bold]Current Size:[/bold] [cyan]" + current_size_slug + "[/cyan]") + current_table = Table(show_header=False, box=None, padding=(0, 2)) + current_table.add_column(style="dim") + current_table.add_column(style="white") + + current_vcpus = current_size_info.get("vcpus", "N/A") + current_memory = current_size_info.get("memory", "N/A") + current_disk = current_size_info.get("disk", "N/A") + current_price = current_size_info.get("price_monthly", 0) + + current_table.add_row("vCPUs:", str(current_vcpus)) + current_table.add_row("Memory:", f"{current_memory} MB") + current_table.add_row("Disk:", f"{current_disk} GB") + current_table.add_row("Price:", f"${current_price:.2f}/month") + + console.print(current_table) + + # Get new size (interactive if not provided) + if size is None: + console.print() + # Fetch available sizes for interactive selection + try: + available_sizes = api.get_available_sizes() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching sizes: {e}[/red]") + raise typer.Exit(1) + + new_size_slug = prompt_with_help( + "\n[bold]New size[/bold]", + default=current_size_slug, + display_func=display_sizes, + data=available_sizes, + ) + else: + new_size_slug = size + + # Check if same size + if new_size_slug == current_size_slug: + console.print( + f"[yellow]Error: New size is the same as current size ({current_size_slug})[/yellow]" + ) + console.print("[dim]No resize needed.[/dim]") + raise typer.Exit(0) + + # Fetch all sizes to get the new size details + try: + all_sizes = api.get_available_sizes() + new_size_info = None + for s in all_sizes: + if s.get("slug") == new_size_slug: + new_size_info = s + break + + if not new_size_info: + console.print( + f"[red]Error: Size '{new_size_slug}' not found or not available[/red]" + ) + raise typer.Exit(1) + + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching size details: {e}[/red]") + raise typer.Exit(1) + + # Display new size + console.print("\n[bold]New Size:[/bold] [cyan]" + new_size_slug + "[/cyan]") + new_table = Table(show_header=False, box=None, padding=(0, 2)) + new_table.add_column(style="dim") + new_table.add_column(style="white") + + new_vcpus = new_size_info.get("vcpus", "N/A") + new_memory = new_size_info.get("memory", "N/A") + new_disk = new_size_info.get("disk", "N/A") + new_price = new_size_info.get("price_monthly", 0) + + new_table.add_row("vCPUs:", str(new_vcpus)) + new_table.add_row("Memory:", f"{new_memory} MB") + new_table.add_row("Disk:", f"{new_disk} GB") + new_table.add_row("Price:", f"${new_price:.2f}/month") + + console.print(new_table) + + # Display changes + console.print("\n[bold]Changes:[/bold]") + changes_table = Table(show_header=False, box=None, padding=(0, 2)) + changes_table.add_column(style="dim") + changes_table.add_column(style="white") + + # vCPUs + vcpu_diff = ( + new_vcpus - current_vcpus + if isinstance(new_vcpus, int) and isinstance(current_vcpus, int) + else 0 + ) + vcpu_change = f"{current_vcpus} → {new_vcpus}" + if vcpu_diff > 0: + vcpu_change += f" [green](+{vcpu_diff})[/green]" + elif vcpu_diff < 0: + vcpu_change += f" [yellow]({vcpu_diff})[/yellow]" + changes_table.add_row("vCPUs:", vcpu_change) + + # Memory + mem_diff = ( + new_memory - current_memory + if isinstance(new_memory, int) and isinstance(current_memory, int) + else 0 + ) + mem_change = f"{current_memory} MB → {new_memory} MB" + if mem_diff > 0: + mem_change += f" [green](+{mem_diff} MB)[/green]" + elif mem_diff < 0: + mem_change += f" [yellow]({mem_diff} MB)[/yellow]" + changes_table.add_row("Memory:", mem_change) + + # Disk + disk_diff = ( + new_disk - current_disk + if isinstance(new_disk, int) and isinstance(current_disk, int) + else 0 + ) + disk_change = f"{current_disk} GB → {new_disk} GB" + if disk and disk_diff > 0: + disk_change += f" [green](+{disk_diff} GB)[/green]" + elif disk and disk_diff < 0: + disk_change += f" [yellow]({disk_diff} GB)[/yellow]" + elif not disk: + disk_change = f"{current_disk} GB (not resized)" + changes_table.add_row("Disk:", disk_change) + + # Price + price_diff = new_price - current_price + price_change = f"${current_price:.2f}/month → ${new_price:.2f}/month" + if price_diff > 0: + price_change += f" [yellow](+${price_diff:.2f}/month)[/yellow]" + elif price_diff < 0: + price_change += f" [green](-${abs(price_diff):.2f}/month)[/green]" + changes_table.add_row("Price:", price_change) + + console.print(changes_table) + + # Show warnings + console.print() + console.print( + "[bold yellow]⚠ WARNING: This operation will cause downtime (droplet will be powered off)[/bold yellow]" + ) + + if disk: + console.print( + "[bold red]⚠ WARNING: Disk resize is PERMANENT and cannot be undone![/bold red]" + ) + else: + console.print( + "[dim]Note: Disk will NOT be resized. You can resize it later, but it's permanent.[/dim]" + ) + + # Confirmation + console.print() + confirm = Prompt.ask( + "[yellow]Are you sure you want to resize this droplet?[/yellow]", + choices=["yes", "no"], + default="no", + ) + + if confirm != "yes": + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + # Initiate resize + console.print() + console.print("[dim]Initiating resize...[/dim]") + + action = api.resize_droplet(droplet_id, new_size_slug, disk=disk) + action_id = action.get("id") + + if not action_id: + console.print("[red]Error: Failed to get action ID from API response[/red]") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Resize action started (ID: [cyan]{action_id}[/cyan])") + + # Wait for action to complete + console.print( + "[dim]Waiting for resize to complete (this may take several minutes)...[/dim]" + ) + + with console.status("[cyan]Resizing...[/cyan]"): + api.wait_for_action_complete(action_id, timeout=600) # 10 minutes + + console.print("[green]✓[/green] Resize completed successfully") + console.print() + console.print( + f"[bold green]Droplet {droplet_name} has been resized to {new_size_slug}[/bold green]" + ) + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("on") +def on(droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name)): + """ + Power on a droplet. + + Only droplets tagged with owner: can be powered on. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get droplet ID and status + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Could not determine droplet ID[/red]") + raise typer.Exit(1) + + status = droplet.get("status", "") + + # Check if already active + if status == "active": + console.print(f"[yellow]Droplet '{droplet_name}' is already active[/yellow]") + raise typer.Exit(0) + + # Show current status + console.print( + Panel.fit( + f"[bold cyan]POWER ON DROPLET: {droplet_name}[/bold cyan]", + border_style="cyan", + ) + ) + console.print(f"Current status: [yellow]{status}[/yellow]") + console.print() + + # Power on + console.print("[dim]Powering on droplet...[/dim]") + + action = api.power_on_droplet(droplet_id) + action_id = action.get("id") + + if not action_id: + console.print("[red]Error: Failed to get action ID from API response[/red]") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Power on action started (ID: [cyan]{action_id}[/cyan])") + + # Wait for action to complete + console.print("[dim]Waiting for droplet to power on...[/dim]") + + with console.status("[cyan]Powering on...[/cyan]"): + api.wait_for_action_complete(action_id, timeout=120) # 2 minutes + + console.print("[green]✓[/green] Droplet powered on successfully") + console.print() + console.print(f"[bold green]Droplet {droplet_name} is now active[/bold green]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("off") +def off(droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name)): + """ + Power off a droplet (requires confirmation). + + Only droplets tagged with owner: can be powered off. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get droplet ID and status + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Could not determine droplet ID[/red]") + raise typer.Exit(1) + + status = droplet.get("status", "") + + # Check if already off + if status == "off": + console.print(f"[yellow]Droplet '{droplet_name}' is already powered off[/yellow]") + raise typer.Exit(0) + + # Show current status + console.print( + Panel.fit( + f"[bold yellow]POWER OFF DROPLET: {droplet_name}[/bold yellow]", + border_style="yellow", + ) + ) + console.print(f"Current status: [green]{status}[/green]") + console.print() + + # Show billing warning + console.print( + "[bold yellow]⚠ Warning:[/bold yellow] DigitalOcean bills for stopped droplets " + "at the full hourly rate." + ) + console.print( + " Consider using [cyan]dropkit hibernate[/cyan] to snapshot and destroy instead." + ) + console.print() + + # Confirmation + confirm = Prompt.ask( + "[yellow]Are you sure you want to power off this droplet?[/yellow]", + choices=["yes", "no"], + default="no", + ) + + if confirm != "yes": + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + # Power off + console.print() + console.print("[dim]Powering off droplet...[/dim]") + + action = api.power_off_droplet(droplet_id) + action_id = action.get("id") + + if not action_id: + console.print("[red]Error: Failed to get action ID from API response[/red]") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Power off action started (ID: [cyan]{action_id}[/cyan])") + + # Wait for action to complete + console.print("[dim]Waiting for droplet to power off...[/dim]") + + with console.status("[cyan]Powering off...[/cyan]"): + api.wait_for_action_complete(action_id, timeout=120) # 2 minutes + + console.print("[green]✓[/green] Droplet powered off successfully") + console.print() + console.print(f"[bold green]Droplet {droplet_name} is now off[/bold green]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("hibernate") +def hibernate( + droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name), + continue_: bool = typer.Option( + False, "--continue", "-c", help="Continue a timed-out hibernate operation" + ), +): + """ + Hibernate a droplet (snapshot and destroy to save costs). + + This will create a snapshot of the droplet, then destroy it. + You can restore it later with 'dropkit wake '. + + Only droplets tagged with owner: can be hibernated. + + Use --continue to resume a hibernate operation that timed out while + creating the snapshot. This will find the in-progress snapshot action + and wait for it to complete, then finish the hibernate process. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Get droplet details + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Could not determine droplet ID[/red]") + raise typer.Exit(1) + + droplet = api.get_droplet(droplet_id) + status = droplet.get("status", "") + size_slug = droplet.get("size_slug", "") + + # Generate snapshot name + snapshot_name = get_snapshot_name(droplet_name) + + # Handle --continue flag: resume a timed-out hibernate + if continue_: + snapshot_action = find_snapshot_action(api, droplet_id) + + if not snapshot_action: + console.print("[red]Error: No snapshot action found for this droplet[/red]") + console.print("[dim]Run hibernate without --continue to start fresh[/dim]") + raise typer.Exit(1) + + action_status = snapshot_action.get("status") + action_id = snapshot_action.get("id") + + if not action_id: + console.print("[red]Error: Snapshot action has no ID[/red]") + raise typer.Exit(1) + + if action_status == "errored": + console.print("[red]Error: Previous snapshot action failed[/red]") + console.print("[dim]Run hibernate without --continue to retry[/dim]") + raise typer.Exit(1) + if action_status == "in-progress": + console.print(f"[dim]Found in-progress snapshot action (ID: {action_id})[/dim]") + try: + with console.status("[cyan]Waiting for snapshot to complete...[/cyan]"): + api.wait_for_action_complete(action_id, timeout=3600) + console.print("[green]✓[/green] Snapshot completed") + except DigitalOceanAPIError as e: + console.print(f"[red]Error: Snapshot wait failed: {e}[/red]") + console.print( + "[yellow]⚠[/yellow] Droplet remains powered off. " + "Run [cyan]dropkit hibernate --continue[/cyan] again to retry." + ) + raise typer.Exit(1) + elif action_status == "completed": + console.print("[green]✓[/green] Snapshot already completed") + + # Detect Tailscale lockdown before completing + tailscale_locked = is_droplet_tailscale_locked(config, droplet_name) + if tailscale_locked: + console.print("[dim]Detected Tailscale lockdown[/dim]") + + # Complete the hibernate (tag, destroy, cleanup) + _complete_hibernate( + api, + config, + droplet_id, + droplet_name, + snapshot_name, + username, + size_slug, + tailscale_locked=tailscale_locked, + ) + return # Exit early, skip normal flow + + # Check if snapshot already exists + user_tag = get_user_tag(username) + existing_snapshot = api.get_snapshot_by_name(snapshot_name, tag=user_tag) + + if existing_snapshot: + console.print(f"[yellow]⚠[/yellow] Snapshot '{snapshot_name}' already exists.") + overwrite = Prompt.ask( + "[yellow]Overwrite existing snapshot?[/yellow]", + choices=["yes", "no"], + default="no", + ) + if overwrite != "yes": + console.print("[dim]Aborted.[/dim]") + raise typer.Exit(0) + + # Delete existing snapshot + console.print("[dim]Deleting existing snapshot...[/dim]") + api.delete_snapshot(int(existing_snapshot["id"])) + console.print("[green]✓[/green] Existing snapshot deleted") + + # Display header + console.print( + Panel.fit( + f"[bold cyan]HIBERNATE DROPLET: {droplet_name}[/bold cyan]", + border_style="cyan", + ) + ) + console.print("This will snapshot the droplet and then destroy it.") + console.print(f"You can restore it later with [cyan]dropkit wake {droplet_name}[/cyan]") + console.print() + + # Confirmation + confirm = Prompt.ask( + "[yellow]Are you sure?[/yellow]", + choices=["yes", "no"], + default="no", + ) + + if confirm != "yes": + console.print("[dim]Cancelled.[/dim]") + raise typer.Exit(0) + + console.print() + + # Step 0: Prepare for hibernate (handle Tailscale lockdown if present) + tailscale_locked = prepare_for_hibernate(config, api, droplet, droplet_name) + + # Step 1: Power off if not already off + if status != "off": + console.print("[dim]Powering off droplet...[/dim]") + action = api.power_off_droplet(droplet_id) + action_id = action.get("id") + + if action_id: + with console.status("[cyan]Powering off...[/cyan]"): + api.wait_for_action_complete(action_id, timeout=120) + console.print("[green]✓[/green] Droplet powered off") + else: + console.print("[dim]Droplet already powered off[/dim]") + + # Step 2: Create snapshot + console.print(f"\n[dim]Creating snapshot '{snapshot_name}'...[/dim]") + start_time = time.time() + + action = api.create_snapshot(droplet_id, snapshot_name) + action_id = action.get("id") + + if not action_id: + console.print("[red]Error: Failed to get action ID for snapshot[/red]") + console.print( + "[yellow]⚠[/yellow] Droplet remains powered off but intact. " + "Please check DigitalOcean console." + ) + raise typer.Exit(1) + + try: + with console.status( + "[cyan]Creating snapshot (this may take several minutes)...[/cyan]" + ): + api.wait_for_action_complete(action_id, timeout=3600) # 60 minutes max + + elapsed = time.time() - start_time + minutes = int(elapsed // 60) + seconds = int(elapsed % 60) + time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s" + console.print(f"[green]✓[/green] Snapshot created (took {time_str})") + except DigitalOceanAPIError as e: + console.print(f"[red]Error: Snapshot creation failed: {e}[/red]") + console.print( + "[yellow]⚠[/yellow] Droplet remains powered off but intact. " + f"Run [cyan]dropkit hibernate --continue {droplet_name}[/cyan] to retry." + ) + raise typer.Exit(1) + + # Complete hibernate: tag snapshot, destroy droplet, remove SSH config + _complete_hibernate( + api, + config, + droplet_id, + droplet_name, + snapshot_name, + username, + size_slug, + tailscale_locked=tailscale_locked, + ) + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +@requires_lock("wake") +def wake( + droplet_name: str = typer.Argument( + ..., autocompletion=complete_snapshot_name, help="Name of the hibernated droplet to restore" + ), + no_tailscale: bool = typer.Option(False, "--no-tailscale", help="Skip Tailscale VPN re-setup"), +): + """ + Wake a hibernated droplet (restore from snapshot). + + This will create a new droplet from the hibernated snapshot. + After successful restoration, you'll be prompted to delete the snapshot. + + If the original droplet had Tailscale lockdown enabled, this command will + re-setup Tailscale after the droplet becomes active. Use --no-tailscale to + skip this and keep public SSH access. + + Use 'dropkit destroy ' to delete a hibernated snapshot without restoring. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + config = config_manager.config + + # Get username + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Check if a droplet with this name already exists + existing_droplet, _ = find_user_droplet(api, droplet_name) + if existing_droplet: + console.print(f"[red]Error: A droplet named '{droplet_name}' already exists.[/red]") + console.print("[dim]Destroy or rename the existing droplet first.[/dim]") + raise typer.Exit(1) + + # Find the hibernated snapshot + snapshot_name = get_snapshot_name(droplet_name) + user_tag = get_user_tag(username) + + console.print(f"[dim]Looking for hibernated snapshot: [cyan]{snapshot_name}[/cyan][/dim]\n") + snapshot = api.get_snapshot_by_name(snapshot_name, tag=user_tag) + + if not snapshot: + console.print(f"[red]Error: No hibernated snapshot found for '{droplet_name}'[/red]") + console.print(f"[dim]Expected snapshot name: {snapshot_name}[/dim]") + raise typer.Exit(1) + + snapshot_id_str = snapshot.get("id") + if not snapshot_id_str: + console.print("[red]Error: Could not determine snapshot ID[/red]") + raise typer.Exit(1) + snapshot_id = int(snapshot_id_str) # API returns string, convert to int + + # Get snapshot details + size_gb = snapshot.get("size_gigabytes", 0) + regions = snapshot.get("regions", []) + original_region = regions[0] if regions else None + + # Get original size and check for tailscale-lockdown tag + tags = snapshot.get("tags", []) + original_size = None + was_tailscale_locked = False + for tag in tags: + if tag.startswith("size:"): + original_size = tag[5:] # Remove "size:" prefix + elif tag == "tailscale-lockdown": + was_tailscale_locked = True + + if not original_region: + console.print("[red]Error: Could not determine original region from snapshot[/red]") + raise typer.Exit(1) + + if not original_size: + console.print( + "[yellow]⚠[/yellow] Could not determine original size from snapshot tags." + ) + console.print("[dim]Using default size from config.[/dim]") + original_size = config.defaults.size + + # Display snapshot info + console.print(f"Found hibernated snapshot: [cyan]{snapshot_name}[/cyan] ({size_gb} GB)") + console.print( + f"Original config: [cyan]{original_region}[/cyan], [cyan]{original_size}[/cyan]" + ) + console.print() + + # Create droplet from snapshot + console.print(f"[dim]Creating droplet '{droplet_name}' from snapshot...[/dim]") + + # Build tags for new droplet + tags_list = build_droplet_tags(username, list(config.defaults.extra_tags)) + + droplet = api.create_droplet_from_snapshot( + name=droplet_name, + region=original_region, + size=original_size, + snapshot_id=snapshot_id, + tags=tags_list, + ssh_keys=config.cloudinit.ssh_key_ids, + ) + + droplet_id = droplet.get("id") + if not droplet_id: + console.print("[red]Error: Failed to get droplet ID from API response[/red]") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Droplet created (ID: [cyan]{droplet_id}[/cyan])") + + # Wait for droplet to become active + console.print("[dim]Waiting for droplet to become active...[/dim]") + + with console.status("[cyan]Waiting...[/cyan]"): + active_droplet = api.wait_for_droplet_active(droplet_id) + + # Get IP address + networks = active_droplet.get("networks", {}) + v4_networks = networks.get("v4", []) + ip_address = None + + for network in v4_networks: + if network.get("type") == "public": + ip_address = network.get("ip_address") + break + + if ip_address: + console.print(f"[green]✓[/green] Droplet is active (IP: [cyan]{ip_address}[/cyan])") + else: + console.print("[green]✓[/green] Droplet is active") + console.print("[yellow]⚠[/yellow] Could not determine IP address") + + # Add SSH config entry + if ip_address and config.ssh.auto_update: + try: + console.print("[dim]Configuring SSH...[/dim]") + ssh_hostname = get_ssh_hostname(droplet_name) + add_ssh_host( + config_path=config.ssh.config_path, + host_name=ssh_hostname, + hostname=ip_address, + user=username, + identity_file=config.ssh.identity_file, + ) + console.print("[green]✓[/green] SSH config updated") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Could not update SSH config: {e}") + + # Handle Tailscale re-setup if the original droplet had Tailscale lockdown + if was_tailscale_locked and ip_address: + ssh_hostname = get_ssh_hostname(droplet_name) + if no_tailscale: + console.print() + console.print("[yellow]⚠[/yellow] Original droplet had Tailscale lockdown enabled.") + console.print( + "[dim]Skipping Tailscale setup (--no-tailscale). " + "Public SSH access available.[/dim]" + ) + console.print( + f"[dim]Enable Tailscale later with: " + f"[cyan]dropkit enable-tailscale {droplet_name}[/cyan][/dim]" + ) + else: + console.print() + console.print( + "[dim]Original droplet had Tailscale lockdown - re-setting up Tailscale...[/dim]" + ) + # Wait a bit for droplet to be fully ready for SSH + console.print("[dim]Waiting for droplet to be ready for SSH...[/dim]") + time.sleep(10) + + # Re-setup Tailscale (clean state from hibernate logout) + tailscale_ip = setup_tailscale(ssh_hostname, username, config) + + if not tailscale_ip: + console.print( + "[yellow]⚠[/yellow] Tailscale setup incomplete. " + "Public SSH access remains available." + ) + console.print( + f"[dim]Complete setup later with: " + f"[cyan]dropkit enable-tailscale {droplet_name}[/cyan][/dim]" + ) + + # Prompt to delete snapshot + console.print() + delete_snapshot = Prompt.ask( + f"[yellow]Delete snapshot '{snapshot_name}'?[/yellow]", + choices=["yes", "no"], + default="yes", + ) + + if delete_snapshot == "yes": + api.delete_snapshot(snapshot_id) + console.print("[green]✓[/green] Snapshot deleted") + else: + console.print("[dim]Snapshot kept[/dim]") + + # Summary + console.print() + console.print(f"[bold green]Droplet '{droplet_name}' is awake![/bold green]") + if ip_address: + ssh_hostname = get_ssh_hostname(droplet_name) + console.print(f"Connect with: [cyan]ssh {ssh_hostname}[/cyan]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="enable-tailscale") +@requires_lock("enable-tailscale") +def enable_tailscale( + droplet_name: str = typer.Argument(..., autocompletion=complete_droplet_name), + no_lockdown: bool = typer.Option( + False, "--no-lockdown", help="Don't lock down firewall to Tailscale only" + ), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Show debug output"), +): + """ + Enable Tailscale VPN on an existing droplet. + + This command sets up Tailscale on a droplet that was created without it + (using --no-tailscale) or on older droplets. + + The command will: + 1. Install Tailscale if not already installed + 2. Start Tailscale and display auth URL for browser login + 3. Update SSH config with Tailscale IP after authentication + 4. Lock down firewall to only allow Tailscale traffic (unless --no-lockdown) + + Only droplets tagged with owner: can be modified. + """ + try: + # Load config and API + config_manager, api = load_config_and_api() + + # Find the droplet + console.print(f"[dim]Looking for droplet: [cyan]{droplet_name}[/cyan][/dim]\n") + droplet, username = find_user_droplet(api, droplet_name) + + if not droplet or not username: + tag = get_user_tag(username) if username else "owner:" + console.print(f"[red]Error: Droplet '{droplet_name}' not found with tag {tag}[/red]") + raise typer.Exit(1) + + # Check droplet status + status = droplet.get("status", "") + if status != "active": + console.print( + f"[red]Error: Droplet must be active to enable Tailscale " + f"(current status: {status})[/red]" + ) + console.print("[dim]Use 'dropkit on' to power on the droplet first.[/dim]") + raise typer.Exit(1) + + # Ensure SSH config exists for this droplet + try: + ssh_hostname = ensure_ssh_config(droplet, droplet_name, username, config_manager.config) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + # Show panel + console.print( + Panel.fit( + f"[bold cyan]ENABLE TAILSCALE: {droplet_name}[/bold cyan]", + border_style="cyan", + ) + ) + + # Check if Tailscale is already installed + console.print("[dim]Checking if Tailscale is installed...[/dim]") + if check_tailscale_installed(ssh_hostname, verbose): + console.print("[green]✓[/green] Tailscale is already installed") + else: + console.print("[dim]Installing Tailscale...[/dim]") + with console.status("[cyan]Installing Tailscale (this may take a minute)...[/cyan]"): + if not install_tailscale_on_droplet(ssh_hostname, verbose): + console.print("[red]Error: Failed to install Tailscale[/red]") + console.print( + f"[dim]Try manually: ssh {ssh_hostname} " + f"'curl -fsSL https://tailscale.com/install.sh | sudo sh'[/dim]" + ) + raise typer.Exit(1) + console.print("[green]✓[/green] Tailscale installed successfully") + + # Handle --no-lockdown by temporarily modifying config + config = config_manager.config + if no_lockdown: + # Override lock_down_firewall setting + config.tailscale.lock_down_firewall = False + + # Run the Tailscale setup flow + tailscale_ip = setup_tailscale( + ssh_hostname=ssh_hostname, + username=username, + config=config, + verbose=verbose, + ) + + if tailscale_ip: + console.print() + console.print(f"[bold green]Tailscale enabled on {droplet_name}![/bold green]") + console.print(f"Connect via: [cyan]ssh {ssh_hostname}[/cyan]") + else: + console.print() + console.print(f"[yellow]Tailscale setup incomplete for {droplet_name}[/yellow]") + console.print( + f"[dim]You can complete setup later: ssh {ssh_hostname} 'sudo tailscale up'[/dim]" + ) + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="list-ssh-keys") +def list_ssh_keys_cmd(): + """List SSH keys registered via dropkit. + + Use 'dropkit add-ssh-key' to add or import additional SSH keys. + """ + try: + # Load config and API + _, api = load_config_and_api() + + # Get username from DigitalOcean for filtering + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Fetch all SSH keys + console.print("[dim]Fetching SSH keys from DigitalOcean...[/dim]\n") + all_keys = api.list_ssh_keys() + + # Filter keys registered via dropkit (prefixed with dropkit-{username}-) + prefix = f"dropkit-{username}-" + dropkit_keys = [key for key in all_keys if key.get("name", "").startswith(prefix)] + + if not dropkit_keys: + console.print( + f"[yellow]No SSH keys found registered via dropkit for user: {username}[/yellow]" + ) + console.print("[dim]Keys are automatically registered during 'dropkit init'[/dim]") + console.print( + "[dim]Use 'dropkit add-ssh-key ' to add or import additional keys[/dim]" + ) + return + + # Display keys in a table + table = Table(title=f"SSH Keys for {username}", show_header=True) + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("ID", style="dim") + table.add_column("Fingerprint", style="white") + + for key in dropkit_keys: + table.add_row( + key.get("name", "N/A"), + str(key.get("id", "N/A")), + key.get("fingerprint", "N/A"), + ) + + console.print(table) + console.print(f"\n[dim]Total: {len(dropkit_keys)} key(s)[/dim]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="add-ssh-key") +@requires_lock("add-ssh-key") +def add_ssh_key_cmd( + key_path: str = typer.Argument(..., help="Path to SSH public key file"), +): + """Add or import an SSH public key to DigitalOcean. + + This command can: + - Register a new SSH key with DigitalOcean + - Import an existing key by renaming it to follow dropkit naming convention + + If the key already exists in DigitalOcean with a different name, you'll be + prompted to rename it to the standard dropkit format. + """ + try: + # Load config and API + _, api = load_config_and_api() + + # Get username from DigitalOcean for key naming + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Expand path + key_file = Path(key_path).expanduser() + + # Validate SSH key + console.print(f"[dim]Validating SSH key: {key_file.name}...[/dim]") + try: + Config.validate_ssh_public_key(str(key_file)) + except FileNotFoundError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + except ValueError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + # Read key content + key_content = Config.read_ssh_key_content(str(key_file)) + + # Compute fingerprint + try: + fingerprint = Config.compute_ssh_key_fingerprint(key_content) + except ValueError as e: + console.print(f"[red]Error computing fingerprint: {e}[/red]") + raise typer.Exit(1) + + console.print(f"[dim]Fingerprint: {fingerprint}[/dim]\n") + + # Check if key already exists by fingerprint + existing_key = api.get_ssh_key_by_fingerprint(fingerprint) + + # Determine the desired key name + fingerprint_prefix = fingerprint.replace(":", "")[:8] + desired_key_name = f"dropkit-{username}-{fingerprint_prefix}" + + if existing_key: + existing_name = existing_key.get("name", "") + existing_id = existing_key.get("id") + + if not existing_id: + console.print("[red]Error: SSH key ID not found in API response[/red]") + raise typer.Exit(1) + + # Check if key already has the correct dropkit name + if existing_name == desired_key_name: + console.print("[yellow]⚠[/yellow] SSH key already registered with correct name:") + console.print(f" Name: [cyan]{existing_name}[/cyan]") + console.print(f" ID: [dim]{existing_id}[/dim]") + console.print(f" Fingerprint: {fingerprint}") + return + + # Key exists but has different name - offer to rename + console.print("[yellow]⚠[/yellow] SSH key already registered in DigitalOcean:") + console.print(f" Current name: [cyan]{existing_name}[/cyan]") + console.print(f" ID: [dim]{existing_id}[/dim]") + console.print(f" Fingerprint: {fingerprint}") + console.print() + console.print(f" Suggested dropkit name: [cyan]{desired_key_name}[/cyan]") + console.print() + + # Ask for confirmation to rename + confirm = Confirm.ask( + "Do you want to rename this key to follow dropkit naming convention?", + default=True, + ) + + if not confirm: + console.print("[dim]Key not renamed[/dim]") + return + + # Rename the key + console.print(f"\n[dim]Renaming SSH key to: {desired_key_name}...[/dim]") + updated_key = api.update_ssh_key(existing_id, desired_key_name) + + console.print( + f"[green]✓[/green] SSH key renamed successfully: [cyan]{desired_key_name}[/cyan]" + ) + console.print(f" ID: [dim]{updated_key.get('id', 'N/A')}[/dim]") + console.print(f" Fingerprint: {updated_key.get('fingerprint', 'N/A')}") + return + + # Register new key with format: dropkit-{username}-{fingerprint_prefix} + console.print(f"[dim]Registering SSH key as: {desired_key_name}...[/dim]") + new_key = api.add_ssh_key(desired_key_name, key_content) + + console.print( + f"\n[green]✓[/green] SSH key registered successfully: [cyan]{desired_key_name}[/cyan]" + ) + console.print(f" ID: [dim]{new_key.get('id', 'N/A')}[/dim]") + console.print(f" Fingerprint: {new_key.get('fingerprint', 'N/A')}") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command(name="delete-ssh-key") +@requires_lock("delete-ssh-key") +def delete_ssh_key_cmd( + key_name: str = typer.Argument(..., help="SSH key name to delete"), +): + """Delete an SSH key registered via dropkit.""" + try: + # Load config and API + _, api = load_config_and_api() + + # Get username from DigitalOcean for validation + try: + username = api.get_username() + except DigitalOceanAPIError as e: + console.print(f"[red]Error fetching username from DigitalOcean: {e}[/red]") + raise typer.Exit(1) + + # Verify it's a dropkit key for this user + prefix = f"dropkit-{username}-" + + if not key_name.startswith(prefix): + console.print(f"[red]Error: Key '{key_name}' is not a dropkit-managed key[/red]") + console.print( + f"[dim]Only keys with prefix '{prefix}' can be deleted via this command[/dim]" + ) + raise typer.Exit(1) + + # Fetch all SSH keys to find the one being deleted + console.print("[dim]Fetching SSH key information...[/dim]\n") + all_keys = api.list_ssh_keys() + + # Find the key by name + target_key = None + for key in all_keys: + if key.get("name") == key_name: + target_key = key + break + + if not target_key: + console.print(f"[red]Error: SSH key '{key_name}' not found[/red]") + console.print("[dim]Run 'dropkit list-ssh-keys' to see available keys[/dim]") + raise typer.Exit(1) + + # Display key information + key_id = target_key.get("id") + if not key_id: + console.print("[red]Error: SSH key ID not found in API response[/red]") + raise typer.Exit(1) + + console.print("[bold]SSH Key to delete:[/bold]") + console.print(f" Name: [cyan]{key_name}[/cyan]") + console.print(f" ID: [dim]{key_id}[/dim]") + console.print(f" Fingerprint: {target_key.get('fingerprint', 'N/A')}") + + # Ask for confirmation + console.print() + confirm = Confirm.ask( + "[yellow]Are you sure you want to delete this SSH key?[/yellow]", + default=False, + ) + + if not confirm: + console.print("[dim]Deletion cancelled[/dim]") + return + + # Delete the key + console.print("\n[dim]Deleting SSH key...[/dim]") + api.delete_ssh_key(key_id) + + console.print(f"[green]✓[/green] SSH key deleted successfully: [cyan]{key_name}[/cyan]") + + except DigitalOceanAPIError as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@app.command() +def version(): + """Show the version of dropkit.""" + from dropkit import __version__ + + console.print(f"dropkit version [cyan]{__version__}[/cyan]") + + +def main(): + """Entry point for the CLI.""" + app() + + +if __name__ == "__main__": + main() diff --git a/dropkit/ssh_config.py b/dropkit/ssh_config.py new file mode 100644 index 0000000..8be2844 --- /dev/null +++ b/dropkit/ssh_config.py @@ -0,0 +1,303 @@ +"""SSH config file management.""" + +import shutil +from pathlib import Path + + +def _backup_config(config_file: Path) -> None: + """ + Create a backup of the SSH config file. + + Args: + config_file: Path to the SSH config file + """ + if not config_file.exists(): + return + + backup_file = config_file.parent / f"{config_file.name}.bak" + + # Copy file + shutil.copy2(config_file, backup_file) + + # Ensure backup has same permissions as original + original_mode = config_file.stat().st_mode + backup_file.chmod(original_mode) + + +def add_ssh_host( + config_path: str, + host_name: str, + hostname: str, + user: str, + identity_file: str | None = None, +) -> None: + """ + Add or update an SSH host entry in the SSH config file. + + Args: + config_path: Path to SSH config file (e.g., ~/.ssh/config) + host_name: Host alias to use (e.g., 'my-droplet') + hostname: IP address or hostname + user: SSH username + identity_file: Path to SSH private key (optional) + """ + config_file = Path(config_path).expanduser() + + # Create SSH directory if it doesn't exist + config_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + + # Backup existing config before modifying + _backup_config(config_file) + + # Read existing config + if config_file.exists(): + with open(config_file) as f: + existing_content = f.read() + else: + existing_content = "" + + # Check if host already exists + if f"Host {host_name}" in existing_content: + # Host exists, update it + lines = existing_content.split("\n") + new_lines = [] + skip_until_next_host = False + + for line in lines: + if line.startswith("Host "): + if line == f"Host {host_name}": + # Found our host, skip this block + skip_until_next_host = True + else: + # Different host + skip_until_next_host = False + new_lines.append(line) + elif skip_until_next_host: + # Skip lines in the old host block + continue + else: + new_lines.append(line) + + existing_content = "\n".join(new_lines).rstrip() + "\n\n" + + # Create new host entry + host_entry = f"Host {host_name}\n" + host_entry += f" HostName {hostname}\n" + host_entry += " ForwardAgent yes\n" + host_entry += f" User {user}\n" + + if identity_file: + host_entry += f" IdentityFile {identity_file}\n" + + # Ensure existing content ends with newline before appending + if existing_content and not existing_content.endswith("\n"): + existing_content += "\n" + + # Append new entry + new_content = existing_content + host_entry + "\n" + + # Write back to file + with open(config_file, "w") as f: + f.write(new_content) + + # Set restrictive permissions + config_file.chmod(0o600) + + +def get_ssh_host_ip(config_path: str, host_name: str) -> str | None: + """ + Get the HostName (IP address) for an SSH host entry. + + Args: + config_path: Path to SSH config file + host_name: Host alias to look up + + Returns: + IP address/hostname if found, None otherwise + """ + config_file = Path(config_path).expanduser() + + if not config_file.exists(): + return None + + with open(config_file) as f: + lines = f.readlines() + + in_target_host = False + for line in lines: + stripped = line.strip() + + # Check for Host directive + if stripped.startswith("Host "): + # Extract host name (handle multiple hosts on same line) + host_part = stripped[5:].strip() + hosts = host_part.split() + in_target_host = host_name in hosts + elif in_target_host and stripped.startswith("HostName "): + # Found HostName in target host block + return stripped[9:].strip() + elif in_target_host and stripped and not stripped.startswith((" ", "\t", "#")): + # Left the host block (non-indented, non-comment line) + # This handles case where there's no HostName + if not line.startswith((" ", "\t")): + in_target_host = False + + return None + + +def host_exists(config_path: str, host_name: str) -> bool: + """ + Check if an SSH host entry exists in the SSH config file. + + Args: + config_path: Path to SSH config file + host_name: Host alias to check + + Returns: + True if host exists, False otherwise + """ + config_file = Path(config_path).expanduser() + + if not config_file.exists(): + return False + + with open(config_file) as f: + content = f.read() + + return f"Host {host_name}" in content + + +def remove_ssh_host(config_path: str, host_name: str) -> bool: + """ + Remove an SSH host entry from the SSH config file. + + Args: + config_path: Path to SSH config file + host_name: Host alias to remove + + Returns: + True if host was found and removed, False otherwise + """ + config_file = Path(config_path).expanduser() + + if not config_file.exists(): + return False + + # Backup existing config before modifying + _backup_config(config_file) + + # Read existing config + with open(config_file) as f: + lines = f.readlines() + + # Find and remove the host block + new_lines = [] + skip_until_next_host = False + found = False + + for line in lines: + if line.strip().startswith("Host "): + if line.strip() == f"Host {host_name}": + # Found our host, skip this block + skip_until_next_host = True + found = True + else: + # Different host + skip_until_next_host = False + new_lines.append(line) + elif skip_until_next_host: + # Skip lines in the host block (indented lines) + if line.strip() and not line.startswith((" ", "\t")): + # This is not an indented line, so we're past the block + skip_until_next_host = False + new_lines.append(line) + else: + new_lines.append(line) + + if found: + # Write back to file + with open(config_file, "w") as f: + f.writelines(new_lines) + + return found + + +def remove_known_hosts_entry(known_hosts_path: str, hostnames: list[str]) -> int: + """ + Remove entries for specified hostnames from known_hosts file. + + Args: + known_hosts_path: Path to known_hosts file (e.g., ~/.ssh/known_hosts) + hostnames: List of hostnames/IPs to remove + + Returns: + Number of entries removed + """ + known_hosts_file = Path(known_hosts_path).expanduser() + + if not known_hosts_file.exists(): + return 0 + + # Backup existing known_hosts before modifying + _backup_config(known_hosts_file) + + # Read existing known_hosts + with open(known_hosts_file) as f: + lines = f.readlines() + + # Normalize hostnames for matching (lowercase) + hostnames_lower = {h.lower() for h in hostnames} + + # Filter out matching entries + new_lines = [] + removed_count = 0 + + for line in lines: + stripped = line.strip() + + # Skip empty lines and comments, preserve them + if not stripped or stripped.startswith("#"): + new_lines.append(line) + continue + + # Skip hashed entries (can't match them) + if stripped.startswith("|1|"): + new_lines.append(line) + continue + + # Parse the first field (comma-separated hostnames) + # Format: hostname[,hostname2,...] keytype key [comment] + parts = stripped.split(None, 1) + if not parts: + new_lines.append(line) + continue + + host_field = parts[0] + entry_hosts = host_field.split(",") + + # Check if any of our hostnames match any entry host + should_remove = False + for entry_host in entry_hosts: + # Handle bracketed entries like [hostname]:port + check_host = entry_host.lower() + if check_host.startswith("["): + # Extract hostname from [hostname]:port + bracket_end = check_host.find("]") + if bracket_end > 0: + check_host = check_host[1:bracket_end] + + if check_host in hostnames_lower: + should_remove = True + break + + if should_remove: + removed_count += 1 + else: + new_lines.append(line) + + # Only write if we removed something + if removed_count > 0: + with open(known_hosts_file, "w") as f: + f.writelines(new_lines) + + return removed_count diff --git a/dropkit/templates/default-cloud-init.yaml b/dropkit/templates/default-cloud-init.yaml new file mode 100644 index 0000000..70826be --- /dev/null +++ b/dropkit/templates/default-cloud-init.yaml @@ -0,0 +1,135 @@ +#cloud-config + +# Update and upgrade system packages +package_update: true +package_upgrade: true +disable_root_opts: no-port-forwarding,no-agent-forwarding,no-X11-forwarding + +apt: + sources: + docker: + keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88 + keyserver: https://download.docker.com/linux/ubuntu/gpg + source: deb [arch=amd64 signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable + +# Install required packages +packages: + - ufw + - zsh + - git + - curl + - wget + - build-essential + - fontconfig + - neovim + - apt-transport-https + - ca-certificates + - gnupg + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +users: + - name: {{ username }} + gecos: "{{ username }} user" + shell: /bin/bash + groups: sudo,admin,wheel,docker + sudo: "ALL=(ALL) NOPASSWD:ALL" + ssh_authorized_keys:{% for key in ssh_keys %} + - {{ key }} +{% endfor %} +# Write custom zshrc file +write_files: + - path: /home/{{ username }}/.zshrc + owner: {{ username }}:{{ username }} + permissions: '0644' + defer: true + content: | + # Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc. + # Initialization code that may require console input (password prompts, [y/n] + # confirmations, etc.) must go above this block; everything else may go below. + if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then + source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" + fi + + DEFAULT_USER="{{ username }}" + + # Zprezto + prompt configuration + source "${ZDOTDIR:-$HOME}/.zprezto/init.zsh" + + autoload -Uz promptinit + promptinit + prompt powerlevel10k + + # You may need to manually set your language environment + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + + # Preferred editor for local and remote sessions + export EDITOR='nvim' + export LESS='-R' + + # Disable open-interpreter telemetry + export DISABLE_TELEMETRY=true + + alias vim="nvim" + + export PATH="$PATH:/home/{{ username }}/.local/bin" + + # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. + [[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh + +# Run commands after boot +runcmd: + # Configure UFW firewall + - ufw default deny incoming + - ufw default allow outgoing + - ufw allow OpenSSH + - ufw limit ssh + - ufw --force enable + + {% if tailscale_enabled %} + # Install Tailscale via official script (daemon only, authentication handled by dropkit) + - curl -fsSL https://tailscale.com/install.sh | sh + {% endif %} + + # Wait for user to be fully created + - | + timeout=30 + count=0 + while ! id {{ username }} >/dev/null 2>&1; do + if [ $count -ge $timeout ]; then + echo "Timeout waiting for user {{ username }} to be created" + exit 1 + fi + sleep 1 + count=$((count + 1)) + done + echo "User {{ username }} is ready" + + # Ensure home directory exists and has correct permissions + - mkdir -p /home/{{ username }} + - chown {{ username }}:{{ username }} /home/{{ username }} + + # Configure git globally for the user + - su {{ username }} -c "git config --global user.name '{{ full_name }}'" + - su {{ username }} -c "git config --global user.email '{{ email }}'" + + # Install zprezto for user + - su {{ username }} -c "zsh -c 'git clone --recursive https://github.com/sorin-ionescu/prezto.git /home/{{ username }}/.zprezto'" + + # Create Zsh configuration using setopt EXTENDED_GLOB and loop through runcoms + - | + su {{ username }} -c "zsh -c ' + setopt EXTENDED_GLOB + for rcfile in /home/{{ username }}/.zprezto/runcoms/^README.md(.N); do + ln -s "\$rcfile" "/home/{{ username }}/.\${rcfile:t}" + done + '" + + # Set Zsh as default shell and ensure ownership + - chsh -s /usr/bin/zsh {{ username }} + - chown -R {{ username }}:{{ username }} /home/{{ username }} + - reboot diff --git a/dropkit/ui.py b/dropkit/ui.py new file mode 100644 index 0000000..aaae18c --- /dev/null +++ b/dropkit/ui.py @@ -0,0 +1,143 @@ +"""UI utilities for dropkit - display functions and prompts.""" + +from collections.abc import Callable +from typing import Any + +from rich.console import Console +from rich.prompt import Prompt +from rich.table import Table + +console = Console() + + +def display_regions(regions: list[dict[str, Any]]) -> None: + """Display available regions in a table, sorted alphabetically by slug.""" + table = Table(title="Available Regions", show_header=True) + table.add_column("Slug", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Features", style="dim") + + # Sort regions alphabetically by slug + sorted_regions = sorted(regions, key=lambda r: r.get("slug", "")) + + for region in sorted_regions: + slug = region.get("slug", "") + name = region.get("name", "") + features = ", ".join(region.get("features", [])[:3]) # Show first 3 features + if len(region.get("features", [])) > 3: + features += "..." + + table.add_row(slug, name, features) + + console.print(table) + + +def display_sizes(sizes: list[dict[str, Any]]) -> None: + """Display available droplet sizes in a table, sorted by price.""" + table = Table(title="Available Droplet Sizes", show_header=True) + table.add_column("Slug", style="cyan", no_wrap=True) + table.add_column("Memory", style="white", justify="right") + table.add_column("vCPUs", style="white", justify="right") + table.add_column("Disk", style="white", justify="right") + table.add_column("Transfer", style="white", justify="right") + table.add_column("Price/mo", style="green", justify="right") + + # Sort sizes by price (monthly) ascending + sorted_sizes = sorted(sizes, key=lambda s: s.get("price_monthly", 0)) + + for size in sorted_sizes: + slug = size.get("slug", "") + memory = f"{size.get('memory', 0)} MB" + vcpus = str(size.get("vcpus", 0)) + disk = f"{size.get('disk', 0)} GB" + transfer = f"{size.get('transfer', 0)} TB" + price = f"${size.get('price_monthly', 0):.2f}" + + table.add_row(slug, memory, vcpus, disk, transfer, price) + + console.print(table) + + +def display_images(images: list[dict[str, Any]]) -> None: + """Display available images in a table, sorted by distribution and name.""" + table = Table(title="Available Images", show_header=True) + table.add_column("Slug", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Distribution", style="dim") + + # Sort images by distribution, then by name + sorted_images = sorted( + images, key=lambda img: (img.get("distribution", ""), img.get("name", "")) + ) + + for image in sorted_images: + slug = image.get("slug", "") + name = image.get("name", "") + distribution = image.get("distribution", "") + + # Only show images with slugs (not snapshots) + if slug: + table.add_row(slug, name, distribution) + + console.print(table) + + +def display_projects(projects: list[dict[str, Any]]) -> None: + """Display available projects in a table, sorted alphabetically by name.""" + table = Table(title="Available Projects", show_header=True) + table.add_column("ID", style="dim", no_wrap=True) + table.add_column("Name", style="cyan") + table.add_column("Purpose", style="white") + table.add_column("Description", style="dim") + + # Sort projects alphabetically by name + sorted_projects = sorted(projects, key=lambda p: p.get("name", "").lower()) + + for project in sorted_projects: + project_id = project.get("id", "") + name = project.get("name", "") + purpose = project.get("purpose", "") + description = project.get("description", "") + + # Truncate description if too long + if len(description) > 50: + description = description[:47] + "..." + + table.add_row(project_id, name, purpose, description) + + console.print(table) + + +def prompt_with_help( + prompt_text: str, + default: str, + display_func: Callable[[list[dict[str, Any]]], None] | None = None, + data: list[dict[str, Any]] | None = None, +) -> str: + """ + Prompt user for input with optional help via '?'. + + Args: + prompt_text: The prompt to display (without the default/? part) + default: Default value + display_func: Function to call when user enters '?' + data: Data to pass to display_func + + Returns: + User's input value + """ + while True: + value = Prompt.ask( + f"[cyan]{prompt_text} (? for help)[/cyan]", + default=default, + ) + + if value == "?": + if display_func and data is not None: + console.print() + display_func(data) + console.print() + else: + console.print("[yellow]No help available[/yellow]") + else: + return value diff --git a/dropkit/version_check.py b/dropkit/version_check.py new file mode 100644 index 0000000..7762bc6 --- /dev/null +++ b/dropkit/version_check.py @@ -0,0 +1,187 @@ +"""Version checking functionality for dropkit.""" + +import json +import subprocess +import time +from pathlib import Path + +from dropkit import __version__ +from dropkit.config import Config + + +def get_last_check_file() -> Path: + """Get the path to the last version check file.""" + return Config.get_config_dir() / ".last_version_check" + + +def should_check_version() -> bool: + """ + Check if we should check for a new version. + + Only checks once per day to avoid slowing down commands. + """ + check_file = get_last_check_file() + + if not check_file.exists(): + return True + + try: + with open(check_file) as f: + data = json.load(f) + last_check = data.get("timestamp", 0) + # Check if more than 24 hours have passed + return (time.time() - last_check) > 86400 # 24 hours in seconds + except (json.JSONDecodeError, OSError): + return True + + +def update_last_check_time() -> None: + """Update the last version check timestamp.""" + check_file = get_last_check_file() + check_file.parent.mkdir(parents=True, exist_ok=True) + + data = {"timestamp": time.time(), "current_version": __version__} + + try: + with open(check_file, "w") as f: + json.dump(data, f) + except OSError: + # Silently fail if we can't write the file + pass + + +def get_latest_git_commit() -> str | None: + """ + Get the latest git commit hash from the main branch. + + Returns: + Latest commit hash (short, 7 chars) or None if unable to fetch + """ + try: + # Get the latest commit from main branch + result = subprocess.run( + [ + "git", + "ls-remote", + "https://github.com/trailofbits/dropkit.git", + "HEAD", + ], + capture_output=True, + timeout=5, + text=True, + ) + + if result.returncode != 0: + return None + + # Parse the output to get the commit hash + # Format: "commit_hash\tHEAD" + lines = result.stdout.strip().split("\n") + if not lines or not lines[0]: + return None + + # Extract commit hash (first column) + commit_hash = lines[0].split("\t")[0] + # Return short hash (first 7 chars) + return commit_hash[:7] if commit_hash else None + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): + return None + + +def extract_commit_from_version(version: str) -> str | None: + """ + Extract commit hash from version string. + + Version format: "0.1.0+git.a1b2c3d" or similar + + Args: + version: Version string potentially containing a commit hash + + Returns: + Commit hash (short) or None if not found + """ + # Version format from hatchling-vcs: "0.1.0+git.a1b2c3d" or "0.1.0.dev123+a1b2c3d" + if "+git." in version: + # Format: "0.1.0+git.a1b2c3d" + parts = version.split("+git.") + if len(parts) == 2: + return parts[1][:7] # Return first 7 chars + elif "+" in version: + # Format: "0.1.0+a1b2c3d" or similar + parts = version.split("+") + if len(parts) == 2: + commit = parts[1] + # Extract hash if it contains other info + if "." in commit: + commit = commit.split(".")[-1] + return commit[:7] + + return None + + +def commits_differ(current_version: str, latest_commit: str) -> bool: + """ + Check if current version's commit differs from latest commit. + + Args: + current_version: Current version string (e.g., "0.1.0+git.a1b2c3d") + latest_commit: Latest commit hash from remote + + Returns: + True if commits differ (update available), False otherwise + """ + current_commit = extract_commit_from_version(current_version) + + if not current_commit: + # Can't determine current commit, don't show update + return False + + # Compare commit hashes (case-insensitive) + return current_commit.lower() != latest_commit.lower() + + +def check_for_updates() -> None: + """ + Check for updates and display a message if a new version is available. + + This function: + - Skips check in development mode (version == "dev") + - Only runs once per day for installed versions + - Fetches the latest git commit from main branch + - Compares with current version's commit + - Shows a non-blocking message if commits differ + """ + # Skip check in development mode + if __version__ == "dev" or "dev" in __version__.lower(): + return + + # Only check once per day + if not should_check_version(): + return + + # Update the check time regardless of success + update_last_check_time() + + # Try to get latest commit + latest_commit = get_latest_git_commit() + + if not latest_commit: + # Silently fail if we can't check + return + + # Compare commits + if commits_differ(__version__, latest_commit): + # Import here to avoid circular dependency + from rich.console import Console + + console = Console() + + current_commit = extract_commit_from_version(__version__) + console.print( + f"\n[yellow]New version available:[/yellow] [cyan]{latest_commit}[/cyan] " + f"[dim](current: {current_commit or __version__})[/dim]" + ) + console.print( + "[yellow]Run[/yellow] [cyan]uv tool upgrade dropkit[/cyan] [yellow]to update[/yellow]\n" + ) diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..5d2bf9f --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,31 @@ +"""Hatchling build hook to embed git commit at build time.""" + +import subprocess +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CustomBuildHook(BuildHookInterface): + """Build hook to capture git commit hash.""" + + def initialize(self, version, build_data): + """Run before the build starts.""" + # Get git commit hash + try: + result = subprocess.run( + ["git", "rev-parse", "--short=7", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0: + commit = result.stdout.strip() + if commit: + # Write commit to a file that will be included in the package + version_file = Path(self.root) / "dropkit" / "_version.txt" + version_file.write_text(commit) + print(f"Embedded git commit: {commit}") + except Exception as e: + print(f"Warning: Could not capture git commit: {e}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bbe076e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,175 @@ +[project] +name = "dropkit" +version = "0.1.0" +description = "Manage DigitalOcean droplets for ToB engineers" +readme = "README.md" +requires-python = ">=3.11" +authors = [{name = "Trail of Bits", email = "opensource@trailofbits.com"}] +license = {text = "Apache-2.0"} +keywords = ["digitalocean", "droplet", "cli", "devops", "ssh", "tailscale", "cloud"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Systems Administration", +] +dependencies = [ + "typer>=0.9.0", + "rich>=13.0.0", + "requests>=2.31.0", + "pyyaml>=6.0.1", + "jinja2>=3.1.0", + "shellingham>=1.5.0", + "pydantic>=2.12.3", + "cryptography>=46.0.3", +] + +[project.scripts] +dropkit = "dropkit.main:app" + +[project.urls] +Homepage = "https://github.com/trailofbits/dropkit" +Repository = "https://github.com/trailofbits/dropkit" +Issues = "https://github.com/trailofbits/dropkit/issues" + +[tool.uv] +package = true +default-groups = ["dev"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.custom] + +[tool.hatch.build.targets.wheel] +packages = ["dropkit"] + +[tool.hatch.build.targets.wheel.force-include] +"dropkit/_version.txt" = "dropkit/_version.txt" + +[tool.hatch.build.targets.sdist] +[tool.hatch.build.targets.sdist.force-include] +"dropkit/_version.txt" = "dropkit/_version.txt" + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-cov", + "ruff>=0.8.0", + "ty", + {include-group = "audit"}, +] +audit = ["pip-audit"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # Documentation + "D", # pydocstyle - skip for existing code + + # Formatter conflicts + "COM812", # missing trailing comma + "ISC001", # implicit string concat + + # Line length (handled by formatter) + "E501", + + # Exception handling style (CLI prefers clean messages) + "B904", # raise ... from err + "TRY003", # avoid specifying long messages outside exception class + "EM101", # exception must not use string literal + "EM102", # exception must not use f-string literal + "BLE001", # blind exception catching (acceptable in CLI error handling) + + # Type annotations (existing code not fully annotated) + "ANN", # flake8-annotations + + # Boolean arguments (common in CLI tools) + "FBT001", # boolean positional arg in function definition + "FBT002", # boolean default value in function definition + "FBT003", # boolean positional value in function call + + # Magic values (acceptable in existing code) + "PLR2004", + + # Import organization (acceptable for lazy imports in CLI) + "PLC0415", + + # Complexity (existing code, would require significant refactoring) + "PLR0912", # too many branches + "PLR0913", # too many arguments + "PLR0915", # too many statements + "C901", # function too complex + + # Subprocess (intentional CLI tool usage) + "S603", # subprocess call without shell=True check + "S607", # partial executable path + + # pytest style + "PT011", # pytest.raises too broad + + # Other + "TRY300", # try-except-else instead of try-except + "TRY301", # abstract raise to inner function + "RET504", # unnecessary assignment before return + "RET505", # unnecessary else after return + "RET506", # unnecessary elif after raise + "PLW1510", # subprocess.run without check + "ERA001", # commented out code (false positives on format comments) + "RUF059", # unused unpacked variable (prefixing with _ breaks readability) + "S110", # try-except-pass (acceptable in version detection) + "S108", # hardcoded temp file (intentional for lockfile) + "S324", # insecure hash md5 (used for SSH key fingerprints, industry standard) + "PTH123", # use Path.open instead of open (existing code pattern) + "PGH003", # use specific rule codes (existing code) + "PIE807", # prefer list over lambda (existing code) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "S101", # assert is fine in tests + "S105", # hardcoded password in tests + "S106", # hardcoded password argument in tests + "ARG", # unused arguments in test fixtures + "SLF001", # private member access in tests + "PLR0913", # too many arguments in test functions + "PT022", # yield vs return in fixtures +] +"hatch_build.py" = [ + "T201", # print is fine in build hooks + "ARG002", # unused arguments in hatch hooks +] + +[tool.ruff.lint.pyupgrade] +# Enforce Python 3.10+ syntax +keep-runtime-typing = false + +[tool.ty.terminal] +error-on-warning = true + +[tool.ty.environment] +python-version = "3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "--cov=dropkit --cov-fail-under=29" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..cb3a486 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for dropkit.""" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..877f1ff --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,107 @@ +"""Tests for DigitalOcean API client.""" + +import pytest + +from dropkit.api import DigitalOceanAPI + + +class TestDigitalOceanAPI: + """Tests for DigitalOceanAPI class.""" + + def test_sanitize_email_for_username_trailofbits(self): + """Test sanitizing trailofbits.com email.""" + username = DigitalOceanAPI._sanitize_email_for_username("john.doe@trailofbits.com") + assert username == "john_doe" + + def test_sanitize_email_for_username_other_domain(self): + """Test sanitizing other domain email.""" + username = DigitalOceanAPI._sanitize_email_for_username("john.doe@example.com") + assert username == "john_doe" + + def test_sanitize_email_for_username_special_chars(self): + """Test sanitizing email with special characters.""" + username = DigitalOceanAPI._sanitize_email_for_username("john-doe+test@trailofbits.com") + assert username == "john_doe_test" + + def test_sanitize_email_for_username_starts_with_number(self): + """Test sanitizing email that starts with number.""" + username = DigitalOceanAPI._sanitize_email_for_username("123user@trailofbits.com") + assert username == "u123user" + + def test_sanitize_email_for_username_empty_fallback(self): + """Test sanitizing empty email.""" + username = DigitalOceanAPI._sanitize_email_for_username("@trailofbits.com") + assert username == "user" + + +class TestValidatePositiveInt: + """Tests for _validate_positive_int method.""" + + def test_valid_positive_int(self): + """Test validation passes for positive integer.""" + # Should not raise + DigitalOceanAPI._validate_positive_int(1, "test_id") + DigitalOceanAPI._validate_positive_int(100, "test_id") + DigitalOceanAPI._validate_positive_int(999999, "test_id") + + def test_zero_raises_error(self): + """Test validation fails for zero.""" + with pytest.raises(ValueError, match="test_id must be a positive integer"): + DigitalOceanAPI._validate_positive_int(0, "test_id") + + def test_negative_raises_error(self): + """Test validation fails for negative integer.""" + with pytest.raises(ValueError, match="droplet_id must be a positive integer"): + DigitalOceanAPI._validate_positive_int(-1, "droplet_id") + + with pytest.raises(ValueError, match="action_id must be a positive integer"): + DigitalOceanAPI._validate_positive_int(-100, "action_id") + + def test_error_message_includes_value(self): + """Test error message includes the invalid value.""" + with pytest.raises(ValueError, match="got: -5"): + DigitalOceanAPI._validate_positive_int(-5, "snapshot_id") + + +class TestGetDropletUrn: + """Tests for get_droplet_urn static method.""" + + def test_urn_format(self): + """Test URN is in correct format.""" + assert DigitalOceanAPI.get_droplet_urn(12345) == "do:droplet:12345" + + def test_urn_with_large_id(self): + """Test URN with large droplet ID.""" + assert DigitalOceanAPI.get_droplet_urn(999999999) == "do:droplet:999999999" + + +class TestListDropletActions: + """Tests for list_droplet_actions method validation.""" + + def test_list_droplet_actions_invalid_id_zero(self): + """Test that list_droplet_actions raises ValueError for zero droplet_id.""" + api = DigitalOceanAPI("fake-token") + with pytest.raises(ValueError, match="droplet_id must be a positive integer"): + api.list_droplet_actions(0) + + def test_list_droplet_actions_invalid_id_negative(self): + """Test that list_droplet_actions raises ValueError for negative droplet_id.""" + api = DigitalOceanAPI("fake-token") + with pytest.raises(ValueError, match="droplet_id must be a positive integer"): + api.list_droplet_actions(-1) + + +class TestRenameDroplet: + """Tests for rename_droplet method validation.""" + + def test_rename_droplet_invalid_id_zero(self): + """Test that rename_droplet raises ValueError for zero droplet_id.""" + api = DigitalOceanAPI("fake-token") + with pytest.raises(ValueError, match="droplet_id must be a positive integer"): + api.rename_droplet(0, "new-name") + + def test_rename_droplet_invalid_id_negative(self): + """Test that rename_droplet raises ValueError for negative droplet_id.""" + api = DigitalOceanAPI("fake-token") + with pytest.raises(ValueError, match="droplet_id must be a positive integer"): + api.rename_droplet(-1, "new-name") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..86ea06b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,523 @@ +"""Comprehensive tests for configuration management with Pydantic validation.""" + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from dropkit.config import ( + CloudInitConfig, + Config, + DefaultsConfig, + DigitalOceanConfig, + DropkitConfig, + SSHConfig, +) + + +@pytest.fixture +def temp_config_dir(tmp_path): + """Create a temporary config directory for testing.""" + config_dir = tmp_path / ".config" / "dropkit" + config_dir.mkdir(parents=True) + yield config_dir + + +@pytest.fixture +def valid_config_dict(): + """Return a valid configuration dictionary.""" + return { + "digitalocean": { + "token": "dop_v1_test_token_12345", + "api_base": "https://api.digitalocean.com/v2", + }, + "defaults": { + "region": "nyc3", + "size": "s-2vcpu-4gb", + "image": "ubuntu-25-04-x64", + "extra_tags": ["custom-tag"], + }, + "cloudinit": { + "template_path": "/home/user/.config/dropkit/cloud-init.yaml", + "ssh_keys": ["/home/user/.ssh/id_ed25519.pub"], + "ssh_key_ids": [12345678], + }, + "ssh": { + "config_path": "/home/user/.ssh/config", + "auto_update": True, + "identity_file": "/home/user/.ssh/id_ed25519", + }, + } + + +class TestDigitalOceanConfig: + """Tests for DigitalOceanConfig validation.""" + + def test_valid_config(self): + """Test valid DigitalOcean config.""" + config = DigitalOceanConfig( + token="dop_v1_test_token", api_base="https://api.digitalocean.com/v2" + ) + assert config.token == "dop_v1_test_token" + assert config.api_base == "https://api.digitalocean.com/v2" + + def test_token_whitespace_stripped(self): + """Test that token whitespace is stripped.""" + config = DigitalOceanConfig(token=" dop_v1_test ") + assert config.token == "dop_v1_test" + + def test_empty_token_fails(self): + """Test that empty token fails validation.""" + with pytest.raises(ValidationError) as exc_info: + DigitalOceanConfig(token="") + # Pydantic catches this with min_length before our custom validator + assert "token" in str(exc_info.value).lower() + + def test_whitespace_only_token_fails(self): + """Test that whitespace-only token fails validation.""" + with pytest.raises(ValidationError) as exc_info: + DigitalOceanConfig(token=" ") + assert "Token cannot be empty" in str(exc_info.value) + + def test_missing_token_fails(self): + """Test that missing token fails validation.""" + with pytest.raises(ValidationError): + DigitalOceanConfig() + + def test_default_api_base(self): + """Test default API base URL.""" + config = DigitalOceanConfig(token="test_token") + assert config.api_base == "https://api.digitalocean.com/v2" + + +class TestDefaultsConfig: + """Tests for DefaultsConfig validation.""" + + def test_valid_config(self): + """Test valid defaults config.""" + config = DefaultsConfig( + region="nyc3", + size="s-2vcpu-4gb", + image="ubuntu-25-04-x64", + extra_tags=["tag1", "tag2"], + ) + assert config.region == "nyc3" + assert config.size == "s-2vcpu-4gb" + assert config.image == "ubuntu-25-04-x64" + assert config.extra_tags == ["tag1", "tag2"] + + def test_empty_region_fails(self): + """Test that empty region fails validation.""" + with pytest.raises(ValidationError): + DefaultsConfig( + region="", + size="s-2vcpu-4gb", + image="ubuntu-25-04-x64", + ) + + def test_empty_size_fails(self): + """Test that empty size fails validation.""" + with pytest.raises(ValidationError): + DefaultsConfig( + region="nyc3", + size="", + image="ubuntu-25-04-x64", + ) + + def test_empty_image_fails(self): + """Test that empty image fails validation.""" + with pytest.raises(ValidationError): + DefaultsConfig( + region="nyc3", + size="s-2vcpu-4gb", + image="", + ) + + def test_empty_extra_tags_allowed(self): + """Test that empty extra_tags list is allowed.""" + config = DefaultsConfig( + region="nyc3", + size="s-2vcpu-4gb", + image="ubuntu-25-04-x64", + extra_tags=[], + ) + assert config.extra_tags == [] + + def test_missing_extra_tags_defaults_to_empty(self): + """Test that missing extra_tags defaults to empty list.""" + config = DefaultsConfig( + region="nyc3", + size="s-2vcpu-4gb", + image="ubuntu-25-04-x64", + ) + assert config.extra_tags == [] + + +class TestCloudInitConfig: + """Tests for CloudInitConfig validation.""" + + def test_valid_config(self): + """Test valid cloud-init config.""" + config = CloudInitConfig( + template_path="/path/to/template.yaml", + ssh_keys=["/path/to/key1.pub", "/path/to/key2.pub"], + ssh_key_ids=[12345, 67890], + ) + assert config.template_path == "/path/to/template.yaml" + assert len(config.ssh_keys) == 2 + assert len(config.ssh_key_ids) == 2 + + def test_empty_ssh_keys_fails(self): + """Test that empty SSH keys list fails validation.""" + with pytest.raises(ValidationError) as exc_info: + CloudInitConfig( + template_path="/path/to/template.yaml", + ssh_keys=[], + ssh_key_ids=[12345], + ) + # Pydantic catches this with min_length + assert "ssh_keys" in str(exc_info.value).lower() + + def test_missing_ssh_keys_fails(self): + """Test that missing SSH keys fails validation.""" + with pytest.raises(ValidationError): + CloudInitConfig( + template_path="/path/to/template.yaml", + ssh_key_ids=[12345], + ) + + def test_single_ssh_key_valid(self): + """Test that single SSH key is valid.""" + config = CloudInitConfig( + template_path="/path/to/template.yaml", + ssh_keys=["/path/to/key.pub"], + ssh_key_ids=[12345], + ) + assert len(config.ssh_keys) == 1 + + +class TestSSHConfig: + """Tests for SSHConfig validation.""" + + def test_valid_config(self): + """Test valid SSH config.""" + config = SSHConfig( + config_path="/home/user/.ssh/config", + auto_update=True, + identity_file="/home/user/.ssh/id_ed25519", + ) + assert config.config_path == "/home/user/.ssh/config" + assert config.auto_update is True + assert config.identity_file == "/home/user/.ssh/id_ed25519" + + def test_auto_update_defaults_to_true(self): + """Test that auto_update defaults to True.""" + config = SSHConfig( + config_path="/home/user/.ssh/config", + identity_file="/home/user/.ssh/id_ed25519", + ) + assert config.auto_update is True + + def test_missing_config_path_fails(self): + """Test that missing config_path fails validation.""" + with pytest.raises(ValidationError): + SSHConfig(identity_file="/home/user/.ssh/id_ed25519") + + def test_missing_identity_file_fails(self): + """Test that missing identity_file fails validation.""" + with pytest.raises(ValidationError): + SSHConfig(config_path="/home/user/.ssh/config") + + +class TestDropkitConfig: + """Tests for DropkitConfig (full configuration) validation.""" + + def test_valid_full_config(self, valid_config_dict): + """Test valid full configuration.""" + config = DropkitConfig(**valid_config_dict) + assert config.digitalocean.token == "dop_v1_test_token_12345" + assert config.defaults.region == "nyc3" + assert config.cloudinit.ssh_keys[0] == "/home/user/.ssh/id_ed25519.pub" + assert config.ssh.auto_update is True + + def test_missing_digitalocean_section_fails(self, valid_config_dict): + """Test that missing digitalocean section fails validation.""" + del valid_config_dict["digitalocean"] + with pytest.raises(ValidationError): + DropkitConfig(**valid_config_dict) + + def test_missing_defaults_section_fails(self, valid_config_dict): + """Test that missing defaults section fails validation.""" + del valid_config_dict["defaults"] + with pytest.raises(ValidationError): + DropkitConfig(**valid_config_dict) + + def test_missing_cloudinit_section_fails(self, valid_config_dict): + """Test that missing cloudinit section fails validation.""" + del valid_config_dict["cloudinit"] + with pytest.raises(ValidationError): + DropkitConfig(**valid_config_dict) + + def test_missing_ssh_section_fails(self, valid_config_dict): + """Test that missing ssh section fails validation.""" + del valid_config_dict["ssh"] + with pytest.raises(ValidationError): + DropkitConfig(**valid_config_dict) + + def test_extra_fields_forbidden(self, valid_config_dict): + """Test that extra fields are forbidden.""" + valid_config_dict["extra_field"] = "not allowed" + with pytest.raises(ValidationError) as exc_info: + DropkitConfig(**valid_config_dict) + assert "extra_field" in str(exc_info.value).lower() + + def test_nested_validation_error(self, valid_config_dict): + """Test that nested validation errors are caught.""" + valid_config_dict["digitalocean"]["token"] = "" + with pytest.raises(ValidationError) as exc_info: + DropkitConfig(**valid_config_dict) + assert "token" in str(exc_info.value).lower() + + +class TestConfigManager: + """Tests for Config manager class.""" + + def test_config_not_loaded_raises_error(self): + """Test that accessing config before loading raises error.""" + config = Config() + with pytest.raises(ValueError) as exc_info: + _ = config.config + assert "not loaded" in str(exc_info.value).lower() + + def test_create_default_config(self): + """Test creating default configuration.""" + config = Config() + config.create_default_config( + token="test_token", + username="testuser", + region="nyc3", + size="s-2vcpu-4gb", + image="ubuntu-25-04-x64", + ssh_keys=["/path/to/key.pub"], + ssh_key_ids=[12345], + extra_tags=["tag1", "tag2"], + ) + + # Should be able to access config now + assert config.config.digitalocean.token == "test_token" + assert config.config.defaults.region == "nyc3" + assert config.config.defaults.extra_tags == ["tag1", "tag2"] + assert config.config.cloudinit.ssh_key_ids == [12345] + + def test_create_config_without_ssh_keys_raises_error(self, monkeypatch): + """Test that creating config without SSH keys raises error.""" + # Mock detect_ssh_keys to return empty list + monkeypatch.setattr(Config, "detect_ssh_keys", staticmethod(list)) + + config = Config() + with pytest.raises(ValueError) as exc_info: + config.create_default_config( + token="test_token", + username="testuser", + ssh_keys=None, # Will try to auto-detect + ssh_key_ids=[12345], + ) + assert "SSH key" in str(exc_info.value) + + def test_save_without_config_raises_error(self): + """Test that saving without config raises error.""" + config = Config() + with pytest.raises(ValueError) as exc_info: + config.save() + assert "No configuration to save" in str(exc_info.value) + + def test_save_and_load_config(self, temp_config_dir, valid_config_dict, monkeypatch): + """Test saving and loading configuration.""" + # Monkey patch Config paths + monkeypatch.setattr(Config, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(Config, "CONFIG_FILE", temp_config_dir / "config.yaml") + monkeypatch.setattr(Config, "CLOUD_INIT_FILE", temp_config_dir / "cloud-init.yaml") + + # Create and save config + config = Config() + config.create_default_config( + token="test_token_save_load", + username="testuser", + region="sfo3", + size="s-1vcpu-1gb", + image="ubuntu-25-04-x64", + ssh_keys=["/path/to/key.pub"], + ssh_key_ids=[98765], + extra_tags=["test_tag"], + ) + config.save() + + # Verify file exists + assert Config.CONFIG_FILE.exists() + + # Verify file permissions + mode = Config.CONFIG_FILE.stat().st_mode & 0o777 + assert mode == 0o600 + + # Load config in new instance + config2 = Config() + config2.load() + + # Verify loaded config matches + assert config2.config.digitalocean.token == "test_token_save_load" + assert config2.config.defaults.region == "sfo3" + assert config2.config.defaults.size == "s-1vcpu-1gb" + assert config2.config.defaults.extra_tags == ["test_tag"] + assert config2.config.cloudinit.ssh_key_ids == [98765] + + def test_load_invalid_config_raises_validation_error(self, temp_config_dir, monkeypatch): + """Test that loading invalid config raises validation error.""" + # Monkey patch Config paths + monkeypatch.setattr(Config, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(Config, "CONFIG_FILE", temp_config_dir / "config.yaml") + + # Write invalid config (missing required fields) + invalid_config = { + "digitalocean": { + "token": "", # Empty token + } + } + + import yaml + + with open(Config.CONFIG_FILE, "w") as f: + yaml.dump(invalid_config, f) + + # Try to load + config = Config() + with pytest.raises(ValidationError): + config.load() + + def test_load_nonexistent_config_raises_file_not_found(self, temp_config_dir, monkeypatch): + """Test that loading non-existent config raises FileNotFoundError.""" + # Monkey patch Config paths + monkeypatch.setattr(Config, "CONFIG_DIR", temp_config_dir) + monkeypatch.setattr(Config, "CONFIG_FILE", temp_config_dir / "nonexistent.yaml") + + config = Config() + with pytest.raises(FileNotFoundError) as exc_info: + config.load() + assert "dropkit init" in str(exc_info.value) + + def test_get_system_username(self, monkeypatch): + """Test getting system username.""" + monkeypatch.setenv("USER", "testuser") + assert Config.get_system_username() == "testuser" + + def test_get_system_username_default(self, monkeypatch): + """Test getting system username with no USER env var.""" + monkeypatch.delenv("USER", raising=False) + assert Config.get_system_username() == "user" + + def test_detect_ssh_keys(self, tmp_path, monkeypatch): + """Test SSH key detection.""" + # Create fake SSH directory + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir() + + # Create some keys + (ssh_dir / "id_ed25519.pub").touch() + (ssh_dir / "id_rsa.pub").touch() + + # Monkey patch home directory + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + keys = Config.detect_ssh_keys() + assert len(keys) == 2 + assert str(ssh_dir / "id_ed25519.pub") in keys + assert str(ssh_dir / "id_rsa.pub") in keys + + def test_detect_ssh_keys_no_ssh_dir(self, tmp_path, monkeypatch): + """Test SSH key detection with no .ssh directory.""" + # Monkey patch home directory (no .ssh dir) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + keys = Config.detect_ssh_keys() + assert keys == [] + + def test_read_ssh_key_content(self, tmp_path): + """Test reading SSH key content.""" + key_file = tmp_path / "test_key.pub" + key_file.write_text("ssh-rsa AAAAB3... test@example.com\n") + + content = Config.read_ssh_key_content(str(key_file)) + assert content == "ssh-rsa AAAAB3... test@example.com" + + def test_read_ssh_key_nonexistent_raises_error(self): + """Test reading non-existent SSH key raises error.""" + with pytest.raises(FileNotFoundError): + Config.read_ssh_key_content("/nonexistent/key.pub") + + def test_validate_ssh_public_key_valid_ed25519(self, tmp_path): + """Test validation of valid ED25519 public key.""" + key_file = tmp_path / "id_ed25519.pub" + key_file.write_text("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@example.com\n") + + # Should not raise any exception + Config.validate_ssh_public_key(str(key_file)) + + def test_validate_ssh_public_key_valid_rsa(self, tmp_path): + """Test validation of valid RSA public key.""" + key_file = tmp_path / "id_rsa.pub" + key_file.write_text("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB... user@example.com\n") + + # Should not raise any exception + Config.validate_ssh_public_key(str(key_file)) + + def test_validate_ssh_public_key_valid_ecdsa(self, tmp_path): + """Test validation of valid ECDSA public key.""" + key_file = tmp_path / "id_ecdsa.pub" + key_file.write_text( + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNT... user@example.com\n" + ) + + # Should not raise any exception + Config.validate_ssh_public_key(str(key_file)) + + def test_validate_ssh_public_key_private_key_fails(self, tmp_path): + """Test that private key (without .pub extension) fails validation.""" + key_file = tmp_path / "id_ed25519" + key_file.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\n...") + + with pytest.raises(ValueError) as exc_info: + Config.validate_ssh_public_key(str(key_file)) + assert "must be a public key" in str(exc_info.value) + assert "Private keys should NEVER" in str(exc_info.value) + + def test_validate_ssh_public_key_nonexistent_fails(self): + """Test that non-existent key file fails validation.""" + with pytest.raises(FileNotFoundError) as exc_info: + Config.validate_ssh_public_key("/nonexistent/key.pub") + assert "not found" in str(exc_info.value) + + def test_validate_ssh_public_key_empty_file_fails(self, tmp_path): + """Test that empty key file fails validation.""" + key_file = tmp_path / "empty.pub" + key_file.write_text("") + + with pytest.raises(ValueError) as exc_info: + Config.validate_ssh_public_key(str(key_file)) + assert "empty" in str(exc_info.value) + + def test_validate_ssh_public_key_invalid_content_fails(self, tmp_path): + """Test that file with invalid content fails validation.""" + key_file = tmp_path / "invalid.pub" + key_file.write_text("This is not a valid SSH public key\n") + + with pytest.raises(ValueError) as exc_info: + Config.validate_ssh_public_key(str(key_file)) + assert "does not appear to be a valid SSH public key" in str(exc_info.value) + + def test_validate_ssh_public_key_private_key_header_fails(self, tmp_path): + """Test that file with private key header fails validation.""" + key_file = tmp_path / "private.pub" # Has .pub extension but contains private key + key_file.write_text("-----BEGIN OPENSSH PRIVATE KEY-----\n") + + with pytest.raises(ValueError) as exc_info: + Config.validate_ssh_public_key(str(key_file)) + assert "does not appear to be a valid SSH public key" in str(exc_info.value) diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..d5e1418 --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,354 @@ +"""Tests for file-based locking mechanism.""" + +import os +import subprocess +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +from dropkit.lock import ( + DEFAULT_TIMEOUT, + LOCK_FILE, + LockError, + LockInfo, + operation_lock, + requires_lock, +) + + +@pytest.fixture +def temp_lock_file(tmp_path): + """Use a temporary lock file for testing.""" + test_lock = tmp_path / "dropkit.lock" + with patch("dropkit.lock.LOCK_FILE", test_lock): + yield test_lock + + +class TestLockInfo: + """Tests for LockInfo class.""" + + def test_to_json(self): + """Test serialization to JSON.""" + info = LockInfo(pid=12345, command="create") + json_str = info.to_json() + assert '"pid": 12345' in json_str + assert '"command": "create"' in json_str + + def test_from_file_valid(self, tmp_path): + """Test reading valid lock info from file.""" + lock_file = tmp_path / "test.lock" + lock_file.write_text('{"pid": 99999, "command": "destroy"}') + info = LockInfo.from_file(lock_file) + assert info is not None + assert info.pid == 99999 + assert info.command == "destroy" + + def test_from_file_invalid_json(self, tmp_path): + """Test reading invalid JSON returns None.""" + lock_file = tmp_path / "test.lock" + lock_file.write_text("not valid json") + info = LockInfo.from_file(lock_file) + assert info is None + + def test_from_file_empty(self, tmp_path): + """Test reading empty file returns None.""" + lock_file = tmp_path / "test.lock" + lock_file.write_text("") + info = LockInfo.from_file(lock_file) + assert info is None + + def test_from_file_nonexistent(self, tmp_path): + """Test reading nonexistent file returns None.""" + nonexistent = tmp_path / "does_not_exist.lock" + info = LockInfo.from_file(nonexistent) + assert info is None + + def test_is_process_alive_current_process(self): + """Test that current process is detected as alive.""" + info = LockInfo(pid=os.getpid(), command="test") + assert info.is_process_alive() is True + + def test_is_process_alive_dead_process(self): + """Test that non-existent PID is detected as dead.""" + # Use a very high PID unlikely to exist + info = LockInfo(pid=9999999, command="test") + assert info.is_process_alive() is False + + +class TestOperationLock: + """Tests for operation_lock context manager.""" + + def test_basic_lock_acquire_release(self, temp_lock_file): + """Test basic lock acquisition and release.""" + with operation_lock("test"): + # Lock should be held + assert temp_lock_file.exists() + content = temp_lock_file.read_text() + assert str(os.getpid()) in content + assert "test" in content + + def test_lock_info_cleared_on_exit(self, temp_lock_file): + """Test lock info is cleared after context exits.""" + with operation_lock("test"): + pass + # Lock file should exist but be empty (truncated) + assert temp_lock_file.exists() + content = temp_lock_file.read_text() + assert content == "" + + def test_lock_released_on_exception(self, temp_lock_file): + """Test lock is released when exception is raised.""" + with pytest.raises(ValueError), operation_lock("test"): + raise ValueError("test error") + + # Lock should be released - can acquire again immediately + with operation_lock("test"): + pass # Should not raise + + def test_lock_with_empty_command_name(self, temp_lock_file): + """Test lock with empty command name.""" + with operation_lock(""): + content = temp_lock_file.read_text() + assert '"command": ""' in content + + def test_lock_with_special_characters(self, temp_lock_file): + """Test lock with special characters in command name.""" + with operation_lock("enable-tailscale"): + content = temp_lock_file.read_text() + assert "enable-tailscale" in content + + +class TestConcurrentLock: + """Tests for concurrent lock behavior using subprocess.""" + + def test_concurrent_lock_blocks(self, tmp_path): + """Test that second process waits for first to release lock.""" + test_lock = tmp_path / "dropkit.lock" + results_file = tmp_path / "results.txt" + + # Script that holds lock and logs events + holder_script = f""" +import sys +import fcntl +import os +import time +lock_path = "{test_lock}" +results_path = "{results_file}" +fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) +fcntl.flock(fd, fcntl.LOCK_EX) +with open(results_path, "a") as f: + f.write("first_acquired\\n") + f.flush() +time.sleep(1) +with open(results_path, "a") as f: + f.write("first_released\\n") +fcntl.flock(fd, fcntl.LOCK_UN) +os.close(fd) +""" + + waiter_script = f""" +import sys +import fcntl +import os +import time +lock_path = "{test_lock}" +results_path = "{results_file}" +fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) +fcntl.flock(fd, fcntl.LOCK_EX) # Will block until holder releases +with open(results_path, "a") as f: + f.write("second_acquired\\n") +fcntl.flock(fd, fcntl.LOCK_UN) +os.close(fd) +""" + + # Start holder first + p1 = subprocess.Popen([sys.executable, "-c", holder_script]) + time.sleep(0.3) # Let holder acquire lock + + # Start waiter + p2 = subprocess.Popen([sys.executable, "-c", waiter_script]) + + p1.wait(timeout=10) + p2.wait(timeout=10) + + # Read results + events = results_file.read_text().strip().split("\n") + + # First should acquire before second + assert "first_acquired" in events + assert "second_acquired" in events + assert events.index("first_acquired") < events.index("second_acquired") + + def test_timeout_raises_lock_error(self, tmp_path): + """Test that timeout raises LockError.""" + test_lock = tmp_path / "dropkit.lock" + ready_file = tmp_path / "ready" + + # Script that holds lock indefinitely until killed + holder_script = f""" +import fcntl +import os +import time +from pathlib import Path +lock_path = "{test_lock}" +ready_path = "{ready_file}" +fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) +fcntl.flock(fd, fcntl.LOCK_EX) +Path(ready_path).touch() # Signal we have the lock +time.sleep(30) # Hold lock +""" + + p = subprocess.Popen([sys.executable, "-c", holder_script]) + + try: + # Wait for holder to acquire lock + for _ in range(50): + if ready_file.exists(): + break + time.sleep(0.1) + else: + pytest.fail("Holder did not acquire lock in time") + + # Now try to acquire with short timeout + with patch("dropkit.lock.LOCK_FILE", test_lock): + with pytest.raises(LockError) as exc_info: # noqa: SIM117 + with operation_lock("waiter", timeout=1): + pass + + error_msg = str(exc_info.value) + assert ( + "Another dropkit operation is in progress" in error_msg + or "Could not acquire lock" in error_msg + ) + finally: + p.terminate() + p.wait() + + def test_stale_lock_cleanup(self, tmp_path): + """Test that stale locks from dead processes are handled.""" + test_lock = tmp_path / "dropkit.lock" + # Write stale lock info with dead PID + test_lock.write_text('{"pid": 9999999, "command": "stale"}') + + # Should still be able to acquire lock + with patch("dropkit.lock.LOCK_FILE", test_lock), operation_lock("new_command"): + content = test_lock.read_text() + assert "new_command" in content + + +class TestRequiresLockDecorator: + """Tests for requires_lock decorator.""" + + def test_decorator_acquires_lock(self, temp_lock_file): + """Test decorator acquires and releases lock.""" + + @requires_lock("decorated") + def my_func(): + content = temp_lock_file.read_text() + assert "decorated" in content + return "success" + + result = my_func() + assert result == "success" + + def test_decorator_preserves_metadata(self): + """Test decorator preserves function metadata.""" + + @requires_lock("test") + def my_function_with_doc(): + """My docstring.""" + + assert my_function_with_doc.__name__ == "my_function_with_doc" + assert my_function_with_doc.__doc__ == "My docstring." + + def test_decorator_passes_arguments(self, temp_lock_file): + """Test decorator passes args and kwargs correctly.""" + + @requires_lock("test") + def func_with_args(a, b, c=None): + return (a, b, c) + + result = func_with_args(1, 2, c=3) + assert result == (1, 2, 3) + + def test_decorator_handles_lock_error(self, tmp_path): + """Test decorator handles LockError and exits.""" + import typer + + test_lock = tmp_path / "dropkit.lock" + ready_file = tmp_path / "ready" + + # Script that holds lock + holder_script = f""" +import fcntl +import os +import time +from pathlib import Path +lock_path = "{test_lock}" +ready_path = "{ready_file}" +fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) +fcntl.flock(fd, fcntl.LOCK_EX) +Path(ready_path).touch() +time.sleep(30) +""" + + p = subprocess.Popen([sys.executable, "-c", holder_script]) + + try: + # Wait for holder to acquire lock + for _ in range(50): + if ready_file.exists(): + break + time.sleep(0.1) + else: + pytest.fail("Holder did not acquire lock in time") + + with ( + patch("dropkit.lock.LOCK_FILE", test_lock), + patch("dropkit.lock.DEFAULT_TIMEOUT", 0.5), + ): + + @requires_lock("blocked") + def blocked_func(): + return "should not reach" + + # Should raise typer.Exit due to LockError + with pytest.raises(typer.Exit) as exc_info: + blocked_func() + + assert exc_info.value.exit_code == 1 + finally: + p.terminate() + p.wait() + + +class TestEdgeCases: + """Tests for edge cases and error conditions.""" + + def test_default_timeout_value(self): + """Test default timeout is 30 seconds.""" + assert DEFAULT_TIMEOUT == 30.0 + + def test_lock_file_path(self): + """Test lock file path is /tmp/dropkit.lock.""" + assert Path("/tmp/dropkit.lock") == LOCK_FILE + + def test_lock_file_created_with_permissions(self, tmp_path): + """Test lock file is created with restrictive permissions.""" + test_lock = tmp_path / "dropkit.lock" + with patch("dropkit.lock.LOCK_FILE", test_lock): + with operation_lock("test"): + pass + # Check permissions (0o600 = owner read/write only) + mode = test_lock.stat().st_mode & 0o777 + assert mode == 0o600 + + def test_multiple_sequential_locks(self, temp_lock_file): + """Test acquiring lock multiple times sequentially.""" + for i in range(5): + with operation_lock(f"command_{i}"): + content = temp_lock_file.read_text() + assert f"command_{i}" in content diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py new file mode 100644 index 0000000..d4af64c --- /dev/null +++ b/tests/test_main_helpers.py @@ -0,0 +1,360 @@ +"""Tests for main module helper functions.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from dropkit.main import ( + add_temporary_ssh_rule, + build_droplet_tags, + find_snapshot_action, + get_droplet_name_from_snapshot, + get_snapshot_name, + get_ssh_hostname, + get_user_tag, + is_droplet_tailscale_locked, + prepare_for_hibernate, +) + + +class TestGetSnapshotName: + """Tests for get_snapshot_name function.""" + + def test_simple_name(self): + """Test snapshot name for simple droplet name.""" + assert get_snapshot_name("myvm") == "dropkit-myvm" + + def test_name_with_hyphen(self): + """Test snapshot name for droplet name with hyphen.""" + assert get_snapshot_name("my-droplet") == "dropkit-my-droplet" + + def test_name_with_numbers(self): + """Test snapshot name for droplet name with numbers.""" + assert get_snapshot_name("test123") == "dropkit-test123" + + def test_empty_name(self): + """Test snapshot name for empty droplet name.""" + assert get_snapshot_name("") == "dropkit-" + + +class TestGetDropletNameFromSnapshot: + """Tests for get_droplet_name_from_snapshot function.""" + + def test_valid_dropkit_snapshot(self): + """Test extracting droplet name from valid dropkit snapshot.""" + assert get_droplet_name_from_snapshot("dropkit-myvm") == "myvm" + + def test_snapshot_with_hyphen_in_name(self): + """Test extracting droplet name with hyphens.""" + assert get_droplet_name_from_snapshot("dropkit-my-droplet") == "my-droplet" + + def test_non_dropkit_snapshot(self): + """Test with non-dropkit snapshot name.""" + assert get_droplet_name_from_snapshot("other-snapshot") is None + + def test_partial_prefix(self): + """Test with partial prefix (should not match).""" + assert get_droplet_name_from_snapshot("dropkit") is None + + def test_different_prefix(self): + """Test with different prefix.""" + assert get_droplet_name_from_snapshot("snapshot-myvm") is None + + def test_empty_after_prefix(self): + """Test snapshot name that is just the prefix.""" + assert get_droplet_name_from_snapshot("dropkit-") == "" + + +class TestGetSshHostname: + """Tests for get_ssh_hostname function.""" + + def test_simple_name(self): + """Test SSH hostname for simple droplet name.""" + assert get_ssh_hostname("myvm") == "dropkit.myvm" + + def test_name_with_hyphen(self): + """Test SSH hostname for droplet name with hyphen.""" + assert get_ssh_hostname("my-droplet") == "dropkit.my-droplet" + + +class TestGetUserTag: + """Tests for get_user_tag function.""" + + def test_simple_username(self): + """Test user tag for simple username.""" + assert get_user_tag("john") == "owner:john" + + def test_username_with_underscore(self): + """Test user tag for username with underscore.""" + assert get_user_tag("john_doe") == "owner:john_doe" + + +class TestBuildDropletTags: + """Tests for build_droplet_tags function.""" + + def test_no_extra_tags(self): + """Test building tags without extra tags.""" + tags = build_droplet_tags("john") + assert tags == ["owner:john", "firewall"] + + def test_with_extra_tags(self): + """Test building tags with extra tags.""" + tags = build_droplet_tags("john", ["production", "webserver"]) + assert tags == ["owner:john", "firewall", "production", "webserver"] + + def test_extra_tags_no_duplicates(self): + """Test that duplicate tags are not added.""" + tags = build_droplet_tags("john", ["firewall", "production"]) + assert tags == ["owner:john", "firewall", "production"] + + def test_empty_extra_tags(self): + """Test with empty extra tags list.""" + tags = build_droplet_tags("john", []) + assert tags == ["owner:john", "firewall"] + + def test_none_extra_tags(self): + """Test with None extra tags.""" + tags = build_droplet_tags("john", None) + assert tags == ["owner:john", "firewall"] + + +class TestFindSnapshotAction: + """Tests for find_snapshot_action function.""" + + def test_finds_snapshot_action(self): + """Test finding a snapshot action in the actions list.""" + mock_api = MagicMock() + mock_api.list_droplet_actions.return_value = [ + {"id": 1, "type": "power_off", "status": "completed"}, + {"id": 2, "type": "snapshot", "status": "in-progress"}, + {"id": 3, "type": "power_on", "status": "completed"}, + ] + + result = find_snapshot_action(mock_api, 12345) + + assert result is not None + assert result["id"] == 2 + assert result["type"] == "snapshot" + mock_api.list_droplet_actions.assert_called_once_with(12345) + + def test_returns_first_snapshot_action(self): + """Test that it returns the first (most recent) snapshot action.""" + mock_api = MagicMock() + mock_api.list_droplet_actions.return_value = [ + {"id": 10, "type": "snapshot", "status": "in-progress"}, + {"id": 5, "type": "snapshot", "status": "completed"}, + ] + + result = find_snapshot_action(mock_api, 12345) + + assert result is not None + assert result["id"] == 10 + + def test_returns_none_when_no_snapshot_action(self): + """Test returning None when no snapshot action exists.""" + mock_api = MagicMock() + mock_api.list_droplet_actions.return_value = [ + {"id": 1, "type": "power_off", "status": "completed"}, + {"id": 2, "type": "power_on", "status": "completed"}, + ] + + result = find_snapshot_action(mock_api, 12345) + + assert result is None + + def test_returns_none_when_empty_actions(self): + """Test returning None when actions list is empty.""" + mock_api = MagicMock() + mock_api.list_droplet_actions.return_value = [] + + result = find_snapshot_action(mock_api, 12345) + + assert result is None + + +@pytest.fixture +def temp_ssh_config(tmp_path): + """Create a temporary SSH config file.""" + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir(mode=0o700) + config_path = ssh_dir / "config" + return str(config_path) + + +class TestIsDropletTailscaleLocked: + """Tests for is_droplet_tailscale_locked function.""" + + def test_tailscale_ip_returns_true(self, temp_ssh_config): + """Test returns True when SSH config has Tailscale IP.""" + # Create SSH config with Tailscale IP + Path(temp_ssh_config).write_text("""Host dropkit.myvm + HostName 100.80.123.45 + User ubuntu +""") + + mock_config = MagicMock() + mock_config.ssh.config_path = temp_ssh_config + + result = is_droplet_tailscale_locked(mock_config, "myvm") + assert result is True + + def test_public_ip_returns_false(self, temp_ssh_config): + """Test returns False when SSH config has public IP.""" + # Create SSH config with public IP + Path(temp_ssh_config).write_text("""Host dropkit.myvm + HostName 192.168.1.100 + User ubuntu +""") + + mock_config = MagicMock() + mock_config.ssh.config_path = temp_ssh_config + + result = is_droplet_tailscale_locked(mock_config, "myvm") + assert result is False + + def test_missing_entry_returns_false(self, temp_ssh_config): + """Test returns False when SSH config has no entry for droplet.""" + # Create SSH config without the target host + Path(temp_ssh_config).write_text("""Host dropkit.othervm + HostName 100.80.123.45 + User ubuntu +""") + + mock_config = MagicMock() + mock_config.ssh.config_path = temp_ssh_config + + result = is_droplet_tailscale_locked(mock_config, "myvm") + assert result is False + + def test_nonexistent_config_file_returns_false(self, temp_ssh_config): + """Test returns False when SSH config file doesn't exist.""" + mock_config = MagicMock() + mock_config.ssh.config_path = "/nonexistent/path/config" + + result = is_droplet_tailscale_locked(mock_config, "myvm") + assert result is False + + +class TestAddTemporarySshRule: + """Tests for add_temporary_ssh_rule function.""" + + @patch("dropkit.main.subprocess.run") + def test_success(self, mock_run): + """Test successful SSH rule addition.""" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = add_temporary_ssh_rule("dropkit.myvm") + + assert result is True + mock_run.assert_called_once() + call_args = mock_run.call_args + assert "dropkit.myvm" in call_args[0][0] + assert "sudo ufw allow in on eth0 to any port 22" in call_args[0][0] + + @patch("dropkit.main.subprocess.run") + def test_failure(self, mock_run): + """Test failed SSH rule addition.""" + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") + + result = add_temporary_ssh_rule("dropkit.myvm") + + assert result is False + + @patch("dropkit.main.subprocess.run") + def test_timeout(self, mock_run): + """Test SSH timeout.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 30) + + result = add_temporary_ssh_rule("dropkit.myvm") + + assert result is False + + +class TestPrepareForHibernate: + """Tests for prepare_for_hibernate function.""" + + def test_not_tailscale_locked_returns_false(self, temp_ssh_config): + """Test returns False when droplet is not Tailscale locked.""" + # Create SSH config with public IP (not Tailscale locked) + Path(temp_ssh_config).write_text("""Host dropkit.myvm + HostName 192.168.1.100 + User ubuntu +""") + + mock_config = MagicMock() + mock_config.ssh.config_path = temp_ssh_config + + mock_api = MagicMock() + mock_droplet = {"networks": {"v4": [{"type": "public", "ip_address": "192.168.1.100"}]}} + + result = prepare_for_hibernate(mock_config, mock_api, mock_droplet, "myvm") + + assert result is False + + @patch("dropkit.main.tailscale_logout") + @patch("dropkit.main.add_temporary_ssh_rule") + @patch("dropkit.main.add_ssh_host") + def test_tailscale_locked_returns_true( + self, mock_add_ssh_host, mock_add_temp_rule, mock_logout, temp_ssh_config + ): + """Test returns True when droplet is Tailscale locked.""" + # Create SSH config with Tailscale IP + Path(temp_ssh_config).write_text("""Host dropkit.myvm + HostName 100.80.123.45 + User ubuntu +""") + + mock_config = MagicMock() + mock_config.ssh.config_path = temp_ssh_config + mock_config.ssh.identity_file = "~/.ssh/id_ed25519" + + mock_api = MagicMock() + mock_api.get_username.return_value = "testuser" + + mock_droplet = {"networks": {"v4": [{"type": "public", "ip_address": "203.0.113.50"}]}} + + mock_add_temp_rule.return_value = True + mock_logout.return_value = True + + result = prepare_for_hibernate(mock_config, mock_api, mock_droplet, "myvm") + + assert result is True + mock_add_temp_rule.assert_called_once() + mock_logout.assert_called_once() + mock_add_ssh_host.assert_called_once() + + @patch("dropkit.main.tailscale_logout") + @patch("dropkit.main.add_temporary_ssh_rule") + @patch("dropkit.main.add_ssh_host") + def test_temp_rule_failure_skips_logout( + self, mock_add_ssh_host, mock_add_temp_rule, mock_logout, temp_ssh_config + ): + """Test returns True but skips logout if temp rule fails (safety).""" + # Create SSH config with Tailscale IP + Path(temp_ssh_config).write_text("""Host dropkit.myvm + HostName 100.80.123.45 + User ubuntu +""") + + mock_config = MagicMock() + mock_config.ssh.config_path = temp_ssh_config + mock_config.ssh.identity_file = "~/.ssh/id_ed25519" + + mock_api = MagicMock() + mock_api.get_username.return_value = "testuser" + + mock_droplet = {"networks": {"v4": [{"type": "public", "ip_address": "203.0.113.50"}]}} + + mock_add_temp_rule.return_value = False # Simulating failure + + result = prepare_for_hibernate(mock_config, mock_api, mock_droplet, "myvm") + + # Should still return True because we detected Tailscale lockdown + assert result is True + # But logout should NOT be called (safety - need public IP fallback first) + mock_logout.assert_not_called() + # And SSH config should NOT be updated (early return) + mock_add_ssh_host.assert_not_called() diff --git a/tests/test_rename.py b/tests/test_rename.py new file mode 100644 index 0000000..fea3f9f --- /dev/null +++ b/tests/test_rename.py @@ -0,0 +1,354 @@ +"""Tests for the rename command functionality.""" + +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from dropkit.main import app + +runner = CliRunner() + + +def create_mock_droplet( + droplet_id: int = 12345, + name: str = "test-droplet", + status: str = "active", + ip_address: str = "192.168.1.100", +) -> dict: + """Create a mock droplet dictionary.""" + return { + "id": droplet_id, + "name": name, + "status": status, + "networks": { + "v4": [ + {"type": "public", "ip_address": ip_address}, + {"type": "private", "ip_address": "10.0.0.1"}, + ] + }, + } + + +def create_mock_config(): + """Create a mock config object.""" + mock_config = MagicMock() + mock_config.ssh.config_path = "~/.ssh/config" + mock_config.ssh.identity_file = "~/.ssh/id_ed25519" + return mock_config + + +class TestRenameCommand: + """Tests for the rename command.""" + + @patch("dropkit.main.add_ssh_host") + @patch("dropkit.main.remove_ssh_host") + @patch("dropkit.main.host_exists") + @patch("dropkit.main.Prompt.ask") + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_successful_rename( + self, + mock_load_config, + mock_find_droplet, + mock_prompt, + mock_host_exists, + mock_remove_ssh, + mock_add_ssh, + ): + """Test successful droplet rename.""" + # Setup mocks + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(name="old-name", status="active") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + # No existing droplets with new name + mock_api.list_droplets.return_value = [mock_droplet] + mock_api.get_droplet.return_value = mock_droplet + mock_api.rename_droplet.return_value = {"id": 99999} + mock_api.wait_for_action_complete.return_value = {"status": "completed"} + + mock_prompt.return_value = "yes" + mock_host_exists.return_value = True + + # Run command + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + # Verify + assert result.exit_code == 0 + assert "successfully renamed" in result.output.lower() + mock_api.rename_droplet.assert_called_once_with(12345, "new-name") + mock_remove_ssh.assert_called_once() + mock_add_ssh.assert_called_once() + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_same_name_exits_early(self, mock_load_config, mock_find_droplet): + """Test that renaming to the same name exits early.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(name="test-droplet") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + result = runner.invoke(app, ["rename", "test-droplet", "test-droplet"]) + + assert result.exit_code == 0 + assert "same as current name" in result.output.lower() + mock_api.rename_droplet.assert_not_called() + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_droplet_not_found(self, mock_load_config, mock_find_droplet): + """Test rename when droplet is not found.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_find_droplet.return_value = (None, "testuser") + + result = runner.invoke(app, ["rename", "nonexistent", "new-name"]) + + assert result.exit_code == 1 + assert "not found" in result.output.lower() + mock_api.rename_droplet.assert_not_called() + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_name_conflict(self, mock_load_config, mock_find_droplet): + """Test rename fails when new name already exists for the same user.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(droplet_id=12345, name="old-name") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + # Another droplet with the target name already exists + existing_droplet = create_mock_droplet(droplet_id=99999, name="existing-name") + mock_api.list_droplets.return_value = [mock_droplet, existing_droplet] + + result = runner.invoke(app, ["rename", "old-name", "existing-name"]) + + assert result.exit_code == 1 + assert "already exists" in result.output.lower() + mock_api.rename_droplet.assert_not_called() + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_droplet_not_active(self, mock_load_config, mock_find_droplet): + """Test rename fails when droplet is not active (powered off).""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + # Droplet is off + mock_droplet = create_mock_droplet(name="old-name", status="off") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + mock_api.list_droplets.return_value = [mock_droplet] + + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + assert result.exit_code == 1 + assert "off" in result.output.lower() + assert "dropkit on" in result.output.lower() + mock_api.rename_droplet.assert_not_called() + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_droplet_status_new(self, mock_load_config, mock_find_droplet): + """Test rename fails when droplet status is 'new' (still initializing).""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(name="old-name", status="new") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + mock_api.list_droplets.return_value = [mock_droplet] + + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + assert result.exit_code == 1 + assert "new" in result.output.lower() + mock_api.rename_droplet.assert_not_called() + + @patch("dropkit.main.Prompt.ask") + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_user_cancels(self, mock_load_config, mock_find_droplet, mock_prompt): + """Test rename cancelled when user says no.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(name="old-name", status="active") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + mock_api.list_droplets.return_value = [mock_droplet] + mock_api.get_droplet.return_value = mock_droplet + + mock_prompt.return_value = "no" + + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + assert result.exit_code == 0 + assert "cancelled" in result.output.lower() + mock_api.rename_droplet.assert_not_called() + + @patch("dropkit.main.host_exists") + @patch("dropkit.main.Prompt.ask") + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_no_ssh_config_entry( + self, mock_load_config, mock_find_droplet, mock_prompt, mock_host_exists + ): + """Test rename when no SSH config entry exists for old name.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(name="old-name", status="active") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + mock_api.list_droplets.return_value = [mock_droplet] + mock_api.get_droplet.return_value = mock_droplet + mock_api.rename_droplet.return_value = {"id": 99999} + mock_api.wait_for_action_complete.return_value = {"status": "completed"} + + mock_prompt.return_value = "yes" + mock_host_exists.return_value = False # No SSH entry + + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + assert result.exit_code == 0 + assert "successfully renamed" in result.output.lower() + assert "not found" in result.output.lower() # SSH config not found message + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_only_checks_user_droplets(self, mock_load_config, mock_find_droplet): + """Test that name conflict check only considers user's own droplets.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(droplet_id=12345, name="old-name") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + # Only user's droplet is returned (filtered by tag) + mock_api.list_droplets.return_value = [mock_droplet] + + # Existing droplet with same name but different user won't be in list + # because list_droplets is called with tag_name=owner:testuser + + runner.invoke(app, ["rename", "old-name", "target-name"]) + + # Should proceed past name conflict check + # Verify list_droplets was called with the user tag + mock_api.list_droplets.assert_called_once() + call_kwargs = mock_api.list_droplets.call_args + assert "owner:testuser" in str(call_kwargs) + + +class TestRenameEdgeCases: + """Edge case tests for rename command.""" + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_droplet_missing_id(self, mock_load_config, mock_find_droplet): + """Test rename when droplet has no ID.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + # Droplet without ID + mock_droplet = {"name": "old-name", "status": "active"} + mock_find_droplet.return_value = (mock_droplet, "testuser") + + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + assert result.exit_code == 1 + assert "could not determine droplet id" in result.output.lower() + + @patch("dropkit.main.add_ssh_host") + @patch("dropkit.main.remove_ssh_host") + @patch("dropkit.main.host_exists") + @patch("dropkit.main.Prompt.ask") + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_ssh_update_failure_continues( + self, + mock_load_config, + mock_find_droplet, + mock_prompt, + mock_host_exists, + mock_remove_ssh, + mock_add_ssh, + ): + """Test that SSH config update failure doesn't block rename success.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + mock_droplet = create_mock_droplet(name="old-name", status="active") + mock_find_droplet.return_value = (mock_droplet, "testuser") + + mock_api.list_droplets.return_value = [mock_droplet] + mock_api.get_droplet.return_value = mock_droplet + mock_api.rename_droplet.return_value = {"id": 99999} + mock_api.wait_for_action_complete.return_value = {"status": "completed"} + + mock_prompt.return_value = "yes" + mock_host_exists.return_value = True + mock_remove_ssh.side_effect = Exception("SSH config error") + + result = runner.invoke(app, ["rename", "old-name", "new-name"]) + + # Rename should still succeed even if SSH config update fails + assert result.exit_code == 0 + assert "successfully renamed" in result.output.lower() + assert "could not update ssh config" in result.output.lower() + + @patch("dropkit.main.find_user_droplet") + @patch("dropkit.main.load_config_and_api") + def test_rename_multiple_droplets_same_user(self, mock_load_config, mock_find_droplet): + """Test name conflict when user has multiple droplets.""" + mock_api = MagicMock() + mock_config_manager = MagicMock() + mock_config_manager.config = create_mock_config() + mock_load_config.return_value = (mock_config_manager, mock_api) + + droplet_to_rename = create_mock_droplet(droplet_id=111, name="droplet-a") + other_droplet = create_mock_droplet(droplet_id=222, name="droplet-b") + target_droplet = create_mock_droplet(droplet_id=333, name="droplet-c") + + mock_find_droplet.return_value = (droplet_to_rename, "testuser") + + # User has 3 droplets + mock_api.list_droplets.return_value = [ + droplet_to_rename, + other_droplet, + target_droplet, + ] + + # Try to rename to existing name + result = runner.invoke(app, ["rename", "droplet-a", "droplet-c"]) + + assert result.exit_code == 1 + assert "already exists" in result.output.lower() diff --git a/tests/test_ssh_config.py b/tests/test_ssh_config.py new file mode 100644 index 0000000..c26a834 --- /dev/null +++ b/tests/test_ssh_config.py @@ -0,0 +1,985 @@ +"""Comprehensive tests for SSH config management.""" + +from pathlib import Path + +import pytest + +from dropkit.ssh_config import ( + add_ssh_host, + get_ssh_host_ip, + remove_known_hosts_entry, + remove_ssh_host, +) + + +@pytest.fixture +def temp_ssh_dir(tmp_path): + """Create a temporary SSH directory for testing.""" + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir(mode=0o700) + yield ssh_dir + # Cleanup is automatic with tmp_path + + +@pytest.fixture +def temp_config(temp_ssh_dir): + """Create a temporary SSH config file path.""" + config_path = temp_ssh_dir / "config" + return str(config_path) + + +class TestAddSSHHost: + """Tests for add_ssh_host function.""" + + def test_empty_file(self, temp_config): + """Test adding host to empty SSH config file.""" + # Create empty file + Path(temp_config).touch(mode=0o600) + + add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu") + + content = Path(temp_config).read_text() + assert "Host myhost" in content + assert "HostName 192.168.1.1" in content + assert "User ubuntu" in content + + def test_non_existent_file(self, temp_config): + """Test creating new file and directory if they don't exist.""" + # Ensure file doesn't exist + assert not Path(temp_config).exists() + + add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu", "~/.ssh/id_ed25519") + + assert Path(temp_config).exists() + content = Path(temp_config).read_text() + assert "Host myhost" in content + assert "IdentityFile ~/.ssh/id_ed25519" in content + + def test_simple_addition(self, temp_config): + """Test adding host to config with existing unrelated hosts.""" + # Create config with existing host + existing = """Host other-server + HostName 10.0.0.1 + User admin + +Host another-host + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu") + + content = Path(temp_config).read_text() + assert "Host other-server" in content + assert "Host another-host" in content + assert "Host myhost" in content + # Count "Host " with space to avoid matching "HostName" + assert content.count("Host ") == 3 + + def test_update_existing_host(self, temp_config): + """Test replacing existing host entry without duplication.""" + # Create config with existing host + existing = """Host myhost + HostName 10.0.0.1 + User old_user + +Host other-host + HostName 10.0.0.2 + User admin +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "myhost", "192.168.1.1", "new_user") + + content = Path(temp_config).read_text() + assert content.count("Host myhost") == 1 # Should not duplicate + assert "HostName 192.168.1.1" in content + assert "User new_user" in content + assert "old_user" not in content + assert "10.0.0.1" not in content + assert "Host other-host" in content # Other hosts preserved + + def test_host_with_similar_names(self, temp_config): + """Test that similar host names don't interfere.""" + existing = """Host myhost + HostName 10.0.0.1 + User user1 + +Host myhost-dev + HostName 10.0.0.2 + User user2 + +Host myhost-prod + HostName 10.0.0.3 + User user3 +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "myhost", "192.168.1.1", "newuser") + + content = Path(temp_config).read_text() + assert "Host myhost-dev" in content + assert "10.0.0.2" in content + assert "Host myhost-prod" in content + assert "10.0.0.3" in content + # myhost should be updated + lines = content.split("\n") + myhost_line = [i for i, line in enumerate(lines) if line.strip() == "Host myhost"][-1] + # Check that the next few lines after myhost contain the new values + section = "\n".join(lines[myhost_line : myhost_line + 5]) + assert "192.168.1.1" in section + assert "newuser" in section + + def test_host_as_substring(self, temp_config): + """Test that substring hosts are handled correctly.""" + existing = """Host prod-server + HostName 10.0.0.1 + User admin + +Host prod + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "prod", "192.168.1.1", "ubuntu") + + content = Path(temp_config).read_text() + assert "Host prod-server" in content + assert "10.0.0.1" in content + # prod should be updated + assert content.count("Host prod\n") >= 1 + + def test_multiple_blank_lines(self, temp_config): + """Test handling config with various blank lines.""" + existing = """Host server1 + HostName 10.0.0.1 + User admin + + +Host server2 + HostName 10.0.0.2 + User root + + + +Host server3 + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "newhost", "192.168.1.1", "user") + + content = Path(temp_config).read_text() + assert "Host server1" in content + assert "Host server2" in content + assert "Host server3" in content + assert "Host newhost" in content + + def test_comments_in_config(self, temp_config): + """Test preserving comments in config.""" + existing = """# This is a comment +Host server1 + HostName 10.0.0.1 + User admin + # Another comment + +# Global settings +Host server2 + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "newhost", "192.168.1.1", "user") + + content = Path(temp_config).read_text() + assert "# This is a comment" in content + assert "# Another comment" in content + assert "# Global settings" in content + + def test_mixed_indentation(self, temp_config): + """Test handling tabs and spaces.""" + existing = """Host server1 + HostName 10.0.0.1 + User admin + +Host server2 + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "newhost", "192.168.1.1", "user") + + content = Path(temp_config).read_text() + # Original hosts should be preserved + assert "Host server1" in content + assert "Host server2" in content + # New host should be added + assert "Host newhost" in content + + def test_host_at_beginning(self, temp_config): + """Test updating a host that's the first entry.""" + existing = """Host first-host + HostName 10.0.0.1 + User admin + +Host second-host + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "first-host", "192.168.1.1", "newuser") + + content = Path(temp_config).read_text() + assert content.count("Host first-host") == 1 + assert "192.168.1.1" in content + assert "10.0.0.1" not in content + assert "Host second-host" in content + + def test_host_at_end(self, temp_config): + """Test updating a host that's the last entry.""" + existing = """Host first-host + HostName 10.0.0.1 + User admin + +Host last-host + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "last-host", "192.168.1.1", "newuser") + + content = Path(temp_config).read_text() + assert "Host first-host" in content + assert content.count("Host last-host") == 1 + assert "192.168.1.1" in content + assert "10.0.0.2" not in content + + def test_host_in_middle(self, temp_config): + """Test updating a host between others.""" + existing = """Host first-host + HostName 10.0.0.1 + User admin + +Host middle-host + HostName 10.0.0.2 + User root + +Host last-host + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "middle-host", "192.168.1.1", "newuser") + + content = Path(temp_config).read_text() + assert "Host first-host" in content + assert "Host last-host" in content + assert content.count("Host middle-host") == 1 + assert "192.168.1.1" in content + assert "10.0.0.2" not in content + + def test_complex_host_entries(self, temp_config): + """Test host with many configuration options.""" + existing = """Host complex-host + HostName 10.0.0.1 + User admin + Port 2222 + ForwardAgent yes + ProxyJump bastion + IdentityFile ~/.ssh/custom_key + StrictHostKeyChecking no +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "complex-host", "192.168.1.1", "newuser", "~/.ssh/new_key") + + content = Path(temp_config).read_text() + assert content.count("Host complex-host") == 1 + assert "192.168.1.1" in content + assert "newuser" in content + # Old complex options should be replaced + assert "Port 2222" not in content + + def test_host_with_extra_spaces(self, temp_config): + """Test host declaration with multiple spaces.""" + existing = """Host server-with-spaces + HostName 10.0.0.1 + User admin +""" + Path(temp_config).write_text(existing) + + # This won't match because our check is exact + add_ssh_host(temp_config, "server-with-spaces", "192.168.1.1", "newuser") + + content = Path(temp_config).read_text() + # Should add a new entry since "Host server-with-spaces" != "Host server-with-spaces" + assert "Host server-with-spaces" in content + + def test_host_patterns(self, temp_config): + """Test existing wildcard hosts.""" + existing = """Host *.example.com + User admin + IdentityFile ~/.ssh/example_key + +Host myserver + HostName 10.0.0.1 + User root +""" + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu") + + content = Path(temp_config).read_text() + assert "Host *.example.com" in content + assert "Host myserver" in content + assert "Host newhost" in content + + def test_no_trailing_newline(self, temp_config): + """Test config file without final newline.""" + existing = """Host myhost + HostName 10.0.0.1 + User admin""" # No trailing newline + Path(temp_config).write_text(existing) + + add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu") + + content = Path(temp_config).read_text() + assert "Host myhost" in content + assert "Host newhost" in content + # Verify Host newhost is on its own line (not concatenated to previous line) + lines = content.split("\n") + host_lines = [line for line in lines if "Host newhost" in line] + assert len(host_lines) == 1 + assert host_lines[0].strip() == "Host newhost" + + def test_permissions_preserved(self, temp_config): + """Test that file permissions are set correctly.""" + Path(temp_config).touch(mode=0o644) + + add_ssh_host(temp_config, "myhost", "192.168.1.1", "ubuntu") + + # File should now have 0600 permissions + mode = Path(temp_config).stat().st_mode & 0o777 + assert mode == 0o600 + + def test_backup_created(self, temp_config): + """Test that backup file is created.""" + existing = """Host oldhost + HostName 10.0.0.1 + User admin +""" + Path(temp_config).write_text(existing) + Path(temp_config).chmod(0o600) + + add_ssh_host(temp_config, "newhost", "192.168.1.1", "ubuntu") + + backup_path = Path(temp_config).parent / "config.bak" + assert backup_path.exists() + + backup_content = backup_path.read_text() + assert "Host oldhost" in backup_content + assert "Host newhost" not in backup_content + + # Check backup has same permissions + backup_mode = backup_path.stat().st_mode & 0o777 + assert backup_mode == 0o600 + + +class TestRemoveSSHHost: + """Tests for remove_ssh_host function.""" + + def test_non_existent_file(self, temp_config): + """Test removing from non-existent config.""" + assert not Path(temp_config).exists() + result = remove_ssh_host(temp_config, "myhost") + assert result is False + + def test_host_doesnt_exist(self, temp_config): + """Test removing host that's not in config.""" + existing = """Host server1 + HostName 10.0.0.1 + User admin +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "nonexistent") + + assert result is False + content = Path(temp_config).read_text() + assert "Host server1" in content # Should be unchanged + + def test_remove_first_host(self, temp_config): + """Test removing the first host entry.""" + existing = """Host first-host + HostName 10.0.0.1 + User admin + +Host second-host + HostName 10.0.0.2 + User root + +Host third-host + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "first-host") + + assert result is True + content = Path(temp_config).read_text() + assert "Host first-host" not in content + assert "Host second-host" in content + assert "Host third-host" in content + + def test_remove_last_host(self, temp_config): + """Test removing the last host entry.""" + existing = """Host first-host + HostName 10.0.0.1 + User admin + +Host second-host + HostName 10.0.0.2 + User root + +Host last-host + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "last-host") + + assert result is True + content = Path(temp_config).read_text() + assert "Host first-host" in content + assert "Host second-host" in content + assert "Host last-host" not in content + + def test_remove_middle_host(self, temp_config): + """Test removing a host between others.""" + existing = """Host first-host + HostName 10.0.0.1 + User admin + +Host middle-host + HostName 10.0.0.2 + User root + +Host last-host + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "middle-host") + + assert result is True + content = Path(temp_config).read_text() + assert "Host first-host" in content + assert "Host middle-host" not in content + assert "Host last-host" in content + + def test_remove_only_host(self, temp_config): + """Test removing the only host in config.""" + existing = """Host only-host + HostName 10.0.0.1 + User admin +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "only-host") + + assert result is True + content = Path(temp_config).read_text() + assert "Host only-host" not in content + # File should be essentially empty or just whitespace + assert content.strip() == "" or "Host" not in content + + def test_remove_similar_host_names(self, temp_config): + """Test removing specific host when similar names exist.""" + existing = """Host myhost + HostName 10.0.0.1 + User user1 + +Host myhost-dev + HostName 10.0.0.2 + User user2 + +Host myhost-prod + HostName 10.0.0.3 + User user3 +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "myhost") + + assert result is True + content = Path(temp_config).read_text() + assert "Host myhost\n" not in content + assert "10.0.0.1" not in content + assert "Host myhost-dev" in content + assert "10.0.0.2" in content + assert "Host myhost-prod" in content + assert "10.0.0.3" in content + + def test_remove_host_with_many_options(self, temp_config): + """Test removing host with many configuration lines.""" + existing = """Host simple-host + HostName 10.0.0.1 + User admin + +Host complex-host + HostName 10.0.0.2 + User root + Port 2222 + ForwardAgent yes + ProxyJump bastion + IdentityFile ~/.ssh/custom_key + StrictHostKeyChecking no + LocalForward 8080 localhost:80 + +Host another-host + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "complex-host") + + assert result is True + content = Path(temp_config).read_text() + assert "Host simple-host" in content + assert "Host complex-host" not in content + assert "Port 2222" not in content + assert "ProxyJump bastion" not in content + assert "Host another-host" in content + + def test_backup_created_on_remove(self, temp_config): + """Test that backup is created when removing host.""" + existing = """Host myhost + HostName 10.0.0.1 + User admin + +Host otherhost + HostName 10.0.0.2 + User root +""" + Path(temp_config).write_text(existing) + Path(temp_config).chmod(0o600) + + remove_ssh_host(temp_config, "myhost") + + backup_path = Path(temp_config).parent / "config.bak" + assert backup_path.exists() + + backup_content = backup_path.read_text() + assert "Host myhost" in backup_content + assert "Host otherhost" in backup_content + + # Check backup has same permissions + backup_mode = backup_path.stat().st_mode & 0o777 + assert backup_mode == 0o600 + + def test_remove_preserves_comments(self, temp_config): + """Test that comments are preserved when removing host.""" + existing = """# Global comment +Host keephost + HostName 10.0.0.1 + User admin + +# This host will be removed +Host removehost + HostName 10.0.0.2 + User root + +# Another comment +Host anotherhost + HostName 10.0.0.3 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = remove_ssh_host(temp_config, "removehost") + + assert result is True + content = Path(temp_config).read_text() + assert "# Global comment" in content + assert "# Another comment" in content + assert "Host keephost" in content + assert "Host removehost" not in content + assert "Host anotherhost" in content + + +class TestGetSSHHostIP: + """Tests for get_ssh_host_ip function.""" + + def test_valid_host(self, temp_config): + """Test getting IP for existing host.""" + existing = """Host myhost + HostName 192.168.1.100 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = get_ssh_host_ip(temp_config, "myhost") + assert result == "192.168.1.100" + + def test_host_not_found(self, temp_config): + """Test getting IP for non-existent host.""" + existing = """Host otherhost + HostName 192.168.1.100 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = get_ssh_host_ip(temp_config, "myhost") + assert result is None + + def test_missing_hostname_field(self, temp_config): + """Test host entry without HostName field.""" + existing = """Host myhost + User ubuntu + IdentityFile ~/.ssh/id_rsa +""" + Path(temp_config).write_text(existing) + + result = get_ssh_host_ip(temp_config, "myhost") + assert result is None + + def test_non_existent_file(self, temp_config): + """Test with non-existent config file.""" + result = get_ssh_host_ip(temp_config, "myhost") + assert result is None + + def test_tailscale_ip(self, temp_config): + """Test getting Tailscale IP address.""" + existing = """Host dropkit.myhost + HostName 100.80.123.45 + User ubuntu + ForwardAgent yes +""" + Path(temp_config).write_text(existing) + + result = get_ssh_host_ip(temp_config, "dropkit.myhost") + assert result == "100.80.123.45" + + def test_multiple_hosts(self, temp_config): + """Test getting IP from config with multiple hosts.""" + existing = """Host firsthost + HostName 10.0.0.1 + User admin + +Host targethost + HostName 192.168.1.50 + User ubuntu + +Host thirdhost + HostName 10.0.0.3 + User root +""" + Path(temp_config).write_text(existing) + + result = get_ssh_host_ip(temp_config, "targethost") + assert result == "192.168.1.50" + + def test_host_with_extra_whitespace(self, temp_config): + """Test HostName with extra whitespace.""" + existing = """Host myhost + HostName 192.168.1.100 + User ubuntu +""" + Path(temp_config).write_text(existing) + + result = get_ssh_host_ip(temp_config, "myhost") + assert result == "192.168.1.100" + + def test_multiple_hosts_on_same_line(self, temp_config): + """Test host with multiple aliases on same line.""" + existing = """Host myhost myalias anotherhost + HostName 192.168.1.100 + User ubuntu +""" + Path(temp_config).write_text(existing) + + # Should work for any of the aliases + assert get_ssh_host_ip(temp_config, "myhost") == "192.168.1.100" + assert get_ssh_host_ip(temp_config, "myalias") == "192.168.1.100" + assert get_ssh_host_ip(temp_config, "anotherhost") == "192.168.1.100" + + def test_hostname_vs_host(self, temp_config): + """Test that we distinguish between Host directive and HostName option.""" + existing = """Host realhost + HostName 192.168.1.100 + User ubuntu + +Host anotherreal + HostName 10.0.0.1 + User admin +""" + Path(temp_config).write_text(existing) + + # Should not find HostName as a host alias + assert get_ssh_host_ip(temp_config, "192.168.1.100") is None + # Should find the actual hosts + assert get_ssh_host_ip(temp_config, "realhost") == "192.168.1.100" + + +class TestRemoveKnownHostsEntry: + """Tests for remove_known_hosts_entry function.""" + + @pytest.fixture + def temp_known_hosts(self, temp_ssh_dir): + """Create a temporary known_hosts file path.""" + known_hosts_path = temp_ssh_dir / "known_hosts" + return str(known_hosts_path) + + def test_non_existent_file(self, temp_known_hosts): + """Test removing from non-existent known_hosts.""" + assert not Path(temp_known_hosts).exists() + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + assert result == 0 + + def test_no_matching_entries(self, temp_known_hosts): + """Test removing hostname that's not in known_hosts.""" + existing = """otherhost ssh-ed25519 AAAA... +192.168.1.1 ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 0 + content = Path(temp_known_hosts).read_text() + assert "otherhost" in content + assert "192.168.1.1" in content + + def test_remove_single_hostname(self, temp_known_hosts): + """Test removing a single hostname entry.""" + existing = """myhost ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "myhost" not in content + assert "otherhost" in content + + def test_remove_ip_address(self, temp_known_hosts): + """Test removing an IP address entry.""" + existing = """192.168.1.100 ssh-ed25519 AAAA... +10.0.0.1 ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "192.168.1.100" not in content + assert "10.0.0.1" in content + + def test_remove_multiple_entries(self, temp_known_hosts): + """Test removing hostname and IP address together.""" + existing = """tobcloud.myhost ssh-ed25519 AAAA... +100.80.123.45 ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["tobcloud.myhost", "100.80.123.45"]) + + assert result == 2 + content = Path(temp_known_hosts).read_text() + assert "tobcloud.myhost" not in content + assert "100.80.123.45" not in content + assert "otherhost" in content + + def test_comma_separated_hostnames(self, temp_known_hosts): + """Test removing entry with comma-separated hostnames.""" + existing = """myhost,192.168.1.100 ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + # Should match by hostname + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "myhost" not in content + assert "192.168.1.100" not in content # Whole line removed + assert "otherhost" in content + + def test_comma_separated_match_by_ip(self, temp_known_hosts): + """Test removing entry by IP when comma-separated.""" + existing = """myhost,192.168.1.100 ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + # Should match by IP + result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "myhost" not in content + assert "192.168.1.100" not in content + assert "otherhost" in content + + def test_bracketed_entry(self, temp_known_hosts): + """Test removing bracketed entry like [hostname]:port.""" + existing = """[myhost]:2222 ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "[myhost]:2222" not in content + assert "otherhost" in content + + def test_bracketed_ip_entry(self, temp_known_hosts): + """Test removing bracketed IP entry like [192.168.1.1]:2222.""" + existing = """[192.168.1.100]:2222 ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["192.168.1.100"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "192.168.1.100" not in content + assert "otherhost" in content + + def test_hashed_entries_preserved(self, temp_known_hosts): + """Test that hashed entries (|1|...) are preserved.""" + existing = """|1|abc123...= ssh-ed25519 AAAA... +myhost ssh-rsa AAAA... +|1|def456...= ssh-ed25519 AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "|1|abc123" in content + assert "|1|def456" in content + assert "myhost" not in content + + def test_comments_preserved(self, temp_known_hosts): + """Test that comments are preserved.""" + existing = """# This is a comment +myhost ssh-ed25519 AAAA... +# Another comment +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "# This is a comment" in content + assert "# Another comment" in content + assert "myhost" not in content + assert "otherhost" in content + + def test_empty_lines_preserved(self, temp_known_hosts): + """Test that empty lines are preserved.""" + existing = """myhost ssh-ed25519 AAAA... + +otherhost ssh-rsa AAAA... + +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "myhost" not in content + assert "otherhost" in content + # Should preserve structure + assert "\n\n" in content or content.count("\n") >= 2 + + def test_case_insensitive_matching(self, temp_known_hosts): + """Test that hostname matching is case-insensitive.""" + existing = """MyHost ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + assert result == 1 + content = Path(temp_known_hosts).read_text() + assert "MyHost" not in content + assert "otherhost" in content + + def test_backup_created(self, temp_known_hosts): + """Test that backup file is created.""" + existing = """myhost ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + Path(temp_known_hosts).chmod(0o600) + + remove_known_hosts_entry(temp_known_hosts, ["myhost"]) + + backup_path = Path(temp_known_hosts).parent / "known_hosts.bak" + assert backup_path.exists() + + backup_content = backup_path.read_text() + assert "myhost" in backup_content + assert "otherhost" in backup_content + + # Check backup has same permissions + backup_mode = backup_path.stat().st_mode & 0o777 + assert backup_mode == 0o600 + + def test_remove_all_entries(self, temp_known_hosts): + """Test removing all entries from known_hosts.""" + existing = """myhost ssh-ed25519 AAAA... +192.168.1.100 ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry(temp_known_hosts, ["myhost", "192.168.1.100"]) + + assert result == 2 + content = Path(temp_known_hosts).read_text() + assert content.strip() == "" + + def test_tobcloud_hostname_format(self, temp_known_hosts): + """Test removing tobcloud-style hostname.""" + existing = """tobcloud.my-droplet ssh-ed25519 AAAA... +100.80.123.45 ssh-ed25519 AAAA... +otherhost ssh-rsa AAAA... +""" + Path(temp_known_hosts).write_text(existing) + + result = remove_known_hosts_entry( + temp_known_hosts, ["tobcloud.my-droplet", "100.80.123.45"] + ) + + assert result == 2 + content = Path(temp_known_hosts).read_text() + assert "tobcloud.my-droplet" not in content + assert "100.80.123.45" not in content + assert "otherhost" in content diff --git a/tests/test_tailscale.py b/tests/test_tailscale.py new file mode 100644 index 0000000..b785dd0 --- /dev/null +++ b/tests/test_tailscale.py @@ -0,0 +1,536 @@ +"""Tests for Tailscale integration functions.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from dropkit.config import TailscaleConfig +from dropkit.main import ( + check_local_tailscale, + check_tailscale_installed, + install_tailscale_on_droplet, + is_tailscale_ip, + lock_down_to_tailscale, + run_tailscale_up, + setup_tailscale, + tailscale_logout, + verify_tailscale_ssh, +) + + +class TestIsTailscaleIP: + """Tests for is_tailscale_ip function.""" + + def test_valid_tailscale_ip_lower_bound(self): + """Test lower bound of CGNAT range (100.64.0.0).""" + assert is_tailscale_ip("100.64.0.0") is True + assert is_tailscale_ip("100.64.0.1") is True + + def test_valid_tailscale_ip_upper_bound(self): + """Test upper bound of CGNAT range (100.127.255.255).""" + assert is_tailscale_ip("100.127.255.255") is True + assert is_tailscale_ip("100.127.0.1") is True + + def test_valid_tailscale_ip_middle_range(self): + """Test middle of CGNAT range.""" + assert is_tailscale_ip("100.100.50.25") is True + assert is_tailscale_ip("100.80.1.1") is True + + def test_invalid_ip_below_cgnat_range(self): + """Test IPs below CGNAT range (100.0.0.0 - 100.63.255.255).""" + assert is_tailscale_ip("100.0.0.1") is False + assert is_tailscale_ip("100.63.255.255") is False + + def test_invalid_ip_above_cgnat_range(self): + """Test IPs above CGNAT range (100.128.0.0+).""" + assert is_tailscale_ip("100.128.0.0") is False + assert is_tailscale_ip("100.200.1.1") is False + + def test_invalid_ip_wrong_first_octet(self): + """Test IPs with wrong first octet.""" + assert is_tailscale_ip("192.168.1.1") is False + assert is_tailscale_ip("10.0.0.1") is False + assert is_tailscale_ip("172.16.0.1") is False + + def test_invalid_ip_format_too_few_octets(self): + """Test invalid IP format with too few octets.""" + assert is_tailscale_ip("100.64.0") is False + assert is_tailscale_ip("100.64") is False + assert is_tailscale_ip("100") is False + + def test_invalid_ip_format_too_many_octets(self): + """Test invalid IP format with too many octets.""" + assert is_tailscale_ip("100.64.0.1.5") is False + + def test_invalid_ip_format_non_numeric(self): + """Test invalid IP format with non-numeric values.""" + assert is_tailscale_ip("100.64.abc.1") is False + assert is_tailscale_ip("foo.bar.baz.qux") is False + + def test_invalid_ip_format_out_of_range_octets(self): + """Test IPs with octets out of 0-255 range.""" + assert is_tailscale_ip("100.64.256.1") is False + assert is_tailscale_ip("100.64.0.-1") is False + + def test_invalid_ip_empty_string(self): + """Test empty string.""" + assert is_tailscale_ip("") is False + + def test_invalid_ip_none_type(self): + """Test None type (should return False, not raise).""" + assert is_tailscale_ip(None) is False # type: ignore + + +class TestCheckLocalTailscale: + """Tests for check_local_tailscale function.""" + + @patch("dropkit.main.subprocess.run") + def test_tailscale_running(self, mock_run): + """Test when Tailscale is running locally.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps({"BackendState": "Running"}).encode("utf-8"), + ) + assert check_local_tailscale() is True + + @patch("dropkit.main.subprocess.run") + def test_tailscale_not_running(self, mock_run): + """Test when Tailscale is installed but not running.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps({"BackendState": "Stopped"}).encode("utf-8"), + ) + assert check_local_tailscale() is False + + @patch("dropkit.main.subprocess.run") + def test_tailscale_command_fails(self, mock_run): + """Test when tailscale command returns non-zero.""" + mock_run.return_value = MagicMock(returncode=1) + assert check_local_tailscale() is False + + @patch("dropkit.main.subprocess.run") + def test_tailscale_not_installed(self, mock_run): + """Test when tailscale is not installed.""" + mock_run.side_effect = FileNotFoundError() + assert check_local_tailscale() is False + + @patch("dropkit.main.subprocess.run") + def test_tailscale_timeout(self, mock_run): + """Test when tailscale command times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("tailscale", 5) + assert check_local_tailscale() is False + + @patch("dropkit.main.subprocess.run") + def test_invalid_json_response(self, mock_run): + """Test when tailscale returns invalid JSON.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=b"not valid json", + ) + assert check_local_tailscale() is False + + +class TestRunTailscaleUp: + """Tests for run_tailscale_up function.""" + + @patch("dropkit.main.subprocess.run") + def test_extracts_auth_url(self, mock_run): + """Test that auth URL is extracted from output.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=b"To authenticate, visit:\n\n\thttps://login.tailscale.com/a/abc123\n", + ) + url = run_tailscale_up("dropkit.test") + assert url == "https://login.tailscale.com/a/abc123" + + @patch("dropkit.main.subprocess.run") + def test_strips_trailing_punctuation(self, mock_run): + """Test that trailing punctuation is stripped from URL.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=b"Visit: https://login.tailscale.com/a/abc123.\n", + ) + url = run_tailscale_up("dropkit.test") + assert url == "https://login.tailscale.com/a/abc123" + + @patch("dropkit.main.subprocess.run") + def test_no_url_in_output(self, mock_run): + """Test when no URL is found in output.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=b"Some other output without URL", + ) + url = run_tailscale_up("dropkit.test") + assert url is None + + @patch("dropkit.main.subprocess.run") + def test_non_tailscale_url_ignored(self, mock_run): + """Test that non-tailscale URLs are ignored.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=b"Visit https://example.com/login for more info", + ) + url = run_tailscale_up("dropkit.test") + assert url is None + + @patch("dropkit.main.subprocess.run") + def test_ssh_timeout(self, mock_run): + """Test when SSH connection times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 30) + url = run_tailscale_up("dropkit.test") + assert url is None + + +class TestLockDownToTailscale: + """Tests for lock_down_to_tailscale function.""" + + @patch("dropkit.main.subprocess.run") + def test_all_commands_succeed(self, mock_run): + """Test when all UFW commands succeed.""" + mock_run.return_value = MagicMock(returncode=0, stderr=b"") + result = lock_down_to_tailscale("dropkit.test") + assert result is True + # Should have called 5 commands + assert mock_run.call_count == 5 + + @patch("dropkit.main.subprocess.run") + def test_first_command_fails(self, mock_run): + """Test when first UFW command fails.""" + mock_run.return_value = MagicMock(returncode=1, stderr=b"ufw error") + result = lock_down_to_tailscale("dropkit.test") + assert result is False + # Should stop after first failure + assert mock_run.call_count == 1 + + @patch("dropkit.main.subprocess.run") + def test_middle_command_fails(self, mock_run): + """Test when a middle command fails.""" + # First two succeed, third fails + mock_run.side_effect = [ + MagicMock(returncode=0, stderr=b""), + MagicMock(returncode=0, stderr=b""), + MagicMock(returncode=1, stderr=b"deny error"), + ] + result = lock_down_to_tailscale("dropkit.test") + assert result is False + assert mock_run.call_count == 3 + + @patch("dropkit.main.subprocess.run") + def test_ssh_timeout(self, mock_run): + """Test when SSH connection times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 30) + result = lock_down_to_tailscale("dropkit.test") + assert result is False + + +class TestVerifyTailscaleSsh: + """Tests for verify_tailscale_ssh function.""" + + @patch("dropkit.main.subprocess.run") + def test_ssh_success(self, mock_run): + """Test when SSH via Tailscale works.""" + mock_run.return_value = MagicMock(returncode=0) + result = verify_tailscale_ssh("100.64.1.1", "testuser", "~/.ssh/id_ed25519") + assert result is True + + @patch("dropkit.main.subprocess.run") + def test_ssh_failure(self, mock_run): + """Test when SSH via Tailscale fails.""" + mock_run.return_value = MagicMock(returncode=255) + result = verify_tailscale_ssh("100.64.1.1", "testuser", "~/.ssh/id_ed25519") + assert result is False + + @patch("dropkit.main.subprocess.run") + def test_ssh_timeout(self, mock_run): + """Test when SSH times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 15) + result = verify_tailscale_ssh("100.64.1.1", "testuser", "~/.ssh/id_ed25519") + assert result is False + + +class TestTailscaleConfig: + """Tests for TailscaleConfig Pydantic model.""" + + def test_defaults(self): + """Test default values.""" + config = TailscaleConfig() + assert config.enabled is True + assert config.lock_down_firewall is True + assert config.auth_timeout == 300 + + def test_custom_values(self): + """Test custom values.""" + config = TailscaleConfig(enabled=False, lock_down_firewall=False, auth_timeout=600) + assert config.enabled is False + assert config.lock_down_firewall is False + assert config.auth_timeout == 600 + + def test_auth_timeout_minimum(self): + """Test that auth_timeout has minimum validation.""" + with pytest.raises(ValueError): + TailscaleConfig(auth_timeout=10) # Below minimum of 30 + + def test_auth_timeout_at_minimum(self): + """Test auth_timeout at minimum value.""" + config = TailscaleConfig(auth_timeout=30) + assert config.auth_timeout == 30 + + +class TestCheckTailscaleInstalled: + """Tests for check_tailscale_installed function.""" + + @patch("dropkit.main.subprocess.run") + def test_tailscale_installed(self, mock_run): + """Test when Tailscale is installed.""" + mock_run.return_value = MagicMock(returncode=0) + assert check_tailscale_installed("dropkit.test") is True + + @patch("dropkit.main.subprocess.run") + def test_tailscale_not_installed(self, mock_run): + """Test when Tailscale is not installed.""" + mock_run.return_value = MagicMock(returncode=1) + assert check_tailscale_installed("dropkit.test") is False + + @patch("dropkit.main.subprocess.run") + def test_ssh_timeout(self, mock_run): + """Test when SSH connection times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 15) + assert check_tailscale_installed("dropkit.test") is False + + @patch("dropkit.main.subprocess.run") + def test_ssh_connection_failed(self, mock_run): + """Test when SSH connection fails.""" + import subprocess + + mock_run.side_effect = subprocess.SubprocessError("Connection refused") + assert check_tailscale_installed("dropkit.test") is False + + +class TestInstallTailscaleOnDroplet: + """Tests for install_tailscale_on_droplet function.""" + + @patch("dropkit.main.subprocess.run") + def test_install_success(self, mock_run): + """Test successful Tailscale installation.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=b"Installation complete!", + ) + assert install_tailscale_on_droplet("dropkit.test") is True + + @patch("dropkit.main.subprocess.run") + def test_install_failure(self, mock_run): + """Test failed Tailscale installation.""" + mock_run.return_value = MagicMock( + returncode=1, + stdout=b"Error: curl failed", + ) + assert install_tailscale_on_droplet("dropkit.test") is False + + @patch("dropkit.main.subprocess.run") + def test_install_timeout(self, mock_run): + """Test when installation times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 120) + assert install_tailscale_on_droplet("dropkit.test") is False + + @patch("dropkit.main.subprocess.run") + def test_ssh_connection_failed(self, mock_run): + """Test when SSH connection fails during install.""" + import subprocess + + mock_run.side_effect = subprocess.SubprocessError("Connection refused") + assert install_tailscale_on_droplet("dropkit.test") is False + + +def create_mock_config(lock_down_firewall: bool = True, auth_timeout: int = 300) -> MagicMock: + """Create a mock DropkitConfig for testing.""" + config = MagicMock() + config.tailscale = MagicMock() + config.tailscale.lock_down_firewall = lock_down_firewall + config.tailscale.auth_timeout = auth_timeout + config.ssh = MagicMock() + config.ssh.config_path = "~/.ssh/config" + config.ssh.identity_file = "~/.ssh/id_ed25519" + return config + + +class TestSetupTailscale: + """Tests for setup_tailscale function.""" + + @patch("dropkit.main.verify_tailscale_ssh") + @patch("dropkit.main.lock_down_to_tailscale") + @patch("dropkit.main.check_local_tailscale") + @patch("dropkit.main.add_ssh_host") + @patch("dropkit.main.wait_for_tailscale_ip") + @patch("dropkit.main.run_tailscale_up") + def test_already_authenticated_succeeds( + self, + mock_tailscale_up, + mock_wait_ip, + mock_add_ssh, + mock_check_local, + mock_lockdown, + mock_verify, + ): + """Test setup succeeds when Tailscale is already authenticated (no auth URL).""" + # No auth URL returned (already authenticated) + mock_tailscale_up.return_value = None + # But Tailscale IP is available + mock_wait_ip.return_value = "100.64.1.1" + mock_check_local.return_value = True + mock_lockdown.return_value = True + mock_verify.return_value = True + + config = create_mock_config() + result = setup_tailscale("dropkit.test", "testuser", config) + + assert result == "100.64.1.1" + # Should have called wait_for_tailscale_ip with short timeout + mock_wait_ip.assert_called_once_with( + "dropkit.test", timeout=10, poll_interval=2, verbose=False + ) + mock_add_ssh.assert_called_once() + mock_lockdown.assert_called_once() + + @patch("dropkit.main.wait_for_tailscale_ip") + @patch("dropkit.main.run_tailscale_up") + def test_no_auth_url_and_not_connected_fails( + self, + mock_tailscale_up, + mock_wait_ip, + ): + """Test setup fails when no auth URL and Tailscale not connected.""" + # No auth URL returned + mock_tailscale_up.return_value = None + # And no Tailscale IP available + mock_wait_ip.return_value = None + + config = create_mock_config() + result = setup_tailscale("dropkit.test", "testuser", config) + + assert result is None + + @patch("dropkit.main.verify_tailscale_ssh") + @patch("dropkit.main.lock_down_to_tailscale") + @patch("dropkit.main.check_local_tailscale") + @patch("dropkit.main.add_ssh_host") + @patch("dropkit.main.wait_for_tailscale_ip") + @patch("dropkit.main.run_tailscale_up") + def test_normal_auth_flow_succeeds( + self, + mock_tailscale_up, + mock_wait_ip, + mock_add_ssh, + mock_check_local, + mock_lockdown, + mock_verify, + ): + """Test normal flow with auth URL succeeds.""" + # Auth URL returned (needs authentication) + mock_tailscale_up.return_value = "https://login.tailscale.com/a/abc123" + # Tailscale IP available after authentication + mock_wait_ip.return_value = "100.64.1.1" + mock_check_local.return_value = True + mock_lockdown.return_value = True + mock_verify.return_value = True + + config = create_mock_config() + result = setup_tailscale("dropkit.test", "testuser", config) + + assert result == "100.64.1.1" + # Should have called wait_for_tailscale_ip with full auth_timeout + mock_wait_ip.assert_called_once_with("dropkit.test", timeout=300, verbose=False) + + @patch("dropkit.main.wait_for_tailscale_ip") + @patch("dropkit.main.run_tailscale_up") + def test_auth_timeout_fails( + self, + mock_tailscale_up, + mock_wait_ip, + ): + """Test setup fails when authentication times out.""" + # Auth URL returned + mock_tailscale_up.return_value = "https://login.tailscale.com/a/abc123" + # But no IP received (user didn't authenticate in time) + mock_wait_ip.return_value = None + + config = create_mock_config() + result = setup_tailscale("dropkit.test", "testuser", config) + + assert result is None + + @patch("dropkit.main.check_local_tailscale") + @patch("dropkit.main.add_ssh_host") + @patch("dropkit.main.wait_for_tailscale_ip") + @patch("dropkit.main.run_tailscale_up") + def test_skips_lockdown_when_disabled( + self, + mock_tailscale_up, + mock_wait_ip, + mock_add_ssh, + mock_check_local, + ): + """Test firewall lockdown is skipped when disabled in config.""" + mock_tailscale_up.return_value = None + mock_wait_ip.return_value = "100.64.1.1" + + config = create_mock_config(lock_down_firewall=False) + result = setup_tailscale("dropkit.test", "testuser", config) + + assert result == "100.64.1.1" + mock_check_local.assert_not_called() # Lockdown logic not entered + + +class TestTailscaleLogout: + """Tests for tailscale_logout function.""" + + @patch("dropkit.main.subprocess.run") + def test_logout_success(self, mock_run): + """Test successful Tailscale logout.""" + mock_run.return_value = MagicMock(returncode=0, stderr=b"") + result = tailscale_logout("dropkit.test") + assert result is True + # Verify correct SSH command was called + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert "ssh" in call_args + assert "dropkit.test" in call_args + assert "sudo tailscale logout" in call_args + + @patch("dropkit.main.subprocess.run") + def test_logout_command_fails(self, mock_run): + """Test when tailscale logout command fails.""" + mock_run.return_value = MagicMock(returncode=1, stderr=b"logout failed") + result = tailscale_logout("dropkit.test") + assert result is False + + @patch("dropkit.main.subprocess.run") + def test_ssh_connection_fails(self, mock_run): + """Test when SSH connection fails.""" + import subprocess + + mock_run.side_effect = subprocess.SubprocessError("Connection refused") + result = tailscale_logout("dropkit.test") + assert result is False + + @patch("dropkit.main.subprocess.run") + def test_ssh_timeout(self, mock_run): + """Test when SSH connection times out.""" + import subprocess + + mock_run.side_effect = subprocess.TimeoutExpired("ssh", 30) + result = tailscale_logout("dropkit.test") + assert result is False diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..09a3e91 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,313 @@ +"""Tests for version_check module.""" + +import json +import subprocess +import time +from unittest.mock import Mock, patch + +from dropkit.version_check import ( + commits_differ, + extract_commit_from_version, + get_last_check_file, + get_latest_git_commit, + should_check_version, + update_last_check_time, +) + + +class TestGetLastCheckFile: + """Tests for get_last_check_file function.""" + + def test_returns_correct_path(self, tmp_path, monkeypatch): + """Test that get_last_check_file returns correct path.""" + # Mock Config.get_config_dir to return tmp_path + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + result = get_last_check_file() + + assert result == tmp_path / ".last_version_check" + assert result.name == ".last_version_check" + + +class TestShouldCheckVersion: + """Tests for should_check_version function.""" + + def test_no_file_returns_true(self, tmp_path, monkeypatch): + """Test returns True when check file doesn't exist.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + result = should_check_version() + + assert result is True + + def test_expired_check_returns_true(self, tmp_path, monkeypatch): + """Test returns True when more than 24 hours have passed.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + # Create check file with old timestamp (25 hours ago) + check_file = tmp_path / ".last_version_check" + old_timestamp = time.time() - (25 * 3600) # 25 hours ago + check_file.write_text(json.dumps({"timestamp": old_timestamp})) + + result = should_check_version() + + assert result is True + + def test_not_expired_returns_false(self, tmp_path, monkeypatch): + """Test returns False when less than 24 hours have passed.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + # Create check file with recent timestamp (1 hour ago) + check_file = tmp_path / ".last_version_check" + recent_timestamp = time.time() - 3600 # 1 hour ago + check_file.write_text(json.dumps({"timestamp": recent_timestamp})) + + result = should_check_version() + + assert result is False + + def test_corrupted_file_returns_true(self, tmp_path, monkeypatch): + """Test returns True when file contains invalid JSON.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + # Create check file with invalid JSON + check_file = tmp_path / ".last_version_check" + check_file.write_text("not valid json {{{") + + result = should_check_version() + + assert result is True + + def test_missing_timestamp_returns_true(self, tmp_path, monkeypatch): + """Test returns True when file missing timestamp key.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + # Create check file without timestamp + check_file = tmp_path / ".last_version_check" + check_file.write_text(json.dumps({"other_key": "value"})) + + result = should_check_version() + + # Should return True because timestamp defaults to 0, making it very old + assert result is True + + +class TestUpdateLastCheckTime: + """Tests for update_last_check_time function.""" + + def test_creates_file_with_correct_structure(self, tmp_path, monkeypatch): + """Test creates file with timestamp and current_version.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + before_time = time.time() + update_last_check_time() + after_time = time.time() + + check_file = tmp_path / ".last_version_check" + assert check_file.exists() + + data = json.loads(check_file.read_text()) + assert "timestamp" in data + assert "current_version" in data + + # Verify timestamp is recent (within the test execution window) + assert before_time <= data["timestamp"] <= after_time + + def test_creates_parent_directory(self, tmp_path, monkeypatch): + """Test creates parent directory if it doesn't exist.""" + from dropkit.config import Config + + nested_path = tmp_path / "nested" / "config" + monkeypatch.setattr(Config, "get_config_dir", lambda: nested_path) + + update_last_check_time() + + check_file = nested_path / ".last_version_check" + assert check_file.exists() + assert check_file.parent == nested_path + + def test_silent_failure_on_permission_error(self, tmp_path, monkeypatch): + """Test silently fails if can't write file.""" + from dropkit.config import Config + + monkeypatch.setattr(Config, "get_config_dir", lambda: tmp_path) + + # Mock open to raise OSError + with patch("builtins.open", side_effect=OSError("Permission denied")): + # Should not raise exception + update_last_check_time() + + +class TestExtractCommitFromVersion: + """Tests for extract_commit_from_version function.""" + + def test_format_git_dot(self): + """Test extraction from '0.1.0+git.abc1234' format.""" + result = extract_commit_from_version("0.1.0+git.abc1234") + assert result == "abc1234" + + def test_format_plus_only(self): + """Test extraction from '0.1.0+abc1234' format.""" + result = extract_commit_from_version("0.1.0+abc1234") + assert result == "abc1234" + + def test_truncates_long_hash(self): + """Test truncates hash to 7 characters.""" + result = extract_commit_from_version("0.1.0+git.abc1234567890") + assert result == "abc1234" + assert len(result) == 7 + + def test_dev_version_returns_none(self): + """Test returns None for 'dev' version.""" + result = extract_commit_from_version("dev") + assert result is None + + def test_no_commit_returns_none(self): + """Test returns None for version without commit.""" + result = extract_commit_from_version("0.1.0") + assert result is None + + def test_empty_string_returns_none(self): + """Test returns None for empty string.""" + result = extract_commit_from_version("") + assert result is None + + def test_format_with_dots_in_suffix(self): + """Test extraction when suffix contains dots.""" + result = extract_commit_from_version("0.1.0+dev.123.abc1234") + # Should extract the last part after splitting by '.' + assert result == "abc1234" + + +class TestCommitsDiffer: + """Tests for commits_differ function.""" + + def test_same_commit_returns_false(self): + """Test returns False when commits match.""" + result = commits_differ("0.1.0+git.abc1234", "abc1234") + assert result is False + + def test_different_commits_returns_true(self): + """Test returns True when commits differ.""" + result = commits_differ("0.1.0+git.abc1234", "xyz9876") + assert result is True + + def test_no_current_commit_returns_false(self): + """Test returns False when current version has no commit.""" + result = commits_differ("0.1.0", "abc1234") + assert result is False + + def test_case_insensitive_comparison(self): + """Test comparison is case-insensitive.""" + result1 = commits_differ("0.1.0+git.ABC1234", "abc1234") + result2 = commits_differ("0.1.0+git.abc1234", "ABC1234") + + assert result1 is False + assert result2 is False + + def test_dev_version_returns_false(self): + """Test returns False for dev version.""" + result = commits_differ("dev", "abc1234") + assert result is False + + +class TestGetLatestGitCommit: + """Tests for get_latest_git_commit function.""" + + @patch("subprocess.run") + def test_success_returns_short_hash(self, mock_run): + """Test successful fetch returns 7-char hash.""" + # Mock successful git ls-remote + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "abc1234567890abcdef1234567890abcdef1234\tHEAD\n" + mock_run.return_value = mock_result + + result = get_latest_git_commit() + + assert result == "abc1234" + assert len(result) == 7 + + @patch("subprocess.run") + def test_nonzero_returncode_returns_none(self, mock_run): + """Test non-zero return code returns None.""" + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_run.return_value = mock_result + + result = get_latest_git_commit() + + assert result is None + + @patch("subprocess.run") + def test_empty_output_returns_none(self, mock_run): + """Test empty stdout returns None.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_run.return_value = mock_result + + result = get_latest_git_commit() + + assert result is None + + @patch("subprocess.run") + def test_timeout_returns_none(self, mock_run): + """Test timeout returns None.""" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) + + result = get_latest_git_commit() + + assert result is None + + @patch("subprocess.run") + def test_subprocess_error_returns_none(self, mock_run): + """Test subprocess error returns None.""" + mock_run.side_effect = subprocess.SubprocessError("Command failed") + + result = get_latest_git_commit() + + assert result is None + + @patch("subprocess.run") + def test_os_error_returns_none(self, mock_run): + """Test OS error returns None.""" + mock_run.side_effect = OSError("Git not found") + + result = get_latest_git_commit() + + assert result is None + + @patch("subprocess.run") + def test_calls_git_with_correct_args(self, mock_run): + """Test calls git ls-remote with correct arguments.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "abc1234567890\tHEAD\n" + mock_run.return_value = mock_result + + get_latest_git_commit() + + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args == [ + "git", + "ls-remote", + "https://github.com/trailofbits/dropkit.git", + "HEAD", + ] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3ef1a26 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1166 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[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.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" }, + { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" }, + { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" }, + { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" }, + { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, + { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, + { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, + { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, + { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, + { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, + { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, + { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, + { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/ed/54ecfa25fc145c58bf4f98090f7b6ffe5188d0759248c57dde44427ea239/cyclonedx_python_lib-11.6.0.tar.gz", hash = "sha256:7fb85a4371fa3a203e5be577ac22b7e9a7157f8b0058b7448731474d6dea7bf0", size = 1408147, upload-time = "2025-12-02T12:28:46.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dropkit" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typer" }, +] + +[package.dev-dependencies] +audit = [ + { name = "pip-audit" }, +] +dev = [ + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=46.0.3" }, + { name = "jinja2", specifier = ">=3.1.0" }, + { name = "pydantic", specifier = ">=2.12.3" }, + { name = "pyyaml", specifier = ">=6.0.1" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "shellingham", specifier = ">=1.5.0" }, + { name = "typer", specifier = ">=0.9.0" }, +] + +[package.metadata.requires-dev] +audit = [{ name = "pip-audit" }] +dev = [ + { name = "pip-audit" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-cov" }, + { name = "ruff", specifier = ">=0.8.0" }, + { name = "ty" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "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 = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pip" +version = "25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[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 = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/4c/f6cbfa1e8efacd00b846764e8484fe173d25b8dab881e277a619177f3384/pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80", size = 2109062, upload-time = "2025-10-14T10:20:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/40b72d3868896bfcd410e1bd7e516e762d326201c48e5b4a06446f6cf9e8/pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae", size = 1916301, upload-time = "2025-10-14T10:20:06.857Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/d203dce8bee7faeca791671c88519969d98d3b4e8f225da5b96dad226fc8/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827", size = 1968728, upload-time = "2025-10-14T10:20:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/6a66187775df87c24d526985b3a5d78d861580ca466fbd9d4d0e792fcf6c/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f", size = 2050238, upload-time = "2025-10-14T10:20:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/78336345de97298cf53236b2f271912ce11f32c1e59de25a374ce12f9cce/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def", size = 2249424, upload-time = "2025-10-14T10:20:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/99/bb/a4584888b70ee594c3d374a71af5075a68654d6c780369df269118af7402/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2", size = 2366047, upload-time = "2025-10-14T10:20:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/17fc5de9d6418e4d2ae8c675f905cdafdc59d3bf3bf9c946b7ab796a992a/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8", size = 2071163, upload-time = "2025-10-14T10:20:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/54/e7/03d2c5c0b8ed37a4617430db68ec5e7dbba66358b629cd69e11b4d564367/pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265", size = 2190585, upload-time = "2025-10-14T10:20:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/be/fc/15d1c9fe5ad9266a5897d9b932b7f53d7e5cfc800573917a2c5d6eea56ec/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c", size = 2150109, upload-time = "2025-10-14T10:20:19.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/e735dd008808226c83ba56972566138665b71477ad580fa5a21f0851df48/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a", size = 2315078, upload-time = "2025-10-14T10:20:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/806efdcf35ff2ac0f938362350cd9827b8afb116cc814b6b75cf23738c7c/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e", size = 2318737, upload-time = "2025-10-14T10:20:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/6ac90673fe6cb36621a2283552897838c020db343fa86e513d3f563b196f/pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03", size = 1974160, upload-time = "2025-10-14T10:20:23.817Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7c5e24ee585c1f8b6356e1d11d40ab807ffde44d2db3b7dfd6d20b09720e/pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e", size = 2021883, upload-time = "2025-10-14T10:20:25.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/5c172357460fc28b2871eb4a0fb3843b136b429c6fa827e4b588877bf115/pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db", size = 1968026, upload-time = "2025-10-14T10:20:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/b0/12/5ba58daa7f453454464f92b3ca7b9d7c657d8641c48e370c3ebc9a82dd78/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b", size = 2122139, upload-time = "2025-10-14T10:22:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/21/fb/6860126a77725c3108baecd10fd3d75fec25191d6381b6eb2ac660228eac/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42", size = 1936674, upload-time = "2025-10-14T10:22:49.555Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/57dcaa3ed595d81f8757e2b44a38240ac5d37628bce25fb20d02c7018776/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee", size = 1956398, upload-time = "2025-10-14T10:22:52.19Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1d/679a344fadb9695f1a6a294d739fbd21d71fa023286daeea8c0ed49e7c2b/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c", size = 2138674, upload-time = "2025-10-14T10:22:54.499Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7d/138e902ed6399b866f7cfe4435d22445e16fff888a1c00560d9dc79a780f/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5", size = 2104721, upload-time = "2025-10-14T10:23:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/0525623cf94627f7b53b4c2034c81edc8491cbfc7c28d5447fa318791479/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2", size = 1931608, upload-time = "2025-10-14T10:23:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/744bc98137d6ef0a233f808bfc9b18cf94624bf30836a18d3b05d08bf418/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd", size = 2132986, upload-time = "2025-10-14T10:23:32.057Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/629e88920171173f6049386cc71f893dff03209a9ef32b4d2f7e7c264bcf/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c", size = 2187516, upload-time = "2025-10-14T10:23:34.871Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/4f2734688d98488782218ca61bcc118329bf5de05bb7fe3adc7dd79b0b86/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405", size = 2146146, upload-time = "2025-10-14T10:23:37.342Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f2/ab385dbd94a052c62224b99cf99002eee99dbec40e10006c78575aead256/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8", size = 2311296, upload-time = "2025-10-14T10:23:40.145Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/e4f12afe1beeb9823bba5375f8f258df0cc61b056b0195fb1cf9f62a1a58/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308", size = 2315386, upload-time = "2025-10-14T10:23:42.624Z" }, + { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +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/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +] + +[[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 = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "ty" +version = "0.0.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/57/22c3d6bf95c2229120c49ffc2f0da8d9e8823755a1c3194da56e51f1cc31/ty-0.0.14.tar.gz", hash = "sha256:a691010565f59dd7f15cf324cdcd1d9065e010c77a04f887e1ea070ba34a7de2", size = 5036573, upload-time = "2026-01-27T00:57:31.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/cb/cc6d1d8de59beb17a41f9a614585f884ec2d95450306c173b3b7cc090d2e/ty-0.0.14-py3-none-linux_armv6l.whl", hash = "sha256:32cf2a7596e693094621d3ae568d7ee16707dce28c34d1762947874060fdddaa", size = 10034228, upload-time = "2026-01-27T00:57:53.133Z" }, + { url = "https://files.pythonhosted.org/packages/f3/96/dd42816a2075a8f31542296ae687483a8d047f86a6538dfba573223eaf9a/ty-0.0.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f971bf9805f49ce8c0968ad53e29624d80b970b9eb597b7cbaba25d8a18ce9a2", size = 9939162, upload-time = "2026-01-27T00:57:43.857Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b4/73c4859004e0f0a9eead9ecb67021438b2e8e5fdd8d03e7f5aca77623992/ty-0.0.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:45448b9e4806423523268bc15e9208c4f3f2ead7c344f615549d2e2354d6e924", size = 9418661, upload-time = "2026-01-27T00:58:03.411Z" }, + { url = "https://files.pythonhosted.org/packages/58/35/839c4551b94613db4afa20ee555dd4f33bfa7352d5da74c5fa416ffa0fd2/ty-0.0.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee94a9b747ff40114085206bdb3205a631ef19a4d3fb89e302a88754cbbae54c", size = 9837872, upload-time = "2026-01-27T00:57:23.718Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/bbecf7e2faa20c04bebd35fc478668953ca50ee5847ce23e08acf20ea119/ty-0.0.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6756715a3c33182e9ab8ffca2bb314d3c99b9c410b171736e145773ee0ae41c3", size = 9848819, upload-time = "2026-01-27T00:57:58.501Z" }, + { url = "https://files.pythonhosted.org/packages/be/60/3c0ba0f19c0f647ad9d2b5b5ac68c0f0b4dc899001bd53b3a7537fb247a2/ty-0.0.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89d0038a2f698ba8b6fec5cf216a4e44e2f95e4a5095a8c0f57fe549f87087c2", size = 10324371, upload-time = "2026-01-27T00:57:29.291Z" }, + { url = "https://files.pythonhosted.org/packages/24/32/99d0a0b37d0397b0a989ffc2682493286aa3bc252b24004a6714368c2c3d/ty-0.0.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c64a83a2d669b77f50a4957039ca1450626fb474619f18f6f8a3eb885bf7544", size = 10865898, upload-time = "2026-01-27T00:57:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/1a/88/30b583a9e0311bb474269cfa91db53350557ebec09002bfc3fb3fc364e8c/ty-0.0.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:242488bfb547ef080199f6fd81369ab9cb638a778bb161511d091ffd49c12129", size = 10555777, upload-time = "2026-01-27T00:58:05.853Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a2/cb53fb6325dcf3d40f2b1d0457a25d55bfbae633c8e337bde8ec01a190eb/ty-0.0.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4790c3866f6c83a4f424fc7d09ebdb225c1f1131647ba8bdc6fcdc28f09ed0ff", size = 10412913, upload-time = "2026-01-27T00:57:38.834Z" }, + { url = "https://files.pythonhosted.org/packages/42/8f/f2f5202d725ed1e6a4e5ffaa32b190a1fe70c0b1a2503d38515da4130b4c/ty-0.0.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:950f320437f96d4ea9a2332bbfb5b68f1c1acd269ebfa4c09b6970cc1565bd9d", size = 9837608, upload-time = "2026-01-27T00:57:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/59a2a0521640c489dafa2c546ae1f8465f92956fede18660653cce73b4c5/ty-0.0.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a0ec3ee70d83887f86925bbc1c56f4628bd58a0f47f6f32ddfe04e1f05466df", size = 9884324, upload-time = "2026-01-27T00:57:46.786Z" }, + { url = "https://files.pythonhosted.org/packages/03/95/8d2a49880f47b638743212f011088552ecc454dd7a665ddcbdabea25772a/ty-0.0.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1a4e6b6da0c58b34415955279eff754d6206b35af56a18bb70eb519d8d139ef", size = 10033537, upload-time = "2026-01-27T00:58:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/4523b36f2ce69f92ccf783855a9e0ebbbd0f0bb5cdce6211ee1737159ed3/ty-0.0.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dc04384e874c5de4c5d743369c277c8aa73d1edea3c7fc646b2064b637db4db3", size = 10495910, upload-time = "2026-01-27T00:57:26.691Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/655beb51224d1bfd4f9ddc0bb209659bfe71ff141bcf05c418ab670698f0/ty-0.0.14-py3-none-win32.whl", hash = "sha256:b20e22cf54c66b3e37e87377635da412d9a552c9bf4ad9fc449fed8b2e19dad2", size = 9507626, upload-time = "2026-01-27T00:57:41.43Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/c569c9961760e20e0a4bc008eeb1415754564304fd53997a371b7cf3f864/ty-0.0.14-py3-none-win_amd64.whl", hash = "sha256:e312ff9475522d1a33186657fe74d1ec98e4a13e016d66f5758a452c90ff6409", size = 10437980, upload-time = "2026-01-27T00:57:36.422Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/186829654f5bfd9a028f6648e9caeb11271960a61de97484627d24443f91/ty-0.0.14-py3-none-win_arm64.whl", hash = "sha256:b6facdbe9b740cb2c15293a1d178e22ffc600653646452632541d01c36d5e378", size = 9885831, upload-time = "2026-01-27T00:57:49.747Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]