diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
deleted file mode 100644
index 867f622..0000000
--- a/.claude/CLAUDE.md
+++ /dev/null
@@ -1,275 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Repository Overview
-
-Personal dotfiles repository using Ansible for automated system configuration on macOS and Debian Linux systems.
-Manages dotfiles, applications, system settings, and home infrastructure (NAS with media server stack).
-
-## Architecture
-
-### Dual Bootstrap System
-
-Two separate bootstrap workflows targeting different environments:
-
-- **`local_bootstrap.sh`** → `playbooks/local_bootstrap.yml`: Personal Mac configuration
-- **`nas_bootstrap.sh`** → `playbooks/nas_bootstrap.yml`: Home NAS/server configuration
-
-Both scripts:
-1. Validate repository structure
-2. Install Homebrew with checksum verification/
-3. Install Ansible via Homebrew
-4. Execute corresponding Ansible playbook
-
-### Inventory Management
-
-- **Primary inventory**: `inventory/` (standalone git clone, managed by run.py)
-- **Repository**: Cloned from `git@github.com:connormason/dotfiles-inventory.git`
-- **Contains**: Host definitions, encrypted vault files for secrets
-- **Setup command**: `python3 run.py update-inventory` (clones if missing, pulls if exists)
-- **Recovery**: `python3 run.py update-inventory --force` (removes and re-clones if corrupted)
-- **Vault password**: Stored in `vault_password.txt` (gitignored, must be created manually)
-
-**Note**: The inventory is NOT a git submodule. It is a separate git repository cloned into the `inventory/` directory by `run.py`. This simplifies the mental model for personal dotfiles while maintaining version control. The directory is excluded from the main repository via `.gitignore`.
-
-Inventory structure:
-```
-inventory/
-├── inventory.yml # Host definitions
-├── group_vars/
-│ ├── all/vault.yml # Shared secrets
-│ └── localhost/
-│ ├── vars.yml # Mac-specific variables
-│ └── vault.yml # Mac-specific secrets
-└── host_vars/
- └── nas/
- ├── vars.yml # NAS-specific variables
- └── vault.yml # NAS-specific secrets
-```
-
-### Role System
-
-Ansible roles organized by target system:
-
-**macOS roles**:
-- `macos`: Homebrew packages, cask apps, Mac App Store apps
-- `macos_settings`: System preferences (Finder, Dock, Activity Monitor, etc.)
-- `macos_dock`: Dock configuration
-- `hammerspoon`: Window management automation
-- `iterm`: iTerm2 configuration
-- `python`: Python tooling (pipx, uv, hatch)
-- `starship`: Shell prompt configuration
-
-**Linux roles**:
-- `debian`: System packages and configuration
-- `docker`: Docker setup with compose stack (Plex, Sonarr, Radarr, PiHole, Home Assistant, etc.)
-- `zfs`: ZFS filesystem configuration
-- `samba`: File sharing setup
-
-**Shared roles**:
-- `git`: Git configuration and gh CLI
-- `ssh`: SSH client configuration
-- `zsh`: Shell configuration
-- `link_dotfile`: Reusable role for symlinking dotfiles
-
-### link_dotfile Role Pattern
-
-Reusable role for safely creating dotfile symlinks:
-- Validates source exists
-- Creates parent directories
-- Backs up existing non-symlink files (with timestamp)
-- Only updates if symlink missing or pointing to wrong target
-
-Usage in playbooks:
-```yaml
-- include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_dir }}/path/to/source"
- link_dotfile_dst: "{{ home_dir }}/.config/destination"
-```
-
-### Python Management Script
-
-`run.py` provides CLI interface to repository operations:
-
-**Command categories**:
-- **Inventory**: `list-hosts`, `update-inventory`
-- **Tool Installation**: `install-uv`, `install-hatch`
-- **Codebase**: `clean`, `pre`, `makefile`
-
-**Key features**:
-- Type-annotated with full typing support
-- ANSI styling via custom `style()` function
-- Command registration via `@command` decorator
-- Auto-generates Makefile from registered commands
-- Retry logic with exponential backoff for network operations
-
-**Environment variables**:
-- `DOTFILES_RUN_DEBUG`: Enable debug output
-- `DOTFILES_INVENTORY_REPO_URL`: Override inventory repo URL
-
-## Development Commands
-
-### Running Ansible Playbooks
-
-```bash
-# Bootstrap local Mac (interactive, asks for sudo password)
-chmod u+x local_bootstrap.sh && ./local_bootstrap.sh
-
-# Bootstrap NAS server
-chmod u+x nas_bootstrap.sh && ./nas_bootstrap.sh
-
-# Run specific tags only
-ansible-playbook playbooks/local_bootstrap.yml -i inventory/inventory.yml --ask-become-pass -vv --tags git,zsh
-
-# Run with extra variables
-ansible-playbook playbooks/local_bootstrap.yml -i inventory/inventory.yml --ask-become-pass -e "some_var=value"
-```
-
-### Python Script Commands
-
-```bash
-# List available inventory hosts
-python3 run.py list-hosts
-
-# Update inventory from remote repository
-python3 run.py update-inventory
-
-# Clean build artifacts and caches
-python3 run.py clean
-
-# Run pre-commit hooks on all files
-python3 run.py pre
-
-# Install Python tooling
-python3 run.py install-uv
-python3 run.py install-hatch
-
-# Generate Makefile from run.py commands
-python3 run.py makefile
-```
-
-### Using Make (auto-generated)
-
-```bash
-# Show available targets
-make help
-
-# All python3 run.py commands available as make targets
-make update-inventory
-make clean
-make pre
-```
-
-## Testing and Validation
-
-### Pre-commit Hooks
-
-Configured in `.pre-commit-config.yaml`:
-
-- **File integrity**: Large files, merge conflicts, private keys, symlinks
-- **Python**: AST validation, debug statements, ruff linting, mypy type checking, interrogate docstring coverage
-- **Data formats**: JSON, YAML, TOML, XML validation
-- **YAML**: yamllint with custom config (`.yamllint.yaml`)
-- **Fixers**: Whitespace, line endings, UTF-8 BOM
-
-Run hooks:
-```bash
-# All files
-pre-commit run --all-files
-
-# Or via script
-python3 run.py pre
-```
-
-### Ansible Linting
-
-Commented out in pre-commit config but available:
-- `ansible-lint` with `.ansible-lint.yaml` configuration
-- `shellcheck` for shell script validation
-
-## Key Configuration Files
-
-- **`.pre-commit-config.yaml`**: Code quality hooks
-- **`.yamllint.yaml`**: YAML linting rules
-- **`.ansible-lint.yaml`**: Ansible best practices
-- **`vault_password.txt`**: Ansible Vault password (gitignored, create manually)
-- **`roles/requirements.yml`**: External Ansible role dependencies
-
-## Security Considerations
-
-### Vault Management
-
-- All secrets stored in Ansible Vault encrypted files
-- Vault password required in `vault_password.txt` for playbook execution
-- Never commit vault password or decrypted secrets
-
-### Bootstrap Script Security
-
-- Homebrew installer checksum verified before execution (local_bootstrap.sh:41)
-- Checksum expected value: `b2ffbf7e7f451c6db3b5d1976fc6a9c2faecf58ee5e1dbf6e498643c91f0d3bc`
-- Update checksum when Homebrew installer changes:
-`curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | shasum -a 256`
-
-## Docker Stack (NAS)
-
-Media server and home automation stack in `roles/docker/files/docker-compose.yml`:
-
-**Services**:
-- **Media**: Plex, Jellyfin, Radarr (movies), Sonarr (TV), Transmission (torrents), Prowlarr (indexer), Flaresolverr
-- **Network**: PiHole (DNS/ad-blocking)
-- **Automation**: Home Assistant, Glance dashboard
-- **Support**: Autoplex (automated file organization)
-
-**Storage paths**: `/storage/media/*` mounted into containers
-
-## macOS-Specific
-
-### Package Management Layers
-
-1. **Homebrew formulae** (`brew_packages` in `roles/macos/defaults/main.yml`): CLI tools
-2. **Homebrew casks** (`brew_cask_packages`): GUI applications
-3. **Mac App Store** (`mas_apps`): App Store apps via `mas` CLI
-
-### System Settings
-
-The `macos_settings` role configures system preferences via `defaults write` commands:
-- Finder behavior and appearance
-- Dock size/position/behavior
-- Activity Monitor preferences
-- Messages app settings
-- Power management
-- I/O devices (keyboard, trackpad)
-
-Applied via separate task files in `roles/macos_settings/tasks/`.
-
-## Common Patterns
-
-### Adding a New Dotfile
-
-1. Place source file in appropriate `roles/*/files/` directory
-2. Use `link_dotfile` role in playbook:
-```yaml
-- include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_dir }}/roles/myapp/files/config"
- link_dotfile_dst: "{{ home_dir }}/.config/myapp/config"
-```
-
-### Adding a New Ansible Role
-
-1. Create role directory: `roles/new-role/`
-2. Add `tasks/main.yml` with role logic
-3. Add `defaults/main.yml` for default variables (optional)
-4. Include role in appropriate playbook (`local_bootstrap.yml` or `nas_bootstrap.yml`)
-5. Add role tags for selective execution
-
-### Adding macOS Applications
-
-Edit `roles/macos/defaults/main.yml`:
-- CLI tools → `brew_packages`
-- GUI apps → `brew_cask_packages`
-- App Store apps → `mas_apps` (requires app ID from App Store)
diff --git a/.claude/agents/ansible-idempotency-reviewer.md b/.claude/agents/ansible-idempotency-reviewer.md
new file mode 100644
index 0000000..01aab7d
--- /dev/null
+++ b/.claude/agents/ansible-idempotency-reviewer.md
@@ -0,0 +1,74 @@
+---
+name: ansible-idempotency-reviewer
+description: Use to review changes to this dotfiles repo's Ansible roles (`roles/*/tasks/*.yml`), playbooks (`playbooks/*.yml`), and custom modules (`library/*.py`) for idempotency, re-runnability, and provisioning safety. Dispatch after editing or adding role tasks, before committing provisioning changes, or when a playbook reports `changed` on a converged host or misbehaves on a re-run. It audits change detection, module vs shell usage, guard conditions, FQCN/convention conformance, and secret handling — then reports concrete, located findings.\n\n\nContext: The user just added tasks to an existing role.\nuser: "I added a task to roles/docker/tasks/main.yml to pull the compose images — does it look right?"\nassistant: "I'll dispatch the ansible-idempotency-reviewer agent to audit the new tasks for idempotency and the repo's role conventions."\n\nNew role tasks must be idempotent and follow the repo's FQCN/tag/link_dotfile conventions — exactly this agent's remit.\n\n\n\n\nContext: A play reports changed every run.\nuser: "Re-running the local bootstrap always shows the git config task as changed."\nassistant: "Let me use the ansible-idempotency-reviewer agent to find the missing change-detection guard in the git role."\n\nA task that is perpetually `changed` on a converged host is the agent's core failure mode to catch.\n\n
+model: sonnet
+color: yellow
+---
+
+You are a focused, read-leaning reviewer of Ansible provisioning content for a personal dotfiles repository. Your remit
+is the `roles/` tree (each role's `tasks/`, `handlers/`, `defaults/`, `templates/`), the `playbooks/`
+(`local_bootstrap.yml`, `nas_bootstrap.yml`), and custom modules under `library/`. You audit for **idempotency,
+re-runnability, and provisioning safety** — the failure modes that make a converged host report `changed`, or that
+brick/corrupt a real machine setup. You do not rewrite the content; you report precise, located findings so the main
+agent can fix them.
+
+## Repository contract (ground truth — verify against the actual files, do not assume)
+
+- Roles are discovered via `roles_path=./roles:…` (`ansible.cfg`); custom modules via `library=./library:…`.
+- Tasks use **fully-qualified collection names** (`ansible.builtin.file`, `ansible.builtin.template`, …) — the existing
+ roles are consistently FQCN.
+- Symlinking dotfiles goes through the reusable **`link_dotfile`** role
+ (`ansible.builtin.include_role: roles/link_dotfile` with `link_dotfile_src`/`link_dotfile_dst`), which already handles
+ source validation, parent-dir creation, timestamped backup of non-symlinks, and idempotent (re)linking. New symlink
+ logic should reuse it, not hand-roll `file`/`command`.
+- Tasks carry **tags** (e.g. `configfile`, `preferences`) for selective execution; new tasks/blocks should be tagged
+ consistently with their role.
+- `mode:` is typically driven by a role `defaults/` variable (e.g. `git_config_mode`), not a hardcoded literal.
+- Secrets are `ansible-vault`-encrypted in the separate `inventory/` clone; `vault_password_file=vault_password.txt`.
+- Both playbooks run with `--ask-become-pass`; `become` is scoped per-task/block, not assumed global.
+
+## What to check, in priority order
+
+1. **Idempotency / change detection.** Every task must be a no-op on a converged host. Flag:
+ `ansible.builtin.command`/`shell` without `creates`/`removes`/`changed_when`/a guarding `when` (raw `command`/`shell`
+ defaults to always-`changed`); `lineinfile`/`blockinfile` preferred over `command: … >> file`; `register` +
+ `changed_when`/`failed_when` where a command's own rc is not a faithful change signal; `get_url`/`unarchive` with a
+ `dest`/`creates` guard. A hand-rolled `command: mv …` is acceptable only when correctly gated (e.g. `when:` +
+ explicit `changed_when`), as `link_dotfile` does for backups.
+2. **Module over shell.** Prefer a real module to `command`/`shell` whenever one exists (`file`, `copy`, `template`,
+ `git`, `homebrew`, `homebrew_cask`, `apt`, `pip`, `systemd`, `lineinfile`). Flag shell-outs that reinvent a module.
+3. **Convention conformance.** FQCN module names; role-appropriate `tags`; `mode` sourced from a `defaults/` var rather
+ than hardcoded; symlinks routed through `link_dotfile`; handlers + `notify` used for service restarts rather than an
+ inline restart task.
+4. **Provisioning safety.** No destructive task (`file: state=absent`, `command: rm/mv`) without a guarding `when:`; no
+ plaintext secret that belongs in vault; `become` present where privilege is required and absent where it is not;
+ `check_mode`/`--check` friendliness (no un-guarded side effects in `command`).
+5. **Correctness under re-run.** `register`ed facts referenced with `| default(...)` where the task may be skipped;
+ `when:` conditions that reference stat results guard on `.stat.exists`.
+6. **README drift.** `roles/README.md` (and per-role docs) describe the role set; a newly added or renamed role should
+ have a corresponding entry. Flag the mismatch.
+
+## Method
+
+1. Identify the changed/target files (ask or use the provided diff; otherwise inspect `roles/`/`playbooks/`).
+2. Skim `roles/link_dotfile/tasks/main.yml` and the target role's `defaults/main.yml` to confirm available variables and
+ the reuse pattern, then read each target `tasks` file in full.
+3. For each task, trace: is it idempotent (no-op on re-run)? does change detection reflect reality? is a module used
+ where one exists? are FQCN/tags/mode conventions followed? Confirm suspicions by reading the relevant lines — do not
+ guess.
+4. Cross-check `roles/README.md` when roles were added or renamed.
+
+## Output format
+
+Lead with a one-line verdict (idempotent & safe / issues found). Then list findings, most severe first:
+
+```
+
+
+- roles/docker/tasks/main.yml:24 — [idempotency] `command: docker compose pull` has no `changed_when`; reports changed every run. Add `changed_when: false` (read-ish) or gate on image digest.
+- roles/git/tasks/main.yml:31 — [convention] symlink hand-rolled with `file: state=link`; reuse the `link_dotfile` role instead.
+- roles/README.md — [drift] no entry for the new `tailscale` role.
+```
+
+Each finding: `file:line — [category] problem → concrete fix`. If a task file is clean, say so explicitly and name what
+you verified (change detection, module usage, conventions, safety). Do not pad with praise or restate the whole file.
diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md
new file mode 100644
index 0000000..5e3223a
--- /dev/null
+++ b/.claude/rules/python-style.md
@@ -0,0 +1,45 @@
+---
+globs: '**/*.py'
+---
+
+# Python Style
+
+Self-contained Python style for this repository (does not depend on any global `~/.claude/rules/`). Python here is
+`run.py`, custom Ansible modules under `library/`, and helper scripts under `scripts/`.
+
+## Tooling (non-negotiable)
+
+- Always run Python via **`uv`** (`uv run …`) — never bare `python`, `python3`, `pip`, or `hatch`.
+- `ruff check --fix` is encouraged. **Never** run `ruff format` — this repo does not use the formatter.
+- Config lives in `pyproject.toml`: ruff `line-length = 120`, `target-version = "py39"` (`keep-runtime-typing = true`),
+ space indent (width 4). `interrogate` enforces **70%** docstring coverage (`fail-under = 70`).
+
+## Imports
+
+- `from __future__ import annotations` is a **required first import** (isort `required-imports`).
+- isort uses **`force-single-line = true`** — one import per line; never `from x import a, b`.
+- Ordering: standard library → third-party → local → `if TYPE_CHECKING:` block.
+- `TC` (flake8-type-checking) is on: move type-only imports into `if TYPE_CHECKING:` blocks. `pydantic.BaseModel`
+ subclasses are runtime-evaluated (exempt).
+
+## Types & naming
+
+- Python 3.9 syntax: `list[str]`, `dict[str, Any]`, `str | None` (via `from __future__ import annotations`).
+- Files/modules snake_case; classes PascalCase; functions/methods snake_case; constants UPPER_SNAKE_CASE.
+- No bare `Any` without justification (`ANN401` is otherwise ignored repo-wide, so use it sparingly and deliberately).
+- No mutable default arguments. Exception chaining required: `raise NewError(...) from e` (or `from None`).
+- Public **modules** define `__all__` after imports; scripts executed directly (e.g. `run.py`) do not.
+
+## Quotes & docstrings
+
+- **Single quotes** for inline strings (`flake8-quotes` inline-quotes = single).
+- Docstring convention is **sphinx** (`interrogate style = "sphinx"`): colon-terminated section headers (`Examples:`,
+ `See Also:`) and Sphinx field tags (`:param:`, `:raises:`, `:return:`). There is **no** enforced summary-line period
+ and **no** dashed section underlines (that is the numpy convention, which this repo does not use).
+- No blank line between a class docstring and its first member.
+
+## `run.py` patterns
+
+Commands register via the `@command` decorator into a global registry that drives both argparse and Makefile generation;
+subprocess calls go through the typed `shell_command()` wrapper. Match these when adding commands. See
+[`.ctx/CONVENTIONS.md`](../../.ctx/CONVENTIONS.md).
diff --git a/.claude/rules/readme-guidelines.md b/.claude/rules/readme-guidelines.md
new file mode 100644
index 0000000..39d259f
--- /dev/null
+++ b/.claude/rules/readme-guidelines.md
@@ -0,0 +1,34 @@
+---
+globs: '**/README.md'
+---
+
+# README Guidelines
+
+Self-contained conventions for per-directory `README.md` files (does not depend on any global `~/.claude/rules/`).
+
+## Core principles
+
+- **Factual accuracy — never guess.** Document only what the source proves (signatures, `__all__`, docstrings, task
+ names, call sites). Anything the code can't answer becomes an explicit "Clarifications needed" question, never a
+ plausible-sounding guess.
+- **Signal over noise.** Every line must help a reader understand what a module does or how it is structured. Cut filler
+ ("this file is intentionally empty", restating a filename).
+- **Diagrams only when they clarify** a non-trivial architecture or flow — never by default.
+- **Relative links only.** Never hard-code absolute home-directory paths.
+
+## Navigation contract
+
+- Up-breadcrumb at the very top (repo root has none):
+ `[Project Root](../README.md) > [Parent](../README.md) > **This Module**`
+- Down-links: a section listing each immediate child directory that has a README.
+- Every source/module directory carries its own `README.md` — `roles/`, `services/`, `playbooks/`, `scripts/install/`,
+ and `docs/` already follow this.
+
+## Repo specifics
+
+- **Markdown is auto-formatted by `mdformat` via the `prek` hook** (`.pre-commit-config.yaml`, priority 30) at the
+ **120-char** line width. `.ctx/` and `docs/plans/` are excluded, so those stay hand-maintained. Run
+ `prek run mdformat --all-files` (or let the commit hook do it) rather than formatting by hand.
+- Files with 100+ lines and 3+ h2 sections should carry the mdformat-toc anchor block (``
+ / ``) immediately after the H1 description; the hook populates it. Shorter files omit it.
+- Never run `ruff format` on markdown.
diff --git a/.claude/rules/script-style.md b/.claude/rules/script-style.md
new file mode 100644
index 0000000..f1ed9d2
--- /dev/null
+++ b/.claude/rules/script-style.md
@@ -0,0 +1,30 @@
+---
+globs: '**/*.py'
+---
+
+# Script Styling & Console Output
+
+Self-contained conventions for styled terminal output in CLI scripts (does not depend on any global `~/.claude/rules/`).
+
+## Reference implementation
+
+[`run.py`](../../run.py) is the canonical in-repo implementation of the styling contract — match its patterns when
+adding styled output to `run.py` or scripts under [`scripts/`](../../scripts) (`env/`, `install/`). It provides:
+
+| Function | Purpose |
+| ------------ | ------------------------------------------------------------------------- |
+| `style()` | Apply ANSI escape codes to text; returns a styled string (does not print) |
+| `printf()` | Print styled text with indent / debug / verbose support |
+| `folduser()` | Replace `$HOME` with `~` in path strings for display |
+| `unstyle()` | Strip all ANSI escape sequences from a string |
+
+It also uses a `RawTextHelpFormatter`-based argparse setup with a typed `Args(argparse.Namespace)` subclass; follow that
+shape for new CLI options.
+
+## Conventions
+
+- Route verbose/debug output to **stderr** so it never contaminates stdout.
+- Use emoji-prefixed status lines (🟢 available, 🔴 failed, 🟡 warning, ✅ success) with a reinforcing text color; action
+ messages (🔎 analyzing, 🧹 cleaning, 📦 building) use a cyan-ish accent. Keep it consistent with `run.py`.
+- Custom **Ansible modules** live in [`library/`](../../library) (e.g. `configure_network_interfaces.py`,
+ `osx_pmset.py`); they follow the same Python style rules but are executed by Ansible, not via `uv`.
diff --git a/.claude/settings.json b/.claude/settings.json
index 3975e47..a6577d7 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,4 +1,33 @@
{
+ "defaultMode": "acceptEdits",
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "if [[ \"$CLAUDE_TOOL_ARG_FILE_PATH\" == *.py ]]; then uv run ruff check --fix-only \"$CLAUDE_TOOL_ARG_FILE_PATH\" 2>/dev/null; fi; exit 0"
+ },
+ {
+ "type": "command",
+ "command": "f=\"$CLAUDE_TOOL_ARG_FILE_PATH\"; if [[ \"$f\" == *.yml || \"$f\" == *.yaml ]]; then uv run yamllint \"$f\" 2>&1 || true; fi; exit 0"
+ }
+ ]
+ }
+ ],
+ "PreToolUse": [
+ {
+ "matcher": "Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "if echo \"$CLAUDE_TOOL_ARG_FILE_PATH\" | grep -qE '(vault_password\\.txt|/vault_password|inventory/)'; then echo 'BLOCKED: Cannot edit vault password or inventory files — these are sensitive and managed separately' >&2; exit 2; fi"
+ }
+ ]
+ }
+ ]
+ },
"permissions": {
"allow": [
"Bash(ansible-lint:*)",
@@ -7,6 +36,7 @@
"Bash(brew info:*)",
"Bash(cat:*)",
"Bash(cmp:*)",
+ "Bash(cp:*)",
"Bash(echo:*)",
"Bash(find:*)",
"Bash(gh pr diff:*)",
@@ -23,11 +53,13 @@
"Bash(hatch env:*)",
"Bash(hatch run:*)",
"Bash(hatch version:*)",
+ "Bash(head:*)",
"Bash(interrogate:*)",
"Bash(jq:*)",
"Bash(ls:*)",
"Bash(make:*)",
"Bash(mkdir:*)",
+ "Bash(mv:*)",
"Bash(mypy:*)",
"Bash(nox:*)",
"Bash(pip3 install:*)",
@@ -38,7 +70,9 @@
"Bash(ruff:*)",
"Bash(sed:*)",
"Bash(shellcheck:*)",
+ "Bash(tail:*)",
"Bash(test:*)",
+ "Bash(touch:*)",
"Bash(tree:*)",
"Bash(uv sync:*)",
"Bash(uv run:*)",
diff --git a/.claude/skills/ruff-autofix/SKILL.md b/.claude/skills/ruff-autofix/SKILL.md
new file mode 100644
index 0000000..1d161d1
--- /dev/null
+++ b/.claude/skills/ruff-autofix/SKILL.md
@@ -0,0 +1,33 @@
+---
+name: ruff-autofix
+description: Auto-fix ruff lint violations in recently edited Python files. Use after editing Python files to catch and fix lint issues immediately rather than waiting for commit-time pre-commit hooks.
+user-invocable: false
+---
+
+# Ruff Auto-Fix
+
+After editing any Python file (`.py`), run ruff to auto-fix lint violations in-place.
+
+## When to Activate
+
+Trigger automatically after any Edit or Write to a `.py` file in the current project that is not included in
+`.gitignore` rules or excluded by ruff per-file ignores (`tool.ruff.lint.per-file-ignores` in pyproject.toml or
+ruff.toml).
+
+## Procedure
+
+1. Run `uv run ruff check --fix --quiet ` on the edited file
+2. If ruff reports remaining unfixable violations, review them and fix manually
+3. Do NOT run `ruff format` — this codebase does not use ruff's formatter
+
+## Rules
+
+- Only operate on `.py` files
+- Don't run on `.py` files that are not tracked by git (based on `.gitignore` rules)
+- Don't run on `.py` files that are excluded in ruff per-file ignores (`tool.ruff.lint.per-file-ignores` in
+ pyproject.toml or ruff.toml)
+- Always use `uv run` to invoke ruff (never bare `ruff`)
+- Use `--fix` to apply safe fixes automatically
+- Use `--quiet` to suppress noise — only show actual errors
+- If a fix changes the file in a way that breaks the edit's intent, revert the fix and address the lint issue manually
+- Do not fix files outside the current change scope
diff --git a/.ctx/ARCHITECTURE.md b/.ctx/ARCHITECTURE.md
new file mode 100644
index 0000000..57044c8
--- /dev/null
+++ b/.ctx/ARCHITECTURE.md
@@ -0,0 +1,158 @@
+# Architecture
+
+Directory layout, key components, and provisioning flow for the dotfiles-personal repository. Loaded on demand from
+[`CLAUDE.md`](../CLAUDE.md).
+
+## Dual Bootstrap System
+
+Two separate bootstrap workflows targeting different environments:
+
+- **`local_bootstrap.sh`** → `playbooks/local_bootstrap.yml`: Personal Mac configuration
+- **`nas_bootstrap.sh`** → `playbooks/nas_bootstrap.yml`: Home NAS/server configuration
+
+Both scripts:
+
+1. Validate repository structure
+2. Install Homebrew with checksum verification
+3. Install Ansible via Homebrew
+4. Execute corresponding Ansible playbook
+
+## Inventory Management
+
+- **Primary inventory**: `inventory/` (standalone git clone, managed by `run.py`)
+- **Repository**: Cloned from `git@github.com:connormason/dotfiles-inventory.git`
+- **Contains**: Host definitions, encrypted vault files for secrets
+- **Setup command**: `python3 run.py update-inventory` (clones if missing, pulls if exists)
+- **Recovery**: `python3 run.py update-inventory --force` (removes and re-clones if corrupted)
+- **Vault password**: Stored in `vault_password.txt` (gitignored, must be created manually)
+
+**Note**: The inventory is NOT a git submodule. It is a separate git repository cloned into the `inventory/` directory
+by `run.py`. This simplifies the mental model for personal dotfiles while maintaining version control. The directory is
+excluded from the main repository via `.gitignore`.
+
+Inventory structure:
+
+```
+inventory/
+├── inventory.yml # Host definitions
+├── group_vars/
+│ ├── all/vault.yml # Shared secrets
+│ └── localhost/
+│ ├── vars.yml # Mac-specific variables
+│ └── vault.yml # Mac-specific secrets
+└── host_vars/
+ └── nas/
+ ├── vars.yml # NAS-specific variables
+ └── vault.yml # NAS-specific secrets
+```
+
+## Role System
+
+Ansible roles organized by target system:
+
+**macOS roles**:
+
+- `macos`: Homebrew packages, cask apps, Mac App Store apps
+- `macos_settings`: System preferences (Finder, Dock, Activity Monitor, etc.)
+- `macos_dock`: Dock configuration
+- `hammerspoon`: Window management automation
+- `iterm`: iTerm2 configuration
+- `python`: Python tooling (pipx, uv, hatch)
+- `starship`: Shell prompt configuration
+
+**Linux roles**:
+
+- `debian`: System packages and configuration
+- `docker`: Docker setup with compose stack (Plex, Sonarr, Radarr, PiHole, Home Assistant, etc.)
+- `zfs`: ZFS filesystem configuration
+- `samba`: File sharing setup
+
+**Shared roles**:
+
+- `git`: Git configuration and gh CLI
+- `ssh`: SSH client configuration
+- `zsh`: Shell configuration
+- `tailscale`: Tailscale VPN setup
+- `link_dotfile`: Reusable role for symlinking dotfiles
+
+## link_dotfile Role Pattern
+
+Reusable role for safely creating dotfile symlinks:
+
+- Validates source exists
+- Creates parent directories
+- Backs up existing non-symlink files (with timestamp)
+- Only updates if symlink missing or pointing to wrong target
+
+Usage details and the include-role snippet live in [`CONVENTIONS.md`](CONVENTIONS.md) "Adding a New Dotfile".
+
+## Python Management Script (`run.py`)
+
+`run.py` provides a CLI interface to repository operations.
+
+**Command categories**:
+
+- **Inventory**: `list-hosts`, `update-inventory`
+- **Tool Installation**: `install-uv`, `install-hatch`
+- **Codebase**: `clean`, `pre`, `makefile`
+
+**Key features**:
+
+- Type-annotated with full typing support
+- ANSI styling via a custom `style()` function
+- Command registration via the `@command` decorator
+- Auto-generates the `Makefile` from registered commands
+- Retry logic with exponential backoff for network operations
+
+**Environment variables**:
+
+- `DOTFILES_RUN_DEBUG`: Enable debug output
+- `DOTFILES_INVENTORY_REPO_URL`: Override inventory repo URL
+
+## Docker Stack (NAS)
+
+Media server and home automation stack in `roles/docker/files/docker-compose.yml`, with per-service definitions under
+`services/`:
+
+**Services**:
+
+- **Media**: Plex, Jellyfin, Radarr (movies), Sonarr (TV), Transmission (torrents), Prowlarr (indexer), Flaresolverr
+- **Network**: PiHole (DNS/ad-blocking), Caddy (reverse proxy)
+- **Automation**: Home Assistant, Glance dashboard
+- **Support**: Autoplex (automated file organization)
+
+**Storage paths**: `/storage/media/*` mounted into containers
+
+## macOS Package Management Layers
+
+1. **Homebrew formulae** (`brew_packages` in `roles/macos/defaults/main.yml`): CLI tools
+2. **Homebrew casks** (`brew_cask_packages`): GUI applications
+3. **Mac App Store** (`mas_apps`): App Store apps via the `mas` CLI
+
+## macOS System Settings
+
+The `macos_settings` role configures system preferences via `defaults write` commands, applied via separate task files
+in `roles/macos_settings/tasks/`:
+
+- Finder behavior and appearance
+- Dock size/position/behavior
+- Activity Monitor preferences
+- Messages app settings
+- Power management
+- I/O devices (keyboard, trackpad)
+
+## Key Configuration Files
+
+- **`.pre-commit-config.yaml`**: Code quality hooks
+- **`.yamllint.yaml`**: YAML linting rules
+- **`.ansible-lint.yaml`**: Ansible best practices
+- **`ansible.cfg`**: Ansible defaults (`roles_path`, `library`, `vault_password_file`)
+- **`vault_password.txt`**: Ansible Vault password (gitignored, create manually)
+- **`roles/requirements.yml`**: External Ansible role dependencies
+
+## Bootstrap Script Security
+
+- Homebrew installer checksum verified before execution (`local_bootstrap.sh`)
+- Expected checksum: `b2ffbf7e7f451c6db3b5d1976fc6a9c2faecf58ee5e1dbf6e498643c91f0d3bc`
+- Update the checksum when the Homebrew installer changes:
+ `curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | shasum -a 256`
diff --git a/.ctx/BUILD.md b/.ctx/BUILD.md
new file mode 100644
index 0000000..dbf627a
--- /dev/null
+++ b/.ctx/BUILD.md
@@ -0,0 +1,82 @@
+# Build / Test / Run
+
+Full command reference for the dotfiles-personal repository. Loaded on demand from [`CLAUDE.md`](../CLAUDE.md).
+
+## Running Ansible Playbooks
+
+```bash
+# Bootstrap local Mac (interactive, asks for sudo password)
+chmod u+x local_bootstrap.sh && ./local_bootstrap.sh
+
+# Bootstrap NAS server
+chmod u+x nas_bootstrap.sh && ./nas_bootstrap.sh
+
+# Run specific tags only
+ansible-playbook playbooks/local_bootstrap.yml -i inventory/inventory.yml --ask-become-pass -vv --tags git,zsh
+
+# Run with extra variables
+ansible-playbook playbooks/local_bootstrap.yml -i inventory/inventory.yml --ask-become-pass -e "some_var=value"
+```
+
+## Python Script Commands (`run.py`)
+
+```bash
+# List available inventory hosts
+python3 run.py list-hosts
+
+# Update inventory from remote repository
+python3 run.py update-inventory
+
+# Clean build artifacts and caches
+python3 run.py clean
+
+# Run prek (pre-commit) hooks on all files
+python3 run.py pre
+
+# Install Python tooling
+python3 run.py install-uv
+python3 run.py install-hatch
+
+# Generate Makefile from run.py commands
+python3 run.py makefile
+```
+
+## Using Make (auto-generated)
+
+```bash
+# Show available targets
+make help
+
+# All python3 run.py commands are available as make targets
+make update-inventory
+make clean
+make pre
+```
+
+## Git Hooks (prek)
+
+Hooks run via [prek](https://github.com/j178/prek) (a faster pre-commit reimplementation), configured in
+`.pre-commit-config.yaml`. Hooks are ordered by `priority`: dependency syncing (0–6) → read-only checks (10) →
+whitespace fixers (20s) → formatters (30) → linters (40).
+
+- **Dependency sync**: `uv-lock`, `uv-sync`, `uv-export` (requirements.txt), `sync-with-uv`, `sync-pre-commit-deps`
+- **Checks**: filesystem safety, large files, merge conflicts, private keys, `detect-secrets`, submodule ban,
+ shebang/executable, TOML/XML/YAML/JSON syntax, `validate-pyproject`, Python AST/debug/test-naming
+- **Formatters**: JSON (`.claude`), shell (`shfmt`), TOML (`taplo`), markdown (`mdformat`), Python (`ruff --fix-only`)
+- **Linters**: `ruff check`, `mypy`, `interrogate`, `shellcheck`, `yamllint`, `codespell`
+
+```bash
+# All files
+prek run --all-files
+
+# Or via the run.py wrapper
+python3 run.py pre
+
+# Install / update the git hook shims
+python3 run.py install-hooks
+```
+
+## Ansible Linting
+
+`ansible-lint` (with `.ansible-lint.yaml`) is available but commented out in `.pre-commit-config.yaml`; enable when
+ready. `shellcheck` is already enabled as a prek linter (priority 40).
diff --git a/.ctx/CONVENTIONS.md b/.ctx/CONVENTIONS.md
new file mode 100644
index 0000000..c77853a
--- /dev/null
+++ b/.ctx/CONVENTIONS.md
@@ -0,0 +1,46 @@
+# Conventions & Common Patterns
+
+Code style, file organization, and the recurring "how do I add X" patterns for the dotfiles-personal repository. Loaded
+on demand from [`CLAUDE.md`](../CLAUDE.md).
+
+## Code Style
+
+Self-contained style rules live in [`.claude/rules/`](../.claude/rules) (they do not depend on any global
+`~/.claude/rules/`):
+
+- [`python-style.md`](../.claude/rules/python-style.md) — ruff line-length 120, target py39, single quotes,
+ `force-single-line` imports, **sphinx** docstring convention, interrogate 70%. Always run Python via `uv`; never run
+ `ruff format`.
+- [`script-style.md`](../.claude/rules/script-style.md) — `run.py` is the reference implementation of the styling
+ contract.
+- [`readme-guidelines.md`](../.claude/rules/readme-guidelines.md) — per-directory READMEs with breadcrumbs;
+ auto-formatted with mdformat via prek (`.ctx/` and `docs/plans/` excluded).
+
+## Adding a New Dotfile
+
+1. Place the source file in the appropriate `roles/*/files/` directory.
+2. Use the `link_dotfile` role in the playbook:
+
+```yaml
+- include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: "{{ dotfiles_dir }}/roles/myapp/files/config"
+ link_dotfile_dst: "{{ home_dir }}/.config/myapp/config"
+```
+
+## Adding a New Ansible Role
+
+1. Create the role directory: `roles/new-role/`
+2. Add `tasks/main.yml` with the role logic (FQCN modules, tags, idempotent tasks).
+3. Add `defaults/main.yml` for default variables (optional).
+4. Include the role in the appropriate playbook (`local_bootstrap.yml` or `nas_bootstrap.yml`).
+5. Add role tags for selective execution.
+
+## Adding macOS Applications
+
+Edit `roles/macos/defaults/main.yml`:
+
+- CLI tools → `brew_packages`
+- GUI apps → `brew_cask_packages`
+- App Store apps → `mas_apps` (requires the app ID from the App Store)
diff --git a/.editorconfig b/.editorconfig
index 9993f83..3234b6f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,17 +7,26 @@ end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
-trim_trailing_whitespace = true
max_line_length = 120
+trim_trailing_whitespace = true
-[*.{yml,yaml}]
+[*.{layout,theme}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+[*.pkl]
+indent_size = 2
+
+[*.{yml,yaml}]
+indent_size = 2
+
[Makefile]
indent_style = tab
[.claude/settings*.json]
indent_size = 2
+
+[*todo.md]
+indent_size = 2
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..ef95fc1
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @connormason
diff --git a/.gitignore b/.gitignore
index e28636b..b4e7eeb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,9 +19,6 @@
*_LOCAL_*.txt
*_REMOTE_*.txt
-# Allow docker compose base file (*.BASE.* pattern above is for git merge artifacts)
-!services/compose.base.yml
-
### macOS ###
# General
.DS_Store
@@ -390,7 +387,19 @@ tags
# End of https://www.toptal.com/developers/gitignore/api/macos,python,git,ansible,pycharm+all,vim,VisualStudioCode,sublimetext
+!services/compose.base.yml
+!scripts/env/
+
+.reports/
+
+# Inventory private repo
+/inventory/*
+!/inventory/.gitkeep
+
+# Ansible Vault password
vault_password.txt
-inventory/
-!scripts/env/
+# Temp cache data
+.ansible/.lock
+.tmp/
+.claude/settings.local.json
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 0000000..d1526f5
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,21 @@
+# Gitleaks configuration for this repository.
+#
+# Extends the built-in default ruleset (see
+# https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml) and layers on a
+# repo-specific allowlist. Gitleaks and the gitleaks-action pick this file up automatically
+# when it lives at the repository root.
+
+title = "dotfiles gitleaks config"
+
+[extend]
+useDefault = true
+
+[allowlist]
+description = "Repo-specific allowlist"
+# The detect-secrets baseline (managed by the `detect-secrets` prek hook) records only SHA1
+# hashes of previously-audited findings, never plaintext secrets. Gitleaks' generic-api-key /
+# high-entropy rules flag those hashes as false positives, so exclude the file from scanning —
+# matching the `should_exclude_file` filter detect-secrets itself applies to this path.
+paths = [
+ '''\.secrets\.baseline$''',
+]
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
new file mode 100644
index 0000000..c79fe22
--- /dev/null
+++ b/.markdownlint.yaml
@@ -0,0 +1,415 @@
+# Example markdownlint configuration with all properties set to their default value
+
+# Default state for all rules
+default: true
+
+# Path to configuration file to extend
+extends: null
+
+# MD001/heading-increment
+# Heading levels should only increment by one level at a time
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md001.md
+MD001:
+ # RegExp for matching title in front matter
+ front_matter_title: "^\\s*title\\s*[:=]"
+
+# MD003/heading-style
+# Heading style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md003.md
+MD003:
+ # Heading style
+ style: "consistent"
+
+# MD004/ul-style
+# Unordered list style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md004.md
+MD004:
+ # List style
+ style: "consistent"
+
+# MD005/list-indent
+# Inconsistent indentation for list items at the same level
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md005.md
+MD005: true
+
+# MD007/ul-indent
+# Unordered list indentation
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md007.md
+MD007:
+ # Spaces for indent
+ indent: 2
+ # Whether to indent the first level of the list
+ start_indented: false
+ # Spaces for first level indent (when start_indented is set)
+ start_indent: 2
+
+# MD009/no-trailing-spaces
+# Trailing spaces
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md009.md
+MD009:
+ # Spaces for line break
+ br_spaces: 2
+ # Include code blocks
+ code_blocks: false
+ # Allow spaces for empty lines in list items
+ list_item_empty_lines: false
+ # Include unnecessary breaks
+ strict: false
+
+# MD010/no-hard-tabs
+# Hard tabs
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md010.md
+MD010:
+ # Include code blocks
+ code_blocks: true
+ # Fenced code languages to ignore
+ ignore_code_languages: []
+ # Number of spaces for each hard tab
+ spaces_per_tab: 1
+
+# MD011/no-reversed-links
+# Reversed link syntax
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md011.md
+MD011: true
+
+# MD012/no-multiple-blanks
+# Multiple consecutive blank lines
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md012.md
+MD012:
+ # Consecutive blank lines
+ maximum: 1
+
+# MD013/line-length
+# Line length
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md013.md
+MD013:
+ # Number of characters
+ line_length: 120
+ # Number of characters for headings
+ heading_line_length: 120
+ # Number of characters for code blocks
+ code_block_line_length: 120
+ # Include code blocks
+ code_blocks: true
+ # Include tables
+ tables: true
+ # Include headings
+ headings: true
+ # Strict length checking
+ strict: false
+ # Stern length checking
+ stern: false
+
+# MD014/commands-show-output
+# Dollar signs used before commands without showing output
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md014.md
+MD014: true
+
+# MD018/no-missing-space-atx
+# No space after hash on atx style heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md018.md
+MD018: true
+
+# MD019/no-multiple-space-atx
+# Multiple spaces after hash on atx style heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md019.md
+MD019: true
+
+# MD020/no-missing-space-closed-atx
+# No space inside hashes on closed atx style heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md020.md
+MD020: true
+
+# MD021/no-multiple-space-closed-atx
+# Multiple spaces inside hashes on closed atx style heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md021.md
+MD021: true
+
+# MD022/blanks-around-headings
+# Headings should be surrounded by blank lines
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md022.md
+MD022:
+ # Blank lines above heading
+ lines_above: 1
+ # Blank lines below heading
+ lines_below: 1
+
+# MD023/heading-start-left
+# Headings must start at the beginning of the line
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md023.md
+MD023: true
+
+# MD024/no-duplicate-heading
+# Multiple headings with the same content
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md024.md
+MD024:
+ # Only check sibling headings
+ siblings_only: false
+
+# MD025/single-title/single-h1
+# Multiple top-level headings in the same document
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md025.md
+MD025:
+ # RegExp for matching title in front matter
+ front_matter_title: "^\\s*title\\s*[:=]"
+ # Heading level
+ level: 1
+
+# MD026/no-trailing-punctuation
+# Trailing punctuation in heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md026.md
+MD026:
+ # Punctuation characters
+ punctuation: ".,;:!。,;:!"
+
+# MD027/no-multiple-space-blockquote
+# Multiple spaces after blockquote symbol
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md027.md
+MD027:
+ # Include list items
+ list_items: true
+
+# MD028/no-blanks-blockquote
+# Blank line inside blockquote
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md028.md
+MD028: true
+
+# MD029/ol-prefix
+# Ordered list item prefix
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md029.md
+MD029:
+ # List style
+ style: "one_or_ordered"
+
+# MD030/list-marker-space
+# Spaces after list markers
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md030.md
+MD030:
+ # Spaces for single-line unordered list items
+ ul_single: 1
+ # Spaces for single-line ordered list items
+ ol_single: 1
+ # Spaces for multi-line unordered list items
+ ul_multi: 1
+ # Spaces for multi-line ordered list items
+ ol_multi: 1
+
+# MD031/blanks-around-fences
+# Fenced code blocks should be surrounded by blank lines
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md031.md
+MD031:
+ # Include list items
+ list_items: true
+
+# MD032/blanks-around-lists
+# Lists should be surrounded by blank lines
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md032.md
+MD032: true
+
+# MD033/no-inline-html
+# Inline HTML
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md033.md
+MD033: false
+# MD033:
+# # Allowed elements
+# allowed_elements: []
+# # Allowed elements in tables
+# table_allowed_elements: []
+
+# MD034/no-bare-urls
+# Bare URL used
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md034.md
+MD034: true
+
+# MD035/hr-style
+# Horizontal rule style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md035.md
+MD035:
+ # Horizontal rule style
+ style: "consistent"
+
+# MD036/no-emphasis-as-heading
+# Emphasis used instead of a heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md036.md
+MD036:
+ # Punctuation characters
+ punctuation: ".,;:!?。,;:!?"
+
+# MD037/no-space-in-emphasis
+# Spaces inside emphasis markers
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md037.md
+MD037: true
+
+# MD038/no-space-in-code
+# Spaces inside code span elements
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md038.md
+MD038: true
+
+# MD039/no-space-in-links
+# Spaces inside link text
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md039.md
+MD039: true
+
+# MD040/fenced-code-language
+# Fenced code blocks should have a language specified
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md040.md
+MD040:
+ # List of languages
+ allowed_languages: []
+ # Require language only
+ language_only: false
+
+# MD041/first-line-heading/first-line-h1
+# First line in a file should be a top-level heading
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md041.md
+MD041:
+ # Allow content before first heading
+ allow_preamble: false
+ # RegExp for matching title in front matter
+ front_matter_title: "^\\s*title\\s*[:=]"
+ # Heading level
+ level: 1
+
+# MD042/no-empty-links
+# No empty links
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md042.md
+MD042: true
+
+# MD043/required-headings
+# Required heading structure
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md043.md
+# MD043:
+# # List of headings
+# headings: []
+# # Match case of headings
+# match_case: false
+
+# MD044/proper-names
+# Proper names should have the correct capitalization
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md044.md
+MD044:
+ # List of proper names
+ names: []
+ # Include code blocks
+ code_blocks: true
+ # Include HTML elements
+ html_elements: true
+
+# MD045/no-alt-text
+# Images should have alternate text (alt text)
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md045.md
+MD045: true
+
+# MD046/code-block-style
+# Code block style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md046.md
+MD046:
+ # Block style
+ style: "consistent"
+
+# MD047/single-trailing-newline
+# Files should end with a single newline character
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md047.md
+MD047: true
+
+# MD048/code-fence-style
+# Code fence style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md048.md
+MD048:
+ # Code fence style
+ style: "consistent"
+
+# MD049/emphasis-style
+# Emphasis style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md049.md
+MD049:
+ # Emphasis style
+ style: "consistent"
+
+# MD050/strong-style
+# Strong style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md050.md
+MD050:
+ # Strong style
+ style: "consistent"
+
+# MD051/link-fragments
+# Link fragments should be valid
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md051.md
+MD051:
+ # Ignore case of fragments
+ ignore_case: false
+ # Pattern for ignoring additional fragments
+ ignored_pattern: ""
+
+# MD052/reference-links-images
+# Reference links and images should use a label that is defined
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md052.md
+MD052:
+ # Ignored link labels
+ ignored_labels:
+ - "x"
+ # Include shortcut syntax
+ shortcut_syntax: false
+
+# MD053/link-image-reference-definitions
+# Link and image reference definitions should be needed
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md053.md
+MD053:
+ # Ignored definitions
+ ignored_definitions:
+ - "//"
+
+# MD054/link-image-style
+# Link and image style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md054.md
+MD054:
+ # Allow autolinks
+ autolink: true
+ # Allow inline links and images
+ inline: true
+ # Allow full reference links and images
+ full: true
+ # Allow collapsed reference links and images
+ collapsed: true
+ # Allow shortcut reference links and images
+ shortcut: true
+ # Allow URLs as inline links
+ url_inline: true
+
+# MD055/table-pipe-style
+# Table pipe style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md055.md
+MD055:
+ # Table pipe style
+ style: "consistent"
+
+# MD056/table-column-count
+# Table column count
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md056.md
+MD056: true
+
+# MD058/blanks-around-tables
+# Tables should be surrounded by blank lines
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md058.md
+MD058: true
+
+# MD059/descriptive-link-text
+# Link text should be descriptive
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md059.md
+MD059:
+ # Prohibited link texts
+ prohibited_texts:
+ - "click here"
+ - "here"
+ - "link"
+ - "more"
+
+# MD060/table-column-style
+# Table column style
+# https://github.com/DavidAnson/markdownlint/blob/v0.40.0/doc/md060.md
+MD060:
+ # Table column style
+ style: "any"
+ # Aligned delimiter columns
+ aligned_delimiter: false
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f4a7cec..b56c309 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,182 +1,498 @@
---
+# prek/pre-commit configuration
+# https://prek.j178.dev/configuration/
default_install_hook_types:
- pre-commit
- post-checkout
- post-merge
- post-rewrite
+exclude: ^archive/
repos:
+ # ========================================================================================
+ # Dependency syncing
+ #
+ # - Priority 0 pyproject.toml -> uv.lock
+ # - Priority 5 uv.lock -> .venv/
+ # uv.lock -> requirements.txt
+ # uv.lock -> .pre-commit-config.yaml (hook versions)
+ # - Priority 6 .pre-commit-config.yaml -> .pre-commit-config.yaml (hook deps)
+ #
+ # ========================================================================================
- # Sync pre-commit hook dependencies based on other installed hooks
- - repo: https://github.com/pre-commit/sync-pre-commit-deps
- rev: v0.0.3
- hooks:
- - id: sync-pre-commit-deps
- name: "[sync] pre-commit hook dependencies"
-
- # Ensure uv.lock is up-to-date
- repo: https://github.com/astral-sh/uv-pre-commit
- rev: 0.11.2
+ rev: 0.11.28
hooks:
+
+ # Ensure uv.lock is up-to-date
+ # pyproject.toml -> uv.lock
- id: uv-lock
- name: "[sync] uv.lock"
+ name: "[sync] dependencies (pyproject.toml -> uv.lock)"
+ stages: [pre-commit, manual]
+ priority: 0
- # Synchronize dependencies with local project virtual environment on checkout/pull/rebase
- - repo: https://github.com/astral-sh/uv-pre-commit
- rev: 0.11.2
- hooks:
+ # Sync .venv/ with uv.lock on checkout/pull/rebase
+ # uv.lock -> .venv/
- id: uv-sync
- name: "[sync] project venv"
+ name: "[sync] dependencies (uv.lock -> .venv)"
+ priority: 5
- # Checks for filesystem safety
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
+ # Export locked package requirements to requirements.txt
+ # uv.lock -> requirements.txt
+ - id: uv-export
+ name: "[sync] dependencies (uv.lock -> requirements.txt)"
+ stages: [pre-commit, manual]
+ priority: 5
+ args:
+ - "--format=requirements.txt"
+ - "--output-file=requirements.txt"
+ - "--frozen"
+ - "--no-hashes"
+ - "--no-header"
+
+ - repo: https://github.com/tsvikas/sync-with-uv
+ rev: v0.5.0
+ hooks:
+
+ # Sync pre-commit hook versions with uv.lock versions
+ # uv.lock -> .pre-commit-config.yaml
+ - id: sync-with-uv
+ name: "[sync] dependencies (uv.lock -> .pre-commit-config.yaml)"
+ stages: [pre-commit, manual]
+ priority: 5
+
+ - repo: https://github.com/pre-commit/sync-pre-commit-deps
+ rev: v0.0.4
+ hooks:
+
+ # Sync pre-commit hook dependencies based on other installed hooks
+ # .pre-commit-config.yaml -> .pre-commit-config.yaml
+ - id: sync-pre-commit-deps
+ name: "[sync] dependencies (.pre-commit-config.yaml -> .pre-commit-config.yaml)"
+ stages: [pre-commit, manual]
+ priority: 6
+
+ # ========================================================================================
+ # Read-only checks
+ #
+ # Priority 10 (run in parallel)
+ # ========================================================================================
+
+ - repo: builtin
hooks:
+
+ # Check for files that would conflict in case-insensitive filesystems
- id: check-case-conflict
- name: "[check] filename case conflicts"
+ name: "[check] filename casing"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Check for broken symlinks
- id: check-symlinks
- name: "[check] symlinks"
+ name: "[check] symlinks"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Detect destroyed symlinks
- id: destroyed-symlinks
- name: "[check] destroyed symlinks"
+ name: "[check] destroyed symlinks"
+ stages: [pre-commit, manual]
+ priority: 10
- # Checks for accidentally-committed large files and files with merge conflicts
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
- hooks:
+ # Ensures that (non-binary) files with a shebang are executable
+ - id: check-shebang-scripts-are-executable
+ name: "[check] files w/ shebangs are executable"
+ stages: [pre-commit, manual]
+ priority: 10
+ exclude: \.j2$ # Jinja templates carry a shebang but are rendered, not executed
+
+ # Ensures that (non-binary) executables have a shebang
+ - id: check-executables-have-shebangs
+ name: "[check] executable files have shebangs"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Prevent committing large files
- id: check-added-large-files
- name: "[check] large files"
+ name: "[check] large files"
+ stages: [pre-commit, manual]
+ priority: 10
+ exclude: |
+ (?x)(
+ ^uv\.lock|
+ ^.*\.md
+ )
+
+ # Check for merge conflicts (git merge strings in files)
- id: check-merge-conflict
- name: "[check] merge conflicts"
+ name: "[check] merge conflicts"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Prevent addition of new submodules
+ - id: forbid-new-submodules
+ name: "[check] no new submodules"
+ stages: [pre-commit, manual]
+ priority: 10
- # Check for accidentally-committed secrets like passwords, API keys, and tokens
- - repo: https://github.com/gitleaks/gitleaks
- rev: v8.30.0
+ # Check that VCS links are permalinks
+ - id: check-vcs-permalinks
+ name: "[check] vcs permalinks"
+ stages: [pre-commit, manual]
+ priority: 10
+ types_or: [markdown, rst]
+
+ # Detect private keys
+ - id: detect-private-key
+ name: "[check] private keys"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ - repo: https://github.com/Yelp/detect-secrets
+ rev: v1.5.0
hooks:
- - id: gitleaks
- name: "[check] hardcoded secrets"
- # Check that scripts with shebangs are marked executable
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
+ # Scan staged content for high-entropy strings / credential patterns.
+ # After auditing new findings, refresh the baseline:
+ # uvx detect-secrets scan --baseline .secrets.baseline && uvx detect-secrets audit .secrets.baseline
+ - id: detect-secrets
+ name: "[check] secrets"
+ stages: [pre-commit, manual]
+ priority: 10
+ args: [--baseline, .secrets.baseline]
+ exclude: |
+ (?x)(
+ ^uv\.lock$|
+ ^\.secrets\.baseline$
+ )
+
+ - repo: builtin
hooks:
- - id: check-executables-have-shebangs
- name: "[check] executable shebangs"
- # Check that links to VCS sites (like github.com) are permalinks
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
+ # Validate TOML file syntax
+ - id: check-toml
+ name: "[check] toml syntax"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Validate XML file syntax
+ - id: check-xml
+ name: "[check] xml syntax"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Validate YAML file syntax
+ - id: check-yaml
+ name: "[check] yaml syntax"
+ stages: [pre-commit, manual]
+ priority: 10
+ exclude: ^services/homeassistant/config/ # Doesn't like !include/!secret macros
+
+ # Validate JSON file syntax
+ - id: check-json
+ name: "[check] json syntax"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ - repo: https://github.com/abravalheri/validate-pyproject
+ rev: v0.25
hooks:
- - id: check-vcs-permalinks
- name: "[check] vcs site permalinks"
- # Fixers for file whitespace and metadata
+ # Validate pyproject.toml
+ - id: validate-pyproject
+ name: "[check] pyproject.toml syntax/schema"
+ stages: [pre-commit, manual]
+ priority: 10
+ args: ["--disable-plugins", "hatch"]
+ additional_dependencies:
+ - validate-pyproject[all]
+ - validate-pyproject-schema-store
+
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
+
+ # Verify python source files parse as valid python
+ - id: check-ast
+ name: "[check] python ast"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Check for debugger imports and py37+ ``breakpoint()`` calls in python source files
+ - id: debug-statements
+ name: "[check] python debug statements"
+ stages: [pre-commit, manual]
+ priority: 10
+
+ # Verify python unit test filenames match pytest expectations
+ - id: name-tests-test
+ name: "[check] unit test filenames"
+ stages: [pre-commit, manual]
+ priority: 10
+ args: [--pytest-test-first]
+
+ # ========================================================================================
+ # Text/whitespace fixers
+ #
+ # Priority 20 - 23 (run sequentially, all may modify any text file)
+ # ========================================================================================
+
+ - repo: builtin
+ hooks:
+
+ # Removes UTF-8 byte order marker
- id: fix-byte-order-marker
- name: "[fix] utf-8 byte order marker"
+ name: "[fix] utf-8 byte order marker"
+ stages: [pre-commit]
+ priority: 20
+
+ # Ensures files end in a single newline
- id: end-of-file-fixer
- name: "[fix] end-of-file"
+ name: "[fix] end-of-file"
+ stages: [pre-commit]
+ priority: 21
+
+ # Replaces mixed line ending
- id: mixed-line-ending
- name: "[fix] mixed line ending"
- exclude: .gitignore
+ name: "[fix] mixed line ending"
+ stages: [pre-commit]
+ priority: 22
+ exclude: ^\.gitignore
+
+ # Trims trailing whitespace from files
- id: trailing-whitespace
- name: "[fix] trailing whitespace"
+ name: "[fix] trailing whitespace"
+ stages: [pre-commit]
+ priority: 23
exclude: |
(?x)(
^.gitignore|
.config/istat_menus.ismp
)
- # Validate pyproject.toml
- - repo: https://github.com/abravalheri/validate-pyproject
- rev: v0.25
- hooks:
- - id: validate-pyproject
- name: "[check] pyproject.toml"
+ # ========================================================================================
+ # Formatters/fixers
+ #
+ # - Priority 30 any file type keep-sorted
+ # - Priority 35 shell scripts shfmt
+ # json (.claude) pretty-format-json
+ # toml taplo
+ # markdown mdformat
+ # python ruff --fix-only
+ #
+ # ========================================================================================
- # Checks for validity of non-code filetypes
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v6.0.0
+ - repo: https://github.com/google/keep-sorted
+ rev: v0.9.1
hooks:
- - id: check-json
- name: "[check] json files"
- - id: check-toml
- name: "[check] toml files"
- - id: check-xml
- name: "[check] xml files"
- - id: check-yaml
- name: "[check] yaml files"
- exclude: ^services/homeassistant/config/ # Doesn't like !include/!secret macros
- # Fix common misspellings in text files
- - repo: https://github.com/codespell-project/codespell
- rev: v2.4.2
+ # Language-agnostic auto-sorting between two markers
+ - id: keep-sorted
+ stages: [pre-commit]
+ name: "[format] sorting (keep-sorted)"
+ priority: 30
+
+ - repo: https://github.com/scop/pre-commit-shfmt
+ rev: v3.13.1-1
hooks:
- - id: codespell
- name: "[fix] common misspellings"
- pass_filenames: false
- args: [--write-changes]
- additional_dependencies:
- - tomli
- # Basic checks for Python code
+ # Shell script auto-formatting
+ - id: shfmt
+ name: "[format] shell scripts (shfmt)"
+ stages: [pre-commit]
+ priority: 35
+
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- - id: check-ast
- name: "[check] python ast"
- - id: debug-statements
- name: "[check] python debug statements"
- - id: name-tests-test
- name: "[check] python unit test filenames"
- args: [--pytest-test-first]
- # Python code docstring coverage
- - repo: https://github.com/econchick/interrogate
- rev: 1.7.0
+ # Format Claude Code settings JSON files
+ - id: pretty-format-json
+ name: "[format] json (.claude, indent=2)"
+ stages: [pre-commit]
+ priority: 35
+ types: [text]
+ args:
+ - "--no-ensure-ascii"
+ - "--indent=2"
+ - "--autofix"
+ - "--top-keys=id,name,type,matcher,mode,command"
+ files: ^\.claude/settings.*\.json$
+
+ - repo: https://github.com/ComPWA/taplo-pre-commit
+ rev: v0.9.3
+ hooks:
+
+ # TOML file auto-formatting
+ - id: taplo-format
+ name: "[format] toml (taplo)"
+ stages: [pre-commit]
+ priority: 35
+
+ - repo: https://github.com/hukkin/mdformat
+ rev: 1.0.0
hooks:
- - id: interrogate
- name: "[check] python docstring coverage"
- pass_filenames: false
- # Python linting, formatting, import organization, typing upgrades, cleanup
+ # Markdown file auto-formatting
+ - id: mdformat
+ name: "[format] markdown (mdformat)"
+ stages: [pre-commit]
+ priority: 35
+ exclude: |
+ (?x)^(
+ ^.ctx/.*|
+ ^docs/plans/.*
+ )$
+ additional_dependencies:
+ - mdformat-config
+ - mdformat-footnote
+ - mdformat-front-matters
+ - mdformat-gfm
+ - mdformat-gfm-alerts
+ - mdformat-pyproject
+ - mdformat-simple-breaks
+ - mdformat-toc
+
+ # Python lint auto-fixes
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.15.8
+ rev: v0.15.20
hooks:
- id: ruff
- name: "[fix] python code (ruff check --fix-only)"
+ name: "[fix] python lint (ruff check --fix-only)"
+ stages: [pre-commit]
+ priority: 35
args: [--fix-only, --exit-non-zero-on-fix]
+
+ # ========================================================================================
+ # Linters
+ #
+ # Priority 40 (run in parallel)
+ # ========================================================================================
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.15.20
+ hooks:
+
+ # Python lint check (must happen after --fix-only stage above)
+ # Check/lint and output errors as junit if running in CI
- id: ruff
- name: "[lint] python code (ruff check)"
+ name: "[lint] python lint (ruff check) - junit output"
+ stages: [manual]
+ priority: 40
+
+ # Python lint check (must happen after --fix-only stage above)
+ # Always check/lint (local or CI), output errors to stdout
+ - id: ruff
+ name: "[lint] python lint (ruff check)"
+ stages: [pre-commit, manual]
+ priority: 40
- # Python static type checking
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.19.1
+ rev: v2.2.0
hooks:
+
+ # Python static type checking
- id: mypy
- name: "[lint] python code (mypy)"
+ name: "[lint] python typing (mypy)"
+ stages: [pre-commit, manual]
+ priority: 40
additional_dependencies:
+ - "--index-url=https://pypi.org/simple"
- types-PyYAML
- types-requests
- exclude: services/homeassistant/config/
+ - types-toml
+ args: [--config-file, pyproject.toml, --junit-xml, .reports/mypy.xml]
+ exclude: ^services/homeassistant/config/
+
+ - repo: https://github.com/econchick/interrogate
+ rev: 1.7.0
+ hooks:
+
+ # Check python code docstring coverage
+ - id: interrogate
+ name: "[check] python docstring coverage"
+ stages: [pre-commit, manual]
+ priority: 40
+ pass_filenames: false
+ args: [--config, pyproject.toml]
+ # NOTE: The exclude list is only considered when `interrogate` is run with pre-commit (prek) hooks.
+ # Excluded files must be duplicated ``tool.interrogate.exclude`` entry of pyproject.toml
+ # if `interrogate` is run outside of pre-commmit/prek.
+ exclude: |
+ (?x)(
+ ^\.cicd/|
+ ^\.claude/|
+ ^\.nox/|
+ ^\.tmp/|
+ ^\.worktrees/|
+ ^build/|
+ ^docs/|
+ ^tests/
+ )
+
+ - repo: https://github.com/shellcheck-py/shellcheck-py
+ rev: v0.11.0.1
+ hooks:
+
+ # Linting for shell scripts
+ - id: shellcheck
+ name: "[lint] shell scripts"
+ stages: [pre-commit, manual]
+ priority: 40
+ exclude: \.zsh$ # shellcheck does not support zsh (e.g. roles/zsh/files/p10k.zsh)
+
+ # - repo: https://github.com/DavidAnson/markdownlint-cli2
+ # rev: v0.22.0
+ # hooks:
+
+ # # Markdown linting
+ # - id: markdownlint-cli2
+ # name: "[lint] Markdown files"
+ # stages: [pre-commit, manual]
+ # priority: 40
+
+ # - repo: https://github.com/ComPWA/taplo-pre-commit
+ # rev: v0.9.3
+ # hooks:
+
+ # # TOML linting
+ # - id: taplo-lint
+ # name: "[lint] toml"
+ # stages: [pre-commit, manual]
+ # priority: 40
+ # exclude: ^archive
- # YAML linting
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.38.0
hooks:
+
+ # YAML linting
- id: yamllint
- name: "[lint] yaml files"
+ name: "[lint] yaml (yamllint)"
+ stages: [pre-commit, manual]
+ priority: 40
+
+ - repo: https://github.com/codespell-project/codespell
+ rev: v2.4.2
+ hooks:
+
+ # Fix common misspellings in text files
+ - id: codespell
+ name: "[check] spelling mistakes"
+ stages: [pre-commit, manual]
+ priority: 40
+ pass_filenames: false
+ args: [--write-changes]
+ additional_dependencies:
+ - tomli
# - repo: https://github.com/ansible/ansible-lint.git
# rev: v25.6.1
# hooks:
-# - id: ansible-lint
-# name: "[lint] ansible"
-# args: [--fix]
-# - repo: https://github.com/koalaman/shellcheck-precommit
-# rev: v0.11.0
-# hooks:
-# - id: shellcheck
-## args: ["--severity=warning"] # Optionally only show errors and warnings
+# # Ansible linting
+# - id: ansible-lint
+# name: "[lint] ansible"
+# stages: [pre-commit, manual]
+# priority: 40
+# args: [--fix]
diff --git a/.secrets.baseline b/.secrets.baseline
new file mode 100644
index 0000000..b1cce99
--- /dev/null
+++ b/.secrets.baseline
@@ -0,0 +1,264 @@
+{
+ "version": "1.5.0",
+ "plugins_used": [
+ {
+ "name": "ArtifactoryDetector"
+ },
+ {
+ "name": "AWSKeyDetector"
+ },
+ {
+ "name": "AzureStorageKeyDetector"
+ },
+ {
+ "name": "Base64HighEntropyString",
+ "limit": 4.5
+ },
+ {
+ "name": "BasicAuthDetector"
+ },
+ {
+ "name": "CloudantDetector"
+ },
+ {
+ "name": "DiscordBotTokenDetector"
+ },
+ {
+ "name": "GitHubTokenDetector"
+ },
+ {
+ "name": "GitLabTokenDetector"
+ },
+ {
+ "name": "HexHighEntropyString",
+ "limit": 3.0
+ },
+ {
+ "name": "IbmCloudIamDetector"
+ },
+ {
+ "name": "IbmCosHmacDetector"
+ },
+ {
+ "name": "IPPublicDetector"
+ },
+ {
+ "name": "JwtTokenDetector"
+ },
+ {
+ "name": "KeywordDetector",
+ "keyword_exclude": ""
+ },
+ {
+ "name": "MailchimpDetector"
+ },
+ {
+ "name": "NpmDetector"
+ },
+ {
+ "name": "OpenAIDetector"
+ },
+ {
+ "name": "PrivateKeyDetector"
+ },
+ {
+ "name": "PypiTokenDetector"
+ },
+ {
+ "name": "SendGridDetector"
+ },
+ {
+ "name": "SlackDetector"
+ },
+ {
+ "name": "SoftlayerDetector"
+ },
+ {
+ "name": "SquareOAuthDetector"
+ },
+ {
+ "name": "StripeDetector"
+ },
+ {
+ "name": "TelegramBotTokenDetector"
+ },
+ {
+ "name": "TwilioKeyDetector"
+ }
+ ],
+ "filters_used": [
+ {
+ "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
+ },
+ {
+ "path": "detect_secrets.filters.common.is_baseline_file",
+ "filename": ".secrets.baseline"
+ },
+ {
+ "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
+ "min_level": 2
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_indirect_reference"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_likely_id_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_lock_file"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_potential_uuid"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_sequential_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_swagger_file"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_templated_secret"
+ },
+ {
+ "path": "detect_secrets.filters.regex.should_exclude_file",
+ "pattern": [
+ "uv\\.lock$",
+ "\\.secrets\\.baseline$"
+ ]
+ }
+ ],
+ "results": {
+ "ansible.cfg": [
+ {
+ "type": "Secret Keyword",
+ "filename": "ansible.cfg",
+ "hashed_secret": "726cb38ae4b27e8b99ccdcc8bf30422eae9d8377",
+ "is_verified": false,
+ "line_number": 29
+ }
+ ],
+ "docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/03-docker-security-env-vars.md": [
+ {
+ "type": "Secret Keyword",
+ "filename": "docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/03-docker-security-env-vars.md",
+ "hashed_secret": "696bc0f488064726c1ee399095fc6ec8b469cdbf",
+ "is_verified": false,
+ "line_number": 93
+ },
+ {
+ "type": "Secret Keyword",
+ "filename": "docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/03-docker-security-env-vars.md",
+ "hashed_secret": "f7a75342741955b2b9b66b55a78e5061f321ff44",
+ "is_verified": false,
+ "line_number": 94
+ }
+ ],
+ "docs/plans/2026-03/2026-03-29-tailscale-setup-design.md": [
+ {
+ "type": "Secret Keyword",
+ "filename": "docs/plans/2026-03/2026-03-29-tailscale-setup-design.md",
+ "hashed_secret": "85b099e5d30374b2900e17ead706e219738b5485",
+ "is_verified": false,
+ "line_number": 283
+ }
+ ],
+ "docs/plans/2026-03/2026-03-29-tailscale-setup-plan.md": [
+ {
+ "type": "Secret Keyword",
+ "filename": "docs/plans/2026-03/2026-03-29-tailscale-setup-plan.md",
+ "hashed_secret": "1a40aedc73cdcc13a8ecf34325a8b32030b7fbd4",
+ "is_verified": false,
+ "line_number": 638
+ },
+ {
+ "type": "Secret Keyword",
+ "filename": "docs/plans/2026-03/2026-03-29-tailscale-setup-plan.md",
+ "hashed_secret": "85b099e5d30374b2900e17ead706e219738b5485",
+ "is_verified": false,
+ "line_number": 702
+ }
+ ],
+ "local_bootstrap.sh": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "local_bootstrap.sh",
+ "hashed_secret": "472fd429d5350124719ffb31e185be416cea1e9a",
+ "is_verified": false,
+ "line_number": 39
+ }
+ ],
+ "nas_bootstrap.sh": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "nas_bootstrap.sh",
+ "hashed_secret": "472fd429d5350124719ffb31e185be416cea1e9a",
+ "is_verified": false,
+ "line_number": 39
+ }
+ ],
+ "playbooks/README.md": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "playbooks/README.md",
+ "hashed_secret": "472fd429d5350124719ffb31e185be416cea1e9a",
+ "is_verified": false,
+ "line_number": 257
+ }
+ ],
+ "services/homeassistant/config/scripts/get_lutron_cert.py": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "services/homeassistant/config/scripts/get_lutron_cert.py",
+ "hashed_secret": "e7b4922d2dfd49a25e906bc9ce2326a6151a933e",
+ "is_verified": false,
+ "line_number": 46
+ },
+ {
+ "type": "Secret Keyword",
+ "filename": "services/homeassistant/config/scripts/get_lutron_cert.py",
+ "hashed_secret": "e7b4922d2dfd49a25e906bc9ce2326a6151a933e",
+ "is_verified": false,
+ "line_number": 46
+ }
+ ],
+ "services/homeassistant/config/secrets_template.yaml": [
+ {
+ "type": "Secret Keyword",
+ "filename": "services/homeassistant/config/secrets_template.yaml",
+ "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7",
+ "is_verified": false,
+ "line_number": 5
+ }
+ ],
+ "services/homeassistant/config/www/hass.html": [
+ {
+ "type": "Base64 High Entropy String",
+ "filename": "services/homeassistant/config/www/hass.html",
+ "hashed_secret": "3fff009038cc1bf1db64d7b5ced22e41da6e0383",
+ "is_verified": false,
+ "line_number": 1429
+ },
+ {
+ "type": "Base64 High Entropy String",
+ "filename": "services/homeassistant/config/www/hass.html",
+ "hashed_secret": "9df1e507a11fe7fec188f6ae41a94d5a585efd45",
+ "is_verified": false,
+ "line_number": 1430
+ },
+ {
+ "type": "Base64 High Entropy String",
+ "filename": "services/homeassistant/config/www/hass.html",
+ "hashed_secret": "f1314239c0be62f1a60d5ae461dfb0db0a58e5f4",
+ "is_verified": false,
+ "line_number": 1431
+ }
+ ]
+ },
+ "generated_at": "2026-07-08T11:16:50Z"
+}
diff --git a/.taplo.toml b/.taplo.toml
new file mode 100644
index 0000000..e76d59c
--- /dev/null
+++ b/.taplo.toml
@@ -0,0 +1,22 @@
+exclude = ["archive/**/*.toml"]
+
+[formatting]
+align_comments = true
+align_entries = true
+allowed_blank_lines = 2
+array_trailing_comma = true
+array_auto_expand = true
+array_auto_collapse = false
+clrf = false
+column_width = 120
+compact_arrays = true
+compact_entries = false
+compact_inline_tables = true
+indent_entries = false
+indent_string = " "
+indent_tables = false
+inline_table_expand = true
+reorder_arrays = false
+reorder_keys = false
+reorder_inline_tables = false
+trailing_newline = true
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..f5cdcd2
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "python-envs.workspaceSearchPaths": ["./**/.venv"]
+}
diff --git a/.yamllint.yaml b/.yamllint.yaml
index 75719e4..741d08f 100644
--- a/.yamllint.yaml
+++ b/.yamllint.yaml
@@ -8,20 +8,42 @@ ignore:
- vault.yaml
rules:
+ anchors: enable
braces:
min-spaces-inside: 0
max-spaces-inside: 1
+ brackets: enable
+ colons: enable
+ commas: enable
comments:
+ level: warning
min-spaces-from-content: 1
- comments-indentation: false
+ comments-indentation:
+ level: warning
+ ignore:
+ - .pre-commit-config.yaml
+ document-end: disable
+ document-start:
+ level: warning
+ empty-lines: enable
+ empty-values: disable
+ float-values: disable
+ hyphens: enable
+ indentation: enable
+ key-duplicates: enable
+ key-ordering: disable
line-length:
- max: 320
+ max: 240
allow-non-breakable-words: true
new-line-at-end-of-file:
level: warning
+ new-lines: enable
octal-values:
forbid-implicit-octal: true
forbid-explicit-octal: true
+ quoted-strings: disable
+ trailing-spaces: enable
truthy:
+ level: warning
ignore:
- .github/workflows/*.yml
diff --git a/CLAUDE-LESSONS.md b/CLAUDE-LESSONS.md
new file mode 100644
index 0000000..9be9cde
--- /dev/null
+++ b/CLAUDE-LESSONS.md
@@ -0,0 +1,26 @@
+# Lessons Learned
+
+Patterns and corrections captured during development to prevent repeated mistakes. Append new lessons here.
+
+## Format
+
+Each lesson follows this pattern:
+
+- **Context**: What was happening
+- **Mistake**: What went wrong
+- **Rule**: The rule to prevent recurrence
+
+---
+
+## Lessons
+
+### Keep this repo's Claude config self-contained — don't assume global `~/.claude` exists
+
+- **Context**: Porting Claude Code infra from the work `~/dotfiles` repo into this personal repo. The work repo's
+ `.claude/rules/*.md` are thin "deltas" that inherit a global baseline at `~/.claude/rules/`.
+- **Mistake**: Wrote the personal repo's rule files the same way — as deltas that reference `~/.claude/rules/`. But this
+ repo cannot guarantee the global `~/.claude` configuration is present (it is only reliably set up alongside the work
+ dotfiles), so the "inherited" baseline could be missing entirely, leaving the rules referencing nothing.
+- **Rule**: Everything under this repo's `.claude/` (and `.ctx/`, `CLAUDE.md`) must stand on its own. Rule files carry
+ the full guidance rather than deferring to a global baseline; agents and skills reference only in-repo artifacts. If
+ the global config is later guaranteed for personal use, rules may optionally be slimmed back to deltas.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..f2ad0a0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,42 @@
+# CLAUDE.md
+
+Personal dotfiles repository using **Ansible** for automated system configuration on macOS and Debian Linux. Manages
+dotfiles, applications, system settings, and home infrastructure (a NAS with a media-server / home-automation stack).
+
+For detailed reference, load on-demand:
+
+- [`.ctx/ARCHITECTURE.md`](.ctx/ARCHITECTURE.md) — bootstrap flow, inventory, role system, `run.py`, Docker stack, macOS
+ layers
+- [`.ctx/BUILD.md`](.ctx/BUILD.md) — full command reference (playbooks / `run.py` / make / prek / lint)
+- [`.ctx/CONVENTIONS.md`](.ctx/CONVENTIONS.md) — code style pointers and the "adding a dotfile / role / macOS app"
+ patterns
+- [`.claude/rules/`](.claude/rules) — self-contained Python / script / README style rules for this repo
+- [`CLAUDE-LESSONS.md`](CLAUDE-LESSONS.md) — captured mistakes and rules to prevent recurrence; append new lessons here
+
+## Entry Points
+
+1. **`local_bootstrap.sh`** → `playbooks/local_bootstrap.yml` — personal Mac configuration.
+2. **`nas_bootstrap.sh`** → `playbooks/nas_bootstrap.yml` — home NAS / server configuration.
+3. **`run.py`** — repository-management CLI (inventory pull, tool installers, codebase chores). `Makefile` wraps these
+ (`make help`). Full command reference in [`docs/RUN_PY_REFERENCE.md`](docs/RUN_PY_REFERENCE.md).
+
+Both bootstrap scripts validate the repo, install Homebrew (checksum-verified) and Ansible, then run their playbook. See
+[`.ctx/ARCHITECTURE.md`](.ctx/ARCHITECTURE.md) for the flow and [`.ctx/BUILD.md`](.ctx/BUILD.md) for invocation (tags /
+extra-vars).
+
+## Inventory
+
+Stored in a separate git repo (`git@github.com:connormason/dotfiles-inventory.git`), cloned into `inventory/` by
+`python3 run.py update-inventory` — **not** a submodule, and gitignored. Contains host definitions and vault-encrypted
+secrets. See [`.ctx/ARCHITECTURE.md`](.ctx/ARCHITECTURE.md) "Inventory Management" for the layout.
+
+## Vault Password
+
+All secrets are `ansible-vault`-encrypted in the `inventory/` clone. The password lives in `vault_password.txt`
+(gitignored, created manually; referenced by `ansible.cfg`'s `vault_password_file`). Never commit the vault password or
+decrypted secrets.
+
+## Plans & Docs
+
+Design docs and implementation plans live under [`docs/plans//`](docs/plans/), named
+`YYYY-MM-DD--{design,plan}.md`. Other extended docs are indexed in [`docs/README.md`](docs/README.md).
diff --git a/Makefile b/Makefile
index a6427e4..853b7ba 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
#
# Auto-generated via `run.py makefile`
#
-.PHONY: help list-hosts inventory-status update-inventory vault-decrypt vault-encrypt install-uv install-hatch clean pre makefile
+.PHONY: help list-hosts inventory-status update-inventory vault-decrypt vault-encrypt install-uv install-hatch clean pre install-hooks uninstall-hooks makefile
BRIGHT_GREEN := \033[0;92m
BRIGHT_WHITE := \033[0;97m
@@ -26,7 +26,9 @@ help: ## Show this help message
@echo ""
@echo "$(BRIGHT_WHITE)Codebase:$(NC)"
@echo " $(YELLOW)clean $(NC) Remove all environments, build artifacts, and caches"
- @echo " $(YELLOW)pre $(NC) Run pre-commit hooks on all project files"
+ @echo " $(YELLOW)pre $(NC) Run prek (pre-commit) hooks on all project files"
+ @echo " $(YELLOW)install-hooks $(NC) Install prek git hook shims (overwrites any existing shims)"
+ @echo " $(YELLOW)uninstall-hooks $(NC) Uninstall prek git hook shims"
@echo " $(YELLOW)makefile $(NC) Generate Makefile from project management script commands [2m(run.py)[0m"
@echo ""
@@ -57,8 +59,14 @@ install-hatch: ## Install [95mhatch[0m Python project manager
clean: ## Remove all environments, build artifacts, and caches
@python3 run.py clean
-pre: ## Run pre-commit hooks on all project files
+pre: ## Run prek (pre-commit) hooks on all project files
@python3 run.py pre
+install-hooks: ## Install prek git hook shims (overwrites any existing shims)
+ @python3 run.py install-hooks
+
+uninstall-hooks: ## Uninstall prek git hook shims
+ @python3 run.py uninstall-hooks
+
makefile: ## Generate Makefile from project management script commands [2m(run.py)[0m
@python3 run.py makefile
diff --git a/README.md b/README.md
index 6ce918a..6083855 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Connor's Dotfiles
-Personal dotfiles repository using Ansible for automated system configuration on macOS and Debian Linux systems.
-Manages dotfiles, applications, system settings, and home infrastructure (NAS with media server stack).
+Personal dotfiles repository using Ansible for automated system configuration on macOS and Debian Linux systems. Manages
+dotfiles, applications, system settings, and home infrastructure (NAS with media server stack).
## Quick Start
@@ -38,6 +38,7 @@ This repository implements a dual-bootstrap system targeting two distinct enviro
### macOS Configuration (`local_bootstrap.yml`)
Configures personal Mac with:
+
- **Development tools**: Git, Python tooling (uv, hatch, pipx), SSH, zsh with plugins
- **GUI applications**: Homebrew casks and Mac App Store apps
- **System settings**: Finder, Dock, Activity Monitor, power management
@@ -47,6 +48,7 @@ Configures personal Mac with:
### NAS/Server Configuration (`nas_bootstrap.yml`)
Configures home NAS server with:
+
- **Storage**: ZFS filesystem management
- **File sharing**: Samba (SMB/CIFS)
- **Media stack**: Plex, Sonarr, Radarr, Transmission, Prowlarr (via Docker)
@@ -56,31 +58,34 @@ Configures home NAS server with:
### Core Components
-| Component | Purpose | Documentation |
-|-----------|---------|---------------|
-| **run.py** | Python CLI for repository management | [📖 docs/RUN_PY_REFERENCE.md](docs/RUN_PY_REFERENCE.md) |
-| **playbooks/** | Ansible playbooks and execution workflows | [📖 playbooks/README.md](playbooks/README.md) |
-| **roles/** | Ansible roles for system configuration | [📖 roles/README.md](roles/README.md) |
-| **inventory/** | Host definitions and encrypted vault files | Managed via `run.py update-inventory` |
-| **cc-statusline/** | Custom Claude Code statusline | [📖 cc-statusline/README.md](cc-statusline/README.md) |
+| Component | Purpose | Documentation |
+| ------------------ | ------------------------------------------ | ------------------------------------------------------- |
+| **run.py** | Python CLI for repository management | [📖 docs/RUN_PY_REFERENCE.md](docs/RUN_PY_REFERENCE.md) |
+| **playbooks/** | Ansible playbooks and execution workflows | [📖 playbooks/README.md](playbooks/README.md) |
+| **roles/** | Ansible roles for system configuration | [📖 roles/README.md](roles/README.md) |
+| **inventory/** | Host definitions and encrypted vault files | Managed via `run.py update-inventory` |
+| **cc-statusline/** | Custom Claude Code statusline | [📖 cc-statusline/README.md](cc-statusline/README.md) |
## Documentation
### User Guides
- **[Python CLI Reference](docs/RUN_PY_REFERENCE.md)** - Complete `run.py` command reference
+
- Inventory management (list-hosts, update-inventory)
- Tool installation (install-uv, install-hatch)
- Codebase maintenance (clean, pre, makefile)
- Architecture details and extension guide
- **[Playbook Workflows](playbooks/README.md)** - Ansible playbook execution guide
+
- Bootstrap scripts with security features
- Tag system for selective execution
- Troubleshooting common issues
- Best practices for development workflow
- **[Ansible Roles](roles/README.md)** - Role system overview
+
- 17 roles organized by platform (macOS, Linux, Shared)
- Role dependencies and usage patterns
- Creating new roles
@@ -91,18 +96,21 @@ Configures home NAS server with:
#### System Configuration
- **[macOS Role](roles/macos/README.md)** - Package management layers
+
- Homebrew formulae (CLI tools)
- Homebrew casks (GUI applications)
- Mac App Store apps
- Adding/removing applications
- **[Docker Role](roles/docker/README.md)** - NAS media stack
+
- 10+ service architecture (Plex, Sonarr, Radarr, etc.)
- Storage layout and network configuration
- Service management and troubleshooting
- Adding new services
- **[link_dotfile Role](roles/link_dotfile/README.md)** - Reusable dotfile linking pattern
+
- Safe symlink creation with backups
- Usage in other roles
- Best practices
@@ -110,12 +118,14 @@ Configures home NAS server with:
#### Developer Tools
- **[cc-statusline](cc-statusline/README.md)** - Custom Claude Code statusline
+
- 15 modular components
- 4 themes and 4 layouts
- Customization guide
- Development guide
- **[Installer Scripts](scripts/install/README.md)** - Python tool installers
+
- uv and hatch installation
- Retry logic and security features
- Extension guide
@@ -132,7 +142,8 @@ Configures home NAS server with:
### Inventory Management
-The inventory repository is a separate private git repository containing host definitions and Ansible Vault encrypted secrets. It is managed as a standalone clone in the `inventory/` directory.
+The inventory repository is a separate private git repository containing host definitions and Ansible Vault encrypted
+secrets. It is managed as a standalone clone in the `inventory/` directory.
**Initial setup:**
@@ -190,7 +201,7 @@ python3 run.py update-inventory
# Clean build artifacts
python3 run.py clean
-# Run pre-commit hooks
+# Run prek hooks
python3 run.py pre
# Generate Makefile from run.py commands
@@ -275,11 +286,16 @@ dotfiles-personal/
│ └── configure_network_interfaces.py
│
├── docs/ # Documentation
-│ └── RUN_PY_REFERENCE.md # Python CLI reference
+│ ├── RUN_PY_REFERENCE.md # Python CLI reference
+│ ├── notes/ # Informal working notes
+│ └── plans/ # Design docs & implementation plans (YYYY-MM/)
│
+├── .ctx/ # On-demand Claude reference docs (architecture, build, conventions)
├── .ansible-lint.yaml # Ansible linting rules
├── .yamllint.yaml # YAML linting rules
-└── .pre-commit-config.yaml # Pre-commit hooks configuration
+├── .pre-commit-config.yaml # prek / pre-commit hook configuration
+├── CLAUDE.md # Claude Code project instructions
+└── CLAUDE-LESSONS.md # Captured rules to prevent recurring mistakes
```
## Key Features
@@ -287,10 +303,12 @@ dotfiles-personal/
### Dual Bootstrap System
Two separate bootstrap workflows targeting different environments:
+
- **`local_bootstrap.sh`** → `playbooks/local_bootstrap.yml`: Personal Mac configuration
- **`nas_bootstrap.sh`** → `playbooks/nas_bootstrap.yml`: Home NAS/server configuration
Both scripts:
+
1. Validate repository structure
2. Install Homebrew with checksum verification
3. Install Ansible via Homebrew
@@ -312,6 +330,7 @@ Both scripts:
### Docker Media Stack (NAS)
Comprehensive media server and home automation:
+
- **Media services**: Plex, Sonarr, Radarr, Transmission, Prowlarr, Flaresolverr
- **Network services**: PiHole (DNS/ad-blocking)
- **Automation**: Home Assistant, Glance dashboard
@@ -327,6 +346,7 @@ Comprehensive media server and home automation:
### Python CLI (`run.py`)
Feature-rich command-line interface:
+
- **Command registration** via decorator pattern
- **ANSI styling** for rich terminal output
- **Retry logic** with exponential backoff for network operations
@@ -336,6 +356,7 @@ Feature-rich command-line interface:
### Custom Claude Code Statusline
Modular statusline system with:
+
- **15 components** (git, model, tokens, cost, etc.)
- **4 themes** (default, emoji, minimal, neon)
- **4 layouts** (minimal, default, full, connor)
@@ -346,6 +367,7 @@ Modular statusline system with:
### Adding Applications (macOS)
Edit `roles/macos/defaults/main.yml`:
+
- CLI tools → `brew_packages`
- GUI apps → `brew_cask_packages`
- App Store apps → `mas_apps` (requires app ID)
@@ -371,7 +393,7 @@ See [roles/README.md](roles/README.md) for details.
### Testing Changes
```bash
-# Run pre-commit hooks
+# Run prek hooks
python3 run.py pre
# Test specific role with check mode
@@ -390,28 +412,31 @@ ansible-playbook playbooks/local_bootstrap.yml \
## Environment Variables
-| Variable | Purpose | Default |
-|----------|---------|---------|
-| `DOTFILES_RUN_DEBUG` | Enable debug output in run.py | `false` |
-| `DOTFILES_INVENTORY_REPO_URL` | Inventory repository URL | `git@github.com:connormason/dotfiles-inventory.git` |
+| Variable | Purpose | Default |
+| ----------------------------- | ----------------------------- | --------------------------------------------------- |
+| `DOTFILES_RUN_DEBUG` | Enable debug output in run.py | `false` |
+| `DOTFILES_INVENTORY_REPO_URL` | Inventory repository URL | `git@github.com:connormason/dotfiles-inventory.git` |
## Troubleshooting
### Inventory Issues
**Inventory directory not found:**
+
```bash
# Clone inventory repository
python3 run.py update-inventory
```
**Inventory out of sync or corrupted:**
+
```bash
# Force re-clone from remote
python3 run.py update-inventory --force
```
**SSH authentication errors:**
+
1. Verify SSH key is added to GitHub account
2. Test SSH connection: `ssh -T git@github.com`
3. Check SSH agent has key loaded: `ssh-add -l`
@@ -429,7 +454,7 @@ See [run.py documentation](docs/RUN_PY_REFERENCE.md#inventory-management) for de
1. Verify if Homebrew installer was legitimately updated at https://github.com/Homebrew/install
2. Generate new checksum:
-`curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | shasum -a 256`
+ `curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | shasum -a 256`
3. Update `EXPECTED_CHECKSUM` in bootstrap scripts
### Role Failures
@@ -444,6 +469,7 @@ See [playbooks/README.md](playbooks/README.md#troubleshooting) for comprehensive
## Pre-commit Hooks
Configured checks include:
+
- **File integrity**: Large files, merge conflicts, private keys, symlinks
- **Python**: AST validation, debug statements, ruff linting, mypy type checking, interrogate docstring coverage
- **Data formats**: JSON, YAML, TOML, XML validation
@@ -451,9 +477,10 @@ Configured checks include:
- **Fixers**: Whitespace, line endings, UTF-8 BOM
Run hooks:
+
```bash
# All files
-pre-commit run --all-files
+prek run --all-files
# Or via script
python3 run.py pre
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..33347de
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,29 @@
+[Project Root](../README.md) > **Docs**
+
+# Docs
+
+Extended documentation for the dotfiles repository. These files cover specific topics in depth without duplicating what
+lives in the root [`README.md`](../README.md), the on-demand [`.ctx/`](../.ctx/) quick-reference files, or the project
+guidance in [`CLAUDE.md`](../CLAUDE.md).
+
+## Reference
+
+| File | Purpose |
+| ------------------------------------------ | ------------------------------------------------------------------ |
+| [RUN_PY_REFERENCE.md](RUN_PY_REFERENCE.md) | Full command reference for the `run.py` repository-management CLI. |
+
+## Notes
+
+Informal, working documents — not authoritative.
+
+| File | Purpose |
+| ------------------------------ | --------------------------------------------- |
+| [notes/todo.md](notes/todo.md) | Informal backlog of ideas and deferred tasks. |
+
+## Plans
+
+The [`plans/`](plans/) subdirectory holds design and implementation documents for larger features and refactors, grouped
+into `plans//` monthly subdirectories and named `YYYY-MM-DD--{design,plan}.md` (`-design` for
+design/brainstorm docs, `-plan` for implementation plans). Multi-document efforts may instead be a dated subdirectory
+(e.g. [`plans/2026-01/2026-01-21-dotfiles-improvement-plan/`](plans/2026-01/2026-01-21-dotfiles-improvement-plan/)).
+These are working documents and may be incomplete or superseded.
diff --git a/docs/RUN_PY_REFERENCE.md b/docs/RUN_PY_REFERENCE.md
index 555dc45..2d52c7a 100644
--- a/docs/RUN_PY_REFERENCE.md
+++ b/docs/RUN_PY_REFERENCE.md
@@ -1,7 +1,7 @@
# run.py - Command Reference
-Python CLI tool for managing the dotfiles repository. Provides commands for inventory management, tool installation,
-and codebase maintenance with ANSI styling and retry logic for robustness.
+Python CLI tool for managing the dotfiles repository. Provides commands for inventory management, tool installation, and
+codebase maintenance with ANSI styling and retry logic for robustness.
## Quick Start
@@ -31,6 +31,7 @@ python3 run.py list-hosts
```
**Output example:**
+
```
Available Bootstrap Targets
============================================================
@@ -48,6 +49,7 @@ Total hosts: 2
```
**Error handling:**
+
- Checks if `inventory/inventory.yml` exists
- Validates YAML structure
- Suggests running `update-inventory` if missing
@@ -61,16 +63,19 @@ python3 run.py update-inventory
```
**Behavior:**
+
- **If inventory exists and is git repo**: Pulls latest changes with `git pull origin main`
- **If inventory exists but not git repo**: Removes directory and clones fresh
- **If inventory doesn't exist**: Clones repository
**Retry logic:**
+
- Pull operation: 3 attempts with 2s delay, exponential backoff (2x multiplier)
- Clone operation: 3 attempts with 5s delay, exponential backoff (2x multiplier)
- 30s timeout for pull, 60s timeout for clone
**Configuration:**
+
- Repository URL: `DOTFILES_INVENTORY_REPO_URL` environment variable
- Default: `git@github.com:connormason/dotfiles-inventory.git`
@@ -97,13 +102,14 @@ python3 run.py install-uv --retries 5 --retry-delay 3
```
**Options:**
+
- `-f, --force`: Force reinstall even if `uv` already installed
- `-v, --verbose`: Enable verbose output for debugging
- `--retries N`: Maximum number of download retry attempts
- `--retry-delay SECONDS`: Initial delay in seconds between retries with exponential backoff
-**Implementation:**
-Delegates to `scripts/install/install_uv.py` (see [scripts/install/README.md](../scripts/install/README.md) for details).
+**Implementation:** Delegates to `scripts/install/install_uv.py` (see
+[scripts/install/README.md](../scripts/install/README.md) for details).
#### `install-hatch`
@@ -117,11 +123,10 @@ python3 run.py install-hatch
python3 run.py install-hatch --force --verbose
```
-**Options:**
-Same as `install-uv` (see above).
+**Options:** Same as `install-uv` (see above).
-**Implementation:**
-Delegates to `scripts/install/install_hatch.py` (see [scripts/install/README.md](../scripts/install/README.md) for details).
+**Implementation:** Delegates to `scripts/install/install_hatch.py` (see
+[scripts/install/README.md](../scripts/install/README.md) for details).
### Codebase Maintenance
@@ -138,15 +143,18 @@ python3 run.py clean
**Cleaned patterns:**
**Package build artifacts:**
+
- `build/`
- `dist/`
- `*.egg-info`
**Package cache files:**
+
- `**/__pycache__/`
- `**/*.pyc`
**Tool cache files:**
+
- `.mypy_cache`
- `.pytest_cache`
- `.ruff_cache`
@@ -155,6 +163,7 @@ python3 run.py clean
- `.coverage`
**Output:**
+
```
🧹 Cleaning project workspace...
Cleaning package build artifacts...
@@ -163,25 +172,25 @@ python3 run.py clean
✅ Cleanup complete
```
-**Debug mode:**
-Shows each file/directory removed (enable with `--debug` flag).
+**Debug mode:** Shows each file/directory removed (enable with `--debug` flag).
#### `pre`
-Run pre-commit hooks on all project files.
+Run prek hooks on all project files.
```bash
python3 run.py pre
```
-**Behavior:**
-Executes `pre-commit run --all-files` with live output. Does not fail on hook failures (exit code ignored).
+**Behavior:** Executes `prek run --all-files` with live output. Does not fail on hook failures (exit code ignored).
+
+**Hooks include:**
-**Pre-commit hooks include:**
-- File integrity checks (large files, merge conflicts, private keys)
+- File integrity checks (large files, merge conflicts, private keys, detect-secrets)
- Python validation (syntax, debug statements, ruff, mypy, interrogate)
- Data format validation (JSON, YAML, TOML, XML)
-- YAML linting with custom config
+- Formatters (shfmt, taplo, mdformat, ruff --fix-only)
+- Linters (shellcheck, yamllint, codespell)
- Whitespace and line ending fixers
See [`.pre-commit-config.yaml`](../.pre-commit-config.yaml) for complete hook configuration.
@@ -195,6 +204,7 @@ python3 run.py makefile
```
**Behavior:**
+
- Reads all `@command` decorated functions from `run.py`
- Generates `Makefile` with targets for each command
- Excludes commands marked `script_only=True`
@@ -202,6 +212,7 @@ python3 run.py makefile
- Includes color-coded help text
**Generated Makefile usage:**
+
```bash
# Show help
make help
@@ -213,6 +224,7 @@ make pre
```
**Auto-generated structure:**
+
```makefile
.PHONY: help list-hosts update-inventory install-uv ...
@@ -248,6 +260,7 @@ def cmd_list_hosts(args: argparse.Namespace) -> None:
```
**Decorator parameters:**
+
- `name`: Command name (defaults to function name with prefixes removed)
- `add_arguments`: Function to add command-specific arguments to subparser
- `description`: Command description (defaults to docstring)
@@ -257,8 +270,7 @@ def cmd_list_hosts(args: argparse.Namespace) -> None:
- `script_only`: If True, exclude from Makefile generation
- `makefile_only`: If True, exclude from script help output
-**Registry:**
-All registered commands stored in `REGISTERED_COMMANDS` dict mapping `name -> CommandInfo`.
+**Registry:** All registered commands stored in `REGISTERED_COMMANDS` dict mapping `name -> CommandInfo`.
### ANSI Styling System
@@ -282,12 +294,14 @@ style('IMPORTANT', fg='red', bold=True, underline=True)
```
**Available colors:**
+
- Basic: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`
- Bright: `bright_black`, `bright_red`, `bright_green`, etc.
- 256-color codes: `0-255`
- RGB tuples: `(r, g, b)` where each component is `0-255`
**Helper function:**
+
```python
printf('Styled output', fg='green', bold=True, indent=4, debug=True)
```
@@ -314,6 +328,7 @@ shell_command(
```
**Parameters:**
+
- `cmd`: List of command arguments
- `text`: If True, return `str` (default); if False, return `bytes`
- `encoding`: Text encoding (when `text=True`)
@@ -325,12 +340,13 @@ shell_command(
- `timeout`: Command timeout in seconds
**Error handling:**
+
- Raises `ShellCommandError` (extends `CalledProcessError`) with formatted output
- Timeout handling with graceful process termination
- Live output via PTY for real-time display
-**Global environment:**
-Commands inherit environment from:
+**Global environment:** Commands inherit environment from:
+
1. `os.environ` (system environment)
2. `RUN_COMMAND_ENVVARS` (global script environment)
3. `env` parameter (command-specific overrides)
@@ -347,12 +363,14 @@ def network_operation():
```
**Behavior:**
+
- Attempt 1: Immediate execution
- Attempt 2: Wait `delay` seconds (2.0s)
- Attempt 3: Wait `delay * backoff` seconds (4.0s)
- Attempt N: Wait `delay * (backoff ** (N-1))` seconds
**Output:**
+
```
└─ Attempt 1/3 failed, retrying in 2.0s...
└─ Attempt 2/3 failed, retrying in 4.0s...
@@ -360,6 +378,7 @@ def network_operation():
```
Used by `update-inventory` for git operations with different parameters:
+
- Pull: 3 attempts, 2s initial delay, 2x backoff
- Clone: 3 attempts, 5s initial delay, 2x backoff
@@ -368,6 +387,7 @@ Used by `update-inventory` for git operations with different parameters:
### Constants
**File paths:**
+
```python
SCRIPT_PATH = Path(__file__)
DOTFILES_DIR = SCRIPT_PATH.parent
@@ -379,6 +399,7 @@ SCRIPTS_DIR = DOTFILES_DIR / 'scripts'
```
**Environment variables:**
+
```python
# Debug mode toggle
RUN_DEBUG_ENVVAR = 'DOTFILES_RUN_DEBUG'
@@ -392,12 +413,12 @@ INVENTORY_REPO_URL = os.getenv(
)
```
-**Clean patterns:**
-See `CLEAN_PATTERN_GROUPS` dict for complete list of glob patterns removed by `clean` command.
+**Clean patterns:** See `CLEAN_PATTERN_GROUPS` dict for complete list of glob patterns removed by `clean` command.
### Type System
**Key type aliases:**
+
```python
PathLike = Union[str, Path]
StyleColor = Union[int, tuple[int, int, int], str]
@@ -406,6 +427,7 @@ AddArgumentsFunc = Callable[[argparse.ArgumentParser], None]
```
**Command info dataclass:**
+
```python
@dataclass
class CommandInfo:
@@ -439,6 +461,7 @@ except subprocess.CalledProcessError as e:
```
**Output format:**
+
```
❌ Failed to push to remote
└─ Exit code: 128
@@ -451,11 +474,11 @@ except subprocess.CalledProcessError as e:
### Exception Types
-**ShellCommandError:**
-Raised by `shell_command()` when subprocess exits with non-zero code and `check=True`. Extends
+**ShellCommandError:** Raised by `shell_command()` when subprocess exits with non-zero code and `check=True`. Extends
`subprocess.CalledProcessError` with formatted `__str__()` that includes stdout/stderr.
**Standard subprocess exceptions:**
+
- `subprocess.CalledProcessError`: Non-zero exit code
- `subprocess.TimeoutExpired`: Command exceeded timeout
- `KeyboardInterrupt`: User cancelled operation (caught in `main()`)
@@ -474,7 +497,7 @@ python3 run.py --debug update-inventory
# Install uv with custom retry behavior
python3 run.py install-uv --retries 5 --retry-delay 3
-# Clean and run pre-commit
+# Clean and run prek
python3 run.py clean
python3 run.py pre
```
@@ -530,6 +553,7 @@ def flaky_operation():
### Adding New Commands
1. **Define command function:**
+
```python
@command(
group='My Group',
@@ -543,6 +567,7 @@ def cmd_my_command(args: argparse.Namespace) -> None:
```
2. **Add arguments (optional):**
+
```python
def add_my_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument('--option', help='Some option')
@@ -557,6 +582,7 @@ def cmd_my_command(args: argparse.Namespace) -> None:
```
3. **Regenerate Makefile:**
+
```bash
python3 run.py makefile
```
@@ -630,6 +656,7 @@ CLEAN_PATTERN_GROUPS: dict[str, list[str]] = {
**Symptom:** `update-inventory` fails with SSH errors
**Solution:**
+
```bash
# Verify SSH key access
ssh -T git@github.com
@@ -642,6 +669,7 @@ ssh-add ~/.ssh/id_ed25519
```
**Alternative:** Use HTTPS URL instead of SSH
+
```bash
export DOTFILES_INVENTORY_REPO_URL=https://github.com/connormason/dotfiles-inventory.git
python3 run.py update-inventory
@@ -652,6 +680,7 @@ python3 run.py update-inventory
**Symptom:** `list-hosts` fails with "Failed to parse inventory file"
**Solution:**
+
```bash
# Validate YAML syntax
python3 -c "import yaml; yaml.safe_load(open('inventory/inventory.yml'))"
@@ -665,6 +694,7 @@ yamllint inventory/inventory.yml
**Symptom:** `install-uv` or `install-hatch` fails
**Solution:**
+
```bash
# Run with verbose output
python3 run.py install-uv --verbose
@@ -681,13 +711,14 @@ python3 run.py install-uv --retries 10 --retry-delay 5
**Symptom:** `pre` command shows hook failures
**Solution:**
+
```bash
-# Update pre-commit hooks
-pre-commit autoupdate
+# Update prek hooks
+prek auto-update
# Clear hook cache
-pre-commit clean
-pre-commit install-hooks
+prek cache clean
+prek prepare-hooks
# Run again
python3 run.py pre
@@ -731,6 +762,7 @@ This provides better UX than `subprocess.PIPE` for long-running commands.
```
**Suggested additions:**
+
- Bootstrap commands to run full playbook workflows
- Ansible playbook execution wrappers with tag support
- Vault password management helpers
diff --git a/TODO.md b/docs/notes/todo.md
similarity index 99%
rename from TODO.md
rename to docs/notes/todo.md
index 743157d..22eccb3 100644
--- a/TODO.md
+++ b/docs/notes/todo.md
@@ -1,4 +1,5 @@
# TODOs
+
- Use Watchtower for automating Docker container updates (https://containrrr.dev/watchtower/)
- Portainer? (https://www.portainer.io)
- lazydocker
@@ -9,7 +10,9 @@
- remove requirements.txt?
## Docker Services Modularization
+
Remaining manual steps (require NAS/GitHub access):
+
1. Audit existing NAS .env for extra variables
2. Migrate ~/docker/shared/ to ~/docker/homeassistant/shared/ on NAS
3. Set up 5 GitHub secrets (Tailscale OAuth, SSH key, NAS host, user)
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/01-inventory-submodule-resolution.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/01-inventory-submodule-resolution.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/01-inventory-submodule-resolution.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/01-inventory-submodule-resolution.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/02-unified-bootstrap-script.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/02-unified-bootstrap-script.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/02-unified-bootstrap-script.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/02-unified-bootstrap-script.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/03-docker-security-env-vars.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/03-docker-security-env-vars.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/03-docker-security-env-vars.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/03-docker-security-env-vars.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/04-bootstrap-security-hardening.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/04-bootstrap-security-hardening.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/04-bootstrap-security-hardening.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/04-bootstrap-security-hardening.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/05-repo-integration-strategy.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/05-repo-integration-strategy.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/05-repo-integration-strategy.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/05-repo-integration-strategy.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/06-precommit-code-quality.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/06-precommit-code-quality.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/06-precommit-code-quality.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/06-precommit-code-quality.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/07-role-documentation-dependencies.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/07-role-documentation-dependencies.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/07-role-documentation-dependencies.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/07-role-documentation-dependencies.md
diff --git a/.claude/plans/01-21-2026-dotfiles-improvement-plan/README.md b/docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/README.md
similarity index 100%
rename from .claude/plans/01-21-2026-dotfiles-improvement-plan/README.md
rename to docs/plans/2026-01/2026-01-21-dotfiles-improvement-plan/README.md
diff --git a/.claude/specs/2026-03-29-nas-docker-deploy-pipeline-design.md b/docs/plans/2026-03/2026-03-29-nas-docker-deploy-pipeline-design.md
similarity index 100%
rename from .claude/specs/2026-03-29-nas-docker-deploy-pipeline-design.md
rename to docs/plans/2026-03/2026-03-29-nas-docker-deploy-pipeline-design.md
diff --git a/.claude/plans/2026-03-29-nas-docker-deploy-pipeline.md b/docs/plans/2026-03/2026-03-29-nas-docker-deploy-pipeline-plan.md
similarity index 100%
rename from .claude/plans/2026-03-29-nas-docker-deploy-pipeline.md
rename to docs/plans/2026-03/2026-03-29-nas-docker-deploy-pipeline-plan.md
diff --git a/.claude/specs/2026-03-29-tailscale-setup-design.md b/docs/plans/2026-03/2026-03-29-tailscale-setup-design.md
similarity index 100%
rename from .claude/specs/2026-03-29-tailscale-setup-design.md
rename to docs/plans/2026-03/2026-03-29-tailscale-setup-design.md
diff --git a/.claude/plans/2026-03-29-tailscale-setup.md b/docs/plans/2026-03/2026-03-29-tailscale-setup-plan.md
similarity index 100%
rename from .claude/plans/2026-03-29-tailscale-setup.md
rename to docs/plans/2026-03/2026-03-29-tailscale-setup-plan.md
diff --git a/docs/superpowers/specs/2026-05-25-jellyfin-service-design.md b/docs/plans/2026-05/2026-05-25-jellyfin-service-design.md
similarity index 100%
rename from docs/superpowers/specs/2026-05-25-jellyfin-service-design.md
rename to docs/plans/2026-05/2026-05-25-jellyfin-service-design.md
diff --git a/docs/superpowers/plans/2026-05-25-jellyfin-service.md b/docs/plans/2026-05/2026-05-25-jellyfin-service-plan.md
similarity index 100%
rename from docs/superpowers/plans/2026-05-25-jellyfin-service.md
rename to docs/plans/2026-05/2026-05-25-jellyfin-service-plan.md
diff --git a/inventory/.gitkeep b/inventory/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/library/configure_network_interfaces.md b/library/configure_network_interfaces.md
index 11753f3..c3c8411 100644
--- a/library/configure_network_interfaces.md
+++ b/library/configure_network_interfaces.md
@@ -5,8 +5,8 @@ This module allows for configuration of macOS network interfaces by wrapping the
## Parameters
- `interfaces` (list): list of dictionaries describing desired port/service configuration (see
- [Interface Configuration](#interface-configuration)). Each entry corresponds to a hardware port
- and its associated network service on the host machine
+ [Interface Configuration](#interface-configuration)). Each entry corresponds to a hardware port and its associated
+ network service on the host machine
### Interface Configuration
@@ -29,28 +29,30 @@ Sub-options for each entry in `interfaces` parameter
- `address` (str, required): IPv6 address to set for interface
- `prefix_length` (str, required): IPv6 prefix length to set for interface
- `router` (str, optional): IPv6 router address to set for interface
-- `dns_servers` (list, optional): DNS servers to set for interface. If None, existing DNS servers will be preserved.
- If empty list, existing DNS servers will be cleared out
-- `search_domains` (list, optional): DNS servers to set for interface. If None, existing DNS servers will be
- preserved. If empty list, existing DNS servers will be cleared out
+- `dns_servers` (list, optional): DNS servers to set for interface. If None, existing DNS servers will be preserved. If
+ empty list, existing DNS servers will be cleared out
+- `search_domains` (list, optional): DNS servers to set for interface. If None, existing DNS servers will be preserved.
+ If empty list, existing DNS servers will be cleared out
- `hardware` (dict, optional): Hardware configuration for port associated with network interface. If not provided,
- hardware configuration will be automatically configured by macOS
- - `speed` (int, optional): Configure speed of network port. Note that not all ports support all speeds.
- Choices: 10, 100, 1000
+ hardware configuration will be automatically configured by macOS
+ - `speed` (int, optional): Configure speed of network port. Note that not all ports support all speeds. Choices: 10,
+ 100, 1000
- `duplex` (str, optional): Configure duplex setting of network port. Note that not all ports support all settings,
- and not all configurations support setting this at all. Choices: half-duplex, full-duplex
+ and not all configurations support setting this at all. Choices: half-duplex, full-duplex
- `flow_control` (bool, optional): Enable flow control for network port. Note that not all configurations support
- this. Required if `duplex` value is provided
+ this. Required if `duplex` value is provided
- `energy_efficient_ethernet` (bool, optional): Enable energy efficient ethernet for network port. Note that not all
- configurations support this. Required if `duplex` value is provided
+ configurations support this. Required if `duplex` value is provided
- `mtu` (int, optional): MTU setting of network port. If not provided, default MTU setting will be used
Note that the following parameters are mutually exclusive:
+
- `dhcp`
- `dhcp_with_manual_address`
- `manual`
For `ipv6` option, the following parameters are mutually exclusive:
+
- `off`
- `automatic`
- `link_local`
@@ -59,6 +61,7 @@ For `ipv6` option, the following parameters are mutually exclusive:
## Returns
### Always Returned
+
- `changed` (bool): whether any changes were made to the network configurations
- `changelog` (list): list of changes made to the network configurations
- `commands_run` (list): list of `networksetup` commands run during configuration
@@ -66,19 +69,21 @@ For `ipv6` option, the following parameters are mutually exclusive:
- `available_network_services` (list): list of information dictionaries describing network services available on host
### Returned on Failure
+
- `cmd` (str): `networksetup` command that was unable to run successfully
- `stdout` (str): Standard out of `networksetup` command that was unable to run successfully
- `stderr` (str): Standard err of `networksetup` command that was unable to run successfully
### Returned Conditionally Based on Configuration
+
- `valid_mtu_range_by_port` (dict): mapping from hardware port MAC address -> supported range of MTU values (min, max)
- `valid_media_by_port` (dict): mapping from hardware port MAC address -> list of dictionaries describing available
- media configurations for the port (speed, duplex, flow control, energy efficient
- ethernet)
+ media configurations for the port (speed, duplex, flow control, energy efficient ethernet)
## Examples
### Configure an interface for DHCP
+
```
- hosts: myhost.mydomain.com
tasks:
@@ -89,6 +94,7 @@ For `ipv6` option, the following parameters are mutually exclusive:
```
### Configure an interface for DHCP w/ manual IP address and set custom service name
+
```
- hosts: myhost.mydomain.com
tasks:
@@ -101,6 +107,7 @@ For `ipv6` option, the following parameters are mutually exclusive:
```
### Configure two interfaces, one for DHCP, one manually
+
```
- hosts: myhost.mydomain.com
tasks:
diff --git a/library/configure_network_interfaces.py b/library/configure_network_interfaces.py
index b5283fc..4138fac 100755
--- a/library/configure_network_interfaces.py
+++ b/library/configure_network_interfaces.py
@@ -431,8 +431,7 @@ def parse_getmtu(stdout: str) -> Optional[CurrentMTUConfig]:
"""
if m := MTU_REGEX.match(stdout):
return CurrentMTUConfig(current=int(m.group('current')), active=int(m.group('active')))
- else:
- return None
+ return None
def parse_listvalidmturange(stdout: str) -> Optional[tuple[int, int]]:
@@ -444,8 +443,7 @@ def parse_listvalidmturange(stdout: str) -> Optional[tuple[int, int]]:
"""
if m := MTU_RANGE_REGEX.match(stdout):
return int(m.group('min')), int(m.group('max'))
- else:
- return None
+ return None
def parse_media(media_str: str) -> Optional[HardwarePortMediaConfig]:
@@ -460,16 +458,15 @@ def parse_media(media_str: str) -> Optional[HardwarePortMediaConfig]:
"""
if m := MEDIA_CONFIG_REGEX.match(media_str):
speed_int = SPEED_REVERSE_MAPPING[m.group('speed')]
- media = [item.strip() for item in m.group('media').split(',')]
- duplex = 'half-duplex' if 'half-duplex' in media else 'full-duplex'
+ media = [item.strip() for item in m.group('media').split(',')]
+ duplex = 'half-duplex' if 'half-duplex' in media else 'full-duplex'
return HardwarePortMediaConfig(
speed=speed_int,
duplex=duplex,
flow_control='flow-control' in media,
energy_efficient_ethernet='energy-efficient-ethernet' in media,
)
- else:
- return None
+ return None
def parse_listvalidmedia(stdout: str) -> list[HardwarePortMediaConfig]:
@@ -504,8 +501,7 @@ def parse_getinfo(name: str, stdout: str) -> Optional[NetworkServiceInfo]:
if (info['configuration'] is None) or (info['configuration'] not in INTERFACE_CONFIGURATION_MAP):
return None
- else:
- info['configuration'] = INTERFACE_CONFIGURATION_MAP[info['configuration']]
+ info['configuration'] = INTERFACE_CONFIGURATION_MAP[info['configuration']]
ipv6_prefix_length: int | None = None
if ipv6_prefix_length_str := info.get('ipv6_prefix_length'):
@@ -740,7 +736,7 @@ def networksetup_cmd(self, cmd: str, *, check_rc: bool = True, **kwargs: Any) ->
:return: (return code, stdout, stderr)
"""
if cmd.startswith('networksetup'):
- cmd = cmd.lstrip('networksetup').lstrip()
+ cmd = cmd.removeprefix('networksetup').lstrip()
full_cmd = f'sudo {self.bin} {cmd}'
self.result['commands_run'].append(full_cmd)
@@ -749,8 +745,7 @@ def networksetup_cmd(self, cmd: str, *, check_rc: bool = True, **kwargs: Any) ->
if (rc != 0) and check_rc:
self._update_result(cmd=full_cmd, returncode=rc, stdout=stdout, stderr=stderr)
raise ConfigurationError(f'Command `networksetup {cmd}` failed: {stderr}')
- else:
- return rc, stdout, stderr
+ return rc, stdout, stderr
""" ``networksetup`` command execution/parsing helpers """
@@ -785,13 +780,14 @@ def get_valid_mtu_range(self, hardware_port: str) -> tuple[int, int]:
"""
cmd = f'networksetup -listvalidMTUrange "{hardware_port}"'
rc, stdout, stderr = self.networksetup_cmd(cmd)
+
result = parse_listvalidmturange(stdout)
if result is not None:
self.result.setdefault('valid_mtu_range_by_port', {})[hardware_port] = (result[0], result[1])
return result[0], result[1]
- else:
- self._update_result(cmd=cmd, returncode=rc, stdout=stdout, stderr=stderr)
- raise ConfigurationError(f'Unable to parse valid MTU range for hardware port "{hardware_port}"')
+
+ self._update_result(cmd=cmd, returncode=rc, stdout=stdout, stderr=stderr)
+ raise ConfigurationError(f'Unable to parse valid MTU range for hardware port "{hardware_port}"')
def get_port_mtu(self, hardware_port: str) -> CurrentMTUConfig:
"""
@@ -803,12 +799,13 @@ def get_port_mtu(self, hardware_port: str) -> CurrentMTUConfig:
"""
cmd = f'networksetup -getMTU "{hardware_port}"'
rc, stdout, stderr = self.networksetup_cmd(cmd)
+
result = parse_getmtu(stdout)
if result is not None:
return result
- else:
- self._update_result(cmd=cmd, returncode=rc, stdout=stdout, stderr=stderr)
- raise ConfigurationError(f'Unable to parse current/active MTU values for hardware port "{hardware_port}"')
+
+ self._update_result(cmd=cmd, returncode=rc, stdout=stdout, stderr=stderr)
+ raise ConfigurationError(f'Unable to parse current/active MTU values for hardware port "{hardware_port}"')
def get_port_media_configuration(self, hardware_port: str) -> CurrentHardwarePortMediaConfig:
"""
@@ -830,14 +827,13 @@ def get_port_media_configuration(self, hardware_port: str) -> CurrentHardwarePor
current: Union[HardwarePortMediaConfig, str]
if current_str == 'autoselect':
current = 'autoselect'
+ elif media_config := parse_media(current_str):
+ current = media_config
else:
- if media_config := parse_media(current_str):
- current = media_config
- else:
- raise ConfigurationError(f'Unable to parse current media configuration for port "{hardware_port}"')
+ raise ConfigurationError(f'Unable to parse current media configuration for port "{hardware_port}"')
active_str = lines[1].replace('Active: ', '').strip()
- active = parse_media(active_str)
+ active = parse_media(active_str)
if active is None:
raise ConfigurationError(f'Unable to parse active media configuration for port "{hardware_port}"')
@@ -872,8 +868,7 @@ def get_network_service_info(self, service: str, *, update_result_on_error: bool
if update_result_on_error:
self._update_result(cmd=cmd, returncode=rc, stdout=stdout, stderr=stderr)
raise ConfigurationError(f'Unable to parse network configuration for service "{service}"')
- else:
- return info
+ return info
def get_network_services_by_mac_address(self) -> dict[str, NetworkServiceInfo]:
"""
@@ -899,7 +894,7 @@ def get_network_services_by_mac_address(self) -> dict[str, NetworkServiceInfo]:
all_service_info[service_info.address] = service_info
self.result['available_network_services'] = all_service_info
- self._network_services_by_mac_address = all_service_info
+ self._network_services_by_mac_address = all_service_info
return self._network_services_by_mac_address
@@ -913,8 +908,7 @@ def get_network_service_dns_servers(self, service: str) -> list[str]:
_, stdout, _ = self.networksetup_cmd(f'networksetup -getdnsservers "{service}"')
if "There aren't any" in stdout:
return []
- else:
- return [line.strip() for line in stdout.splitlines()]
+ return [line.strip() for line in stdout.splitlines()]
def get_network_service_search_domains(self, service: str) -> list[str]:
"""
@@ -926,8 +920,7 @@ def get_network_service_search_domains(self, service: str) -> list[str]:
_, stdout, _ = self.networksetup_cmd(f'networksetup -getsearchdomains "{service}"')
if "There aren't any" in stdout:
return []
- else:
- return [line.strip() for line in stdout.splitlines()]
+ return [line.strip() for line in stdout.splitlines()]
""" Main configuration command methods """
@@ -964,17 +957,16 @@ def validate_config(self) -> None:
f'MTU setting ({mtu}) for interface {i} ({port_info.address}) below minimum supported value '
f'for hardware port (supported range: {min}-{max})'
)
- elif mtu > max:
+ if mtu > max:
raise ConfigurationError(
f'MTU setting ({mtu}) for interface {i} ({port_info.address}) above maximum supported value '
f'for hardware port (supported range: {min}-{max})'
)
# Validate hardware settings (all other than MTU are gated on speed being present)
- speed = hardware.get('speed')
- if speed:
+ if speed := hardware.get('speed'):
supported_media = self.get_valid_port_media_configurations(port_info.name)
- media_config = HardwarePortMediaConfig(
+ media_config = HardwarePortMediaConfig(
speed=speed,
duplex='half-duplex' if hardware['duplex'] in ['half', 'half-duplex'] else 'full-duplex',
flow_control=hardware['flow_control'],
@@ -1012,11 +1004,7 @@ def configure_interface(self, config: dict[str, Any]) -> None:
elif config['dhcp_with_manual_address']:
new_ip_address = config['dhcp_with_manual_address']['ip_address']
self.networksetup_cmd(f'networksetup -setmanualwithdhcprouter "{service.name}" "{new_ip_address}"')
- if service.configuration != 'dhcp_with_manual_address':
- self.result['changelog'].append(
- f'Set service "{service.name}" to DHCP w/ manual address "{new_ip_address}"'
- )
- elif service.ip_address != new_ip_address:
+ if service.configuration != 'dhcp_with_manual_address' or service.ip_address != new_ip_address:
self.result['changelog'].append(
f'Set service "{service.name}" to DHCP w/ manual address "{new_ip_address}"'
)
@@ -1175,7 +1163,7 @@ def run(self) -> None:
self.validate_config()
# Configure interfaces
- for i, interface in enumerate(self.interfaces):
+ for _, interface in enumerate(self.interfaces):
self.configure_interface(interface)
diff --git a/library/osx_pmset.py b/library/osx_pmset.py
index 20e1973..572f869 100755
--- a/library/osx_pmset.py
+++ b/library/osx_pmset.py
@@ -106,7 +106,7 @@ def run_module() -> None:
commands: list[Any] = []
result: dict[str, Any] = {
'changed': False,
- 'diff': {
+ 'diff': {
'before': '',
'after': '',
},
@@ -114,7 +114,7 @@ def run_module() -> None:
def add_diff(block: str, param: str, old_value: Any, new_value: Any) -> None:
result['diff']['before'] += f'{block}.{param}={old_value}\n'
- result['diff']['after'] += f'{block}.{param}={new_value}\n'
+ result['diff']['after'] += f'{block}.{param}={new_value}\n'
blocks: list[tuple[str, str, Any]] = [
('on_battery', '-b', output['Battery Power']),
@@ -124,7 +124,7 @@ def add_diff(block: str, param: str, old_value: Any, new_value: Any) -> None:
for param, value in module.params[block].items():
if value is None:
continue
- elif param not in current_values:
+ if param not in current_values:
module.fail_json(
msg=(
f'{param} is not present in pmset output. '
diff --git a/local_bootstrap.sh b/local_bootstrap.sh
index 3c88966..4475360 100755
--- a/local_bootstrap.sh
+++ b/local_bootstrap.sh
@@ -12,13 +12,11 @@ set -e
# Determine script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOTFILES_DIR="${SCRIPT_DIR}"
-HOME_DIR=$HOME
-USERNAME=$(whoami)
# Validate we're in the right place by checking for required files/directories
-if [[ ! -f "${DOTFILES_DIR}/run.py" ]] || \
- [[ ! -f "${DOTFILES_DIR}/local_bootstrap.sh" ]] || \
- [[ ! -d "${DOTFILES_DIR}/playbooks" ]]; then
+if [[ ! -f "${DOTFILES_DIR}/run.py" ]] ||
+ [[ ! -f "${DOTFILES_DIR}/local_bootstrap.sh" ]] ||
+ [[ ! -d "${DOTFILES_DIR}/playbooks" ]]; then
echo -e "${RED}ERROR: This doesn't appear to be the dotfiles directory!${NC}"
echo -e "${RED}Expected to find: run.py, local_bootstrap.sh, playbooks/${NC}"
echo -e "${RED}Current directory: ${DOTFILES_DIR}${NC}"
diff --git a/nas_bootstrap.sh b/nas_bootstrap.sh
index f5aa322..405011a 100755
--- a/nas_bootstrap.sh
+++ b/nas_bootstrap.sh
@@ -12,13 +12,11 @@ set -e
# Determine script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOTFILES_DIR="${SCRIPT_DIR}"
-HOME_DIR=$HOME
-USERNAME=$(whoami)
# Validate we're in the right place by checking for required files/directories
-if [[ ! -f "${DOTFILES_DIR}/run.py" ]] || \
- [[ ! -f "${DOTFILES_DIR}/nas_bootstrap.sh" ]] || \
- [[ ! -d "${DOTFILES_DIR}/playbooks" ]]; then
+if [[ ! -f "${DOTFILES_DIR}/run.py" ]] ||
+ [[ ! -f "${DOTFILES_DIR}/nas_bootstrap.sh" ]] ||
+ [[ ! -d "${DOTFILES_DIR}/playbooks" ]]; then
echo -e "${RED}ERROR: This doesn't appear to be the dotfiles directory!${NC}"
echo -e "${RED}Expected to find: run.py, nas_bootstrap.sh, playbooks/${NC}"
echo -e "${RED}Current directory: ${DOTFILES_DIR}${NC}"
diff --git a/playbooks/README.md b/playbooks/README.md
index 1dba2c4..2175552 100644
--- a/playbooks/README.md
+++ b/playbooks/README.md
@@ -19,12 +19,13 @@ different environments: personal macOS machines and home NAS/server infrastructu
The playbook system provides two complete environment bootstraps:
-| Playbook | Target | Bootstrap Script | Purpose |
-|----------|--------|------------------|---------|
-| `local_bootstrap.yml` | macOS (localhost) | `local_bootstrap.sh` | Personal Mac configuration with GUI apps, dev tools, and system settings |
-| `nas_bootstrap.yml` | Debian server (nas host) | `nas_bootstrap.sh` | Home NAS with Docker media stack, file sharing, and network services |
+| Playbook | Target | Bootstrap Script | Purpose |
+| --------------------- | ------------------------ | -------------------- | ------------------------------------------------------------------------ |
+| `local_bootstrap.yml` | macOS (localhost) | `local_bootstrap.sh` | Personal Mac configuration with GUI apps, dev tools, and system settings |
+| `nas_bootstrap.yml` | Debian server (nas host) | `nas_bootstrap.sh` | Home NAS with Docker media stack, file sharing, and network services |
Both playbooks:
+
- Require inventory from separate git submodule (`inventory/`)
- Use encrypted Ansible Vault for secrets
- Support selective execution via tags
@@ -40,28 +41,29 @@ Configures personal macOS machines with development environment, GUI application
**Role execution order:**
-1. **zsh** - Shell configuration and plugins
- - Tags: `zsh`, `configfile`
-2. **macos** - Package manager setup (Homebrew, casks, Mac App Store)
- - Tags: `macos`
-3. **macos_settings** - System preferences (Finder, Activity Monitor, etc.)
- - Tags: `macos`, `dock`
-4. **macos_dock** - Dock configuration and layout
- - Tags: `macos`, `dock`
-5. **git** - Git configuration and gh CLI
- - Tags: `git`, `configfile`
-6. **hammerspoon** - Window management automation
- - Tags: `hammerspoon`, `configfile`
-7. **iterm** - iTerm2 terminal configuration
- - Tags: `iterm`
-8. **ssh** - SSH client configuration
- - Tags: `ssh`, `configfile`
-9. **starship** - Shell prompt configuration
- - Tags: `starship`
+01. **zsh** - Shell configuration and plugins
+ - Tags: `zsh`, `configfile`
+02. **macos** - Package manager setup (Homebrew, casks, Mac App Store)
+ - Tags: `macos`
+03. **macos_settings** - System preferences (Finder, Activity Monitor, etc.)
+ - Tags: `macos`, `dock`
+04. **macos_dock** - Dock configuration and layout
+ - Tags: `macos`, `dock`
+05. **git** - Git configuration and gh CLI
+ - Tags: `git`, `configfile`
+06. **hammerspoon** - Window management automation
+ - Tags: `hammerspoon`, `configfile`
+07. **iterm** - iTerm2 terminal configuration
+ - Tags: `iterm`
+08. **ssh** - SSH client configuration
+ - Tags: `ssh`, `configfile`
+09. **starship** - Shell prompt configuration
+ - Tags: `starship`
10. **python** - Python tooling (pipx, uv, hatch)
- Tags: `python`
**Variables required:**
+
- `admin_password` (from `inventory/group_vars/all/vault.yml` or `inventory/group_vars/localhost/vault.yml`)
**Common usage examples:**
@@ -110,6 +112,7 @@ Configures home NAS server running Debian with Docker media stack, ZFS storage,
- Tags: `zsh`, `configfile`
**Variables required:**
+
- `admin_password` (from `inventory/group_vars/all/vault.yml` or `inventory/host_vars/nas/vault.yml`)
**Common usage examples:**
@@ -145,6 +148,7 @@ chmod u+x nas_bootstrap.sh
```
**What the scripts do:**
+
1. Validate repository structure (checks for `run.py`, playbooks, etc.)
2. Install Homebrew with checksum verification (if not present)
3. Install Ansible via Homebrew (if not present)
@@ -263,14 +267,16 @@ fi
```
**Why:** Protects against:
+
- Man-in-the-middle attacks
- DNS hijacking
- Compromised download sources
**When checksum changes:**
+
1. Verify new installer at https://github.com/Homebrew/install
2. Generate new checksum:
-`curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | shasum -a 256`
+ `curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | shasum -a 256`
3. Update `EXPECTED_CHECKSUM` in both bootstrap scripts
4. Update "Last updated" comment
@@ -287,26 +293,31 @@ set -e # Exit immediately on any command failure
**Both scripts follow this pattern:**
1. **Setup**
+
- Define color codes for output
- Enable fail-fast (`set -e`)
- Determine script directory
2. **Validation**
+
- Check for required files/directories
- Exit with error if validation fails
3. **Dependency Installation**
+
- Install Homebrew if missing (with checksum verification)
- Add Homebrew to PATH
- Install Ansible via Homebrew if missing
4. **Playbook Execution**
+
- Run corresponding playbook with:
- Inventory file: `inventory/inventory.yml`
- Become password prompt: `--ask-become-pass`
- Verbose output: `-vv`
5. **Post-Bootstrap** (macOS only)
+
- Kick off macOS software update
- Note: System may restart automatically
@@ -314,10 +325,10 @@ set -e # Exit immediately on any command failure
### Tag Categories
-**Role-specific tags:**
-Each role has its own tag matching the role name (e.g., `zsh`, `git`, `macos`).
+**Role-specific tags:** Each role has its own tag matching the role name (e.g., `zsh`, `git`, `macos`).
**Functional tags:**
+
- `configfile` - All roles that create/link configuration files
- `macos` - All macOS-specific roles (app installation + settings)
- `dock` - Dock-related configuration (settings + layout)
@@ -331,13 +342,13 @@ Each role has its own tag matching the role name (e.g., `zsh`, `git`, `macos`).
Roles can have multiple tags for flexible targeting:
```yaml
-- include_role:
- name: roles/zsh
- apply:
- tags: zsh
- tags:
- - zsh
- - configfile
+ - include_role:
+ name: roles/zsh
+ apply:
+ tags: zsh
+ tags:
+ - zsh
+ - configfile
```
This role runs when either `zsh` OR `configfile` tags are specified.
@@ -368,6 +379,7 @@ This role runs when either `zsh` OR `configfile` tags are specified.
**All secrets are encrypted using Ansible Vault:**
1. Create `vault_password.txt` in repository root (gitignored):
+
```bash
echo "your-vault-password" > vault_password.txt
chmod 600 vault_password.txt
@@ -376,11 +388,13 @@ This role runs when either `zsh` OR `configfile` tags are specified.
2. Ansible automatically uses this file (configured in `ansible.cfg` or inventory)
3. Encrypted vault files in inventory:
+
- `inventory/group_vars/all/vault.yml` - Shared secrets
- `inventory/group_vars/localhost/vault.yml` - Mac-specific secrets
- `inventory/host_vars/nas/vault.yml` - NAS-specific secrets
**Never commit:**
+
- `vault_password.txt`
- Decrypted vault contents
- Plain-text passwords or API keys
@@ -390,15 +404,16 @@ This role runs when either `zsh` OR `configfile` tags are specified.
Both playbooks set `ansible_become_pass` from vault at the start:
```yaml
-- name: Set ansible_become_pass
- ansible.builtin.set_fact:
- ansible_become_pass: "{{ admin_password }}"
- no_log: true # Prevents password from appearing in logs
- tags:
- - always
+ - name: Set ansible_become_pass
+ ansible.builtin.set_fact:
+ ansible_become_pass: '{{ admin_password }}'
+ no_log: true # Prevents password from appearing in logs
+ tags:
+ - always
```
**Benefits:**
+
- Only one password prompt (via `--ask-become-pass`)
- Password available to all roles for privilege escalation
- Not logged to output (even with `-vvv`)
@@ -408,16 +423,19 @@ Both playbooks set `ansible_become_pass` from vault at the start:
For remote playbooks (like `nas_bootstrap.yml`):
1. Ensure SSH key access to target host:
+
```bash
ssh-copy-id user@nas-hostname
```
2. Verify connection:
+
```bash
ansible nas -i inventory/inventory.yml -m ping
```
3. If using custom SSH keys, configure in `inventory/inventory.yml`:
+
```yaml
nas:
ansible_host: 192.168.1.100
@@ -430,12 +448,13 @@ For remote playbooks (like `nas_bootstrap.yml`):
### Inventory Not Found
**Symptom:**
+
```
ERROR! the playbook could not be found
```
-**Solution:**
-Update inventory submodule:
+**Solution:** Update inventory submodule:
+
```bash
python3 run.py update-inventory
# Or manually:
@@ -445,11 +464,13 @@ git submodule update --init --recursive
### Vault Password Issues
**Symptom:**
+
```
ERROR! Attempting to decrypt but no vault secrets found
```
**Solution:**
+
1. Verify `vault_password.txt` exists in repository root
2. Ensure file contains correct password
3. Check file permissions: `chmod 600 vault_password.txt`
@@ -458,11 +479,13 @@ ERROR! Attempting to decrypt but no vault secrets found
### Homebrew Checksum Mismatch
**Symptom:**
+
```
WARNING: Homebrew installer checksum mismatch!
```
**Solution:**
+
1. Verify if Homebrew installer was legitimately updated:
- Check https://github.com/Homebrew/install for recent commits
2. Generate new checksum:
@@ -476,12 +499,14 @@ WARNING: Homebrew installer checksum mismatch!
### Role Failures
**Symptom:**
+
```
TASK [roles/some-role : Some task] *****************************************
fatal: [localhost]: FAILED! => {...}
```
**Solution:**
+
1. Check verbose output for specific error
2. Run specific role with extra verbosity:
```bash
@@ -496,10 +521,10 @@ fatal: [localhost]: FAILED! => {...}
### macOS Dock Configuration Not Applying
-**Symptom:**
-Dock settings changed by playbook revert after restart
+**Symptom:** Dock settings changed by playbook revert after restart
**Solution:**
+
1. Kill Dock to force reload:
```bash
killall Dock
@@ -509,10 +534,10 @@ Dock settings changed by playbook revert after restart
### Docker Stack Issues (NAS)
-**Symptom:**
-Docker containers not starting or configuration not applied
+**Symptom:** Docker containers not starting or configuration not applied
**Solution:**
+
1. Check Docker service status:
```bash
ssh nas "sudo systemctl status docker"
@@ -533,11 +558,13 @@ Docker containers not starting or configuration not applied
### Permission Denied Errors
**Symptom:**
+
```
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Permission denied"}
```
**Solution:**
+
1. Verify `--ask-become-pass` flag is used
2. Check become password is correct in vault
3. Ensure user has sudo privileges
@@ -555,6 +582,7 @@ fatal: [localhost]: FAILED! => {"changed": false, "msg": "Permission denied"}
### Development Workflow
1. **Test with specific tags:**
+
```bash
ansible-playbook playbooks/local_bootstrap.yml \
-i inventory/inventory.yml \
@@ -565,6 +593,7 @@ fatal: [localhost]: FAILED! => {"changed": false, "msg": "Permission denied"}
```
2. **Use check mode for validation:**
+
```bash
ansible-playbook playbooks/local_bootstrap.yml \
-i inventory/inventory.yml \
@@ -573,6 +602,7 @@ fatal: [localhost]: FAILED! => {"changed": false, "msg": "Permission denied"}
```
3. **Run full bootstrap periodically:**
+
- Weekly for active development machines
- Monthly for stable production systems
- After major OS updates
@@ -582,13 +612,13 @@ fatal: [localhost]: FAILED! => {"changed": false, "msg": "Permission denied"}
1. Create role in `roles/` directory
2. Add to appropriate playbook:
```yaml
- - include_role:
- name: roles/new-role
- apply:
- tags: new-role
- tags:
- - new-role
- - configfile # If it creates config files
+ - include_role:
+ name: roles/new-role
+ apply:
+ tags: new-role
+ tags:
+ - new-role
+ - configfile # If it creates config files
```
3. Test with tag:
```bash
diff --git a/pyproject.toml b/pyproject.toml
index a7655eb..dcf3b76 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,53 +1,129 @@
-####################
-# Project metadata #
-####################
+# ####################################################################################################################
+# Project metadata
+# ####################################################################################################################
[project]
-name = "connor-mason-dotfiles"
-version = "0.0.1"
+name = "dotfiles"
+version = "0.0.1"
description = "Connor's dotfiles"
+readme = "README.md"
+authors = [{name = "Connor Mason"}]
+keywords = ["dotfiles", "config", "configuration", "shell"]
+classifiers = [
+ # keep-sorted start
+ "Development Status :: 4 - Beta",
+ "Environment :: Console",
+ "Natural Language :: English",
+ "Operating System :: MacOS :: MacOS X",
+ "Operating System :: MacOS",
+ "Programming Language :: Python",
+ "Topic :: Software Development",
+ "Typing :: Typed",
+ # keep-sorted end
+]
# Project requirements
# ====================
-requires-python = ">= 3.10"
-dependencies = [
+requires-python = ">= 3.12"
+dependencies = [
+ # keep-sorted start case=no
+ "ansible-core",
+ "PyYaml >= 6.0",
"typing-extensions",
+ # keep-sorted end
]
# Project URLs
# ============
[project.urls]
-Homepage = "https://github.com/connormason/dotclaude"
-Repository = "https://github.com/connormason/dotclaude"
-Source = "https://github.com/connormason/dotclaude"
+Homepage = "https://github.com/connormason/dotfiles"
+Repository = "https://github.com/connormason/dotfiles"
+Source = "https://github.com/connormason/dotfiles"
# Project dependency groups
# =========================
[dependency-groups]
dev = [
{include-group = "lint"},
+ {include-group = "test"},
]
lint = [
+ # keep-sorted start case=no
+ "codespell",
+ "flake8-dunder-all",
"interrogate >= 1.7.0",
+ "mdformat",
+ "mdformat-config",
+ "mdformat-footnote",
+ "mdformat-front-matters",
+ "mdformat-gfm",
+ "mdformat-gfm-alerts",
+ "mdformat-pyproject",
+ "mdformat-simple-breaks",
+ "mdformat-toc",
+ "mypy >= 1.19.1, < 1.20 ; python_version < '3.10'",
+ "mypy >= 2.0 ; python_version >= '3.10'",
+ "prek",
+ "pyjson5",
"ruff >= 0.15.8",
- "mypy >= 1.19.1",
- "pre-commit",
+ "shellcheck-py",
+ "shfmt_py",
+ "taplo",
"types-PyYaml",
"types-requests",
+ "types-toml",
+ "validate-pyproject-schema-store",
+ "validate-pyproject[all]",
+ "yamllint",
+ # keep-sorted end
+]
+test = [
+ # keep-sorted start case=no
+ "coverage[toml] >= 7.11",
+ "pytest >= 9.0",
+ "pytest-clarity >= 1.0.1", # Better assertion diffs
+ "pytest-html >= 3.2.0", # HTML test reports
+ "pytest-mock >= 3.11.1", # Mocking support
+ "pytest-sugar >= 0.9.7", # Better pytest output
+ "pytest-timeout >= 2.1.0", # Test timeouts
+ "pytest-xdist >= 3.3.0", # Parallel test execution
+ "pyyaml >= 6.0.1", # YAML parsing
+ "yamllint >= 1.32.0", # Ansible testing
+ # keep-sorted end
]
-# ####################
-# Tool configuration #
-# ####################
+# ####################################################################################################################
+# Package management tool configuration
+# ####################################################################################################################
+
+# `uv` configuration
+# ==================
+[tool.uv]
+default-groups = ["dev"]
+
+[[tool.uv.index]]
+name = "default"
+url = "https://pypi.org/simple"
+default = true
+
+# ####################################################################################################################
+# Linting tool configuration
+# ####################################################################################################################
# `codespell` configuration
+# =========================
[tool.codespell]
-count = true
-summary = true
-skip = "smb.conf.j2"
+count = true
+summary = true
+skip = "smb.conf.j2"
ignore-words-list = [
+ # keep-sorted start
+ "*.css",
+ "*.html",
"hass",
"iterm",
+ "wit",
+ # keep-sorted end
]
# `interrogate` configuration
@@ -66,11 +142,21 @@ ignore-private = true
ignore-property-decorators = false
ignore-semiprivate = true
ignore-setters = true
+
+# NOTE: This exclude list not currently considered when `interrogate` is run with pre-commit (prek) hooks.
+# Excluded files are duplicated in .pre-commit-config.yaml
exclude = [
+ # keep-sorted start
+ ".cicd/",
+ ".claude/",
+ ".nox/",
".tmp/",
+ ".worktrees/",
+ "archive/",
"build/",
"docs/",
"tests/",
+ # keep-sorted end
]
# `mypy` configuration
@@ -79,85 +165,308 @@ exclude = [
disallow_untyped_defs = true
exclude_gitignore = true
ignore_missing_imports = true
-python_version = "3.9"
+python_version = "3.9" # TODO: update
warn_unused_configs = true
exclude = [
- "services/homeassistant/"
+ "services/homeassistant/",
]
-# `pytest` configuration
-# ======================
-[tool.pytest.ini_options]
-testpaths = ["tests"]
+[[tool.mypy.overrides]]
+disable_error_code = ["override"]
+module = ["run"]
-# `ruff` configuration
-# ====================
+# `ruff` global configuration
+# ===========================
[tool.ruff]
-line-length = 120
-indent-width = 4
-target-version = "py39"
+indent-width = 4
+line-length = 120
+output-format = "full"
+respect-gitignore = true
+show-fixes = true
+target-version = "py39"
+
extend-exclude = [
"docs/",
"services/homeassistant/config/custom_components/",
]
+# `ruff check` linter configuration
+# =================================
[tool.ruff.lint]
-typing-extensions = true
-typing-modules = ["typing", "types", "typing_extensions"]
+future-annotations = true
+typing-extensions = true
+typing-modules = ["typing", "types", "typing_extensions"]
+
+exclude = [
+ "archive/**/*.py",
+]
+extend-safe-fixes = [
+ # keep-sorted start
+ "B007", # Loop control variable{name}not used within loop body
+ "B028", # No explicit `stacklevel` keyword argument found
+ "D400", # First line should end with a period
+ # keep-sorted end
+]
extend-select = [
- "ANN", # flake8-annotations
- "C4", # flake8-comprehensions
- "COM", # flake8-commas
- "EXE", # flake8-executable
- "FA", # flake8-future-imports
- "I", # isort
- "ICN", # flake8-import-convetions
- "INP", # flake8-no-pep420
- "ISC", # flake8-implicit-str-concat
- "LOG", # flake8-logging
- "PGH", # pygrep-hooks
- "PT", # flake8-pytest-style
- "PTH", # flake8-use-pathlib
- "Q", # flake8-quotes
- "RSE", # flake8-raise
- "RUF", # ruff
- "TC", # flake8-type-checking
- "TID", # flake8-tidy-imports
- "UP", # pyupgrade
- "W", # pycodestyle warnings
+ # keep-sorted start
+ "ANN", # flake8-annotations
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "COM", # flake8-commas
+ "E", # pycodestyle errors [E4, E7, E9 included by default (lint.select)]
+ "EXE", # flake8-executable
+ "F", # pyflakes [included by default (lint.select)]
+ "FA", # flake8-future-imports
+ "FURB", # refurb
+ "I", # isort
+ "ICN", # flake8-import-conventions
+ "INP", # flake8-no-pep420
+ "ISC", # flake8-implicit-str-concat
+ "LOG", # flake8-logging
+ "PERF", # perflint
+ "PGH", # pygrep-hooks
+ "PIE", # flake8-pie
+ "PLC", # Pylint - Convention
+ "PLE", # Pylint - Error
+ "PLR", # Pylint - Refactor
+ "PLW", # Pylint - Warning
+ "PT", # flake8-pytest-style
+ "PTH", # flake8-use-pathlib
+ "PYI", # flake8-pyi
+ "Q", # flake8-quotes
+ "RET", # flake8-return
+ "RSE", # flake8-raise
+ "RUF", # ruff
+ "S", # flake8-bandit
+ "SIM", # flake8-simplify
+ "SLOT", # flake8-slots
+ "T10", # flake8-debugger
+ "TC", # flake8-type-checking
+ "TID", # flake8-tidy-imports
+ "UP", # pyupgrade
+ "W", # pycodestyle warnings
+ "YTT", # flake8-2020
+ # keep-sorted end
]
ignore = [
- "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name}
- "C408", # Unnecessary {kind}() call (rewrite as a literal)
- "COM812", # Trailing comma missing
- "PT011", # pytest.raises({exception}) is too broad, set the match parameter or use a more specific exception
- "PT012", # pytest.raises() block should contain a single simple statement
- "PT017", # Found assertion on exception {name} in except block, use pytest.raises() instead
- "RUF022", # `__all__` is not sorted
+ # flake8-annotations ----------------------------------------------------------------------------------------------
+ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name}
+ # flake8-bugbear --------------------------------------------------------------------------------------------------
+ "B009", # Do not call `getattr` with a constant attribute value
+ "B010", # Do not call `setattr` with a constant attribute value
+ # flake8-comprehensions -------------------------------------------------------------------------------------------
+ "C408", # Unnecessary {kind}() call (rewrite as a literal)
+ # flake8-commas ---------------------------------------------------------------------------------------------------
+ "COM812", # Trailing comma missing
+ # perflint --------------------------------------------------------------------------------------------------------
+ "PERF203", # `try`-`except` within a loop incurs performance overhead
+ # flake8-pie ------------------------------------------------------------------------------------------------------
+ "PIE790", # Unnecessary `pass` statement
+ "PIE796", # Enum contains duplicate value: `{value}`
+ # Pylint - Convention ---------------------------------------------------------------------------------------------
+ "PLC0414", # Import alias does not rename original package
+ "PLC0415", # `import` should be at the top-level of a file
+ # Pylint - Refactor -----------------------------------------------------------------------------------------------
+ "PLR0911", # Too many return statements
+ "PLR0912", # Too many branches ({branches} > {max_branches})
+ "PLR0913", # Too many arguments in function definition ({c_args} > {max_args})
+ "PLR0915", # Too many statements ({statements} > {max_statements})
+ # Pylint - Warning ------------------------------------------------------------------------------------------------
+ "PLW0603", # Using the global statement to update {name} is discouraged
+ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
+ # flake8-pytest-style ---------------------------------------------------------------------------------------------
+ "PT011", # pytest.raises({exception}) is too broad, set the match parameter or use a more specific exception
+ "PT012", # pytest.raises() block should contain a single simple statement
+ "PT017", # Found assertion on exception {name} in except block, use pytest.raises() instead
+ # flake8-pyi ------------------------------------------------------------------------------------------------------
+ "PYI051", # `Literal[{literal}]` is redundant in a union with `{builtin_type}`
+ # flake8-bandit ---------------------------------------------------------------------------------------------------
+ "S108", # Probable insecure usage of temporary file or directory: "{}"
+ "S110", # `try`-`except`-`pass` detected, consider logging the exception
+ "S603", # `subprocess` call: check for execution of untrusted input
+ "S607", # Starting a process with a partial executable path
+ "S608", # Possible SQL injection vector through string-based query construction
+ # flake8-simplify -------------------------------------------------------------------------------------------------
+ "SIM118", # Use `key {operator} dict` instead of `key {operator} dict.keys()`
+
+ # TODO: ??
+ # "RUF022", # `__all__` is not sorted
]
+
+# `ruff check` linter per-file rule ignores
+# =========================================
[tool.ruff.lint.per-file-ignores]
-"scripts/env/venvshell.py" = ["RUF001"] # String contains ambiguous {}. Did you mean {}?
+# keep-sorted start
+"run.py" = ["S112"]
+"scripts/env/venvshell.py" = ["RUF001"]
+"services/homeassistant/config/scripts/get_lutron_cert.py" = ["S"]
+"tests/**" = ["B", "S", "SIM", "PERF"]
+# keep-sorted end
-[tool.ruff.lint.isort]
-force-single-line = true
-required-imports = ["from __future__ import annotations"]
+# `ruff check` linter rule group-specific settings
+# ================================================
+# "flake8-annotations" lint rule settings [ANN]
+# ---------------------------------------------
+[tool.ruff.lint.flake8-annotations]
+suppress-dummy-args = true
+# "flake8-quotes" lint rule settings [Q]
+# --------------------------------------
[tool.ruff.lint.flake8-quotes]
-inline-quotes = "single"
+docstring-quotes = "double"
+inline-quotes = "single"
+multiline-quotes = "double"
+# "flake8-type-checking" lint rule settings [TC]
+# ----------------------------------------------
[tool.ruff.lint.flake8-type-checking]
-exempt-modules = ["typing", "typing_extensions"]
+exempt-modules = ["collections.abc", "typing", "typing_extensions"]
runtime-evaluated-base-classes = ["pydantic.BaseModel"]
+runtime-evaluated-decorators = []
+
+# "isort" lint rule settings [I]
+# ------------------------------
+[tool.ruff.lint.isort]
+force-single-line = true
+required-imports = ["from __future__ import annotations"]
+
+# "pycodestyle" lint rule settings [E, W]
+# ---------------------------------------
+[tool.ruff.lint.pycodestyle]
+ignore-overlong-task-comments = true
+max-doc-length = 120
+max-line-length = 120
+
+# "pydocstyle" lint rule settings [D]
+# -----------------------------------
+[tool.ruff.lint.pydocstyle]
+convention = "numpy"
+ignore-decorators = ["typing.overload"]
+property-decorators = []
+
+# "pylint" lint rule settings [PLC, PLE, PLR, PLW]
+# ------------------------------------------------
+[tool.ruff.lint.pylint]
+allow-magic-value-types = ["str", "bytes", "int", "float"] # PLR2004
+# "pyupgrade" lint rule settings [UP]
+# -----------------------------------
[tool.ruff.lint.pyupgrade]
keep-runtime-typing = true
-# `uv` configuration
-# ==================
-[[tool.uv.index]]
-name = "default"
-url = "https://pypi.org/simple"
-default = true
+
+# ####################################################################################################################
+# Formatting tool configuration
+# ####################################################################################################################
+
+# `mdformat` configuration
+# ========================
+[tool.mdformat]
+end_of_line = "lf" # options: {"lf", "crlf", "keep"}
+number = true # options: {false, true}
+validate = true # options: {false, true}
+wrap = 120 # options: {"keep", "no", INTEGER}
+
+# `ruff format` configuration
+# ===========================
+[tool.ruff.format]
+docstring-code-format = true
+exclude = [".gitignore"]
+indent-style = "space"
+quote-style = "single"
+
+
+# ####################################################################################################################
+# Testing tool configuration
+# ####################################################################################################################
+
+# `pytest` configuration
+# ======================
+[tool.pytest.ini_options]
+minversion = "3.11"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+
+junit_family = "xunit2"
+render_collapsed = "all"
+
+addopts = """
+-v
+-l
+-ra
+--strict-markers
+-W default
+-p no:rerunfailures
+--html=.reports/pytest.html
+--junit-xml=.reports/pytest.xml
+"""
+
+markers = [
+ # keep-sorted start
+ "macos_only: Tests that only run on macOS",
+ "requires_homebrew: Tests that require Homebrew to be installed",
+ "role: Role-specific tests",
+ "slow: Tests that take significant time",
+ # keep-sorted end
+]
+norecursedirs = [
+ # keep-sorted start
+ "*.egg",
+ ".git",
+ ".tox",
+ "__pycache__",
+ "archive",
+ "build",
+ "dist",
+ "inventory",
+ # keep-sorted end
+]
+
+# `coverage` configuration
+# ========================
+[tool.coverage.run]
+source = ["."]
+omit = [
+ # keep-sorted start
+ "*/dist-packages/*",
+ "*/migrations/*",
+ "*/site-packages/*",
+ ".venv/*",
+ "archive/*",
+ "inventory/*",
+ "setup.py",
+ "tests/*",
+ "venv/*",
+ # keep-sorted end
+]
+
+[tool.coverage.html]
+directory = ".reports/coverage"
+
+[tool.coverage.json]
+output = ".reports/coverage.json"
+
+[tool.coverage.report]
+skip_covered = false
+show_empty = true
+show_missing = true
+precision = 2
+fail_under = 70
+exclude_lines = [
+ # keep-sorted start
+ "def __repr__",
+ "if TYPE_CHECKING:",
+ "if __name__ == .__main__.:",
+ "if self\\.debug",
+ "if typing.TYPE_CHECKING:",
+ "pass",
+ "pragma: no cover",
+ "raise AssertionError",
+ "raise NotImplementedError",
+ # keep-sorted end
+]
diff --git a/requirements.txt b/requirements.txt
index 5f1a1db..9375ab6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,186 @@
-ansible
-pre-commit
+ansible-core==2.21.1
+ # via dotfiles
+ast-serialize==0.6.0
+ # via mypy
+astatine==0.3.3
+ # via flake8-dunder-all
+asttokens==3.0.1
+ # via astatine
+attrs==26.1.0
+ # via interrogate
+cffi==2.1.0 ; platform_python_implementation != 'PyPy'
+ # via cryptography
+click==8.4.2
+ # via
+ # consolekit
+ # flake8-dunder-all
+ # interrogate
+codespell==2.4.2
+colorama==0.4.6
+ # via
+ # click
+ # interrogate
+ # pytest
+consolekit==1.13.0
+ # via flake8-dunder-all
+coverage==7.15.0
+cryptography==49.0.0
+ # via ansible-core
+deprecation==2.1.0
+ # via deprecation-alias
+deprecation-alias==0.4.0
+ # via consolekit
+domdf-python-tools==3.10.0
+ # via
+ # astatine
+ # consolekit
+ # flake8-dunder-all
+execnet==2.1.2
+ # via pytest-xdist
+fastjsonschema==2.21.2
+ # via validate-pyproject
+flake8==7.3.0
+ # via flake8-dunder-all
+flake8-dunder-all==0.5.0
+iniconfig==2.3.0
+ # via pytest
+interrogate==1.7.0
+jinja2==3.1.6
+ # via
+ # ansible-core
+ # pytest-html
+librt==0.12.0 ; platform_python_implementation != 'PyPy'
+ # via mypy
+markdown-it-py==4.2.0
+ # via
+ # mdformat
+ # mdformat-gfm
+ # mdit-py-plugins
+ # rich
+markupsafe==3.0.3
+ # via jinja2
+mccabe==0.7.0
+ # via flake8
+mdformat==1.0.0
+ # via
+ # mdformat-config
+ # mdformat-footnote
+ # mdformat-front-matters
+ # mdformat-gfm
+ # mdformat-gfm-alerts
+ # mdformat-pyproject
+ # mdformat-simple-breaks
+ # mdformat-toc
+mdformat-config==0.2.1
+mdformat-footnote==0.1.3
+mdformat-front-matters==2.0.0
+mdformat-gfm==1.0.0
+mdformat-gfm-alerts==2.0.0
+mdformat-pyproject==0.1.1
+mdformat-simple-breaks==0.1.0
+mdformat-toc==0.5.0
+mdit-py-plugins==0.6.1
+ # via
+ # mdformat-footnote
+ # mdformat-front-matters
+ # mdformat-gfm
+ # mdformat-gfm-alerts
+mdurl==0.1.2
+ # via markdown-it-py
+mistletoe==1.5.1
+ # via consolekit
+mypy==2.2.0
+mypy-extensions==1.1.0
+ # via mypy
+natsort==8.4.0
+ # via
+ # domdf-python-tools
+ # flake8-dunder-all
+packaging==26.2
+ # via
+ # ansible-core
+ # deprecation
+ # deprecation-alias
+ # pytest
+ # validate-pyproject
+pathspec==1.1.1
+ # via
+ # mypy
+ # yamllint
+pluggy==1.6.0
+ # via pytest
+pprintpp==0.4.0
+ # via pytest-clarity
+prek==0.4.8
+py==1.11.0
+ # via interrogate
+pycodestyle==2.14.0
+ # via flake8
+pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
+ # via cffi
+pyflakes==3.4.0
+ # via flake8
+pygments==2.20.0
+ # via
+ # pytest
+ # rich
+pyjson5==2.0.1
+pytest==9.1.1
+ # via
+ # pytest-clarity
+ # pytest-html
+ # pytest-metadata
+ # pytest-mock
+ # pytest-sugar
+ # pytest-timeout
+ # pytest-xdist
+pytest-clarity==1.0.1
+pytest-html==4.2.0
+pytest-metadata==3.1.1
+ # via pytest-html
+pytest-mock==3.15.1
+pytest-sugar==1.1.1
+pytest-timeout==2.4.0
+pytest-xdist==3.8.0
+pyyaml==6.0.3
+ # via
+ # ansible-core
+ # dotfiles
+ # yamllint
+resolvelib==1.2.1
+ # via ansible-core
+rich==15.0.0
+ # via pytest-clarity
+ruamel-yaml==0.19.1
+ # via
+ # mdformat-config
+ # mdformat-front-matters
+ruff==0.15.20
+shellcheck-py==0.11.0.1
+shfmt-py==4.0.0
+tabulate==0.10.0
+ # via interrogate
+taplo==0.9.3
+ # via mdformat-config
+termcolor==3.3.0
+ # via pytest-sugar
+toml==0.10.2
+ # via mdformat-front-matters
+trove-classifiers==2026.6.1.19
+ # via validate-pyproject
+types-pyyaml==6.0.12.20260518
+types-requests==2.33.0.20260518
+types-toml==0.10.8.20260518
+typing-extensions==4.16.0
+ # via
+ # consolekit
+ # domdf-python-tools
+ # dotfiles
+ # mypy
+urllib3==2.7.0
+ # via types-requests
+validate-pyproject==0.25
+validate-pyproject-schema-store==2026.7.8
+wcwidth==0.8.2
+ # via mdformat-gfm
+yamllint==1.38.0
diff --git a/roles/README.md b/roles/README.md
index c831a9c..75f1894 100644
--- a/roles/README.md
+++ b/roles/README.md
@@ -1,11 +1,12 @@
# Ansible Roles
-This directory contains Ansible roles for automated configuration of macOS and Debian Linux systems. Roles are
-organized by platform and provide modular, reusable configuration components for the dotfiles repository.
+This directory contains Ansible roles for automated configuration of macOS and Debian Linux systems. Roles are organized
+by platform and provide modular, reusable configuration components for the dotfiles repository.
## Overview
The role system provides:
+
- **Platform-specific roles** for macOS and Linux system configuration
- **Shared roles** for cross-platform tools and configurations
- **Utility roles** for common operations like dotfile linking
@@ -19,39 +20,39 @@ Roles are executed by playbooks in `playbooks/` and can be selectively applied u
Platform-specific roles for macOS configuration:
-| Role | Description | Key Features |
-|------|-------------|--------------|
-| **macos** | Core macOS package management | Homebrew formulae (30+ CLI tools), cask apps (GUI applications), Mac App Store apps via `mas` CLI |
-| **macos_settings** | System preferences configuration | Finder, Dock, Activity Monitor, Messages, power management, I/O devices via `defaults write` |
-| **macos_dock** | Dock icon configuration | Manages Dock items and layout |
-| **hammerspoon** | Window management automation | Configures Hammerspoon app for macOS window management |
-| **iterm** | Terminal configuration | iTerm2 application setup and preferences |
-| **python** | Python tooling for macOS | Global pip packages, pipx packages |
+| Role | Description | Key Features |
+| ------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------- |
+| **macos** | Core macOS package management | Homebrew formulae (30+ CLI tools), cask apps (GUI applications), Mac App Store apps via `mas` CLI |
+| **macos_settings** | System preferences configuration | Finder, Dock, Activity Monitor, Messages, power management, I/O devices via `defaults write` |
+| **macos_dock** | Dock icon configuration | Manages Dock items and layout |
+| **hammerspoon** | Window management automation | Configures Hammerspoon app for macOS window management |
+| **iterm** | Terminal configuration | iTerm2 application setup and preferences |
+| **python** | Python tooling for macOS | Global pip packages, pipx packages |
### Linux Roles
Platform-specific roles for Debian Linux systems:
-| Role | Description | Key Features |
-|------|-------------|--------------|
-| **debian** | Base Debian system setup | System packages and configuration |
+| Role | Description | Key Features |
+| ---------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| **debian** | Base Debian system setup | System packages and configuration |
| **docker** | Docker containerization platform | Docker engine, Caddy reverse proxy, Autoplex/PyAutoplex image builds, Home Assistant, 195-line docker-compose stack |
-| **zfs** | ZFS filesystem support | ZFS dependencies and configuration |
-| **samba** | File sharing | Samba server configuration for network file sharing |
-| **glance** | Dashboard application | [Glance](https://github.com/glanceapp/glance) dashboard setup |
+| **zfs** | ZFS filesystem support | ZFS dependencies and configuration |
+| **samba** | File sharing | Samba server configuration for network file sharing |
+| **glance** | Dashboard application | [Glance](https://github.com/glanceapp/glance) dashboard setup |
### Shared Roles
Cross-platform roles that work on both macOS and Linux:
-| Role | Description | Key Features |
-|------|-------------|--------------|
-| **git** | Git version control | Git configuration, gh CLI tool |
-| **ssh** | SSH client/server | Key generation for remote hosts, ssh-agent loading for macOS, GitHub key authorization |
-| **zsh** | Shell configuration | oh-my-zsh installation, Powerlevel10k theme, `.zshrc`/`.zprofile` setup, default shell configuration |
-| **starship** | Shell prompt | Starship prompt installation and configuration (alternative to Powerlevel10k) |
-| **link_dotfile** | Dotfile linking utility | Reusable role for safely creating symlinks with validation and backup |
-| **tailscale** | VPN networking | [Tailscale VPN](https://tailscale.com/) setup using [artis3n.tailscale](https://github.com/artis3n/ansible-role-tailscale) role |
+| Role | Description | Key Features |
+| ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| **git** | Git version control | Git configuration, gh CLI tool |
+| **ssh** | SSH client/server | Key generation for remote hosts, ssh-agent loading for macOS, GitHub key authorization |
+| **zsh** | Shell configuration | oh-my-zsh installation, Powerlevel10k theme, `.zshrc`/`.zprofile` setup, default shell configuration |
+| **starship** | Shell prompt | Starship prompt installation and configuration (alternative to Powerlevel10k) |
+| **link_dotfile** | Dotfile linking utility | Reusable role for safely creating symlinks with validation and backup |
+| **tailscale** | VPN networking | [Tailscale VPN](https://tailscale.com/) setup using [artis3n.tailscale](https://github.com/artis3n/ansible-role-tailscale) role |
## Role Dependencies
@@ -60,15 +61,18 @@ Cross-platform roles that work on both macOS and Linux:
External roles and collections are managed in `roles/requirements.yml`:
**External Roles:**
+
- `elliotweiser.osx-command-line-tools` - macOS command-line tools installation
- `artis3n.tailscale` - Tailscale VPN setup
**Ansible Collections:**
+
- `ansible.posix` - POSIX-specific modules
- `community.general` - Community-maintained general modules (includes `pipx` module)
- `geerlingguy.mac` - macOS-specific modules (Homebrew, Mac App Store)
Install external dependencies:
+
```bash
ansible-galaxy install -r roles/requirements.yml
```
@@ -78,24 +82,29 @@ ansible-galaxy install -r roles/requirements.yml
Some roles depend on other roles in this repository:
**macos role:**
+
- Uses `elliotweiser.osx-command-line-tools` for Xcode CLI tools
- Uses `geerlingguy.mac.homebrew` for package management
- Uses `geerlingguy.mac.mas` for App Store apps
**python role:**
+
- Requires `macos` role to install `pipx` via Homebrew (macOS)
- Uses pipx executable from Homebrew prefix
**zsh role:**
+
- On macOS: Uses `elliotweiser.osx-command-line-tools` and `geerlingguy.mac.homebrew`
- Installs oh-my-zsh and Powerlevel10k theme
- Different behavior for localhost (symlinks) vs. remote hosts (copies files)
**starship role:**
+
- Uses `link_dotfile` role to symlink configuration
- Alternative to Powerlevel10k prompt in zsh
**docker role:**
+
- Linux only
- Sets up complete Docker infrastructure including custom image builds
- Clones and builds multiple custom images (Caddy, Autoplex, PyAutoplex)
@@ -109,16 +118,16 @@ Some roles depend on other roles in this repository:
Roles are included in playbooks using `include_role`:
```yaml
-- name: Personal Mac bootstrap
- hosts: localhost
- tasks:
- - include_role:
- name: roles/git
- apply:
- tags: git
- tags:
- - git
- - configfile
+ - name: Personal Mac bootstrap
+ hosts: localhost
+ tasks:
+ - include_role:
+ name: roles/git
+ apply:
+ tags: git
+ tags:
+ - git
+ - configfile
```
### Running Specific Roles
@@ -160,15 +169,19 @@ ansible-playbook test_role.yml
### Common Playbook Patterns
**Local Mac Bootstrap** (`playbooks/local_bootstrap.yml`):
+
```bash
chmod u+x local_bootstrap.sh && ./local_bootstrap.sh
```
+
Runs: zsh, macos, macos_settings, macos_dock, git, hammerspoon, iterm, ssh, starship, python
**NAS Server Bootstrap** (`playbooks/nas_bootstrap.yml`):
+
```bash
chmod u+x nas_bootstrap.sh && ./nas_bootstrap.sh
```
+
Runs: debian, zfs, samba, docker, glance, git, ssh, zsh
## The link_dotfile Pattern
@@ -187,40 +200,40 @@ locations. This is the **preferred method** for managing dotfile symlinks across
### Basic Usage
```yaml
-- name: Link git config
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/git/files/gitconfig"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.gitconfig"
+ - name: Link git config
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/git/files/gitconfig'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.gitconfig"
```
### Required Variables
-| Variable | Description | Example |
-|----------|-------------|---------|
-| `link_dotfile_src` | Absolute path to source file in dotfiles repo | `{{ dotfiles_home }}/roles/git/files/gitconfig` |
-| `link_dotfile_dst` | Absolute path to destination (target location) | `{{ ansible_facts['user_dir'] }}/.gitconfig` |
+| Variable | Description | Example |
+| ------------------ | ---------------------------------------------- | ----------------------------------------------- |
+| `link_dotfile_src` | Absolute path to source file in dotfiles repo | `{{ dotfiles_home }}/roles/git/files/gitconfig` |
+| `link_dotfile_dst` | Absolute path to destination (target location) | `{{ ansible_facts['user_dir'] }}/.gitconfig` |
### Optional Variables
-| Variable | Description | Default |
-|----------|-------------|---------|
+| Variable | Description | Default |
+| ------------------ | ------------------------------------------ | --------------------- |
| `dotfile_dir_mode` | Permissions for created parent directories | `omit` (uses default) |
### Advanced Example: Linking Multiple Files
```yaml
-- name: Link shell dotfiles
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/shell/files/{{ item }}"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.{{ item }}"
- loop:
- - zshrc
- - zprofile
- - zshenv
+ - name: Link shell dotfiles
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/shell/files/{{ item }}'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.{{ item }}"
+ loop:
+ - zshrc
+ - zprofile
+ - zshenv
```
### Real-World Example: Starship Configuration
@@ -228,22 +241,23 @@ locations. This is the **preferred method** for managing dotfile symlinks across
From `roles/starship/tasks/main.yaml`:
```yaml
-- name: Configure Starship
- tags:
- - configfile
- - preferences
- block:
- - name: Link starship.toml config
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/starship/files/starship.toml"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.config/starship.toml"
+ - name: Configure Starship
+ tags:
+ - configfile
+ - preferences
+ block:
+ - name: Link starship.toml config
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/starship/files/starship.toml'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.config/starship.toml"
```
### Behavior
**Linking Decision Tree:**
+
```
1. Check destination file status
├─ Does not exist
@@ -259,8 +273,8 @@ From `roles/starship/tasks/main.yaml`:
└─ Remove symlink → Create new symlink
```
-**Backup Example:**
-When a non-symlink file exists at the destination, it's backed up with a timestamp:
+**Backup Example:** When a non-symlink file exists at the destination, it's backed up with a timestamp:
+
```
.zshrc.backup_20250219143022
```
@@ -335,13 +349,13 @@ EOF
Edit `playbooks/local_bootstrap.yml` or `playbooks/nas_bootstrap.yml`:
```yaml
-- include_role:
- name: roles/my_new_role
- apply:
- tags: my_new_role
- tags:
- - my_new_role
- - configfile # Add to existing tag groups if appropriate
+ - include_role:
+ name: roles/my_new_role
+ apply:
+ tags: my_new_role
+ tags:
+ - my_new_role
+ - configfile # Add to existing tag groups if appropriate
```
#### 3. Test the Role
@@ -365,50 +379,44 @@ ansible-playbook playbooks/local_bootstrap.yml \
### Example: Role for Linking Dotfiles
```yaml
-# roles/my_app/tasks/main.yml
----
-- name: Link my app configuration
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/my_app/files/config.yaml"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.config/myapp/config.yaml"
+ - name: Link my app configuration
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/my_app/files/config.yaml'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.config/myapp/config.yaml"
```
### Example: Platform-Specific Role
```yaml
-# roles/my_tool/tasks/main.yml
----
-- name: Install on macOS
- when: ansible_distribution == 'MacOSX'
- block:
- - name: Install with Homebrew
- community.general.homebrew:
- name: my-tool
- state: present
-
-- name: Install on Debian
- when: ansible_distribution == 'Debian'
- block:
- - name: Install with apt
- become: true
- ansible.builtin.apt:
- name: my-tool
- state: present
+ - name: Install on macOS
+ when: ansible_distribution == 'MacOSX'
+ block:
+ - name: Install with Homebrew
+ community.general.homebrew:
+ name: my-tool
+ state: present
+
+ - name: Install on Debian
+ when: ansible_distribution == 'Debian'
+ block:
+ - name: Install with apt
+ become: true
+ ansible.builtin.apt:
+ name: my-tool
+ state: present
```
### Example: Role with External Dependencies
```yaml
-# roles/my_tool/tasks/main.yml
----
-- name: Install command-line tools (macOS)
- when: ansible_distribution == 'MacOSX'
- include_role:
- name: elliotweiser.osx-command-line-tools
+ - name: Install command-line tools (macOS)
+ when: ansible_distribution == 'MacOSX'
+ include_role:
+ name: elliotweiser.osx-command-line-tools
-- name: Install the tool
+ - name: Install the tool
# ... installation tasks
```
@@ -430,32 +438,32 @@ Variables commonly used across roles, defined in inventory (`inventory/inventory
### Path Variables
```yaml
-dotfiles_home: "/path/to/dotfiles-personal" # Repository root
+dotfiles_home: /path/to/dotfiles-personal # Repository root
ansible_facts['user_dir']: "{{ ansible_facts['user_dir'] }}" # User home directory
-homebrew_prefix: "/opt/homebrew" # Homebrew installation path (macOS)
+homebrew_prefix: /opt/homebrew # Homebrew installation path (macOS)
```
### User Variables
```yaml
-ansible_user: "username" # Current user for SSH/sudo operations
-admin_password: "{{ vault_pass }}" # Admin password from Ansible Vault
+ansible_user: username # Current user for SSH/sudo operations
+admin_password: '{{ vault_pass }}' # Admin password from Ansible Vault
```
### Service-Specific Variables
```yaml
# Docker role
-docker_dir: "/path/to/docker"
-caddy_dnsimple_repo_url: "git@github.com:..."
-autoplex_repo_url: "git@github.com:..."
-homeassistant_repo_url: "git@github.com:..."
+docker_dir: /path/to/docker
+caddy_dnsimple_repo_url: git@github.com:...
+autoplex_repo_url: git@github.com:...
+homeassistant_repo_url: git@github.com:...
# Starship role
-starship_version: "v1.17.1"
-starship_install_directory: "/usr/local/bin"
-starship_owner: "root"
-starship_group: "root"
+starship_version: v1.17.1
+starship_install_directory: /usr/local/bin
+starship_owner: root
+starship_group: root
# Python role
global_python_packages:
@@ -469,18 +477,18 @@ pipx_packages:
### Accessing Variables
In role tasks:
+
```yaml
-- name: Link dotfile
- ansible.builtin.file:
- src: "{{ dotfiles_home }}/roles/git/files/gitconfig"
- dest: "{{ ansible_facts['user_dir'] }}/.gitconfig"
- state: link
+ - name: Link dotfile
+ ansible.builtin.file:
+ src: '{{ dotfiles_home }}/roles/git/files/gitconfig'
+ dest: "{{ ansible_facts['user_dir'] }}/.gitconfig"
+ state: link
```
In role defaults:
+
```yaml
-# roles/my_role/defaults/main.yml
----
my_config_dir: "{{ ansible_facts['user_dir'] }}/.config/my_app"
my_data_dir: "{{ ansible_facts['user_dir'] }}/.local/share/my_app"
```
@@ -492,6 +500,7 @@ my_data_dir: "{{ ansible_facts['user_dir'] }}/.local/share/my_app"
The `macos` role manages three layers of macOS package installation:
**1. Homebrew Formulae** (CLI tools):
+
```yaml
# roles/macos/defaults/main.yml
brew_packages:
@@ -508,6 +517,7 @@ brew_packages:
```
**2. Homebrew Casks** (GUI applications):
+
```yaml
brew_cask_packages:
- google-chrome
@@ -520,6 +530,7 @@ brew_cask_packages:
```
**3. Mac App Store Apps** (via `mas` CLI):
+
```yaml
mas_apps:
- name: Amphetamine
@@ -535,31 +546,36 @@ mas_apps:
The `python` role manages Python packages through multiple installation methods:
**Global pip packages**:
+
```yaml
global_python_packages:
- package1
- package2
```
+
Installed via: `/usr/bin/pip3 install`
**Isolated pipx packages**:
+
```yaml
pipx_packages:
- package3
- package4
```
+
Installed via: `pipx install` (requires pipx from Homebrew)
### Debian Package Management
The `debian` role manages system packages via apt:
+
```yaml
-- name: Install system packages
- become: true
- ansible.builtin.apt:
- pkg:
- - package1
- - package2
+ - name: Install system packages
+ become: true
+ ansible.builtin.apt:
+ pkg:
+ - package1
+ - package2
```
## Docker Stack Configuration
@@ -572,6 +588,7 @@ roles in the repository.
The 195-line `docker-compose.yml` deploys:
**Media Server Stack:**
+
- **Plex**: Media server
- **Sonarr**: TV show automation
- **Radarr**: Movie automation
@@ -581,6 +598,7 @@ The 195-line `docker-compose.yml` deploys:
- **Autoplex/PyAutoplex**: Automated file organization
**Infrastructure:**
+
- **PiHole**: DNS server and ad blocker
- **Caddy**: Reverse proxy with automatic HTTPS
- **Home Assistant**: Home automation platform
@@ -589,21 +607,25 @@ The 195-line `docker-compose.yml` deploys:
### Docker Role Features
**1. Docker Engine Installation:**
+
- Adds Docker official repository
- Installs Docker CE, CLI, Containerd
- Installs docker-compose plugin
**2. Custom Image Builds:**
+
- Clones and builds custom Caddy image with DNSimple support
- Builds Autoplex from private repository
- Builds PyAutoplex from private repository
**3. Home Assistant Setup:**
+
- Clones Home Assistant configuration repository
- Generates `secrets.yaml` from Ansible Vault variables
- Copies Lutron Caseta certificates/keys for smart home integration
**4. Service Configuration:**
+
- Generates Caddyfile from Jinja2 template with host IP
- Creates `.env` file with DNSimple OAuth token
- Deploys complete docker-compose stack
@@ -611,48 +633,52 @@ The 195-line `docker-compose.yml` deploys:
### Docker Role Variables
Required variables in inventory:
+
```yaml
-docker_dir: "/path/to/docker/configs"
-caddy_dnsimple_repo_url: "git@github.com:user/caddy-dnsimple.git"
-caddy_dnsimple_repo_dest: "/path/to/caddy-dnsimple"
-caddy_dnsimple_repo_branch: "main"
-autoplex_repo_url: "git@github.com:user/autoplex.git"
-autoplex_repo_dest: "/path/to/autoplex"
-autoplex_repo_branch: "main"
-pyautoplex_repo_url: "git@github.com:user/pyautoplex.git"
-pyautoplex_repo_dest: "/path/to/pyautoplex"
-pyautoplex_repo_branch: "main"
-homeassistant_repo_url: "git@github.com:user/homeassistant-config.git"
-homeassistant_repo_dest: "/path/to/homeassistant"
-homeassistant_repo_branch: "main"
+docker_dir: /path/to/docker/configs
+caddy_dnsimple_repo_url: git@github.com:user/caddy-dnsimple.git
+caddy_dnsimple_repo_dest: /path/to/caddy-dnsimple
+caddy_dnsimple_repo_branch: main
+autoplex_repo_url: git@github.com:user/autoplex.git
+autoplex_repo_dest: /path/to/autoplex
+autoplex_repo_branch: main
+pyautoplex_repo_url: git@github.com:user/pyautoplex.git
+pyautoplex_repo_dest: /path/to/pyautoplex
+pyautoplex_repo_branch: main
+homeassistant_repo_url: git@github.com:user/homeassistant-config.git
+homeassistant_repo_dest: /path/to/homeassistant
+homeassistant_repo_branch: main
```
## Role Testing and Validation
### Pre-commit Validation
-Roles are validated via pre-commit hooks (`.pre-commit-config.yaml`):
+Roles are validated via prek hooks (`.pre-commit-config.yaml`):
```bash
# Run all checks
-pre-commit run --all-files
+prek run --all-files
# Or via Python script
python3 run.py pre
```
**Active checks:**
+
- YAML validation (yamllint)
- File integrity checks
- Whitespace/line ending fixers
**Available but commented:**
+
- ansible-lint (full Ansible best practices)
- shellcheck (shell script validation)
### ansible-lint Configuration
When enabled, ansible-lint uses `.ansible-lint.yaml`:
+
```yaml
# Skip certain rule categories
skip_list:
@@ -663,6 +689,7 @@ skip_list:
### yamllint Configuration
YAML linting uses `.yamllint.yaml`:
+
```yaml
# Custom rules for role files
rules:
@@ -675,6 +702,7 @@ rules:
### Manual Testing
**Dry run (check mode):**
+
```bash
ansible-playbook playbooks/local_bootstrap.yml \
-i inventory/inventory.yml \
@@ -684,6 +712,7 @@ ansible-playbook playbooks/local_bootstrap.yml \
```
**Verbose output:**
+
```bash
ansible-playbook playbooks/local_bootstrap.yml \
-i inventory/inventory.yml \
@@ -692,6 +721,7 @@ ansible-playbook playbooks/local_bootstrap.yml \
```
**Test specific role:**
+
```bash
ansible-playbook playbooks/local_bootstrap.yml \
-i inventory/inventory.yml \
@@ -721,17 +751,17 @@ ansible-galaxy collection install -r roles/requirements.yml --force
Edit `roles/requirements.yml`:
```yaml
----
roles:
- name: username.rolename
- version: "1.0.0" # Optional: pin to specific version
+ version: 1.0.0 # Optional: pin to specific version
collections:
- name: namespace.collection
- version: ">=2.0.0" # Optional: version constraint
+ version: '>=2.0.0' # Optional: version constraint
```
Then install:
+
```bash
ansible-galaxy install -r roles/requirements.yml
```
@@ -739,6 +769,7 @@ ansible-galaxy install -r roles/requirements.yml
### Role Versioning
Roles in this repository follow semantic versioning through git:
+
- Use git tags for version tracking
- Document breaking changes in role README.md
- Test role changes before merging to main branch
@@ -748,11 +779,13 @@ Roles in this repository follow semantic versioning through git:
### Role Not Found
**Error:**
+
```
ERROR! the role 'roles/my_role' was not found
```
**Solutions:**
+
1. Check role name spelling in playbook
2. Verify role directory exists: `ls -la roles/my_role`
3. Ensure `tasks/main.yml` exists in role directory
@@ -760,11 +793,13 @@ ERROR! the role 'roles/my_role' was not found
### External Role Not Found
**Error:**
+
```
ERROR! the role 'external.role' was not found
```
**Solutions:**
+
1. Install dependencies: `ansible-galaxy install -r roles/requirements.yml`
2. Check `roles/requirements.yml` contains the role
3. Verify installation: `ansible-galaxy role list`
@@ -772,11 +807,13 @@ ERROR! the role 'external.role' was not found
### Variable Undefined
**Error:**
+
```
fatal: [localhost]: FAILED! => {"msg": "The task includes an option with an undefined variable"}
```
**Solutions:**
+
1. Check variable is defined in `defaults/main.yml` or `vars/main.yml`
2. Verify variable exists in inventory: `inventory/group_vars/` or `inventory/host_vars/`
3. Pass variable via command line: `-e "var_name=value"`
@@ -784,11 +821,13 @@ fatal: [localhost]: FAILED! => {"msg": "The task includes an option with an unde
### Permission Denied
**Error:**
+
```
fatal: [localhost]: FAILED! => {"msg": "Permission denied"}
```
**Solutions:**
+
1. Add `become: true` to task requiring sudo
2. Use `--ask-become-pass` when running playbook
3. Verify sudo password is correct in vault
@@ -796,11 +835,13 @@ fatal: [localhost]: FAILED! => {"msg": "Permission denied"}
### Vault Decryption Failed
**Error:**
+
```
ERROR! Attempting to decrypt but no vault secrets found
```
**Solutions:**
+
1. Create `vault_password.txt` in repository root
2. Verify password is correct
3. Check vault file is properly encrypted: `ansible-vault view inventory/group_vars/all/vault.yml`
diff --git a/roles/docker/README.md b/roles/docker/README.md
index efd1b62..0b97775 100644
--- a/roles/docker/README.md
+++ b/roles/docker/README.md
@@ -37,7 +37,9 @@ The stack is designed to provide a fully automated media server experience with
### Media Services
#### Plex (Port 32400)
+
**Purpose**: Media streaming server
+
- **Image**: `linuxserver/plex`
- **Network Mode**: `host` (temporary workaround for Docker/Plex networking issues)
- **Storage**: Mounts `/storage/media/tv`, `/storage/media/movies`, `/storage/media/music`
@@ -45,7 +47,9 @@ The stack is designed to provide a fully automated media server experience with
- **Configuration**: `/storage/media/config/plex`
#### Autoplex (No exposed ports)
+
**Purpose**: Automated file organization for Plex
+
- **Image**: `danielmmetz/autoplex:latest` (custom build)
- **Functionality**: Monitors Transmission download directory and copies completed files to appropriate media folders
- **Mode**: Copy mode (preserves original files)
@@ -57,7 +61,9 @@ The stack is designed to provide a fully automated media server experience with
### Media Lookup/Download Services
#### Transmission (Port 9091)
+
**Purpose**: BitTorrent client
+
- **Image**: `linuxserver/transmission`
- **Web UI**: Port 9091
- **Storage**:
@@ -67,20 +73,26 @@ The stack is designed to provide a fully automated media server experience with
- **Dependencies**: Used by Sonarr and Radarr for downloads
#### Prowlarr (Port 9696)
+
**Purpose**: Torrent indexer aggregator
+
- **Image**: `linuxserver/prowlarr`
- **Functionality**: Centralized management of torrent indexers for Sonarr and Radarr
- **Configuration**: `/storage/media/config/prowlarr`
- **Dependencies**: Integrates with Flaresolverr for Cloudflare bypass
#### Flaresolverr (No exposed ports)
+
**Purpose**: Cloudflare protection bypass
+
- **Image**: `ghcr.io/flaresolverr/flaresolverr`
- **Functionality**: Proxy service to bypass Cloudflare CAPTCHA challenges
- **Dependencies**: Used by Prowlarr
#### Sonarr (Port 8989)
+
**Purpose**: TV show management and automation
+
- **Image**: `linuxserver/sonarr`
- **Functionality**:
- Monitors TV show releases
@@ -94,7 +106,9 @@ The stack is designed to provide a fully automated media server experience with
- **Dependencies**: Prowlarr (indexers), Transmission (downloads)
#### Radarr (Port 7878)
+
**Purpose**: Movie management and automation
+
- **Image**: `linuxserver/radarr`
- **Functionality**:
- Monitors movie releases
@@ -110,7 +124,9 @@ The stack is designed to provide a fully automated media server experience with
### Network Services
#### PiHole (Ports 53/tcp, 53/udp, 67/udp, 8053/tcp)
+
**Purpose**: Network-wide ad blocking and DNS server
+
- **Image**: `pihole/pihole:latest`
- **Ports**:
- 53/tcp, 53/udp: DNS service
@@ -125,7 +141,9 @@ The stack is designed to provide a fully automated media server experience with
### Home Automation & Dashboards
#### Home Assistant (Port 8123)
+
**Purpose**: Home automation platform
+
- **Image**: `homeassistant/home-assistant`
- **Network Mode**: `host` (required for service discovery)
- **Configuration**: `./homeassistant` (git repository)
@@ -138,7 +156,9 @@ The stack is designed to provide a fully automated media server experience with
- Shared: `./shared`
#### Glance (Port 8080)
+
**Purpose**: Personal dashboard
+
- **Image**: `glanceapp/glance`
- **Configuration**: `./glance/config`, `./glance/assets`
- **Features**:
@@ -149,14 +169,18 @@ The stack is designed to provide a fully automated media server experience with
### Disabled Services
#### Caddy (Commented out)
+
**Purpose**: Reverse proxy with automatic HTTPS
+
- **Image**: `danielmmetz/caddy-dnsimple` (custom build)
- **Functionality**: Would provide `*.conmason.com` subdomains for services
- **Configuration**: `Caddyfile` (generated from template)
- **Note**: Currently disabled in docker-compose.yml
#### PyAutoplex (Commented out)
+
**Purpose**: Alternative Python-based file organizer
+
- **Image**: `connormason/pyautoplex:latest` (custom build)
- **Note**: Replaced by Autoplex (Go implementation)
@@ -214,21 +238,25 @@ The stack is designed to provide a fully automated media server experience with
### Data Flow
1. **Content Discovery**:
+
- User adds TV show to Sonarr or movie to Radarr
- Sonarr/Radarr searches Prowlarr for available torrents
- Prowlarr queries configured indexers (using Flaresolverr if needed)
2. **Download**:
+
- Sonarr/Radarr sends torrent to Transmission
- Transmission downloads to `/storage/media/downloads`
3. **Organization**:
+
- Autoplex monitors download completion
- Copies completed files to appropriate directories:
- TV shows → `/storage/media/tv`
- Movies → `/storage/media/movies`
4. **Consumption**:
+
- Plex scans media directories and makes content available
- Users stream via Plex clients
@@ -277,53 +305,60 @@ The stack is designed to provide a fully automated media server experience with
### Volume Mounts
**Media Services:**
+
- Plex: Read-only access to `/storage/media/tv`, `/storage/media/movies`, `/storage/media/music`
- Autoplex: Read from `/storage/media/downloads`, write to `/storage/media/tv` and `/storage/media/movies`
**Download Services:**
+
- Transmission: Read/write to `/storage/media/downloads` and `/storage/media/watch`
- Sonarr: Read-only `/storage/media/tv`, read/write `/storage/media/downloads`
- Radarr: Read-only `/storage/media/movies`, read/write `/storage/media/downloads`
**Configuration Persistence:**
+
- All services: Config directories under `/storage/media/config/*`
## Network Configuration
### Port Mapping
-| Service | Port | Protocol | Purpose |
-|---------|------|----------|---------|
-| PiHole | 53 | TCP/UDP | DNS queries |
-| PiHole | 67 | UDP | DHCP server (optional) |
-| PiHole | 8053 | TCP | Web UI |
-| Plex | 32400 | TCP | Media streaming (host network) |
-| Transmission | 9091 | TCP | Web UI |
-| Prowlarr | 9696 | TCP | Web UI |
-| Sonarr | 8989 | TCP | Web UI |
-| Radarr | 7878 | TCP | Web UI |
-| Home Assistant | 8123 | TCP | Web UI (host network) |
-| Glance | 8080 | TCP | Dashboard |
+| Service | Port | Protocol | Purpose |
+| -------------- | ----- | -------- | ------------------------------ |
+| PiHole | 53 | TCP/UDP | DNS queries |
+| PiHole | 67 | UDP | DHCP server (optional) |
+| PiHole | 8053 | TCP | Web UI |
+| Plex | 32400 | TCP | Media streaming (host network) |
+| Transmission | 9091 | TCP | Web UI |
+| Prowlarr | 9696 | TCP | Web UI |
+| Sonarr | 8989 | TCP | Web UI |
+| Radarr | 7878 | TCP | Web UI |
+| Home Assistant | 8123 | TCP | Web UI (host network) |
+| Glance | 8080 | TCP | Dashboard |
### Network Modes
**Host Mode:**
+
- **Plex**: Uses host networking to avoid Docker networking complexities with media streaming
- **Home Assistant**: Uses host networking for service discovery (mDNS, UPnP)
**Bridge Mode (default):**
+
- All other services use Docker's default bridge network
- Services communicate via container names (e.g., `sonarr:8989`)
### Reverse Proxy (Disabled)
The Caddy reverse proxy configuration (currently commented out) would provide:
+
- HTTPS endpoints: `tv.conmason.com`, `movies.conmason.com`, `transmission.conmason.com`, `prowlarr.conmason.com`,
-`plex.conmason.com`
+ `plex.conmason.com`
- Automatic HTTPS via DNS-01 challenge with DNSimple
- TLS certificate management
To enable:
+
1. Uncomment Caddy service in `roles/docker/files/docker-compose.yml`
2. Uncomment Caddy setup in `roles/docker/tasks/main.yml`
3. Ensure `dnsimple_oauth_token` is set in inventory vault
@@ -333,30 +368,36 @@ To enable:
### Environment Variables
**Common Variables (LinuxServer.io images):**
+
- `PUID=1000`: User ID for file permissions
- `PGID=1000`: Group ID for file permissions
- `VERSION=docker`: Use Docker-managed version updates
- `TZ='America/Los_Angeles'`: Timezone setting
**Secrets (from `.env` file):**
+
- `DNSIMPLE_OAUTH_TOKEN`: DNSimple API token for Caddy ACME DNS-01
- `FTLCONF_webserver_api_password`: PiHole admin password
**Service-Specific:**
+
- PiHole: `FTLCONF_dns_listeningMode=ALL` (listen on all interfaces)
- Autoplex: Command-line arguments for source/destination paths
### Configuration Files
**Generated from Templates:**
+
- `.env`: Contains secrets from Ansible Vault
- `Caddyfile`: Reverse proxy configuration with dynamic IP address
- `homeassistant/secrets.yaml`: Home Assistant secrets
**Static Files:**
+
- `docker-compose.yml`: Copied from `roles/docker/files/`
**Git Repositories:**
+
- Home Assistant config: Cloned from `git@github.com:connormason/homeassistant.git`
- Autoplex: Cloned from `https://github.com/danielmmetz/autoplex.git`
- PyAutoplex: Cloned from `git@github.com:connormason/pyautoplex.git`
@@ -365,6 +406,7 @@ To enable:
### Lutron Caseta Integration
Home Assistant requires Lutron Caseta certificates for smart home integration:
+
- Certificates stored in `inventory/group_vars/all/`
- Copied to `~/docker/homeassistant/` during role execution
- Files: `caseta.crt`, `caseta.key`, `caseta-bridge.crt`
@@ -374,6 +416,7 @@ Home Assistant requires Lutron Caseta certificates for smart home integration:
### Autoplex
**Source**: `https://github.com/danielmmetz/autoplex.git`
+
- **Language**: Go
- **Purpose**: File organization service
- **Build Process**:
@@ -385,6 +428,7 @@ Home Assistant requires Lutron Caseta certificates for smart home integration:
### PyAutoplex (Unused)
**Source**: `git@github.com:connormason/pyautoplex.git`
+
- **Language**: Python
- **Purpose**: Alternative file organization service
- **Build Process**: Same as Autoplex
@@ -393,6 +437,7 @@ Home Assistant requires Lutron Caseta certificates for smart home integration:
### Caddy DNSimple (Disabled)
**Source**: `https://github.com/danielmmetz/caddy-dnsimple`
+
- **Purpose**: Caddy reverse proxy with DNSimple DNS integration
- **Build Process**: Same as Autoplex
- **Status**: Built but Caddy service commented out
@@ -404,46 +449,46 @@ The role performs the following operations in order:
### 1. Docker Installation
```yaml
-- Setup Docker repository (GPG key, apt repository)
-- Install Docker Engine (docker-ce, docker-ce-cli, containerd.io)
-- Install Docker Compose (both plugin and standalone)
-- Start Docker daemon
+ - Setup Docker repository (GPG key, apt repository)
+ - Install Docker Engine (docker-ce, docker-ce-cli, containerd.io)
+ - Install Docker Compose (both plugin and standalone)
+ - Start Docker daemon
```
### 2. Network Configuration
```yaml
-- Detect host IP address (used in Caddyfile template)
+ - Detect host IP address (used in Caddyfile template)
```
### 3. Custom Image Builds
```yaml
-- Clone Caddy DNSimple repository
-- Build caddy-dnsimple Docker image
-- Generate Caddyfile from template
-- Generate .env file from template
+ - Clone Caddy DNSimple repository
+ - Build caddy-dnsimple Docker image
+ - Generate Caddyfile from template
+ - Generate .env file from template
-- Clone Autoplex repository
-- Build autoplex Docker image
+ - Clone Autoplex repository
+ - Build autoplex Docker image
-- Clone PyAutoplex repository
-- Build pyautoplex Docker image
+ - Clone PyAutoplex repository
+ - Build pyautoplex Docker image
```
### 4. Home Assistant Setup
```yaml
-- Clone Home Assistant config repository
-- Generate secrets.yaml from template
-- Copy Lutron Caseta certificates from inventory
+ - Clone Home Assistant config repository
+ - Generate secrets.yaml from template
+ - Copy Lutron Caseta certificates from inventory
```
### 5. Docker Compose Deployment
```yaml
-- Copy docker-compose.yml to ~/docker/
-- Create backup of existing docker-compose.yml if present
+ - Copy docker-compose.yml to ~/docker/
+ - Create backup of existing docker-compose.yml if present
```
**Note**: The role does NOT automatically start services (`docker-compose up`). This must be done manually.
@@ -463,6 +508,7 @@ ansible-playbook playbooks/nas_bootstrap.yml -i inventory/inventory.yml --ask-be
```
This will:
+
1. Install Docker
2. Build custom images
3. Deploy docker-compose.yml
@@ -701,18 +747,18 @@ docker build --no-cache -t danielmmetz/autoplex:latest .
Edit `roles/docker/files/docker-compose.yml`:
```yaml
- newservice:
- image: linuxserver/newservice
- container_name: newservice
- restart: unless-stopped
- environment:
- - PUID=1000
- - PGID=1000
- - TZ='America/Los_Angeles'
- volumes:
- - /storage/media/config/newservice:/config
- ports:
- - "8080:8080"
+newservice:
+ image: linuxserver/newservice
+ container_name: newservice
+ restart: unless-stopped
+ environment:
+ - PUID=1000
+ - PGID=1000
+ - TZ='America/Los_Angeles'
+ volumes:
+ - /storage/media/config/newservice:/config
+ ports:
+ - 8080:8080
```
### Step 2: Add Storage Directories (if needed)
@@ -753,6 +799,7 @@ docker-compose up -d newservice
### Step 5: Add to Documentation
Update this README with:
+
- Service description in [Service Stack](#service-stack)
- Port mapping in [Network Configuration](#network-configuration)
- Dependencies in [Architecture](#architecture)
@@ -762,36 +809,39 @@ Update this README with:
If the service requires a custom image:
1. **Add repository variables** to `roles/docker/defaults/main.yml`:
+
```yaml
- newservice_repo_dest: "{{ docker_dir }}/newservice"
+ newservice_repo_dest: '{{ docker_dir }}/newservice'
newservice_repo_url: https://github.com/user/newservice.git
newservice_repo_branch: main
```
2. **Add build task** to `roles/docker/tasks/main.yml`:
+
```yaml
- - name: Setup newservice
- block:
- - name: Clone newservice repo
- ansible.builtin.git:
- repo: "{{ newservice_repo_url }}"
- dest: "{{ newservice_repo_dest }}"
- version: "{{ newservice_repo_branch }}"
- clone: true
- update: true
- accept_hostkey: true
-
- - name: Build newservice image
- community.docker.docker_image:
- build:
- path: "{{ newservice_repo_dest }}"
- name: user/newservice
- tag: latest
- source: build
- become: true
+ - name: Setup newservice
+ block:
+ - name: Clone newservice repo
+ ansible.builtin.git:
+ repo: '{{ newservice_repo_url }}'
+ dest: '{{ newservice_repo_dest }}'
+ version: '{{ newservice_repo_branch }}'
+ clone: true
+ update: true
+ accept_hostkey: true
+
+ - name: Build newservice image
+ community.docker.docker_image:
+ build:
+ path: '{{ newservice_repo_dest }}'
+ name: user/newservice
+ tag: latest
+ source: build
+ become: true
```
3. **Use custom image** in docker-compose.yml:
+
```yaml
newservice:
image: user/newservice:latest
@@ -803,16 +853,19 @@ If the service requires a custom image:
### Regular Tasks
**Weekly:**
+
- Check Docker container status: `docker-compose ps`
- Review service logs for errors: `docker-compose logs --tail=100`
- Monitor disk usage: `df -h /storage/media`
**Monthly:**
+
- Update service images: `docker-compose pull && docker-compose up -d`
- Clean old downloads: `rm -rf /storage/media/downloads/complete/*`
- Review PiHole statistics for blocked domains
**Quarterly:**
+
- Review and update service configurations
- Check for security updates: `apt update && apt list --upgradable`
- Backup Home Assistant configuration
@@ -821,6 +874,7 @@ If the service requires a custom image:
### Backup Strategy
**Configuration Backups:**
+
```bash
# Home Assistant (already in git)
cd ~/docker/homeassistant
@@ -837,11 +891,13 @@ tar -czf ~/backups/docker-dir-$(date +%Y%m%d).tar.gz ~/docker/
```
**Media Library Backups:**
+
- Media files are large; consider separate backup strategy
- Metadata can be regenerated by Plex/Sonarr/Radarr
- Prioritize backing up `/storage/media/config/` over media files
**Database Backups:**
+
```bash
# Sonarr/Radarr databases are in config directories
# Stop services before backup
@@ -860,6 +916,7 @@ docker-compose start sonarr radarr prowlarr
### Monitoring
**Service Health:**
+
```bash
# All container status
docker-compose ps
@@ -872,6 +929,7 @@ docker-compose ps --format "table {{.Name}}\t{{.Status}}"
```
**Disk Usage:**
+
```bash
# Media storage
du -sh /storage/media/*
@@ -884,6 +942,7 @@ find /storage/media -type f -size +5G
```
**Network Monitoring:**
+
```bash
# Port listeners
sudo netstat -tulpn | grep -E "53|32400|9091|8989|7878"
@@ -911,6 +970,7 @@ Then restart Docker: `sudo systemctl restart docker`
### Security Updates
**Update Docker Engine:**
+
```bash
sudo apt update
sudo apt upgrade docker-ce docker-ce-cli containerd.io
@@ -918,6 +978,7 @@ sudo systemctl restart docker
```
**Update Container Images:**
+
```bash
cd ~/docker
docker-compose pull
@@ -925,6 +986,7 @@ docker-compose up -d
```
**Audit Open Ports:**
+
```bash
# Check exposed ports
sudo ufw status
@@ -935,6 +997,7 @@ sudo ufw allow from 192.168.1.0/24 to any port 8989 # Sonarr from local network
```
**Review Credentials:**
+
- Rotate PiHole admin password in inventory vault
- Update DNSimple OAuth token if compromised
- Review Home Assistant user accounts
@@ -942,6 +1005,7 @@ sudo ufw allow from 192.168.1.0/24 to any port 8989 # Sonarr from local network
### Performance Optimization
**Docker Storage:**
+
```bash
# Clean unused images, containers, networks
docker system prune -a
@@ -951,11 +1015,13 @@ docker volume prune
```
**Plex Transcoding:**
+
- Consider hardware transcoding if available
- Monitor transcoding directory size
- Optimize library for direct play
**Transmission:**
+
- Limit active downloads
- Set bandwidth limits during peak hours
- Clean completed downloads regularly
@@ -976,6 +1042,7 @@ ansible-playbook playbooks/nas_bootstrap.yml -i inventory/inventory.yml --tags d
```
This will:
+
- Update docker-compose.yml
- Rebuild custom images (Autoplex, Caddy)
- Regenerate configuration files (.env, Caddyfile)
@@ -989,26 +1056,26 @@ Note: Does not automatically restart services. Use `docker-compose up -d` after
Defined in `roles/docker/defaults/main.yml`:
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `docker_dir` | `{{ ansible_facts['user_dir'] }}/docker` | Docker Compose project directory |
-| `autoplex_repo_url` | `https://github.com/danielmmetz/autoplex.git` | Autoplex source repository |
-| `autoplex_repo_branch` | `master` | Autoplex git branch |
-| `pyautoplex_repo_url` | `git@github.com:connormason/pyautoplex.git` | PyAutoplex source repository |
-| `pyautoplex_repo_branch` | `main` | PyAutoplex git branch |
-| `caddy_dnsimple_repo_url` | `https://github.com/danielmmetz/caddy-dnsimple` | Caddy DNSimple source |
-| `caddy_dnsimple_repo_branch` | `master` | Caddy DNSimple git branch |
-| `homeassistant_repo_url` | `git@github.com:connormason/homeassistant.git` | Home Assistant config repo |
-| `homeassistant_repo_branch` | `nas_bringup` | Home Assistant git branch |
+| Variable | Default | Description |
+| ---------------------------- | ----------------------------------------------- | -------------------------------- |
+| `docker_dir` | `{{ ansible_facts['user_dir'] }}/docker` | Docker Compose project directory |
+| `autoplex_repo_url` | `https://github.com/danielmmetz/autoplex.git` | Autoplex source repository |
+| `autoplex_repo_branch` | `master` | Autoplex git branch |
+| `pyautoplex_repo_url` | `git@github.com:connormason/pyautoplex.git` | PyAutoplex source repository |
+| `pyautoplex_repo_branch` | `main` | PyAutoplex git branch |
+| `caddy_dnsimple_repo_url` | `https://github.com/danielmmetz/caddy-dnsimple` | Caddy DNSimple source |
+| `caddy_dnsimple_repo_branch` | `master` | Caddy DNSimple git branch |
+| `homeassistant_repo_url` | `git@github.com:connormason/homeassistant.git` | Home Assistant config repo |
+| `homeassistant_repo_branch` | `nas_bringup` | Home Assistant git branch |
### Required Variables from Inventory
Must be defined in inventory vault files:
-| Variable | Source | Used By |
-|----------|--------|---------|
-| `dnsimple_oauth_token` | Vault | Caddy ACME DNS-01 |
-| `pihole_web_password` | Vault | PiHole web UI |
+| Variable | Source | Used By |
+| ---------------------- | ------ | ----------------- |
+| `dnsimple_oauth_token` | Vault | Caddy ACME DNS-01 |
+| `pihole_web_password` | Vault | PiHole web UI |
Home Assistant secrets (defined in `templates/ha_secrets.yaml.j2`) - refer to Home Assistant repository for details.
diff --git a/roles/docker/files/deploy.sh b/roles/docker/files/deploy.sh
index d075925..364bcc8 100755
--- a/roles/docker/files/deploy.sh
+++ b/roles/docker/files/deploy.sh
@@ -53,7 +53,7 @@ main() {
# Default to "all" if no arguments
if [[ ${#services[@]} -eq 0 ]] || [[ "${services[0]}" == "all" ]]; then
- read -ra services <<< "$(discover_services)"
+ read -ra services <<<"$(discover_services)"
echo "Deploying all services: ${services[*]}"
fi
diff --git a/roles/link_dotfile/README.md b/roles/link_dotfile/README.md
index 49d1098..84de5ee 100644
--- a/roles/link_dotfile/README.md
+++ b/roles/link_dotfile/README.md
@@ -2,21 +2,24 @@
## Purpose
-A reusable utility role for safely creating symbolic links from dotfiles in the repository to their target locations
-in the user's home directory. This role handles validation, backup, and idempotent linking operations.
+A reusable utility role for safely creating symbolic links from dotfiles in the repository to their target locations in
+the user's home directory. This role handles validation, backup, and idempotent linking operations.
## What It Does
1. **Validates inputs**:
+
- Ensures required variables are defined
- Verifies source file exists before linking
2. **Handles existing files**:
+
- Backs up existing non-symlink files with timestamp
- Removes incorrect symlinks
- Preserves correct existing symlinks (idempotent)
3. **Creates symlinks**:
+
- Creates parent directories as needed
- Links source to destination
- Reports when files are already correctly linked
@@ -25,15 +28,15 @@ in the user's home directory. This role handles validation, backup, and idempote
This role must be called with two required variables:
-| Variable | Description | Example |
-|----------|-------------|---------|
-| `link_dotfile_src` | Absolute path to source file in dotfiles repo | `{{ dotfiles_home }}/roles/git/files/gitconfig` |
-| `link_dotfile_dst` | Absolute path to destination (target location) | `{{ ansible_facts['user_dir'] }}/.gitconfig` |
+| Variable | Description | Example |
+| ------------------ | ---------------------------------------------- | ----------------------------------------------- |
+| `link_dotfile_src` | Absolute path to source file in dotfiles repo | `{{ dotfiles_home }}/roles/git/files/gitconfig` |
+| `link_dotfile_dst` | Absolute path to destination (target location) | `{{ ansible_facts['user_dir'] }}/.gitconfig` |
## Optional Variables
-| Variable | Description | Default |
-|----------|-------------|---------|
+| Variable | Description | Default |
+| ------------------ | ------------------------------------------ | --------------------- |
| `dotfile_dir_mode` | Permissions for created parent directories | `omit` (uses default) |
## Usage Examples
@@ -43,39 +46,39 @@ This role must be called with two required variables:
Include the role with required variables:
```yaml
-- name: Link git config
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/git/files/gitconfig"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.gitconfig"
+ - name: Link git config
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/git/files/gitconfig'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.gitconfig"
```
### Link Multiple Files in a Loop
```yaml
-- name: Link shell dotfiles
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/shell/files/{{ item }}"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.{{ item }}"
- loop:
- - zshrc
- - zprofile
- - zshenv
+ - name: Link shell dotfiles
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/shell/files/{{ item }}'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.{{ item }}"
+ loop:
+ - zshrc
+ - zprofile
+ - zshenv
```
### Specify Directory Permissions
```yaml
-- name: Link config with custom directory mode
- ansible.builtin.include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/roles/app/files/config.yml"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.config/app/config.yml"
- dotfile_dir_mode: "0700"
+ - name: Link config with custom directory mode
+ ansible.builtin.include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/roles/app/files/config.yml'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.config/app/config.yml"
+ dotfile_dir_mode: '0700'
```
## Behavior Details
@@ -181,17 +184,17 @@ link_dotfile_dst: /Users/username/.config/app/config.yml
```yaml
# Good
-- include_role:
- name: roles/link_dotfile
- vars:
- link_dotfile_src: "{{ dotfiles_home }}/files/{{ item }}"
- link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.{{ item }}"
- loop: [file1, file2, file3]
+ - include_role:
+ name: roles/link_dotfile
+ vars:
+ link_dotfile_src: '{{ dotfiles_home }}/files/{{ item }}'
+ link_dotfile_dst: "{{ ansible_facts['user_dir'] }}/.{{ item }}"
+ loop: [file1, file2, file3]
# Bad (repetitive)
-- include_role: ... # file1
-- include_role: ... # file2
-- include_role: ... # file3
+ - include_role: '...' # file1
+ - include_role: '...' # file2
+ - include_role: '...' # file3
```
## Testing
diff --git a/roles/macos/README.md b/roles/macos/README.md
index 16a60a9..bf6bd23 100644
--- a/roles/macos/README.md
+++ b/roles/macos/README.md
@@ -23,11 +23,13 @@ environment and quickly bootstrap new macOS systems.
**Purpose**: Command-line tools and utilities
**When to use**:
+
- CLI tools and utilities (e.g., `git`, `jq`, `ripgrep`)
- Development tools without GUIs (e.g., `node`, `python`, `hatch`)
- System utilities (e.g., `direnv`, `autojump`)
**Example packages**:
+
```yaml
brew_packages:
- bat # cat alternative with syntax highlighting
@@ -37,6 +39,7 @@ brew_packages:
```
**Special syntax**:
+
- Standard packages: `package-name`
- Tap packages: `username/tap-name/package-name` (e.g., `jesseduffield/lazydocker/lazydocker`)
@@ -45,12 +48,14 @@ brew_packages:
**Purpose**: GUI applications distributed outside the Mac App Store
**When to use**:
+
- Desktop applications with graphical interfaces
- Developer tools (IDEs, editors)
- Third-party applications not available in the Mac App Store
- Applications requiring more frequent updates than App Store allows
**Example packages**:
+
```yaml
brew_cask_packages:
- google-chrome # Web browser
@@ -60,6 +65,7 @@ brew_cask_packages:
```
**Benefits**:
+
- No App Store sandboxing restrictions
- Often more up-to-date than App Store versions
- Can install beta/nightly builds
@@ -70,12 +76,14 @@ brew_cask_packages:
**Purpose**: Applications distributed through Apple's Mac App Store
**When to use**:
+
- Applications only available in the Mac App Store
- Applications requiring App Store entitlements
- Apple's own applications
- Apps where App Store version is preferred
**Example packages**:
+
```yaml
mas_apps:
- name: Amphetamine # Keep Mac awake utility
@@ -85,10 +93,12 @@ mas_apps:
```
**Requirements**:
+
- Must be signed into Mac App Store with Apple ID
- App ID can be found in the App Store URL (e.g., `https://apps.apple.com/app/id937984704`)
**Finding App IDs**:
+
```bash
# Search for an app
mas search "app name"
@@ -102,7 +112,6 @@ mas list
All package lists are defined in `roles/macos/defaults/main.yml`:
```yaml
----
brew_packages:
- package1
- package2
@@ -121,6 +130,7 @@ mas_upgrade_all_apps: false
### Current Package Inventory
**CLI Tools** (29 packages):
+
- Version control: `git-delta`, `git-extras`, `git-lfs`, `gh`, `hub`, `lazygit`
- Development: `hatch`, `pipx`, `node`, `mermaid-cli`
- File utilities: `bat`, `ripgrep`, `tree`, `jq`
@@ -130,6 +140,7 @@ mas_upgrade_all_apps: false
- System tools: `shellcheck`, `nmap`, `mas`, `just`, `moor`
**GUI Applications** (8 packages):
+
- Browsers: `google-chrome`
- Development: `pycharm-ce`, `sublime-text`, `macdown`, `iterm2`
- Automation: `hammerspoon`
@@ -137,6 +148,7 @@ mas_upgrade_all_apps: false
- Media: `spotify`
**Mac App Store** (3 apps):
+
- System monitoring: `iStat Menus`
- Utilities: `Amphetamine`
- VPN: `Tailscale`
@@ -189,6 +201,7 @@ brew_cask_packages:
### Adding Mac App Store Apps
1. Find the app ID from the Mac App Store:
+
- Visit app page in browser
- Copy ID from URL: `https://apps.apple.com/app/id123456789`
- Or use `mas search "app name"`
@@ -257,12 +270,12 @@ ansible-galaxy install -r roles/requirements.yml
All variables defined in `roles/macos/defaults/main.yml`:
-| Variable | Type | Description | Default |
-|----------|------|-------------|---------|
-| `brew_packages` | list[string] | Homebrew formulae to install | See defaults file |
-| `brew_cask_packages` | list[string] | Homebrew casks to install | See defaults file |
-| `mas_apps` | list[object] | Mac App Store apps to install | See defaults file |
-| `mas_upgrade_all_apps` | boolean | Whether to upgrade all installed MAS apps | `false` |
+| Variable | Type | Description | Default |
+| ---------------------- | ------------ | ----------------------------------------- | ----------------- |
+| `brew_packages` | list[string] | Homebrew formulae to install | See defaults file |
+| `brew_cask_packages` | list[string] | Homebrew casks to install | See defaults file |
+| `mas_apps` | list[object] | Mac App Store apps to install | See defaults file |
+| `mas_upgrade_all_apps` | boolean | Whether to upgrade all installed MAS apps | `false` |
### Variable Overrides
@@ -381,8 +394,8 @@ ansible-playbook playbooks/local_bootstrap.yml \
```
Example `custom_packages.yml`:
+
```yaml
----
brew_packages:
- git
- vim
@@ -402,6 +415,7 @@ mas_apps: []
**Cause**: The `mas` CLI tool is installed via `brew_packages` but may not be in PATH yet during the same playbook run.
**Solution**: Run the playbook twice, or install `mas` manually first:
+
```bash
brew install mas
```
@@ -411,6 +425,7 @@ brew install mas
**Cause**: Not signed into Mac App Store with Apple ID.
**Solution**:
+
1. Open Mac App Store
2. Sign in with your Apple ID
3. Re-run the playbook
@@ -420,10 +435,12 @@ brew install mas
**Cause**: Application was installed manually, conflicts with Homebrew-managed version.
**Solution**: Remove manual installation first:
+
```bash
brew uninstall --cask app-name --force
rm -rf /Applications/AppName.app
```
+
Then re-run playbook.
#### Issue: Command-line tools installation hangs
@@ -431,6 +448,7 @@ Then re-run playbook.
**Cause**: Interactive prompts waiting for user input.
**Solution**: Install manually first:
+
```bash
xcode-select --install
```
@@ -440,6 +458,7 @@ xcode-select --install
**Cause**: Homebrew directories have incorrect ownership.
**Solution**: Fix Homebrew permissions:
+
```bash
sudo chown -R $(whoami) $(brew --prefix)/*
```
@@ -449,6 +468,7 @@ sudo chown -R $(whoami) $(brew --prefix)/*
**Cause**: Network issues or slow connection.
**Solution**: Retry the playbook, or install individual package manually:
+
```bash
brew install package-name
brew install --cask app-name
@@ -458,11 +478,13 @@ mas install app-id
### Debugging Tips
1. **Check Homebrew status**:
+
```bash
brew doctor
```
2. **List installed packages**:
+
```bash
brew list # formulae
brew list --cask # casks
@@ -470,6 +492,7 @@ mas list # Mac App Store apps
```
3. **Check available updates**:
+
```bash
brew outdated
brew outdated --cask
@@ -477,11 +500,13 @@ mas outdated
```
4. **View Homebrew logs**:
+
```bash
brew install --verbose package-name
```
5. **Test playbook with increased verbosity**:
+
```bash
ansible-playbook playbooks/local_bootstrap.yml \
-i inventory/inventory.yml \
@@ -509,6 +534,7 @@ ansible-playbook playbooks/local_bootstrap.yml \
### Keeping Package Lists Updated
1. **Review installed packages**:
+
```bash
brew list | wc -l # count formulae
brew list --cask | wc -l # count casks
@@ -517,11 +543,13 @@ brew list --cask | wc -l # count casks
2. **Remove unused packages** from `defaults/main.yml`
3. **Update Homebrew itself**:
+
```bash
brew update
```
4. **Upgrade all packages** (outside Ansible):
+
```bash
brew upgrade
brew upgrade --cask
@@ -546,7 +574,8 @@ When modifying this role:
2. Add comments for non-obvious package choices
3. Test changes on a clean macOS system if possible
4. Update this README if adding new variables or functionality
-5. Run pre-commit hooks before committing:
+5. Run prek hooks before committing:
+
```bash
-pre-commit run --all-files
+prek run --all-files
```
diff --git a/roles/starship/files/starship.toml b/roles/starship/files/starship.toml
index d4b2ea1..480ea17 100644
--- a/roles/starship/files/starship.toml
+++ b/roles/starship/files/starship.toml
@@ -430,61 +430,61 @@ charging_symbol = ""
discharging_symbol = ""
unknown_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 90% and 100%
+[[battery.display]] # Battery/charging display settings when capacity between 90% and 100%
threshold = 100
style = "bright-green"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 80% and 90%
+[[battery.display]] # Battery/charging display settings when capacity between 80% and 90%
threshold = 90
style = "green"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 70% and 80%
+[[battery.display]] # Battery/charging display settings when capacity between 70% and 80%
threshold = 80
style = "green"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 60% and 70%
+[[battery.display]] # Battery/charging display settings when capacity between 60% and 70%
threshold = 70
style = "green"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 50% and 60%
+[[battery.display]] # Battery/charging display settings when capacity between 50% and 60%
threshold = 60
style = "green"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 40% and 50%
+[[battery.display]] # Battery/charging display settings when capacity between 40% and 50%
threshold = 50
style = "green"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 30% and 40%
+[[battery.display]] # Battery/charging display settings when capacity between 30% and 40%
threshold = 40
style = "dimmed bright-yellow"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 20% and 30%
+[[battery.display]] # Battery/charging display settings when capacity between 20% and 30%
threshold = 30
style = "orange"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 10% and 20%
+[[battery.display]] # Battery/charging display settings when capacity between 10% and 20%
threshold = 20
style = "bright-red"
discharging_symbol = ""
charging_symbol = ""
-[[battery.display]] # Battery/charging display settings when capacity between 80% and 10%
+[[battery.display]] # Battery/charging display settings when capacity between 80% and 10%
threshold = 10
style = "red"
discharging_symbol = ""
diff --git a/roles/tailscale/README.md b/roles/tailscale/README.md
index b812e8b..4fcb38d 100644
--- a/roles/tailscale/README.md
+++ b/roles/tailscale/README.md
@@ -24,8 +24,8 @@ Sets up [Tailscale VPN](https://tailscale.com/) on Debian and macOS systems.
Set these in your Ansible Vault (`inventory/group_vars/all/vault.yml`):
```yaml
-tailscale_auth_key: "tskey-auth-..."
-tailscale_tailnet_name: "your-tailnet.ts.net"
+tailscale_auth_key: tskey-auth-...
+tailscale_tailnet_name: your-tailnet.ts.net
```
## Optional Variables
@@ -37,28 +37,28 @@ Key overrides for NAS (`inventory/host_vars/nas/vars.yml`):
```yaml
tailscale_https_enabled: true
tailscale_services:
- - { name: sonarr, port: 8989, path: sonarr }
- - { name: radarr, port: 7878, path: radarr }
- - { name: transmission, port: 9091, path: transmission }
- - { name: prowlarr, port: 9696, path: prowlarr }
- - { name: pihole, port: 80, path: pihole }
- - { name: glance, port: 8080, path: glance, strip_prefix: true }
- - { name: plex, port: 32400, dedicated_port: 32443 }
+ - {name: sonarr, port: 8989, path: sonarr}
+ - {name: radarr, port: 7878, path: radarr}
+ - {name: transmission, port: 9091, path: transmission}
+ - {name: prowlarr, port: 9696, path: prowlarr}
+ - {name: pihole, port: 80, path: pihole}
+ - {name: glance, port: 8080, path: glance, strip_prefix: true}
+ - {name: plex, port: 32400, dedicated_port: 32443}
```
## Service Routing
Services are accessible via `https://nas..ts.net/`:
-| Service | URL | Notes |
-|---|---|---|
-| Sonarr | `/sonarr` | Requires `UrlBase = /sonarr` in app settings |
-| Radarr | `/radarr` | Requires `UrlBase = /radarr` in app settings |
-| Prowlarr | `/prowlarr` | Requires `UrlBase = /prowlarr` in app settings |
-| Transmission | `/transmission` | Requires `rpc-url = /transmission/` in settings.json |
-| PiHole | `/pihole` | Automatically rewrites to `/admin` |
-| Glance | `/glance` | No app config needed |
-| Plex | `:32443` (dedicated port) | Access via `https://nas..ts.net:32443` |
+| Service | URL | Notes |
+| ------------ | ------------------------- | ---------------------------------------------------- |
+| Sonarr | `/sonarr` | Requires `UrlBase = /sonarr` in app settings |
+| Radarr | `/radarr` | Requires `UrlBase = /radarr` in app settings |
+| Prowlarr | `/prowlarr` | Requires `UrlBase = /prowlarr` in app settings |
+| Transmission | `/transmission` | Requires `rpc-url = /transmission/` in settings.json |
+| PiHole | `/pihole` | Automatically rewrites to `/admin` |
+| Glance | `/glance` | No app config needed |
+| Plex | `:32443` (dedicated port) | Access via `https://nas..ts.net:32443` |
## Post-Deployment Steps
diff --git a/roles/zsh/README.md b/roles/zsh/README.md
index fb03961..62b8309 100644
--- a/roles/zsh/README.md
+++ b/roles/zsh/README.md
@@ -3,6 +3,7 @@
Sets up zsh shell
## Tasks
+
- Install oh-my-zsh
- Set zsh as default shell
- Install [powerlevel10k zsh theme](https://github.com/romkatv/powerlevel10k.git) and copy theme config
diff --git a/roles/zsh/files/p10k.zsh b/roles/zsh/files/p10k.zsh
index 077b11d..6f7a989 100644
--- a/roles/zsh/files/p10k.zsh
+++ b/roles/zsh/files/p10k.zsh
@@ -13,1362 +13,1362 @@
# Temporarily change options.
'builtin' 'local' '-a' 'p10k_config_opts'
-[[ ! -o 'aliases' ]] || p10k_config_opts+=('aliases')
-[[ ! -o 'sh_glob' ]] || p10k_config_opts+=('sh_glob')
+[[ ! -o 'aliases' ]] || p10k_config_opts+=('aliases')
+[[ ! -o 'sh_glob' ]] || p10k_config_opts+=('sh_glob')
[[ ! -o 'no_brace_expand' ]] || p10k_config_opts+=('no_brace_expand')
'builtin' 'setopt' 'no_aliases' 'no_sh_glob' 'brace_expand'
() {
- emulate -L zsh
- setopt no_unset extended_glob
-
- # Unset all configuration options. This allows you to apply configiguration changes without
- # restarting zsh. Edit ~/.p10k.zsh and type `source ~/.p10k.zsh`.
- unset -m 'POWERLEVEL9K_*'
-
- autoload -Uz is-at-least && is-at-least 5.1 || return
-
- zmodload zsh/langinfo
- if [[ ${langinfo[CODESET]:-} != (utf|UTF)(-|)8 ]]; then
- local LC_ALL=${${(@M)$(locale -a):#*.(utf|UTF)(-|)8}[1]:-en_US.UTF-8}
- fi
-
- # The list of segments shown on the left. Fill it with the most important segments.
- typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(
- # =========================[ Line #1 ]=========================
- # os_icon # os identifier
- dir # current directory
- vcs # git status
- # =========================[ Line #2 ]=========================
- newline # \n
- # prompt_char # prompt symbol
- )
-
- # The list of segments shown on the right. Fill it with less important segments.
- # Right prompt on the last prompt line (where you are typing your commands) gets
- # automatically hidden when the input line reaches it. Right prompt above the
- # last prompt line gets hidden if it would overlap with left prompt.
- typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=(
- # =========================[ Line #1 ]=========================
- status # exit code of the last command
- command_execution_time # duration of the last command
- background_jobs # presence of background jobs
- direnv # direnv status (https://direnv.net/)
- asdf # asdf version manager (https://github.com/asdf-vm/asdf)
- virtualenv # python virtual environment (https://docs.python.org/3/library/venv.html)
- anaconda # conda environment (https://conda.io/)
- pyenv # python environment (https://github.com/pyenv/pyenv)
- goenv # go environment (https://github.com/syndbg/goenv)
- nodenv # node.js version from nodenv (https://github.com/nodenv/nodenv)
- nvm # node.js version from nvm (https://github.com/nvm-sh/nvm)
- nodeenv # node.js environment (https://github.com/ekalinin/nodeenv)
- # node_version # node.js version
- # go_version # go version (https://golang.org)
- # rust_version # rustc version (https://www.rust-lang.org)
- # dotnet_version # .NET version (https://dotnet.microsoft.com)
- rbenv # ruby version from rbenv (https://github.com/rbenv/rbenv)
- rvm # ruby version from rvm (https://rvm.io)
- fvm # flutter version management (https://github.com/leoafarias/fvm)
- luaenv # lua version from luaenv (https://github.com/cehoffman/luaenv)
- jenv # java version from jenv (https://github.com/jenv/jenv)
- plenv # perl version from plenv (https://github.com/tokuhirom/plenv)
- kubecontext # current kubernetes context (https://kubernetes.io/)
- terraform # terraform workspace (https://www.terraform.io)
- aws # aws profile (https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html)
- aws_eb_env # aws elastic beanstalk environment (https://aws.amazon.com/elasticbeanstalk/)
- azure # azure account name (https://docs.microsoft.com/en-us/cli/azure)
- gcloud # google cloud cli account and project (https://cloud.google.com/)
- google_app_cred # google application credentials (https://cloud.google.com/docs/authentication/production)
- context # user@hostname
- nordvpn # nordvpn connection status, linux only (https://nordvpn.com/)
- ranger # ranger shell (https://github.com/ranger/ranger)
- nnn # nnn shell (https://github.com/jarun/nnn)
- vim_shell # vim shell indicator (:sh)
- midnight_commander # midnight commander shell (https://midnight-commander.org/)
- nix_shell # nix shell (https://nixos.org/nixos/nix-pills/developing-with-nix-shell.html)
- vi_mode # vi mode (you don't need this if you've enabled prompt_char)
- # load # CPU load
- # disk_usage # disk usage
- # ram # free RAM
- # swap # used swap
- todo # todo items (https://github.com/todotxt/todo.txt-cli)
- timewarrior # timewarrior tracking status (https://timewarrior.net/)
- time # current time
- # =========================[ Line #2 ]=========================
- newline # \n
- # ip # ip address and bandwidth usage for a specified network interface
- # public_ip # public IP address
- # proxy # system-wide http/https/ftp proxy
- # battery # internal battery
- # wifi # wifi speed
- # example # example user-defined segment (see prompt_example function below)
- )
-
- # To enable default icons for all segments, don't define POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION
- # or set it to '${P9K_VISUAL_IDENTIFIER}'.
- #
- # To remove trailing space from all default icons, set POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION
- # to '${P9K_VISUAL_IDENTIFIER% }'.
- #
- # To enable default icons for one segment (e.g., dir), set
- # POWERLEVEL9K_DIR_VISUAL_IDENTIFIER_EXPANSION='${P9K_VISUAL_IDENTIFIER}'.
- #
- # To assign a specific icon to one segment (e.g., dir), set
- # POWERLEVEL9K_DIR_VISUAL_IDENTIFIER_EXPANSION='⭐'.
- #
- # To assign a specific icon to a segment in a given state (e.g., dir in state NOT_WRITABLE),
- # set POWERLEVEL9K_DIR_NOT_WRITABLE_VISUAL_IDENTIFIER_EXPANSION='⭐'.
- #
- # Note: You can use $'\u2B50' instead of '⭐'. It's especially convenient when specifying
- # icons that your text editor cannot render. Don't forget to put $ and use single quotes when
- # defining icons via Unicode codepoints.
- #
- # Note: Many default icons cannot be displayed with system fonts. You'll need to install a
- # capable font to use them. See POWERLEVEL9K_MODE below.
- typeset -g POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION='${P9K_VISUAL_IDENTIFIER}'
-
- # This option makes a difference only when default icons are enabled for all or some prompt
- # segments (see POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION above). LOCK_ICON can be printed as
- # $'\uE0A2', $'\uE138' or $'\uF023' depending on POWERLEVEL9K_MODE. The correct value of this
- # parameter depends on the provider of the font your terminal is using.
- #
- # Font Provider | POWERLEVEL9K_MODE
- # ---------------------------------+-------------------
- # Powerline | powerline
- # Font Awesome | awesome-fontconfig
- # Adobe Source Code Pro | awesome-fontconfig
- # Source Code Pro | awesome-fontconfig
- # Awesome-Terminal Fonts (regular) | awesome-fontconfig
- # Awesome-Terminal Fonts (patched) | awesome-patched
- # Nerd Fonts | nerdfont-complete
- # Other | compatible
- #
- # If this looks overwhelming, either stick with a preinstalled system font and set
- # POWERLEVEL9K_MODE=compatible, or install the recommended Powerlevel10k font from
- # https://github.com/romkatv/powerlevel10k/#recommended-meslo-nerd-font-patched-for-powerlevel10k
- # and set POWERLEVEL9K_MODE=nerdfont-complete.
- typeset -g POWERLEVEL9K_MODE=compatible
-
- # When set to true, icons appear before content on both sides of the prompt. When set
- # to false, icons go after content. If empty or not set, icons go before content in the left
- # prompt and after content in the right prompt.
- #
- # You can also override it for a specific segment:
- #
- # POWERLEVEL9K_STATUS_ICON_BEFORE_CONTENT=false
- #
- # Or for a specific segment in specific state:
- #
- # POWERLEVEL9K_DIR_NOT_WRITABLE_ICON_BEFORE_CONTENT=false
- typeset -g POWERLEVEL9K_ICON_BEFORE_CONTENT=
-
- # Add an empty line before each prompt.
- typeset -g POWERLEVEL9K_PROMPT_ADD_NEWLINE=true
-
- # Connect left prompt lines with these symbols. You'll probably want to use the same color
- # as POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND below.
- typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_PREFIX='%242F╭─'
- typeset -g POWERLEVEL9K_MULTILINE_NEWLINE_PROMPT_PREFIX='%242F├─'
- typeset -g POWERLEVEL9K_MULTILINE_LAST_PROMPT_PREFIX='%242F╰─'
- # Connect right prompt lines with these symbols.
- typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_SUFFIX='%242F─╮'
- typeset -g POWERLEVEL9K_MULTILINE_NEWLINE_PROMPT_SUFFIX='%242F─┤'
- typeset -g POWERLEVEL9K_MULTILINE_LAST_PROMPT_SUFFIX='%242F─╯'
-
- # Filler between left and right prompt on the first prompt line. You can set it to ' ', '·' or
- # '─'. The last two make it easier to see the alignment between left and right prompt and to
- # separate prompt from command output. You might want to set POWERLEVEL9K_PROMPT_ADD_NEWLINE=false
- # for more compact prompt if using using this option.
- typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR=' '
- typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_BACKGROUND=
- if [[ $POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR != ' ' ]]; then
- # The color of the filler. You'll probably want to match the color of POWERLEVEL9K_MULTILINE
- # ornaments defined above.
- typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND=242
- # Start filler from the edge of the screen if there are no left segments on the first line.
- typeset -g POWERLEVEL9K_EMPTY_LINE_LEFT_PROMPT_FIRST_SEGMENT_END_SYMBOL='%{%}'
- # End filler on the edge of the screen if there are no right segments on the first line.
- typeset -g POWERLEVEL9K_EMPTY_LINE_RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL='%{%}'
- fi
-
- # Default background color.
- typeset -g POWERLEVEL9K_BACKGROUND=238
-
- # Separator between same-color segments on the left.
- typeset -g POWERLEVEL9K_LEFT_SUBSEGMENT_SEPARATOR='%246F|'
- # Separator between same-color segments on the right.
- typeset -g POWERLEVEL9K_RIGHT_SUBSEGMENT_SEPARATOR='%246F|'
- # Separator between different-color segments on the left.
- typeset -g POWERLEVEL9K_LEFT_SEGMENT_SEPARATOR=''
- # Separator between different-color segments on the right.
- typeset -g POWERLEVEL9K_RIGHT_SEGMENT_SEPARATOR=''
- # The right end of left prompt.
- typeset -g POWERLEVEL9K_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL='▓▒░'
- # The left end of right prompt.
- typeset -g POWERLEVEL9K_RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL='░▒▓'
- # The left end of left prompt.
- typeset -g POWERLEVEL9K_LEFT_PROMPT_FIRST_SEGMENT_START_SYMBOL=''
- # The right end of right prompt.
- typeset -g POWERLEVEL9K_RIGHT_PROMPT_LAST_SEGMENT_END_SYMBOL=''
- # Left prompt terminator for lines without any segments.
- typeset -g POWERLEVEL9K_EMPTY_LINE_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL=
-
- #################################[ os_icon: os identifier ]##################################
- # OS identifier color.
- typeset -g POWERLEVEL9K_OS_ICON_FOREGROUND=255
- # Make the icon bold.
- typeset -g POWERLEVEL9K_OS_ICON_CONTENT_EXPANSION='%B${P9K_CONTENT}'
-
- ################################[ prompt_char: prompt symbol ]################################
- # Transparent background.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_BACKGROUND=
- # Green prompt symbol if the last command succeeded.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_OK_{VIINS,VICMD,VIVIS,VIOWR}_FOREGROUND=76
- # Red prompt symbol if the last command failed.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_ERROR_{VIINS,VICMD,VIVIS,VIOWR}_FOREGROUND=196
- # Default prompt symbol.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIINS_CONTENT_EXPANSION='❯'
- # Prompt symbol in command vi mode.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VICMD_CONTENT_EXPANSION='❮'
- # Prompt symbol in visual vi mode.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIVIS_CONTENT_EXPANSION='Ⅴ'
- # Prompt symbol in overwrite vi mode.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIOWR_CONTENT_EXPANSION='▶'
- typeset -g POWERLEVEL9K_PROMPT_CHAR_OVERWRITE_STATE=true
- # No line terminator if prompt_char is the last segment.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL=
- # No line introducer if prompt_char is the first segment.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_FIRST_SEGMENT_START_SYMBOL=
- # No surrounding whitespace.
- typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_{LEFT,RIGHT}_WHITESPACE=
-
- ##################################[ dir: current directory ]##################################
- # Default current directory color.
- typeset -g POWERLEVEL9K_DIR_FOREGROUND=31
- # If directory is too long, shorten some of its segments to the shortest possible unique
- # prefix. The shortened directory can be tab-completed to the original.
- typeset -g POWERLEVEL9K_SHORTEN_STRATEGY=truncate_to_unique
- # Replace removed segment suffixes with this symbol.
- typeset -g POWERLEVEL9K_SHORTEN_DELIMITER=
- # Color of the shortened directory segments.
- typeset -g POWERLEVEL9K_DIR_SHORTENED_FOREGROUND=103
- # Color of the anchor directory segments. Anchor segments are never shortened. The first
- # segment is always an anchor.
- typeset -g POWERLEVEL9K_DIR_ANCHOR_FOREGROUND=39
- # Display anchor directory segments in bold.
- typeset -g POWERLEVEL9K_DIR_ANCHOR_BOLD=true
- # Don't shorten directories that contain any of these files. They are anchors.
- local anchor_files=(
- .bzr
- .citc
- .git
- .hg
- .node-version
- .python-version
- .ruby-version
- .shorten_folder_marker
- .svn
- .terraform
- CVS
- Cargo.toml
- composer.json
- go.mod
- package.json
- )
- typeset -g POWERLEVEL9K_SHORTEN_FOLDER_MARKER="(${(j:|:)anchor_files})"
- # If set to true, remove everything before the last (deepest) subdirectory that contains files
- # matching $POWERLEVEL9K_SHORTEN_FOLDER_MARKER. For example, when the current directory is
- # /foo/bar/git_repo/baz, prompt will display git_repo/baz. This assumes that /foo/bar/git_repo
- # contains a marker (.git) and other directories don't.
- typeset -g POWERLEVEL9K_DIR_TRUNCATE_BEFORE_MARKER=false
- # Don't shorten this many last directory segments. They are anchors.
- typeset -g POWERLEVEL9K_SHORTEN_DIR_LENGTH=1
- # Shorten directory if it's longer than this even if there is space for it. The value can
- # be either absolute (e.g., '80') or a percentage of terminal width (e.g, '50%'). If empty,
- # directory will be shortened only when prompt doesn't fit or when other parameters demand it
- # (see POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS and POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS_PCT below).
- # If set to `0`, directory will always be shortened to its minimum length.
- typeset -g POWERLEVEL9K_DIR_MAX_LENGTH=80
- # When `dir` segment is on the last prompt line, try to shorten it enough to leave at least this
- # many columns for typing commands.
- typeset -g POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS=40
- # When `dir` segment is on the last prompt line, try to shorten it enough to leave at least
- # COLUMNS * POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS_PCT * 0.01 columns for typing commands.
- typeset -g POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS_PCT=50
- # If set to true, embed a hyperlink into the directory. Useful for quickly
- # opening a directory in the file manager simply by clicking the link.
- # Can also be handy when the directory is shortened, as it allows you to see
- # the full directory that was used in previous commands.
- typeset -g POWERLEVEL9K_DIR_HYPERLINK=false
-
- # Enable special styling for non-writable directories.
- typeset -g POWERLEVEL9K_DIR_SHOW_WRITABLE=true
- # Show this icon when the current directory is not writable. POWERLEVEL9K_DIR_SHOW_WRITABLE
- # above must be set to true for this parameter to have effect.
- typeset -g POWERLEVEL9K_DIR_NOT_WRITABLE_VISUAL_IDENTIFIER_EXPANSION='∅'
-
- # Custom prefix.
- # typeset -g POWERLEVEL9K_DIR_PREFIX='%248Fin '
-
- # POWERLEVEL9K_DIR_CLASSES allows you to specify custom icons for different directories.
- # It must be an array with 3 * N elements. Each triplet consists of:
- #
- # 1. A pattern against which the current directory is matched. Matching is done with
- # extended_glob option enabled.
- # 2. Directory class for the purpose of styling.
- # 3. Icon.
- #
- # Triplets are tried in order. The first triplet whose pattern matches $PWD wins. If there
- # are no matches, the directory will have no icon.
- #
- # Example:
- #
- # typeset -g POWERLEVEL9K_DIR_CLASSES=(
- # '~/work(|/*)' WORK '(╯°□°)╯︵ ┻━┻'
- # '~(|/*)' HOME '⌂'
- # '*' DEFAULT '')
- #
- # With these settings, the current directory in the prompt may look like this:
- #
- # (╯°□°)╯︵ ┻━┻ ~/work/projects/important/urgent
- #
- # Or like this:
- #
- # ⌂ ~/best/powerlevel10k
- #
- # You can also set different colors for directories of different classes. Remember to override
- # FOREGROUND, SHORTENED_FOREGROUND and ANCHOR_FOREGROUND for every directory class that you wish
- # to have its own color.
- #
- # typeset -g POWERLEVEL9K_DIR_WORK_FOREGROUND=31
- # typeset -g POWERLEVEL9K_DIR_WORK_SHORTENED_FOREGROUND=103
- # typeset -g POWERLEVEL9K_DIR_WORK_ANCHOR_FOREGROUND=39
- #
- typeset -g POWERLEVEL9K_DIR_CLASSES=()
-
- #####################################[ vcs: git status ]######################################
- # Branch icon. Set this parameter to '\uF126 ' for the popular Powerline branch icon.
- typeset -g POWERLEVEL9K_VCS_BRANCH_ICON=
- POWERLEVEL9K_VCS_BRANCH_ICON=${(g::)POWERLEVEL9K_VCS_BRANCH_ICON}
-
- # Untracked files icon. It's really a question mark, your font isn't broken.
- # Change the value of this parameter to show a different icon.
- typeset -g POWERLEVEL9K_VCS_UNTRACKED_ICON='?'
- POWERLEVEL9K_VCS_UNTRACKED_ICON=${(g::)POWERLEVEL9K_VCS_UNTRACKED_ICON}
-
- # Formatter for Git status.
- #
- # Example output: master ⇣42⇡42 *42 merge ~42 +42 !42 ?42.
- #
- # You can edit the function to customize how Git status looks.
- #
- # VCS_STATUS_* parameters are set by gitstatus plugin. See reference:
- # https://github.com/romkatv/gitstatus/blob/master/gitstatus.plugin.zsh.
- function my_git_formatter() {
emulate -L zsh
+ setopt no_unset extended_glob
- if [[ -n $P9K_CONTENT ]]; then
- # If P9K_CONTENT is not empty, use it. It's either "loading" or from vcs_info (not from
- # gitstatus plugin). VCS_STATUS_* parameters are not available in this case.
- typeset -g my_git_format=$P9K_CONTENT
- return
- fi
+ # Unset all configuration options. This allows you to apply configiguration changes without
+ # restarting zsh. Edit ~/.p10k.zsh and type `source ~/.p10k.zsh`.
+ unset -m 'POWERLEVEL9K_*'
- if (( $1 )); then
- # Styling for up-to-date Git status.
- local meta='%248F' # grey foreground
- local clean='%76F' # green foreground
- local modified='%178F' # yellow foreground
- local untracked='%39F' # blue foreground
- local conflicted='%196F' # red foreground
- else
- # Styling for incomplete and stale Git status.
- local meta='%244F' # grey foreground
- local clean='%244F' # grey foreground
- local modified='%244F' # grey foreground
- local untracked='%244F' # grey foreground
- local conflicted='%244F' # grey foreground
- fi
+ autoload -Uz is-at-least && is-at-least 5.1 || return
- local res
- local where # branch or tag
- if [[ -n $VCS_STATUS_LOCAL_BRANCH ]]; then
- res+="${clean}${POWERLEVEL9K_VCS_BRANCH_ICON}"
- where=${(V)VCS_STATUS_LOCAL_BRANCH}
- elif [[ -n $VCS_STATUS_TAG ]]; then
- res+="${meta}#"
- where=${(V)VCS_STATUS_TAG}
+ zmodload zsh/langinfo
+ if [[ ${langinfo[CODESET]:-} != (utf|UTF)(-|)8 ]]; then
+ local LC_ALL=${${(@M)$(locale -a):#*.(utf|UTF)(-|)8}[1]:-en_US.UTF-8}
fi
- # If local branch name or tag is at most 32 characters long, show it in full.
- # Otherwise show the first 12 … the last 12.
- (( $#where > 50 )) && where[13,-13]="…"
- res+="${clean}${where//\%/%%}" # escape %
-
- # Display the current Git commit if there is no branch or tag.
- # Tip: To always display the current Git commit, remove `[[ -z $where ]] &&` from the next line.
- [[ -z $where ]] && res+="${meta}@${clean}${VCS_STATUS_COMMIT[1,8]}"
-
- # Show tracking branch name if it differs from local branch.
- if [[ -n ${VCS_STATUS_REMOTE_BRANCH:#$VCS_STATUS_LOCAL_BRANCH} ]]; then
- res+="${meta}:${clean}${(V)VCS_STATUS_REMOTE_BRANCH//\%/%%}" # escape %
+ # The list of segments shown on the left. Fill it with the most important segments.
+ typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(
+ # =========================[ Line #1 ]=========================
+ # os_icon # os identifier
+ dir # current directory
+ vcs # git status
+ # =========================[ Line #2 ]=========================
+ newline # \n
+ # prompt_char # prompt symbol
+ )
+
+ # The list of segments shown on the right. Fill it with less important segments.
+ # Right prompt on the last prompt line (where you are typing your commands) gets
+ # automatically hidden when the input line reaches it. Right prompt above the
+ # last prompt line gets hidden if it would overlap with left prompt.
+ typeset -g POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS=(
+ # =========================[ Line #1 ]=========================
+ status # exit code of the last command
+ command_execution_time # duration of the last command
+ background_jobs # presence of background jobs
+ direnv # direnv status (https://direnv.net/)
+ asdf # asdf version manager (https://github.com/asdf-vm/asdf)
+ virtualenv # python virtual environment (https://docs.python.org/3/library/venv.html)
+ anaconda # conda environment (https://conda.io/)
+ pyenv # python environment (https://github.com/pyenv/pyenv)
+ goenv # go environment (https://github.com/syndbg/goenv)
+ nodenv # node.js version from nodenv (https://github.com/nodenv/nodenv)
+ nvm # node.js version from nvm (https://github.com/nvm-sh/nvm)
+ nodeenv # node.js environment (https://github.com/ekalinin/nodeenv)
+ # node_version # node.js version
+ # go_version # go version (https://golang.org)
+ # rust_version # rustc version (https://www.rust-lang.org)
+ # dotnet_version # .NET version (https://dotnet.microsoft.com)
+ rbenv # ruby version from rbenv (https://github.com/rbenv/rbenv)
+ rvm # ruby version from rvm (https://rvm.io)
+ fvm # flutter version management (https://github.com/leoafarias/fvm)
+ luaenv # lua version from luaenv (https://github.com/cehoffman/luaenv)
+ jenv # java version from jenv (https://github.com/jenv/jenv)
+ plenv # perl version from plenv (https://github.com/tokuhirom/plenv)
+ kubecontext # current kubernetes context (https://kubernetes.io/)
+ terraform # terraform workspace (https://www.terraform.io)
+ aws # aws profile (https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html)
+ aws_eb_env # aws elastic beanstalk environment (https://aws.amazon.com/elasticbeanstalk/)
+ azure # azure account name (https://docs.microsoft.com/en-us/cli/azure)
+ gcloud # google cloud cli account and project (https://cloud.google.com/)
+ google_app_cred # google application credentials (https://cloud.google.com/docs/authentication/production)
+ context # user@hostname
+ nordvpn # nordvpn connection status, linux only (https://nordvpn.com/)
+ ranger # ranger shell (https://github.com/ranger/ranger)
+ nnn # nnn shell (https://github.com/jarun/nnn)
+ vim_shell # vim shell indicator (:sh)
+ midnight_commander # midnight commander shell (https://midnight-commander.org/)
+ nix_shell # nix shell (https://nixos.org/nixos/nix-pills/developing-with-nix-shell.html)
+ vi_mode # vi mode (you don't need this if you've enabled prompt_char)
+ # load # CPU load
+ # disk_usage # disk usage
+ # ram # free RAM
+ # swap # used swap
+ todo # todo items (https://github.com/todotxt/todo.txt-cli)
+ timewarrior # timewarrior tracking status (https://timewarrior.net/)
+ time # current time
+ # =========================[ Line #2 ]=========================
+ newline # \n
+ # ip # ip address and bandwidth usage for a specified network interface
+ # public_ip # public IP address
+ # proxy # system-wide http/https/ftp proxy
+ # battery # internal battery
+ # wifi # wifi speed
+ # example # example user-defined segment (see prompt_example function below)
+ )
+
+ # To enable default icons for all segments, don't define POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION
+ # or set it to '${P9K_VISUAL_IDENTIFIER}'.
+ #
+ # To remove trailing space from all default icons, set POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION
+ # to '${P9K_VISUAL_IDENTIFIER% }'.
+ #
+ # To enable default icons for one segment (e.g., dir), set
+ # POWERLEVEL9K_DIR_VISUAL_IDENTIFIER_EXPANSION='${P9K_VISUAL_IDENTIFIER}'.
+ #
+ # To assign a specific icon to one segment (e.g., dir), set
+ # POWERLEVEL9K_DIR_VISUAL_IDENTIFIER_EXPANSION='⭐'.
+ #
+ # To assign a specific icon to a segment in a given state (e.g., dir in state NOT_WRITABLE),
+ # set POWERLEVEL9K_DIR_NOT_WRITABLE_VISUAL_IDENTIFIER_EXPANSION='⭐'.
+ #
+ # Note: You can use $'\u2B50' instead of '⭐'. It's especially convenient when specifying
+ # icons that your text editor cannot render. Don't forget to put $ and use single quotes when
+ # defining icons via Unicode codepoints.
+ #
+ # Note: Many default icons cannot be displayed with system fonts. You'll need to install a
+ # capable font to use them. See POWERLEVEL9K_MODE below.
+ typeset -g POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION='${P9K_VISUAL_IDENTIFIER}'
+
+ # This option makes a difference only when default icons are enabled for all or some prompt
+ # segments (see POWERLEVEL9K_VISUAL_IDENTIFIER_EXPANSION above). LOCK_ICON can be printed as
+ # $'\uE0A2', $'\uE138' or $'\uF023' depending on POWERLEVEL9K_MODE. The correct value of this
+ # parameter depends on the provider of the font your terminal is using.
+ #
+ # Font Provider | POWERLEVEL9K_MODE
+ # ---------------------------------+-------------------
+ # Powerline | powerline
+ # Font Awesome | awesome-fontconfig
+ # Adobe Source Code Pro | awesome-fontconfig
+ # Source Code Pro | awesome-fontconfig
+ # Awesome-Terminal Fonts (regular) | awesome-fontconfig
+ # Awesome-Terminal Fonts (patched) | awesome-patched
+ # Nerd Fonts | nerdfont-complete
+ # Other | compatible
+ #
+ # If this looks overwhelming, either stick with a preinstalled system font and set
+ # POWERLEVEL9K_MODE=compatible, or install the recommended Powerlevel10k font from
+ # https://github.com/romkatv/powerlevel10k/#recommended-meslo-nerd-font-patched-for-powerlevel10k
+ # and set POWERLEVEL9K_MODE=nerdfont-complete.
+ typeset -g POWERLEVEL9K_MODE=compatible
+
+ # When set to true, icons appear before content on both sides of the prompt. When set
+ # to false, icons go after content. If empty or not set, icons go before content in the left
+ # prompt and after content in the right prompt.
+ #
+ # You can also override it for a specific segment:
+ #
+ # POWERLEVEL9K_STATUS_ICON_BEFORE_CONTENT=false
+ #
+ # Or for a specific segment in specific state:
+ #
+ # POWERLEVEL9K_DIR_NOT_WRITABLE_ICON_BEFORE_CONTENT=false
+ typeset -g POWERLEVEL9K_ICON_BEFORE_CONTENT=
+
+ # Add an empty line before each prompt.
+ typeset -g POWERLEVEL9K_PROMPT_ADD_NEWLINE=true
+
+ # Connect left prompt lines with these symbols. You'll probably want to use the same color
+ # as POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND below.
+ typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_PREFIX='%242F╭─'
+ typeset -g POWERLEVEL9K_MULTILINE_NEWLINE_PROMPT_PREFIX='%242F├─'
+ typeset -g POWERLEVEL9K_MULTILINE_LAST_PROMPT_PREFIX='%242F╰─'
+ # Connect right prompt lines with these symbols.
+ typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_SUFFIX='%242F─╮'
+ typeset -g POWERLEVEL9K_MULTILINE_NEWLINE_PROMPT_SUFFIX='%242F─┤'
+ typeset -g POWERLEVEL9K_MULTILINE_LAST_PROMPT_SUFFIX='%242F─╯'
+
+ # Filler between left and right prompt on the first prompt line. You can set it to ' ', '·' or
+ # '─'. The last two make it easier to see the alignment between left and right prompt and to
+ # separate prompt from command output. You might want to set POWERLEVEL9K_PROMPT_ADD_NEWLINE=false
+ # for more compact prompt if using using this option.
+ typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR=' '
+ typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_BACKGROUND=
+ if [[ $POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR != ' ' ]]; then
+ # The color of the filler. You'll probably want to match the color of POWERLEVEL9K_MULTILINE
+ # ornaments defined above.
+ typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND=242
+ # Start filler from the edge of the screen if there are no left segments on the first line.
+ typeset -g POWERLEVEL9K_EMPTY_LINE_LEFT_PROMPT_FIRST_SEGMENT_END_SYMBOL='%{%}'
+ # End filler on the edge of the screen if there are no right segments on the first line.
+ typeset -g POWERLEVEL9K_EMPTY_LINE_RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL='%{%}'
fi
- # ⇣42 if behind the remote.
- (( VCS_STATUS_COMMITS_BEHIND )) && res+=" ${clean}⇣${VCS_STATUS_COMMITS_BEHIND}"
- # ⇡42 if ahead of the remote; no leading space if also behind the remote: ⇣42⇡42.
- (( VCS_STATUS_COMMITS_AHEAD && !VCS_STATUS_COMMITS_BEHIND )) && res+=" "
- (( VCS_STATUS_COMMITS_AHEAD )) && res+="${clean}⇡${VCS_STATUS_COMMITS_AHEAD}"
- # ⇠42 if behind the push remote.
- (( VCS_STATUS_PUSH_COMMITS_BEHIND )) && res+=" ${clean}⇠${VCS_STATUS_PUSH_COMMITS_BEHIND}"
- (( VCS_STATUS_PUSH_COMMITS_AHEAD && !VCS_STATUS_PUSH_COMMITS_BEHIND )) && res+=" "
- # ⇢42 if ahead of the push remote; no leading space if also behind: ⇠42⇢42.
- (( VCS_STATUS_PUSH_COMMITS_AHEAD )) && res+="${clean}⇢${VCS_STATUS_PUSH_COMMITS_AHEAD}"
- # *42 if have stashes.
- (( VCS_STATUS_STASHES )) && res+=" ${clean}*${VCS_STATUS_STASHES}"
- # 'merge' if the repo is in an unusual state.
- [[ -n $VCS_STATUS_ACTION ]] && res+=" ${conflicted}${VCS_STATUS_ACTION}"
- # ~42 if have merge conflicts.
- (( VCS_STATUS_NUM_CONFLICTED )) && res+=" ${conflicted}~${VCS_STATUS_NUM_CONFLICTED}"
- # +42 if have staged changes.
- (( VCS_STATUS_NUM_STAGED )) && res+=" ${modified}+${VCS_STATUS_NUM_STAGED}"
- # !42 if have unstaged changes.
- (( VCS_STATUS_NUM_UNSTAGED )) && res+=" ${modified}!${VCS_STATUS_NUM_UNSTAGED}"
- # ?42 if have untracked files. It's really a question mark, your font isn't broken.
- # See POWERLEVEL9K_VCS_UNTRACKED_ICON above if you want to use a different icon.
- # Remove the next line if you don't want to see untracked files at all.
- (( VCS_STATUS_NUM_UNTRACKED )) && res+=" ${untracked}${POWERLEVEL9K_VCS_UNTRACKED_ICON}${VCS_STATUS_NUM_UNTRACKED}"
- # "─" if the number of unstaged files is unknown. This can happen due to
- # POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY (see below) being set to a non-negative number lower
- # than the number of files in the Git index, or due to bash.showDirtyState being set to false
- # in the repository config. The number of staged and untracked files may also be unknown
- # in this case.
- (( VCS_STATUS_HAS_UNSTAGED == -1 )) && res+=" ${modified}─"
-
- typeset -g my_git_format=$res
- }
- functions -M my_git_formatter 2>/dev/null
-
- # Don't count the number of unstaged, untracked and conflicted files in Git repositories with
- # more than this many files in the index. Negative value means infinity.
- #
- # If you are working in Git repositories with tens of millions of files and seeing performance
- # sagging, try setting POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY to a number lower than the output
- # of `git ls-files | wc -l`. Alternatively, add `bash.showDirtyState = false` to the repository's
- # config: `git config bash.showDirtyState false`.
- typeset -g POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY=-1
-
- # Don't show Git status in prompt for repositories whose workdir matches this pattern.
- # For example, if set to '~', the Git repository at $HOME/.git will be ignored.
- # Multiple patterns can be combined with '|': '~|~/some/dir'.
- typeset -g POWERLEVEL9K_VCS_DISABLED_WORKDIR_PATTERN='~'
-
- # Disable the default Git status formatting.
- typeset -g POWERLEVEL9K_VCS_DISABLE_GITSTATUS_FORMATTING=true
- # Install our own Git status formatter.
- typeset -g POWERLEVEL9K_VCS_CONTENT_EXPANSION='${$((my_git_formatter(1)))+${my_git_format}}'
- typeset -g POWERLEVEL9K_VCS_LOADING_CONTENT_EXPANSION='${$((my_git_formatter(0)))+${my_git_format}}'
- # Enable counters for staged, unstaged, etc.
- typeset -g POWERLEVEL9K_VCS_{STAGED,UNSTAGED,UNTRACKED,CONFLICTED,COMMITS_AHEAD,COMMITS_BEHIND}_MAX_NUM=-1
-
- # Icon color.
- typeset -g POWERLEVEL9K_VCS_VISUAL_IDENTIFIER_COLOR=76
- typeset -g POWERLEVEL9K_VCS_LOADING_VISUAL_IDENTIFIER_COLOR=244
- # Custom icon.
- typeset -g POWERLEVEL9K_VCS_VISUAL_IDENTIFIER_EXPANSION=
- # Custom prefix.
- typeset -g POWERLEVEL9K_VCS_PREFIX='%248Fon '
-
- # Show status of repositories of these types. You can add svn and/or hg if you are
- # using them. If you do, your prompt may become slow even when your current directory
- # isn't in an svn or hg reposotiry.
- typeset -g POWERLEVEL9K_VCS_BACKENDS=(git)
-
- # These settings are used for repositories other than Git or when gitstatusd fails and
- # Powerlevel10k has to fall back to using vcs_info.
- typeset -g POWERLEVEL9K_VCS_CLEAN_FOREGROUND=76
- typeset -g POWERLEVEL9K_VCS_UNTRACKED_FOREGROUND=76
- typeset -g POWERLEVEL9K_VCS_MODIFIED_FOREGROUND=178
-
- ##########################[ status: exit code of the last command ]###########################
- # Enable OK_PIPE, ERROR_PIPE and ERROR_SIGNAL status states to allow us to enable, disable and
- # style them independently from the regular OK and ERROR state.
- typeset -g POWERLEVEL9K_STATUS_EXTENDED_STATES=true
-
- # Status on success. No content, just an icon. No need to show it if prompt_char is enabled as
- # it will signify success by turning green.
- typeset -g POWERLEVEL9K_STATUS_OK=true
- typeset -g POWERLEVEL9K_STATUS_OK_FOREGROUND=70
- typeset -g POWERLEVEL9K_STATUS_OK_VISUAL_IDENTIFIER_EXPANSION='✔'
-
- # Status when some part of a pipe command fails but the overall exit status is zero. It may look
- # like this: 1|0.
- typeset -g POWERLEVEL9K_STATUS_OK_PIPE=true
- typeset -g POWERLEVEL9K_STATUS_OK_PIPE_FOREGROUND=70
- typeset -g POWERLEVEL9K_STATUS_OK_PIPE_VISUAL_IDENTIFIER_EXPANSION='✔'
-
- # Status when it's just an error code (e.g., '1'). No need to show it if prompt_char is enabled as
- # it will signify error by turning red.
- typeset -g POWERLEVEL9K_STATUS_ERROR=true
- typeset -g POWERLEVEL9K_STATUS_ERROR_FOREGROUND=160
- typeset -g POWERLEVEL9K_STATUS_ERROR_VISUAL_IDENTIFIER_EXPANSION='х'
-
- # Status when the last command was terminated by a signal.
- typeset -g POWERLEVEL9K_STATUS_ERROR_SIGNAL=true
- typeset -g POWERLEVEL9K_STATUS_ERROR_SIGNAL_FOREGROUND=160
- # Use terse signal names: "INT" instead of "SIGINT(2)".
- typeset -g POWERLEVEL9K_STATUS_VERBOSE_SIGNAME=false
- typeset -g POWERLEVEL9K_STATUS_ERROR_SIGNAL_VISUAL_IDENTIFIER_EXPANSION='х'
-
- # Status when some part of a pipe command fails and the overall exit status is also non-zero.
- # It may look like this: 1|0.
- typeset -g POWERLEVEL9K_STATUS_ERROR_PIPE=true
- typeset -g POWERLEVEL9K_STATUS_ERROR_PIPE_FOREGROUND=160
- typeset -g POWERLEVEL9K_STATUS_ERROR_PIPE_VISUAL_IDENTIFIER_EXPANSION='х'
-
- ###################[ command_execution_time: duration of the last command ]###################
- # Show duration of the last command if takes longer than this many seconds.
- typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_THRESHOLD=3
- # Show this many fractional digits. Zero means round to seconds.
- typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_PRECISION=0
- # Execution time color.
- typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_FOREGROUND=248
- # Duration format: 1d 2h 3m 4s.
- typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_FORMAT='d h m s'
- # Custom icon.
- typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_VISUAL_IDENTIFIER_EXPANSION=
- # Custom prefix.
- typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_PREFIX='%248Ftook '
-
- #######################[ background_jobs: presence of background jobs ]#######################
- # Don't show the number of background jobs.
- typeset -g POWERLEVEL9K_BACKGROUND_JOBS_VERBOSE=false
- # Background jobs color.
- typeset -g POWERLEVEL9K_BACKGROUND_JOBS_FOREGROUND=37
- # Custom icon.
- typeset -g POWERLEVEL9K_BACKGROUND_JOBS_VISUAL_IDENTIFIER_EXPANSION='≡'
-
- #######################[ direnv: direnv status (https://direnv.net/) ]########################
- # Direnv color.
- typeset -g POWERLEVEL9K_DIRENV_FOREGROUND=178
- # Custom icon.
- # typeset -g POWERLEVEL9K_DIRENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###############[ asdf: asdf version manager (https://github.com/asdf-vm/asdf) ]###############
- # Default asdf color. Only used to display tools for which there is no color override (see below).
- typeset -g POWERLEVEL9K_ASDF_FOREGROUND=66
-
- # There are three parameters that can be used to hide tools. If at least one of them decides
- # to hide a tool, that tool gets hidden. POWERLEVEL9K_ASDF_SHOW_SYSTEM=false hides "system". To
- # see the difference between POWERLEVEL9K_ASDF_SOURCES and POWERLEVEL9K_ASDF_PROMPT_ALWAYS_SHOW
- # consider the effect of the following commands:
- #
- # asdf local python 3.8.1
- # asdf global python 3.8.1
- #
- # After running both commands the current python version is 3.8.1 and its source is "local" as
- # it takes precedence over "global". If POWERLEVEL9K_ASDF_PROMPT_ALWAYS_SHOW is set to false,
- # it'll hide python version in this case because 3.8.1 is the same as the global version.
- # POWERLEVEL9K_ASDF_SOURCES will hide python version only if the value of this parameter doesn't
- # contain "local".
-
- # Hide tool versions that don't come from one of these sources.
- #
- # Available sources:
- #
- # - shell `asdf current` says "set by ASDF_${TOOL}_VERSION environment variable"
- # - local `asdf current` says "set by /some/not/home/directory/file"
- # - global `asdf current` says "set by /home/username/file"
- #
- # Note: If this parameter is set to (shell local global), it won't hide tools.
- # Tip: Override this parameter for ${TOOL} with POWERLEVEL9K_ASDF_${TOOL}_SOURCES.
- typeset -g POWERLEVEL9K_ASDF_SOURCES=(shell local global)
-
- # If set to false, hide tool versions that are the same as global.
- #
- # Note: The name of this parameter doesn't reflect its meaning at all.
- # Note: If this parameter is set to true, it won't hide tools.
- # Tip: Override this parameter for ${TOOL} with POWERLEVEL9K_ASDF_${TOOL}_PROMPT_ALWAYS_SHOW.
- typeset -g POWERLEVEL9K_ASDF_PROMPT_ALWAYS_SHOW=false
-
- # If set to false, hide tool versions that are equal to "system".
- #
- # Note: If this parameter is set to true, it won't hide tools.
- # Tip: Override this parameter for ${TOOL} with POWERLEVEL9K_ASDF_${TOOL}_SHOW_SYSTEM.
- typeset -g POWERLEVEL9K_ASDF_SHOW_SYSTEM=true
-
- # Ruby version from asdf.
- typeset -g POWERLEVEL9K_ASDF_RUBY_FOREGROUND=168
- # typeset -g POWERLEVEL9K_ASDF_RUBY_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Python version from asdf.
- typeset -g POWERLEVEL9K_ASDF_PYTHON_FOREGROUND=37
- # typeset -g POWERLEVEL9K_ASDF_PYTHON_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Go version from asdf.
- typeset -g POWERLEVEL9K_ASDF_GO_FOREGROUND=37
- # typeset -g POWERLEVEL9K_ASDF_GO_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Node.js version from asdf.
- typeset -g POWERLEVEL9K_ASDF_NODEJS_FOREGROUND=70
- # typeset -g POWERLEVEL9K_ASDF_NODEJS_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Rust version from asdf.
- typeset -g POWERLEVEL9K_ASDF_RUST_FOREGROUND=37
- # typeset -g POWERLEVEL9K_ASDF_RUST_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # .NET Core version from asdf.
- typeset -g POWERLEVEL9K_ASDF_DOTNET_CORE_FOREGROUND=134
- # typeset -g POWERLEVEL9K_ASDF_DOTNET_CORE_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Flutter version from asdf.
- typeset -g POWERLEVEL9K_ASDF_FLUTTER_FOREGROUND=38
- # typeset -g POWERLEVEL9K_ASDF_FLUTTER_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Lua version from asdf.
- typeset -g POWERLEVEL9K_ASDF_LUA_FOREGROUND=32
- # typeset -g POWERLEVEL9K_ASDF_LUA_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Java version from asdf.
- typeset -g POWERLEVEL9K_ASDF_JAVA_FOREGROUND=32
- # typeset -g POWERLEVEL9K_ASDF_JAVA_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Perl version from asdf.
- typeset -g POWERLEVEL9K_ASDF_PERL_FOREGROUND=67
- # typeset -g POWERLEVEL9K_ASDF_PERL_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Erlang version from asdf.
- typeset -g POWERLEVEL9K_ASDF_ERLANG_FOREGROUND=125
- # typeset -g POWERLEVEL9K_ASDF_ERLANG_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Elixir version from asdf.
- typeset -g POWERLEVEL9K_ASDF_ELIXIR_FOREGROUND=129
- # typeset -g POWERLEVEL9K_ASDF_ELIXIR_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Postgres version from asdf.
- typeset -g POWERLEVEL9K_ASDF_POSTGRES_FOREGROUND=31
- # typeset -g POWERLEVEL9K_ASDF_POSTGRES_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##########[ nordvpn: nordvpn connection status, linux only (https://nordvpn.com/) ]###########
- # NordVPN connection indicator color.
- typeset -g POWERLEVEL9K_NORDVPN_FOREGROUND=39
- # Hide NordVPN connection indicator when not connected.
- typeset -g POWERLEVEL9K_NORDVPN_{DISCONNECTED,CONNECTING,DISCONNECTING}_CONTENT_EXPANSION=
- typeset -g POWERLEVEL9K_NORDVPN_{DISCONNECTED,CONNECTING,DISCONNECTING}_VISUAL_IDENTIFIER_EXPANSION=
- # Custom icon.
- # typeset -g POWERLEVEL9K_NORDVPN_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #################[ ranger: ranger shell (https://github.com/ranger/ranger) ]##################
- # Ranger shell color.
- typeset -g POWERLEVEL9K_RANGER_FOREGROUND=178
- # Custom icon.
- typeset -g POWERLEVEL9K_RANGER_VISUAL_IDENTIFIER_EXPANSION='▲'
-
- ######################[ nnn: nnn shell (https://github.com/jarun/nnn) ]#######################
- # Nnn shell color.
- typeset -g POWERLEVEL9K_NNN_FOREGROUND=72
- # Custom icon.
- # typeset -g POWERLEVEL9K_NNN_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###########################[ vim_shell: vim shell indicator (:sh) ]###########################
- # Vim shell indicator color.
- typeset -g POWERLEVEL9K_VIM_SHELL_FOREGROUND=34
- # Custom icon.
- # typeset -g POWERLEVEL9K_VIM_SHELL_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ######[ midnight_commander: midnight commander shell (https://midnight-commander.org/) ]######
- # Midnight Commander shell color.
- typeset -g POWERLEVEL9K_MIDNIGHT_COMMANDER_FOREGROUND=178
- # Custom icon.
- # typeset -g POWERLEVEL9K_MIDNIGHT_COMMANDER_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #[ nix_shell: nix shell (https://nixos.org/nixos/nix-pills/developing-with-nix-shell.html) ]##
- # Nix shell color.
- typeset -g POWERLEVEL9K_NIX_SHELL_FOREGROUND=74
-
- # Tip: If you want to see just the icon without "pure" and "impure", uncomment the next line.
- # typeset -g POWERLEVEL9K_NIX_SHELL_CONTENT_EXPANSION=
-
- # Custom icon.
- # typeset -g POWERLEVEL9K_NIX_SHELL_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##################################[ disk_usgae: disk usage ]##################################
- # Colors for different levels of disk usage.
- typeset -g POWERLEVEL9K_DISK_USAGE_NORMAL_FOREGROUND=35
- typeset -g POWERLEVEL9K_DISK_USAGE_WARNING_FOREGROUND=220
- typeset -g POWERLEVEL9K_DISK_USAGE_CRITICAL_FOREGROUND=160
- # Thresholds for different levels of disk usage (percentage points).
- typeset -g POWERLEVEL9K_DISK_USAGE_WARNING_LEVEL=90
- typeset -g POWERLEVEL9K_DISK_USAGE_CRITICAL_LEVEL=95
- # If set to true, hide disk usage when below $POWERLEVEL9K_DISK_USAGE_WARNING_LEVEL percent.
- typeset -g POWERLEVEL9K_DISK_USAGE_ONLY_WARNING=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_DISK_USAGE_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###########[ vi_mode: vi mode (you don't need this if you've enabled prompt_char) ]###########
- # Text and color for normal (a.k.a. command) vi mode.
- typeset -g POWERLEVEL9K_VI_COMMAND_MODE_STRING=NORMAL
- typeset -g POWERLEVEL9K_VI_MODE_NORMAL_FOREGROUND=106
- # Text and color for visual vi mode.
- typeset -g POWERLEVEL9K_VI_VISUAL_MODE_STRING=VISUAL
- typeset -g POWERLEVEL9K_VI_MODE_VISUAL_FOREGROUND=68
- # Text and color for overtype (a.k.a. overwrite and replace) vi mode.
- typeset -g POWERLEVEL9K_VI_OVERWRITE_MODE_STRING=OVERTYPE
- typeset -g POWERLEVEL9K_VI_MODE_OVERWRITE_FOREGROUND=172
- # Text and color for insert vi mode.
- typeset -g POWERLEVEL9K_VI_INSERT_MODE_STRING=
- typeset -g POWERLEVEL9K_VI_MODE_INSERT_FOREGROUND=66
-
- # Custom icon.
- typeset -g POWERLEVEL9K_RANGER_VISUAL_IDENTIFIER_EXPANSION='▲'
-
- ######################################[ ram: free RAM ]#######################################
- # RAM color.
- typeset -g POWERLEVEL9K_RAM_FOREGROUND=66
- # Custom icon.
- # typeset -g POWERLEVEL9K_RAM_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #####################################[ swap: used swap ]######################################
- # Swap color.
- typeset -g POWERLEVEL9K_SWAP_FOREGROUND=96
- # Custom icon.
- # typeset -g POWERLEVEL9K_SWAP_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ######################################[ load: CPU load ]######################################
- # Show average CPU load over this many last minutes. Valid values are 1, 5 and 15.
- typeset -g POWERLEVEL9K_LOAD_WHICH=5
- # Load color when load is under 50%.
- typeset -g POWERLEVEL9K_LOAD_NORMAL_FOREGROUND=66
- # Load color when load is between 50% and 70%.
- typeset -g POWERLEVEL9K_LOAD_WARNING_FOREGROUND=178
- # Load color when load is over 70%.
- typeset -g POWERLEVEL9K_LOAD_CRITICAL_FOREGROUND=166
- # Custom icon.
- # typeset -g POWERLEVEL9K_LOAD_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ################[ todo: todo items (https://github.com/todotxt/todo.txt-cli) ]################
- # Todo color.
- typeset -g POWERLEVEL9K_TODO_FOREGROUND=110
- # Hide todo when the total number of tasks is zero.
- typeset -g POWERLEVEL9K_TODO_HIDE_ZERO_TOTAL=true
- # Hide todo when the number of tasks after filtering is zero.
- typeset -g POWERLEVEL9K_TODO_HIDE_ZERO_FILTERED=false
-
- # Todo format. The following parameters are available within the expansion.
- #
- # - P9K_TODO_TOTAL_TASK_COUNT The total number of tasks.
- # - P9K_TODO_FILTERED_TASK_COUNT The number of tasks after filtering.
- #
- # These variables correspond to the last line of the output of `todo.sh -p ls`:
- #
- # TODO: 24 of 42 tasks shown
- #
- # Here 24 is P9K_TODO_FILTERED_TASK_COUNT and 42 is P9K_TODO_TOTAL_TASK_COUNT.
- #
- # typeset -g POWERLEVEL9K_TODO_CONTENT_EXPANSION='$P9K_TODO_FILTERED_TASK_COUNT'
-
- # Custom icon.
- # typeset -g POWERLEVEL9K_TODO_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###########[ timewarrior: timewarrior tracking status (https://timewarrior.net/) ]############
- # Timewarrior color.
- typeset -g POWERLEVEL9K_TIMEWARRIOR_FOREGROUND=110
- # If the tracked task is longer than 24 characters, truncate and append "…".
- # Tip: To always display tasks without truncation, delete the following parameter.
- # Tip: To hide task names and display just the icon when time tracking is enabled, set the
- # value of the following parameter to "".
- typeset -g POWERLEVEL9K_TIMEWARRIOR_CONTENT_EXPANSION='${P9K_CONTENT:0:24}${${P9K_CONTENT:24}:+…}'
-
- # Custom icon.
- # typeset -g POWERLEVEL9K_TIMEWARRIOR_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##################################[ context: user@hostname ]##################################
- # Context color when running with privileges.
- typeset -g POWERLEVEL9K_CONTEXT_ROOT_FOREGROUND=178
- # Context color in SSH without privileges.
- typeset -g POWERLEVEL9K_CONTEXT_{REMOTE,REMOTE_SUDO}_FOREGROUND=180
- # Default context color (no privileges, no SSH).
- typeset -g POWERLEVEL9K_CONTEXT_FOREGROUND=180
-
- # Context format when running with privileges: bold user@hostname.
- typeset -g POWERLEVEL9K_CONTEXT_ROOT_TEMPLATE='%B%n@%m'
- # Context format when in SSH without privileges: user@hostname.
- typeset -g POWERLEVEL9K_CONTEXT_{REMOTE,REMOTE_SUDO}_TEMPLATE='%n@%m'
- # Default context format (no privileges, no SSH): user@hostname.
- typeset -g POWERLEVEL9K_CONTEXT_TEMPLATE='%n@%m'
-
- # Don't show context unless running with privileges or in SSH.
- # Tip: Remove the next line to always show context.
- typeset -g POWERLEVEL9K_CONTEXT_{DEFAULT,SUDO}_{CONTENT,VISUAL_IDENTIFIER}_EXPANSION=
-
- # Custom icon.
- # typeset -g POWERLEVEL9K_CONTEXT_VISUAL_IDENTIFIER_EXPANSION='⭐'
- # Custom prefix.
- typeset -g POWERLEVEL9K_CONTEXT_PREFIX='%248Fwith '
-
- ###[ virtualenv: python virtual environment (https://docs.python.org/3/library/venv.html) ]###
- # Python virtual environment color.
- typeset -g POWERLEVEL9K_VIRTUALENV_FOREGROUND=37
- # Don't show Python version next to the virtual environment name.
- typeset -g POWERLEVEL9K_VIRTUALENV_SHOW_PYTHON_VERSION=false
- # Separate environment name from Python version only with a space.
- typeset -g POWERLEVEL9K_VIRTUALENV_{LEFT,RIGHT}_DELIMITER=
- # Custom icon.
- # typeset -g POWERLEVEL9K_VIRTUALENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #####################[ anaconda: conda environment (https://conda.io/) ]######################
- # Anaconda environment color.
- typeset -g POWERLEVEL9K_ANACONDA_FOREGROUND=37
- # Don't show Python version next to the anaconda environment name.
- typeset -g POWERLEVEL9K_ANACONDA_SHOW_PYTHON_VERSION=false
- # Separate environment name from Python version only with a space.
- typeset -g POWERLEVEL9K_ANACONDA_{LEFT,RIGHT}_DELIMITER=
- # Custom icon.
- # typeset -g POWERLEVEL9K_ANACONDA_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ################[ pyenv: python environment (https://github.com/pyenv/pyenv) ]################
- # Pyenv color.
- typeset -g POWERLEVEL9K_PYENV_FOREGROUND=37
- # Hide python version if it doesn't come from one of these sources.
- typeset -g POWERLEVEL9K_PYENV_SOURCES=(shell local global)
- # If set to false, hide python version if it's the same as global:
- # $(pyenv version-name) == $(pyenv global).
- typeset -g POWERLEVEL9K_PYENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_PYENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ################[ goenv: go environment (https://github.com/syndbg/goenv) ]################
- # Goenv color.
- typeset -g POWERLEVEL9K_GOENV_FOREGROUND=37
- # Hide go version if it doesn't come from one of these sources.
- typeset -g POWERLEVEL9K_GOENV_SOURCES=(shell local global)
- # If set to false, hide go version if it's the same as global:
- # $(goenv version-name) == $(goenv global).
- typeset -g POWERLEVEL9K_GOENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_GOENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##########[ nodenv: node.js version from nodenv (https://github.com/nodenv/nodenv) ]##########
- # Nodenv color.
- typeset -g POWERLEVEL9K_NODENV_FOREGROUND=70
- # Don't show node version if it's the same as global: $(nodenv version-name) == $(nodenv global).
- typeset -g POWERLEVEL9K_NODENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_NODENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##############[ nvm: node.js version from nvm (https://github.com/nvm-sh/nvm) ]###############
- # Nvm color.
- typeset -g POWERLEVEL9K_NVM_FOREGROUND=70
- # Custom icon.
- # typeset -g POWERLEVEL9K_NVM_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ############[ nodeenv: node.js environment (https://github.com/ekalinin/nodeenv) ]############
- # Nodeenv color.
- typeset -g POWERLEVEL9K_NODEENV_FOREGROUND=70
- # Don't show Node version next to the environment name.
- typeset -g POWERLEVEL9K_NODEENV_SHOW_NODE_VERSION=false
- # Separate environment name from Node version only with a space.
- typeset -g POWERLEVEL9K_NODEENV_{LEFT,RIGHT}_DELIMITER=
- # Custom icon.
- # typeset -g POWERLEVEL9K_NODEENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##############################[ node_version: node.js version ]###############################
- # Node version color.
- typeset -g POWERLEVEL9K_NODE_VERSION_FOREGROUND=70
- # Show node version only when in a directory tree containing package.json.
- typeset -g POWERLEVEL9K_NODE_VERSION_PROJECT_ONLY=true
- # Custom icon.
- # typeset -g POWERLEVEL9K_NODE_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #######################[ go_version: go version (https://golang.org) ]########################
- # Go version color.
- typeset -g POWERLEVEL9K_GO_VERSION_FOREGROUND=37
- # Show go version only when in a go project subdirectory.
- typeset -g POWERLEVEL9K_GO_VERSION_PROJECT_ONLY=true
- # Custom icon.
- # typeset -g POWERLEVEL9K_GO_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #################[ rust_version: rustc version (https://www.rust-lang.org) ]##################
- # Rust version color.
- typeset -g POWERLEVEL9K_RUST_VERSION_FOREGROUND=37
- # Show rust version only when in a rust project subdirectory.
- typeset -g POWERLEVEL9K_RUST_VERSION_PROJECT_ONLY=true
- # Custom icon.
- # typeset -g POWERLEVEL9K_RUST_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###############[ dotnet_version: .NET version (https://dotnet.microsoft.com) ]################
- # .NET version color.
- typeset -g POWERLEVEL9K_DOTNET_VERSION_FOREGROUND=134
- # Show .NET version only when in a .NET project subdirectory.
- typeset -g POWERLEVEL9K_DOTNET_VERSION_PROJECT_ONLY=true
- # Custom icon.
- # typeset -g POWERLEVEL9K_DOTNET_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #############[ rbenv: ruby version from rbenv (https://github.com/rbenv/rbenv) ]##############
- # Rbenv color.
- typeset -g POWERLEVEL9K_RBENV_FOREGROUND=168
- # Hide ruby version if it doesn't come from one of these sources.
- typeset -g POWERLEVEL9K_RBENV_SOURCES=(shell local global)
- # If set to false, hide ruby version if it's the same as global:
- # $(rbenv version-name) == $(rbenv global).
- typeset -g POWERLEVEL9K_RBENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_RBENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #######################[ rvm: ruby version from rvm (https://rvm.io) ]########################
- # Rvm color.
- typeset -g POWERLEVEL9K_RVM_FOREGROUND=168
- # Don't show @gemset at the end.
- typeset -g POWERLEVEL9K_RVM_SHOW_GEMSET=false
- # Don't show ruby- at the front.
- typeset -g POWERLEVEL9K_RVM_SHOW_PREFIX=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_RVM_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###########[ fvm: flutter version management (https://github.com/leoafarias/fvm) ]############
- # Fvm color.
- typeset -g POWERLEVEL9K_FVM_FOREGROUND=38
- # Custom icon.
- # typeset -g POWERLEVEL9K_FVM_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ##########[ luaenv: lua version from luaenv (https://github.com/cehoffman/luaenv) ]###########
- # Lua color.
- typeset -g POWERLEVEL9K_LUAENV_FOREGROUND=32
- # Hide lua version if it doesn't come from one of these sources.
- typeset -g POWERLEVEL9K_LUAENV_SOURCES=(shell local global)
- # If set to false, hide lua version if it's the same as global:
- # $(luaenv version-name) == $(luaenv global).
- typeset -g POWERLEVEL9K_LUAENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_LUAENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###############[ jenv: java version from jenv (https://github.com/jenv/jenv) ]################
- # Java color.
- typeset -g POWERLEVEL9K_JENV_FOREGROUND=32
- # Hide java version if it doesn't come from one of these sources.
- typeset -g POWERLEVEL9K_JENV_SOURCES=(shell local global)
- # If set to false, hide java version if it's the same as global:
- # $(jenv version-name) == $(jenv global).
- typeset -g POWERLEVEL9K_JENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_JENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###########[ plenv: perl version from plenv (https://github.com/tokuhirom/plenv) ]############
- # Perl color.
- typeset -g POWERLEVEL9K_PLENV_FOREGROUND=67
- # Hide perl version if it doesn't come from one of these sources.
- typeset -g POWERLEVEL9K_PLENV_SOURCES=(shell local global)
- # If set to false, hide perl version if it's the same as global:
- # $(plenv version-name) == $(plenv global).
- typeset -g POWERLEVEL9K_PLENV_PROMPT_ALWAYS_SHOW=false
- # Custom icon.
- # typeset -g POWERLEVEL9K_PLENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ################[ terraform: terraform workspace (https://www.terraform.io) ]#################
- # POWERLEVEL9K_TERRAFORM_CLASSES is an array with even number of elements. The first element
- # in each pair defines a pattern against which the current terraform workspace gets matched.
- # More specifically, it's P9K_CONTENT prior to the application of context expansion (see below)
- # that gets matched. If you unset all POWERLEVEL9K_TERRAFORM_*CONTENT_EXPANSION parameters,
- # you'll see this value in your prompt. The second element of each pair in
- # POWERLEVEL9K_TERRAFORM_CLASSES defines the workspace class. Patterns are tried in order. The
- # first match wins.
- #
- # For example, given these settings:
- #
- # typeset -g POWERLEVEL9K_TERRAFORM_CLASSES=(
- # '*prod*' PROD
- # '*test*' TEST
- # '*' DEFAULT)
- #
- # If your current terraform workspace is "project_test", its class is TEST because "project_test"
- # doesn't match the pattern '*prod*' but does match '*test*'.
- #
- # You can define different colors, icons and content expansions for different classes:
- #
- # typeset -g POWERLEVEL9K_TERRAFORM_TEST_FOREGROUND=28
- # typeset -g POWERLEVEL9K_TERRAFORM_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
- # typeset -g POWERLEVEL9K_TERRAFORM_TEST_CONTENT_EXPANSION='> ${P9K_CONTENT} <'
- typeset -g POWERLEVEL9K_TERRAFORM_CLASSES=(
- # '*prod*' PROD # These values are examples that are unlikely
- # '*test*' TEST # to match your needs. Customize them as needed.
- '*' DEFAULT)
- typeset -g POWERLEVEL9K_TERRAFORM_DEFAULT_FOREGROUND=38
- # typeset -g POWERLEVEL9K_TERRAFORM_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #############[ kubecontext: current kubernetes context (https://kubernetes.io/) ]#############
- # Show kubecontext only when the the command you are typing invokes one of these tools.
- # Tip: Remove the next line to always show kubecontext.
- typeset -g POWERLEVEL9K_KUBECONTEXT_SHOW_ON_COMMAND='kubectl|helm|kubens|kubectx|oc'
-
- # Kubernetes context classes for the purpose of using different colors, icons and expansions with
- # different contexts.
- #
- # POWERLEVEL9K_KUBECONTEXT_CLASSES is an array with even number of elements. The first element
- # in each pair defines a pattern against which the current kubernetes context gets matched.
- # More specifically, it's P9K_CONTENT prior to the application of context expansion (see below)
- # that gets matched. If you unset all POWERLEVEL9K_KUBECONTEXT_*CONTENT_EXPANSION parameters,
- # you'll see this value in your prompt. The second element of each pair in
- # POWERLEVEL9K_KUBECONTEXT_CLASSES defines the context class. Patterns are tried in order. The
- # first match wins.
- #
- # For example, given these settings:
- #
- # typeset -g POWERLEVEL9K_KUBECONTEXT_CLASSES=(
- # '*prod*' PROD
- # '*test*' TEST
- # '*' DEFAULT)
- #
- # If your current kubernetes context is "deathray-testing/default", its class is TEST
- # because "deathray-testing/default" doesn't match the pattern '*prod*' but does match '*test*'.
- #
- # You can define different colors, icons and content expansions for different classes:
- #
- # typeset -g POWERLEVEL9K_KUBECONTEXT_TEST_FOREGROUND=28
- # typeset -g POWERLEVEL9K_KUBECONTEXT_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
- # typeset -g POWERLEVEL9K_KUBECONTEXT_TEST_CONTENT_EXPANSION='> ${P9K_CONTENT} <'
- typeset -g POWERLEVEL9K_KUBECONTEXT_CLASSES=(
- # '*prod*' PROD # These values are examples that are unlikely
- # '*test*' TEST # to match your needs. Customize them as needed.
- '*' DEFAULT)
- typeset -g POWERLEVEL9K_KUBECONTEXT_DEFAULT_FOREGROUND=134
- typeset -g POWERLEVEL9K_KUBECONTEXT_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='○'
-
- # Use POWERLEVEL9K_KUBECONTEXT_CONTENT_EXPANSION to specify the content displayed by kubecontext
- # segment. Parameter expansions are very flexible and fast, too. See reference:
- # http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion.
- #
- # Within the expansion the following parameters are always available:
- #
- # - P9K_CONTENT The content that would've been displayed if there was no content
- # expansion defined.
- # - P9K_KUBECONTEXT_NAME The current context's name. Corresponds to column NAME in the
- # output of `kubectl config get-contexts`.
- # - P9K_KUBECONTEXT_CLUSTER The current context's cluster. Corresponds to column CLUSTER in the
- # output of `kubectl config get-contexts`.
- # - P9K_KUBECONTEXT_NAMESPACE The current context's namespace. Corresponds to column NAMESPACE
- # in the output of `kubectl config get-contexts`. If there is no
- # namespace, the parameter is set to "default".
- # - P9K_KUBECONTEXT_USER The current context's user. Corresponds to column AUTHINFO in the
- # output of `kubectl config get-contexts`.
- #
- # If the context points to Google Kubernetes Engine (GKE) or Elastic Kubernetes Service (EKS),
- # the following extra parameters are available:
- #
- # - P9K_KUBECONTEXT_CLOUD_NAME Either "gke" or "eks".
- # - P9K_KUBECONTEXT_CLOUD_ACCOUNT Account/project ID.
- # - P9K_KUBECONTEXT_CLOUD_ZONE Availability zone.
- # - P9K_KUBECONTEXT_CLOUD_CLUSTER Cluster.
- #
- # P9K_KUBECONTEXT_CLOUD_* parameters are derived from P9K_KUBECONTEXT_CLUSTER. For example,
- # if P9K_KUBECONTEXT_CLUSTER is "gke_my-account_us-east1-a_my-cluster-01":
- #
- # - P9K_KUBECONTEXT_CLOUD_NAME=gke
- # - P9K_KUBECONTEXT_CLOUD_ACCOUNT=my-account
- # - P9K_KUBECONTEXT_CLOUD_ZONE=us-east1-a
- # - P9K_KUBECONTEXT_CLOUD_CLUSTER=my-cluster-01
- #
- # If P9K_KUBECONTEXT_CLUSTER is "arn:aws:eks:us-east-1:123456789012:cluster/my-cluster-01":
- #
- # - P9K_KUBECONTEXT_CLOUD_NAME=eks
- # - P9K_KUBECONTEXT_CLOUD_ACCOUNT=123456789012
- # - P9K_KUBECONTEXT_CLOUD_ZONE=us-east-1
- # - P9K_KUBECONTEXT_CLOUD_CLUSTER=my-cluster-01
- typeset -g POWERLEVEL9K_KUBECONTEXT_DEFAULT_CONTENT_EXPANSION=
- # Show P9K_KUBECONTEXT_CLOUD_CLUSTER if it's not empty and fall back to P9K_KUBECONTEXT_NAME.
- POWERLEVEL9K_KUBECONTEXT_DEFAULT_CONTENT_EXPANSION+='${P9K_KUBECONTEXT_CLOUD_CLUSTER:-${P9K_KUBECONTEXT_NAME}}'
- # Append the current context's namespace if it's not "default".
- POWERLEVEL9K_KUBECONTEXT_DEFAULT_CONTENT_EXPANSION+='${${:-/$P9K_KUBECONTEXT_NAMESPACE}:#/default}'
-
- # Custom prefix.
- typeset -g POWERLEVEL9K_KUBECONTEXT_PREFIX='%248Fat '
-
- #[ aws: aws profile (https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html) ]#
- # Show aws only when the the command you are typing invokes one of these tools.
- # Tip: Remove the next line to always show aws.
- typeset -g POWERLEVEL9K_AWS_SHOW_ON_COMMAND='aws|awless|terraform|pulumi'
-
- # POWERLEVEL9K_AWS_CLASSES is an array with even number of elements. The first element
- # in each pair defines a pattern against which the current AWS profile gets matched.
- # More specifically, it's P9K_CONTENT prior to the application of context expansion (see below)
- # that gets matched. If you unset all POWERLEVEL9K_AWS_*CONTENT_EXPANSION parameters,
- # you'll see this value in your prompt. The second element of each pair in
- # POWERLEVEL9K_AWS_CLASSES defines the profile class. Patterns are tried in order. The
- # first match wins.
- #
- # For example, given these settings:
- #
- # typeset -g POWERLEVEL9K_AWS_CLASSES=(
- # '*prod*' PROD
- # '*test*' TEST
- # '*' DEFAULT)
- #
- # If your current AWS profile is "company_test", its class is TEST
- # because "company_test" doesn't match the pattern '*prod*' but does match '*test*'.
- #
- # You can define different colors, icons and content expansions for different classes:
- #
- # typeset -g POWERLEVEL9K_AWS_TEST_FOREGROUND=28
- # typeset -g POWERLEVEL9K_AWS_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
- # typeset -g POWERLEVEL9K_AWS_TEST_CONTENT_EXPANSION='> ${P9K_CONTENT} <'
- typeset -g POWERLEVEL9K_AWS_CLASSES=(
- # '*prod*' PROD # These values are examples that are unlikely
- # '*test*' TEST # to match your needs. Customize them as needed.
- '*' DEFAULT)
- typeset -g POWERLEVEL9K_AWS_DEFAULT_FOREGROUND=208
- # typeset -g POWERLEVEL9K_AWS_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #[ aws_eb_env: aws elastic beanstalk environment (https://aws.amazon.com/elasticbeanstalk/) ]#
- # AWS Elastic Beanstalk environment color.
- typeset -g POWERLEVEL9K_AWS_EB_ENV_FOREGROUND=70
- # Custom icon.
- typeset -g POWERLEVEL9K_AWS_EB_ENV_VISUAL_IDENTIFIER_EXPANSION='eb'
-
- ##########[ azure: azure account name (https://docs.microsoft.com/en-us/cli/azure) ]##########
- # Show azure only when the the command you are typing invokes one of these tools.
- # Tip: Remove the next line to always show azure.
- typeset -g POWERLEVEL9K_AZURE_SHOW_ON_COMMAND='az|terraform|pulumi'
- # Azure account name color.
- typeset -g POWERLEVEL9K_AZURE_FOREGROUND=32
- # Custom icon.
- typeset -g POWERLEVEL9K_AZURE_VISUAL_IDENTIFIER_EXPANSION='az'
-
- ##########[ gcloud: google cloud account and project (https://cloud.google.com/) ]###########
- # Show gcloud only when the the command you are typing invokes one of these tools.
- # Tip: Remove the next line to always show gcloud.
- typeset -g POWERLEVEL9K_GCLOUD_SHOW_ON_COMMAND='gcloud|gcs'
- # Google cloud color.
- typeset -g POWERLEVEL9K_GCLOUD_FOREGROUND=32
-
- # Google cloud format. Change the value of POWERLEVEL9K_GCLOUD_CONTENT_EXPANSION if the default
- # is too verbose or not informative enough.
- #
- # P9K_GCLOUD_ACCOUNT: the output of `gcloud config get-value account`
- # P9K_GCLOUD_PROJECT: the output of `gcloud config get-value project`
- # ${VARIABLE//\%/%%}: ${VARIABLE} with all occurrences of '%' replaced with '%%'.
- #
- typeset -g POWERLEVEL9K_GCLOUD_CONTENT_EXPANSION='${P9K_GCLOUD_PROJECT//\%/%%}'
-
- # Custom icon.
- # typeset -g POWERLEVEL9K_GCLOUD_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #[ google_app_cred: google application credentials (https://cloud.google.com/docs/authentication/production) ]#
- # Show google_app_cred only when the the command you are typing invokes one of these tools.
- # Tip: Remove the next line to always show google_app_cred.
- typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_SHOW_ON_COMMAND='terraform|pulumi'
-
- # Google application credentials classes for the purpose of using different colors, icons and
- # expansions with different credentials.
- #
- # POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES is an array with even number of elements. The first
- # element in each pair defines a pattern against which the current kubernetes context gets
- # matched. More specifically, it's P9K_CONTENT prior to the application of context expansion
- # (see below) that gets matched. If you unset all POWERLEVEL9K_GOOGLE_APP_CRED_*CONTENT_EXPANSION
- # parameters, you'll see this value in your prompt. The second element of each pair in
- # POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES defines the context class. Patterns are tried in order.
- # The first match wins.
- #
- # For example, given these settings:
- #
- # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES=(
- # '*:*prod*:*' PROD
- # '*:*test*:*' TEST
- # '*' DEFAULT)
- #
- # If your current Google application credentials is "service_account deathray-testing x@y.com",
- # its class is TEST because it doesn't match the pattern '* *prod* *' but does match '* *test* *'.
- #
- # You can define different colors, icons and content expansions for different classes:
- #
- # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_TEST_FOREGROUND=28
- # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
- # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_TEST_CONTENT_EXPANSION='$P9K_GOOGLE_APP_CRED_PROJECT_ID'
- typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES=(
- # '*:*prod*:*' PROD # These values are examples that are unlikely
- # '*:*test*:*' TEST # to match your needs. Customize them as needed.
- '*' DEFAULT)
- typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_DEFAULT_FOREGROUND=32
- # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Use POWERLEVEL9K_GOOGLE_APP_CRED_CONTENT_EXPANSION to specify the content displayed by
- # google_app_cred segment. Parameter expansions are very flexible and fast, too. See reference:
- # http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion.
- #
- # You can use the following parameters in the expansion. Each of them corresponds to one of the
- # fields in the JSON file pointed to by GOOGLE_APPLICATION_CREDENTIALS.
- #
- # Parameter | JSON key file field
- # ---------------------------------+---------------
- # P9K_GOOGLE_APP_CRED_TYPE | type
- # P9K_GOOGLE_APP_CRED_PROJECT_ID | project_id
- # P9K_GOOGLE_APP_CRED_CLIENT_EMAIL | client_email
- #
- # Note: ${VARIABLE//\%/%%} expands to ${VARIABLE} with all occurrences of '%' replaced by '%%'.
- typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_DEFAULT_CONTENT_EXPANSION='${P9K_GOOGLE_APP_CRED_PROJECT_ID//\%/%%}'
-
- ###############################[ public_ip: public IP address ]###############################
- # Public IP color.
- typeset -g POWERLEVEL9K_PUBLIC_IP_FOREGROUND=94
- # Custom icon.
- # typeset -g POWERLEVEL9K_PUBLIC_IP_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ########################[ vpn_ip: virtual private network indicator ]#########################
- # VPN IP color.
- typeset -g POWERLEVEL9K_VPN_IP_FOREGROUND=81
- # When on VPN, show just an icon without the IP address.
- # Tip: To display the private IP address when on VPN, remove the next line.
- typeset -g POWERLEVEL9K_VPN_IP_CONTENT_EXPANSION=
- # Regular expression for the VPN network interface. Run `ifconfig` or `ip -4 a show` while on VPN
- # to see the name of the interface.
- typeset -g POWERLEVEL9K_VPN_IP_INTERFACE='(wg|(.*tun))[0-9]*'
- # Custom icon.
- # typeset -g POWERLEVEL9K_VPN_IP_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ###########[ ip: ip address and bandwidth usage for a specified network interface ]###########
- # IP color.
- typeset -g POWERLEVEL9K_IP_FOREGROUND=38
- # The following parameters are accessible within the expansion:
- #
- # Parameter | Meaning
- # ----------------------+---------------
- # P9K_IP_IP | IP address
- # P9K_IP_INTERFACE | network interface
- # P9K_IP_RX_BYTES | total number of bytes received
- # P9K_IP_TX_BYTES | total number of bytes sent
- # P9K_IP_RX_RATE | receive rate (since last prompt)
- # P9K_IP_TX_RATE | send rate (since last prompt)
- typeset -g POWERLEVEL9K_IP_CONTENT_EXPANSION='%70F⇣$P9K_IP_RX_RATE %215F⇡$P9K_IP_TX_RATE %38F$P9K_IP_IP'
- # Show information for the first network interface whose name matches this regular expression.
- # Run `ifconfig` or `ip -4 a show` to see the names of all network interfaces.
- typeset -g POWERLEVEL9K_IP_INTERFACE='e.*'
- # Custom icon.
- # typeset -g POWERLEVEL9K_IP_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- #########################[ proxy: system-wide http/https/ftp proxy ]##########################
- # Proxy color.
- typeset -g POWERLEVEL9K_PROXY_FOREGROUND=68
- # Custom icon.
- # typeset -g POWERLEVEL9K_PROXY_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- ################################[ battery: internal battery ]#################################
- # Show battery in red when it's below this level and not connected to power supply.
- typeset -g POWERLEVEL9K_BATTERY_LOW_THRESHOLD=20
- typeset -g POWERLEVEL9K_BATTERY_LOW_FOREGROUND=160
- # Show battery in green when it's charging or fully charged.
- typeset -g POWERLEVEL9K_BATTERY_{CHARGING,CHARGED}_FOREGROUND=70
- # Show battery in yellow when it's discharging.
- typeset -g POWERLEVEL9K_BATTERY_DISCONNECTED_FOREGROUND=178
- # Battery pictograms going from low to high level of charge.
- typeset -g POWERLEVEL9K_BATTERY_STAGES=('%K{232}▁' '%K{232}▂' '%K{232}▃' '%K{232}▄' '%K{232}▅' '%K{232}▆' '%K{232}▇' '%K{232}█')
- # Don't show the remaining time to charge/discharge.
- typeset -g POWERLEVEL9K_BATTERY_VERBOSE=false
-
- #####################################[ wifi: wifi speed ]#####################################
- # WiFi color.
- typeset -g POWERLEVEL9K_WIFI_FOREGROUND=68
- # Custom icon.
- # typeset -g POWERLEVEL9K_WIFI_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Use different colors and icons depending on signal strength ($P9K_WIFI_BARS).
- #
- # # Wifi colors and icons for different signal strength levels (low to high).
- # typeset -g my_wifi_fg=(68 68 68 68 68) # <-- change these values
- # typeset -g my_wifi_icon=('WiFi' 'WiFi' 'WiFi' 'WiFi' 'WiFi') # <-- change these values
- #
- # typeset -g POWERLEVEL9K_WIFI_CONTENT_EXPANSION='%F{${my_wifi_fg[P9K_WIFI_BARS+1]}}$P9K_WIFI_LAST_TX_RATE Mbps'
- # typeset -g POWERLEVEL9K_WIFI_VISUAL_IDENTIFIER_EXPANSION='%F{${my_wifi_fg[P9K_WIFI_BARS+1]}}${my_wifi_icon[P9K_WIFI_BARS+1]}'
- #
- # The following parameters are accessible within the expansions:
- #
- # Parameter | Meaning
- # ----------------------+---------------
- # P9K_WIFI_SSID | service set identifier, a.k.a. network name
- # P9K_WIFI_LINK_AUTH | authentication protocol such as "wpa2-psk" or "none"
- # P9K_WIFI_LAST_TX_RATE | wireless transmit rate in megabits per second
- # P9K_WIFI_RSSI | signal strength in dBm, from -120 to 0
- # P9K_WIFI_NOISE | noise in dBm, from -120 to 0
- # P9K_WIFI_BARS | signal strength in bars, from 0 to 4 (derived from P9K_WIFI_RSSI and P9K_WIFI_NOISE)
- #
- # All parameters except P9K_WIFI_BARS are extracted from the output of the following command:
- #
- # /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I
-
- ####################################[ time: current time ]####################################
- # Current time color.
- typeset -g POWERLEVEL9K_TIME_FOREGROUND=66
- # Format for the current time: 09:51:02. See `man 3 strftime`.
- typeset -g POWERLEVEL9K_TIME_FORMAT='%D{%H:%M:%S}'
- # If set to true, time will update when you hit enter. This way prompts for the past
- # commands will contain the start times of their commands as opposed to the default
- # behavior where they contain the end times of their preceding commands.
- typeset -g POWERLEVEL9K_TIME_UPDATE_ON_COMMAND=false
- # Custom icon.
- typeset -g POWERLEVEL9K_TIME_VISUAL_IDENTIFIER_EXPANSION=
- # Custom prefix.
- typeset -g POWERLEVEL9K_TIME_PREFIX='%248Fat '
-
- # Example of a user-defined prompt segment. Function prompt_example will be called on every
- # prompt if `example` prompt segment is added to POWERLEVEL9K_LEFT_PROMPT_ELEMENTS or
- # POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS. It displays an icon and orange text greeting the user.
- #
- # Type `p10k help segment` for documentation and a more sophisticated example.
- function prompt_example() {
- p10k segment -f 208 -i '⭐' -t 'hello, %n'
- }
-
- # User-defined prompt segments may optionally provide an instant_prompt_* function. Its job
- # is to generate the prompt segment for display in instant prompt. See
- # https://github.com/romkatv/powerlevel10k/blob/master/README.md#instant-prompt.
- #
- # Powerlevel10k will call instant_prompt_* at the same time as the regular prompt_* function
- # and will record all `p10k segment` calls it makes. When displaying instant prompt, Powerlevel10k
- # will replay these calls without actually calling instant_prompt_*. It is imperative that
- # instant_prompt_* always makes the same `p10k segment` calls regardless of environment. If this
- # rule is not observed, the content of instant prompt will be incorrect.
- #
- # Usually, you should either not define instant_prompt_* or simply call prompt_* from it. If
- # instant_prompt_* is not defined for a segment, the segment won't be shown in instant prompt.
- function instant_prompt_example() {
- # Since prompt_example always makes the same `p10k segment` calls, we can call it from
- # instant_prompt_example. This will give us the same `example` prompt segment in the instant
- # and regular prompts.
- prompt_example
- }
-
- # User-defined prompt segments can be customized the same way as built-in segments.
- # typeset -g POWERLEVEL9K_EXAMPLE_FOREGROUND=208
- # typeset -g POWERLEVEL9K_EXAMPLE_VISUAL_IDENTIFIER_EXPANSION='⭐'
-
- # Transient prompt works similarly to the builtin transient_rprompt option. It trims down prompt
- # when accepting a command line. Supported values:
- #
- # - off: Don't change prompt when accepting a command line.
- # - always: Trim down prompt when accepting a command line.
- # - same-dir: Trim down prompt when accepting a command line unless this is the first command
- # typed after changing current working directory.
- typeset -g POWERLEVEL9K_TRANSIENT_PROMPT=always
-
- # Instant prompt mode.
- #
- # - off: Disable instant prompt. Choose this if you've tried instant prompt and found
- # it incompatible with your zsh configuration files.
- # - quiet: Enable instant prompt and don't print warnings when detecting console output
- # during zsh initialization. Choose this if you've read and understood
- # https://github.com/romkatv/powerlevel10k/blob/master/README.md#instant-prompt.
- # - verbose: Enable instant prompt and print a warning when detecting console output during
- # zsh initialization. Choose this if you've never tried instant prompt, haven't
- # seen the warning, or if you are unsure what this all means.
- typeset -g POWERLEVEL9K_INSTANT_PROMPT=verbose
-
- # Hot reload allows you to change POWERLEVEL9K options after Powerlevel10k has been initialized.
- # For example, you can type POWERLEVEL9K_BACKGROUND=red and see your prompt turn red. Hot reload
- # can slow down prompt by 1-2 milliseconds, so it's better to keep it turned off unless you
- # really need it.
- typeset -g POWERLEVEL9K_DISABLE_HOT_RELOAD=true
-
- # If p10k is already loaded, reload configuration.
- # This works even with POWERLEVEL9K_DISABLE_HOT_RELOAD=true.
- (( ! $+functions[p10k] )) || p10k reload
+ # Default background color.
+ typeset -g POWERLEVEL9K_BACKGROUND=238
+
+ # Separator between same-color segments on the left.
+ typeset -g POWERLEVEL9K_LEFT_SUBSEGMENT_SEPARATOR='%246F|'
+ # Separator between same-color segments on the right.
+ typeset -g POWERLEVEL9K_RIGHT_SUBSEGMENT_SEPARATOR='%246F|'
+ # Separator between different-color segments on the left.
+ typeset -g POWERLEVEL9K_LEFT_SEGMENT_SEPARATOR=''
+ # Separator between different-color segments on the right.
+ typeset -g POWERLEVEL9K_RIGHT_SEGMENT_SEPARATOR=''
+ # The right end of left prompt.
+ typeset -g POWERLEVEL9K_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL='▓▒░'
+ # The left end of right prompt.
+ typeset -g POWERLEVEL9K_RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL='░▒▓'
+ # The left end of left prompt.
+ typeset -g POWERLEVEL9K_LEFT_PROMPT_FIRST_SEGMENT_START_SYMBOL=''
+ # The right end of right prompt.
+ typeset -g POWERLEVEL9K_RIGHT_PROMPT_LAST_SEGMENT_END_SYMBOL=''
+ # Left prompt terminator for lines without any segments.
+ typeset -g POWERLEVEL9K_EMPTY_LINE_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL=
+
+ #################################[ os_icon: os identifier ]##################################
+ # OS identifier color.
+ typeset -g POWERLEVEL9K_OS_ICON_FOREGROUND=255
+ # Make the icon bold.
+ typeset -g POWERLEVEL9K_OS_ICON_CONTENT_EXPANSION='%B${P9K_CONTENT}'
+
+ ################################[ prompt_char: prompt symbol ]################################
+ # Transparent background.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_BACKGROUND=
+ # Green prompt symbol if the last command succeeded.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_OK_{VIINS,VICMD,VIVIS,VIOWR}_FOREGROUND=76
+ # Red prompt symbol if the last command failed.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_ERROR_{VIINS,VICMD,VIVIS,VIOWR}_FOREGROUND=196
+ # Default prompt symbol.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIINS_CONTENT_EXPANSION='❯'
+ # Prompt symbol in command vi mode.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VICMD_CONTENT_EXPANSION='❮'
+ # Prompt symbol in visual vi mode.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIVIS_CONTENT_EXPANSION='Ⅴ'
+ # Prompt symbol in overwrite vi mode.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_{OK,ERROR}_VIOWR_CONTENT_EXPANSION='▶'
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_OVERWRITE_STATE=true
+ # No line terminator if prompt_char is the last segment.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL=
+ # No line introducer if prompt_char is the first segment.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_FIRST_SEGMENT_START_SYMBOL=
+ # No surrounding whitespace.
+ typeset -g POWERLEVEL9K_PROMPT_CHAR_LEFT_{LEFT,RIGHT}_WHITESPACE=
+
+ ##################################[ dir: current directory ]##################################
+ # Default current directory color.
+ typeset -g POWERLEVEL9K_DIR_FOREGROUND=31
+ # If directory is too long, shorten some of its segments to the shortest possible unique
+ # prefix. The shortened directory can be tab-completed to the original.
+ typeset -g POWERLEVEL9K_SHORTEN_STRATEGY=truncate_to_unique
+ # Replace removed segment suffixes with this symbol.
+ typeset -g POWERLEVEL9K_SHORTEN_DELIMITER=
+ # Color of the shortened directory segments.
+ typeset -g POWERLEVEL9K_DIR_SHORTENED_FOREGROUND=103
+ # Color of the anchor directory segments. Anchor segments are never shortened. The first
+ # segment is always an anchor.
+ typeset -g POWERLEVEL9K_DIR_ANCHOR_FOREGROUND=39
+ # Display anchor directory segments in bold.
+ typeset -g POWERLEVEL9K_DIR_ANCHOR_BOLD=true
+ # Don't shorten directories that contain any of these files. They are anchors.
+ local anchor_files=(
+ .bzr
+ .citc
+ .git
+ .hg
+ .node-version
+ .python-version
+ .ruby-version
+ .shorten_folder_marker
+ .svn
+ .terraform
+ CVS
+ Cargo.toml
+ composer.json
+ go.mod
+ package.json
+ )
+ typeset -g POWERLEVEL9K_SHORTEN_FOLDER_MARKER="(${(j:|:)anchor_files})"
+ # If set to true, remove everything before the last (deepest) subdirectory that contains files
+ # matching $POWERLEVEL9K_SHORTEN_FOLDER_MARKER. For example, when the current directory is
+ # /foo/bar/git_repo/baz, prompt will display git_repo/baz. This assumes that /foo/bar/git_repo
+ # contains a marker (.git) and other directories don't.
+ typeset -g POWERLEVEL9K_DIR_TRUNCATE_BEFORE_MARKER=false
+ # Don't shorten this many last directory segments. They are anchors.
+ typeset -g POWERLEVEL9K_SHORTEN_DIR_LENGTH=1
+ # Shorten directory if it's longer than this even if there is space for it. The value can
+ # be either absolute (e.g., '80') or a percentage of terminal width (e.g, '50%'). If empty,
+ # directory will be shortened only when prompt doesn't fit or when other parameters demand it
+ # (see POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS and POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS_PCT below).
+ # If set to `0`, directory will always be shortened to its minimum length.
+ typeset -g POWERLEVEL9K_DIR_MAX_LENGTH=80
+ # When `dir` segment is on the last prompt line, try to shorten it enough to leave at least this
+ # many columns for typing commands.
+ typeset -g POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS=40
+ # When `dir` segment is on the last prompt line, try to shorten it enough to leave at least
+ # COLUMNS * POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS_PCT * 0.01 columns for typing commands.
+ typeset -g POWERLEVEL9K_DIR_MIN_COMMAND_COLUMNS_PCT=50
+ # If set to true, embed a hyperlink into the directory. Useful for quickly
+ # opening a directory in the file manager simply by clicking the link.
+ # Can also be handy when the directory is shortened, as it allows you to see
+ # the full directory that was used in previous commands.
+ typeset -g POWERLEVEL9K_DIR_HYPERLINK=false
+
+ # Enable special styling for non-writable directories.
+ typeset -g POWERLEVEL9K_DIR_SHOW_WRITABLE=true
+ # Show this icon when the current directory is not writable. POWERLEVEL9K_DIR_SHOW_WRITABLE
+ # above must be set to true for this parameter to have effect.
+ typeset -g POWERLEVEL9K_DIR_NOT_WRITABLE_VISUAL_IDENTIFIER_EXPANSION='∅'
+
+ # Custom prefix.
+ # typeset -g POWERLEVEL9K_DIR_PREFIX='%248Fin '
+
+ # POWERLEVEL9K_DIR_CLASSES allows you to specify custom icons for different directories.
+ # It must be an array with 3 * N elements. Each triplet consists of:
+ #
+ # 1. A pattern against which the current directory is matched. Matching is done with
+ # extended_glob option enabled.
+ # 2. Directory class for the purpose of styling.
+ # 3. Icon.
+ #
+ # Triplets are tried in order. The first triplet whose pattern matches $PWD wins. If there
+ # are no matches, the directory will have no icon.
+ #
+ # Example:
+ #
+ # typeset -g POWERLEVEL9K_DIR_CLASSES=(
+ # '~/work(|/*)' WORK '(╯°□°)╯︵ ┻━┻'
+ # '~(|/*)' HOME '⌂'
+ # '*' DEFAULT '')
+ #
+ # With these settings, the current directory in the prompt may look like this:
+ #
+ # (╯°□°)╯︵ ┻━┻ ~/work/projects/important/urgent
+ #
+ # Or like this:
+ #
+ # ⌂ ~/best/powerlevel10k
+ #
+ # You can also set different colors for directories of different classes. Remember to override
+ # FOREGROUND, SHORTENED_FOREGROUND and ANCHOR_FOREGROUND for every directory class that you wish
+ # to have its own color.
+ #
+ # typeset -g POWERLEVEL9K_DIR_WORK_FOREGROUND=31
+ # typeset -g POWERLEVEL9K_DIR_WORK_SHORTENED_FOREGROUND=103
+ # typeset -g POWERLEVEL9K_DIR_WORK_ANCHOR_FOREGROUND=39
+ #
+ typeset -g POWERLEVEL9K_DIR_CLASSES=()
+
+ #####################################[ vcs: git status ]######################################
+ # Branch icon. Set this parameter to '\uF126 ' for the popular Powerline branch icon.
+ typeset -g POWERLEVEL9K_VCS_BRANCH_ICON=
+ POWERLEVEL9K_VCS_BRANCH_ICON=${(g::)POWERLEVEL9K_VCS_BRANCH_ICON}
+
+ # Untracked files icon. It's really a question mark, your font isn't broken.
+ # Change the value of this parameter to show a different icon.
+ typeset -g POWERLEVEL9K_VCS_UNTRACKED_ICON='?'
+ POWERLEVEL9K_VCS_UNTRACKED_ICON=${(g::)POWERLEVEL9K_VCS_UNTRACKED_ICON}
+
+ # Formatter for Git status.
+ #
+ # Example output: master ⇣42⇡42 *42 merge ~42 +42 !42 ?42.
+ #
+ # You can edit the function to customize how Git status looks.
+ #
+ # VCS_STATUS_* parameters are set by gitstatus plugin. See reference:
+ # https://github.com/romkatv/gitstatus/blob/master/gitstatus.plugin.zsh.
+ function my_git_formatter() {
+ emulate -L zsh
+
+ if [[ -n $P9K_CONTENT ]]; then
+ # If P9K_CONTENT is not empty, use it. It's either "loading" or from vcs_info (not from
+ # gitstatus plugin). VCS_STATUS_* parameters are not available in this case.
+ typeset -g my_git_format=$P9K_CONTENT
+ return
+ fi
+
+ if (($1)); then
+ # Styling for up-to-date Git status.
+ local meta='%248F' # grey foreground
+ local clean='%76F' # green foreground
+ local modified='%178F' # yellow foreground
+ local untracked='%39F' # blue foreground
+ local conflicted='%196F' # red foreground
+ else
+ # Styling for incomplete and stale Git status.
+ local meta='%244F' # grey foreground
+ local clean='%244F' # grey foreground
+ local modified='%244F' # grey foreground
+ local untracked='%244F' # grey foreground
+ local conflicted='%244F' # grey foreground
+ fi
+
+ local res
+ local where # branch or tag
+ if [[ -n $VCS_STATUS_LOCAL_BRANCH ]]; then
+ res+="${clean}${POWERLEVEL9K_VCS_BRANCH_ICON}"
+ where=${(V)VCS_STATUS_LOCAL_BRANCH}
+ elif [[ -n $VCS_STATUS_TAG ]]; then
+ res+="${meta}#"
+ where=${(V)VCS_STATUS_TAG}
+ fi
+
+ # If local branch name or tag is at most 32 characters long, show it in full.
+ # Otherwise show the first 12 … the last 12.
+ (($#where > 50)) && where[13,-13]="…"
+ res+="${clean}${where//\%/%%}" # escape %
+
+ # Display the current Git commit if there is no branch or tag.
+ # Tip: To always display the current Git commit, remove `[[ -z $where ]] &&` from the next line.
+ [[ -z $where ]] && res+="${meta}@${clean}${VCS_STATUS_COMMIT[1,8]}"
+
+ # Show tracking branch name if it differs from local branch.
+ if [[ -n ${VCS_STATUS_REMOTE_BRANCH:#$VCS_STATUS_LOCAL_BRANCH} ]]; then
+ res+="${meta}:${clean}${(V)VCS_STATUS_REMOTE_BRANCH//\%/%%}" # escape %
+ fi
+
+ # ⇣42 if behind the remote.
+ ((VCS_STATUS_COMMITS_BEHIND)) && res+=" ${clean}⇣${VCS_STATUS_COMMITS_BEHIND}"
+ # ⇡42 if ahead of the remote; no leading space if also behind the remote: ⇣42⇡42.
+ ((VCS_STATUS_COMMITS_AHEAD && !VCS_STATUS_COMMITS_BEHIND)) && res+=" "
+ ((VCS_STATUS_COMMITS_AHEAD)) && res+="${clean}⇡${VCS_STATUS_COMMITS_AHEAD}"
+ # ⇠42 if behind the push remote.
+ ((VCS_STATUS_PUSH_COMMITS_BEHIND)) && res+=" ${clean}⇠${VCS_STATUS_PUSH_COMMITS_BEHIND}"
+ ((VCS_STATUS_PUSH_COMMITS_AHEAD && !VCS_STATUS_PUSH_COMMITS_BEHIND)) && res+=" "
+ # ⇢42 if ahead of the push remote; no leading space if also behind: ⇠42⇢42.
+ ((VCS_STATUS_PUSH_COMMITS_AHEAD)) && res+="${clean}⇢${VCS_STATUS_PUSH_COMMITS_AHEAD}"
+ # *42 if have stashes.
+ ((VCS_STATUS_STASHES)) && res+=" ${clean}*${VCS_STATUS_STASHES}"
+ # 'merge' if the repo is in an unusual state.
+ [[ -n $VCS_STATUS_ACTION ]] && res+=" ${conflicted}${VCS_STATUS_ACTION}"
+ # ~42 if have merge conflicts.
+ ((VCS_STATUS_NUM_CONFLICTED)) && res+=" ${conflicted}~${VCS_STATUS_NUM_CONFLICTED}"
+ # +42 if have staged changes.
+ ((VCS_STATUS_NUM_STAGED)) && res+=" ${modified}+${VCS_STATUS_NUM_STAGED}"
+ # !42 if have unstaged changes.
+ ((VCS_STATUS_NUM_UNSTAGED)) && res+=" ${modified}!${VCS_STATUS_NUM_UNSTAGED}"
+ # ?42 if have untracked files. It's really a question mark, your font isn't broken.
+ # See POWERLEVEL9K_VCS_UNTRACKED_ICON above if you want to use a different icon.
+ # Remove the next line if you don't want to see untracked files at all.
+ ((VCS_STATUS_NUM_UNTRACKED)) && res+=" ${untracked}${POWERLEVEL9K_VCS_UNTRACKED_ICON}${VCS_STATUS_NUM_UNTRACKED}"
+ # "─" if the number of unstaged files is unknown. This can happen due to
+ # POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY (see below) being set to a non-negative number lower
+ # than the number of files in the Git index, or due to bash.showDirtyState being set to false
+ # in the repository config. The number of staged and untracked files may also be unknown
+ # in this case.
+ ((VCS_STATUS_HAS_UNSTAGED == -1)) && res+=" ${modified}─"
+
+ typeset -g my_git_format=$res
+ }
+ functions -M my_git_formatter 2>/dev/null
+
+ # Don't count the number of unstaged, untracked and conflicted files in Git repositories with
+ # more than this many files in the index. Negative value means infinity.
+ #
+ # If you are working in Git repositories with tens of millions of files and seeing performance
+ # sagging, try setting POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY to a number lower than the output
+ # of `git ls-files | wc -l`. Alternatively, add `bash.showDirtyState = false` to the repository's
+ # config: `git config bash.showDirtyState false`.
+ typeset -g POWERLEVEL9K_VCS_MAX_INDEX_SIZE_DIRTY=-1
+
+ # Don't show Git status in prompt for repositories whose workdir matches this pattern.
+ # For example, if set to '~', the Git repository at $HOME/.git will be ignored.
+ # Multiple patterns can be combined with '|': '~|~/some/dir'.
+ typeset -g POWERLEVEL9K_VCS_DISABLED_WORKDIR_PATTERN='~'
+
+ # Disable the default Git status formatting.
+ typeset -g POWERLEVEL9K_VCS_DISABLE_GITSTATUS_FORMATTING=true
+ # Install our own Git status formatter.
+ typeset -g POWERLEVEL9K_VCS_CONTENT_EXPANSION='${$((my_git_formatter(1)))+${my_git_format}}'
+ typeset -g POWERLEVEL9K_VCS_LOADING_CONTENT_EXPANSION='${$((my_git_formatter(0)))+${my_git_format}}'
+ # Enable counters for staged, unstaged, etc.
+ typeset -g POWERLEVEL9K_VCS_{STAGED,UNSTAGED,UNTRACKED,CONFLICTED,COMMITS_AHEAD,COMMITS_BEHIND}_MAX_NUM=-1
+
+ # Icon color.
+ typeset -g POWERLEVEL9K_VCS_VISUAL_IDENTIFIER_COLOR=76
+ typeset -g POWERLEVEL9K_VCS_LOADING_VISUAL_IDENTIFIER_COLOR=244
+ # Custom icon.
+ typeset -g POWERLEVEL9K_VCS_VISUAL_IDENTIFIER_EXPANSION=
+ # Custom prefix.
+ typeset -g POWERLEVEL9K_VCS_PREFIX='%248Fon '
+
+ # Show status of repositories of these types. You can add svn and/or hg if you are
+ # using them. If you do, your prompt may become slow even when your current directory
+ # isn't in an svn or hg reposotiry.
+ typeset -g POWERLEVEL9K_VCS_BACKENDS=(git)
+
+ # These settings are used for repositories other than Git or when gitstatusd fails and
+ # Powerlevel10k has to fall back to using vcs_info.
+ typeset -g POWERLEVEL9K_VCS_CLEAN_FOREGROUND=76
+ typeset -g POWERLEVEL9K_VCS_UNTRACKED_FOREGROUND=76
+ typeset -g POWERLEVEL9K_VCS_MODIFIED_FOREGROUND=178
+
+ ##########################[ status: exit code of the last command ]###########################
+ # Enable OK_PIPE, ERROR_PIPE and ERROR_SIGNAL status states to allow us to enable, disable and
+ # style them independently from the regular OK and ERROR state.
+ typeset -g POWERLEVEL9K_STATUS_EXTENDED_STATES=true
+
+ # Status on success. No content, just an icon. No need to show it if prompt_char is enabled as
+ # it will signify success by turning green.
+ typeset -g POWERLEVEL9K_STATUS_OK=true
+ typeset -g POWERLEVEL9K_STATUS_OK_FOREGROUND=70
+ typeset -g POWERLEVEL9K_STATUS_OK_VISUAL_IDENTIFIER_EXPANSION='✔'
+
+ # Status when some part of a pipe command fails but the overall exit status is zero. It may look
+ # like this: 1|0.
+ typeset -g POWERLEVEL9K_STATUS_OK_PIPE=true
+ typeset -g POWERLEVEL9K_STATUS_OK_PIPE_FOREGROUND=70
+ typeset -g POWERLEVEL9K_STATUS_OK_PIPE_VISUAL_IDENTIFIER_EXPANSION='✔'
+
+ # Status when it's just an error code (e.g., '1'). No need to show it if prompt_char is enabled as
+ # it will signify error by turning red.
+ typeset -g POWERLEVEL9K_STATUS_ERROR=true
+ typeset -g POWERLEVEL9K_STATUS_ERROR_FOREGROUND=160
+ typeset -g POWERLEVEL9K_STATUS_ERROR_VISUAL_IDENTIFIER_EXPANSION='х'
+
+ # Status when the last command was terminated by a signal.
+ typeset -g POWERLEVEL9K_STATUS_ERROR_SIGNAL=true
+ typeset -g POWERLEVEL9K_STATUS_ERROR_SIGNAL_FOREGROUND=160
+ # Use terse signal names: "INT" instead of "SIGINT(2)".
+ typeset -g POWERLEVEL9K_STATUS_VERBOSE_SIGNAME=false
+ typeset -g POWERLEVEL9K_STATUS_ERROR_SIGNAL_VISUAL_IDENTIFIER_EXPANSION='х'
+
+ # Status when some part of a pipe command fails and the overall exit status is also non-zero.
+ # It may look like this: 1|0.
+ typeset -g POWERLEVEL9K_STATUS_ERROR_PIPE=true
+ typeset -g POWERLEVEL9K_STATUS_ERROR_PIPE_FOREGROUND=160
+ typeset -g POWERLEVEL9K_STATUS_ERROR_PIPE_VISUAL_IDENTIFIER_EXPANSION='х'
+
+ ###################[ command_execution_time: duration of the last command ]###################
+ # Show duration of the last command if takes longer than this many seconds.
+ typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_THRESHOLD=3
+ # Show this many fractional digits. Zero means round to seconds.
+ typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_PRECISION=0
+ # Execution time color.
+ typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_FOREGROUND=248
+ # Duration format: 1d 2h 3m 4s.
+ typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_FORMAT='d h m s'
+ # Custom icon.
+ typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_VISUAL_IDENTIFIER_EXPANSION=
+ # Custom prefix.
+ typeset -g POWERLEVEL9K_COMMAND_EXECUTION_TIME_PREFIX='%248Ftook '
+
+ #######################[ background_jobs: presence of background jobs ]#######################
+ # Don't show the number of background jobs.
+ typeset -g POWERLEVEL9K_BACKGROUND_JOBS_VERBOSE=false
+ # Background jobs color.
+ typeset -g POWERLEVEL9K_BACKGROUND_JOBS_FOREGROUND=37
+ # Custom icon.
+ typeset -g POWERLEVEL9K_BACKGROUND_JOBS_VISUAL_IDENTIFIER_EXPANSION='≡'
+
+ #######################[ direnv: direnv status (https://direnv.net/) ]########################
+ # Direnv color.
+ typeset -g POWERLEVEL9K_DIRENV_FOREGROUND=178
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_DIRENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###############[ asdf: asdf version manager (https://github.com/asdf-vm/asdf) ]###############
+ # Default asdf color. Only used to display tools for which there is no color override (see below).
+ typeset -g POWERLEVEL9K_ASDF_FOREGROUND=66
+
+ # There are three parameters that can be used to hide tools. If at least one of them decides
+ # to hide a tool, that tool gets hidden. POWERLEVEL9K_ASDF_SHOW_SYSTEM=false hides "system". To
+ # see the difference between POWERLEVEL9K_ASDF_SOURCES and POWERLEVEL9K_ASDF_PROMPT_ALWAYS_SHOW
+ # consider the effect of the following commands:
+ #
+ # asdf local python 3.8.1
+ # asdf global python 3.8.1
+ #
+ # After running both commands the current python version is 3.8.1 and its source is "local" as
+ # it takes precedence over "global". If POWERLEVEL9K_ASDF_PROMPT_ALWAYS_SHOW is set to false,
+ # it'll hide python version in this case because 3.8.1 is the same as the global version.
+ # POWERLEVEL9K_ASDF_SOURCES will hide python version only if the value of this parameter doesn't
+ # contain "local".
+
+ # Hide tool versions that don't come from one of these sources.
+ #
+ # Available sources:
+ #
+ # - shell `asdf current` says "set by ASDF_${TOOL}_VERSION environment variable"
+ # - local `asdf current` says "set by /some/not/home/directory/file"
+ # - global `asdf current` says "set by /home/username/file"
+ #
+ # Note: If this parameter is set to (shell local global), it won't hide tools.
+ # Tip: Override this parameter for ${TOOL} with POWERLEVEL9K_ASDF_${TOOL}_SOURCES.
+ typeset -g POWERLEVEL9K_ASDF_SOURCES=(shell local global)
+
+ # If set to false, hide tool versions that are the same as global.
+ #
+ # Note: The name of this parameter doesn't reflect its meaning at all.
+ # Note: If this parameter is set to true, it won't hide tools.
+ # Tip: Override this parameter for ${TOOL} with POWERLEVEL9K_ASDF_${TOOL}_PROMPT_ALWAYS_SHOW.
+ typeset -g POWERLEVEL9K_ASDF_PROMPT_ALWAYS_SHOW=false
+
+ # If set to false, hide tool versions that are equal to "system".
+ #
+ # Note: If this parameter is set to true, it won't hide tools.
+ # Tip: Override this parameter for ${TOOL} with POWERLEVEL9K_ASDF_${TOOL}_SHOW_SYSTEM.
+ typeset -g POWERLEVEL9K_ASDF_SHOW_SYSTEM=true
+
+ # Ruby version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_RUBY_FOREGROUND=168
+ # typeset -g POWERLEVEL9K_ASDF_RUBY_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Python version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_PYTHON_FOREGROUND=37
+ # typeset -g POWERLEVEL9K_ASDF_PYTHON_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Go version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_GO_FOREGROUND=37
+ # typeset -g POWERLEVEL9K_ASDF_GO_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Node.js version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_NODEJS_FOREGROUND=70
+ # typeset -g POWERLEVEL9K_ASDF_NODEJS_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Rust version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_RUST_FOREGROUND=37
+ # typeset -g POWERLEVEL9K_ASDF_RUST_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # .NET Core version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_DOTNET_CORE_FOREGROUND=134
+ # typeset -g POWERLEVEL9K_ASDF_DOTNET_CORE_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Flutter version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_FLUTTER_FOREGROUND=38
+ # typeset -g POWERLEVEL9K_ASDF_FLUTTER_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Lua version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_LUA_FOREGROUND=32
+ # typeset -g POWERLEVEL9K_ASDF_LUA_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Java version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_JAVA_FOREGROUND=32
+ # typeset -g POWERLEVEL9K_ASDF_JAVA_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Perl version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_PERL_FOREGROUND=67
+ # typeset -g POWERLEVEL9K_ASDF_PERL_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Erlang version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_ERLANG_FOREGROUND=125
+ # typeset -g POWERLEVEL9K_ASDF_ERLANG_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Elixir version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_ELIXIR_FOREGROUND=129
+ # typeset -g POWERLEVEL9K_ASDF_ELIXIR_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Postgres version from asdf.
+ typeset -g POWERLEVEL9K_ASDF_POSTGRES_FOREGROUND=31
+ # typeset -g POWERLEVEL9K_ASDF_POSTGRES_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##########[ nordvpn: nordvpn connection status, linux only (https://nordvpn.com/) ]###########
+ # NordVPN connection indicator color.
+ typeset -g POWERLEVEL9K_NORDVPN_FOREGROUND=39
+ # Hide NordVPN connection indicator when not connected.
+ typeset -g POWERLEVEL9K_NORDVPN_{DISCONNECTED,CONNECTING,DISCONNECTING}_CONTENT_EXPANSION=
+ typeset -g POWERLEVEL9K_NORDVPN_{DISCONNECTED,CONNECTING,DISCONNECTING}_VISUAL_IDENTIFIER_EXPANSION=
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NORDVPN_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #################[ ranger: ranger shell (https://github.com/ranger/ranger) ]##################
+ # Ranger shell color.
+ typeset -g POWERLEVEL9K_RANGER_FOREGROUND=178
+ # Custom icon.
+ typeset -g POWERLEVEL9K_RANGER_VISUAL_IDENTIFIER_EXPANSION='▲'
+
+ ######################[ nnn: nnn shell (https://github.com/jarun/nnn) ]#######################
+ # Nnn shell color.
+ typeset -g POWERLEVEL9K_NNN_FOREGROUND=72
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NNN_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###########################[ vim_shell: vim shell indicator (:sh) ]###########################
+ # Vim shell indicator color.
+ typeset -g POWERLEVEL9K_VIM_SHELL_FOREGROUND=34
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_VIM_SHELL_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ######[ midnight_commander: midnight commander shell (https://midnight-commander.org/) ]######
+ # Midnight Commander shell color.
+ typeset -g POWERLEVEL9K_MIDNIGHT_COMMANDER_FOREGROUND=178
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_MIDNIGHT_COMMANDER_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #[ nix_shell: nix shell (https://nixos.org/nixos/nix-pills/developing-with-nix-shell.html) ]##
+ # Nix shell color.
+ typeset -g POWERLEVEL9K_NIX_SHELL_FOREGROUND=74
+
+ # Tip: If you want to see just the icon without "pure" and "impure", uncomment the next line.
+ # typeset -g POWERLEVEL9K_NIX_SHELL_CONTENT_EXPANSION=
+
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NIX_SHELL_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##################################[ disk_usgae: disk usage ]##################################
+ # Colors for different levels of disk usage.
+ typeset -g POWERLEVEL9K_DISK_USAGE_NORMAL_FOREGROUND=35
+ typeset -g POWERLEVEL9K_DISK_USAGE_WARNING_FOREGROUND=220
+ typeset -g POWERLEVEL9K_DISK_USAGE_CRITICAL_FOREGROUND=160
+ # Thresholds for different levels of disk usage (percentage points).
+ typeset -g POWERLEVEL9K_DISK_USAGE_WARNING_LEVEL=90
+ typeset -g POWERLEVEL9K_DISK_USAGE_CRITICAL_LEVEL=95
+ # If set to true, hide disk usage when below $POWERLEVEL9K_DISK_USAGE_WARNING_LEVEL percent.
+ typeset -g POWERLEVEL9K_DISK_USAGE_ONLY_WARNING=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_DISK_USAGE_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###########[ vi_mode: vi mode (you don't need this if you've enabled prompt_char) ]###########
+ # Text and color for normal (a.k.a. command) vi mode.
+ typeset -g POWERLEVEL9K_VI_COMMAND_MODE_STRING=NORMAL
+ typeset -g POWERLEVEL9K_VI_MODE_NORMAL_FOREGROUND=106
+ # Text and color for visual vi mode.
+ typeset -g POWERLEVEL9K_VI_VISUAL_MODE_STRING=VISUAL
+ typeset -g POWERLEVEL9K_VI_MODE_VISUAL_FOREGROUND=68
+ # Text and color for overtype (a.k.a. overwrite and replace) vi mode.
+ typeset -g POWERLEVEL9K_VI_OVERWRITE_MODE_STRING=OVERTYPE
+ typeset -g POWERLEVEL9K_VI_MODE_OVERWRITE_FOREGROUND=172
+ # Text and color for insert vi mode.
+ typeset -g POWERLEVEL9K_VI_INSERT_MODE_STRING=
+ typeset -g POWERLEVEL9K_VI_MODE_INSERT_FOREGROUND=66
+
+ # Custom icon.
+ typeset -g POWERLEVEL9K_RANGER_VISUAL_IDENTIFIER_EXPANSION='▲'
+
+ ######################################[ ram: free RAM ]#######################################
+ # RAM color.
+ typeset -g POWERLEVEL9K_RAM_FOREGROUND=66
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_RAM_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #####################################[ swap: used swap ]######################################
+ # Swap color.
+ typeset -g POWERLEVEL9K_SWAP_FOREGROUND=96
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_SWAP_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ######################################[ load: CPU load ]######################################
+ # Show average CPU load over this many last minutes. Valid values are 1, 5 and 15.
+ typeset -g POWERLEVEL9K_LOAD_WHICH=5
+ # Load color when load is under 50%.
+ typeset -g POWERLEVEL9K_LOAD_NORMAL_FOREGROUND=66
+ # Load color when load is between 50% and 70%.
+ typeset -g POWERLEVEL9K_LOAD_WARNING_FOREGROUND=178
+ # Load color when load is over 70%.
+ typeset -g POWERLEVEL9K_LOAD_CRITICAL_FOREGROUND=166
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_LOAD_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ################[ todo: todo items (https://github.com/todotxt/todo.txt-cli) ]################
+ # Todo color.
+ typeset -g POWERLEVEL9K_TODO_FOREGROUND=110
+ # Hide todo when the total number of tasks is zero.
+ typeset -g POWERLEVEL9K_TODO_HIDE_ZERO_TOTAL=true
+ # Hide todo when the number of tasks after filtering is zero.
+ typeset -g POWERLEVEL9K_TODO_HIDE_ZERO_FILTERED=false
+
+ # Todo format. The following parameters are available within the expansion.
+ #
+ # - P9K_TODO_TOTAL_TASK_COUNT The total number of tasks.
+ # - P9K_TODO_FILTERED_TASK_COUNT The number of tasks after filtering.
+ #
+ # These variables correspond to the last line of the output of `todo.sh -p ls`:
+ #
+ # TODO: 24 of 42 tasks shown
+ #
+ # Here 24 is P9K_TODO_FILTERED_TASK_COUNT and 42 is P9K_TODO_TOTAL_TASK_COUNT.
+ #
+ # typeset -g POWERLEVEL9K_TODO_CONTENT_EXPANSION='$P9K_TODO_FILTERED_TASK_COUNT'
+
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_TODO_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###########[ timewarrior: timewarrior tracking status (https://timewarrior.net/) ]############
+ # Timewarrior color.
+ typeset -g POWERLEVEL9K_TIMEWARRIOR_FOREGROUND=110
+ # If the tracked task is longer than 24 characters, truncate and append "…".
+ # Tip: To always display tasks without truncation, delete the following parameter.
+ # Tip: To hide task names and display just the icon when time tracking is enabled, set the
+ # value of the following parameter to "".
+ typeset -g POWERLEVEL9K_TIMEWARRIOR_CONTENT_EXPANSION='${P9K_CONTENT:0:24}${${P9K_CONTENT:24}:+…}'
+
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_TIMEWARRIOR_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##################################[ context: user@hostname ]##################################
+ # Context color when running with privileges.
+ typeset -g POWERLEVEL9K_CONTEXT_ROOT_FOREGROUND=178
+ # Context color in SSH without privileges.
+ typeset -g POWERLEVEL9K_CONTEXT_{REMOTE,REMOTE_SUDO}_FOREGROUND=180
+ # Default context color (no privileges, no SSH).
+ typeset -g POWERLEVEL9K_CONTEXT_FOREGROUND=180
+
+ # Context format when running with privileges: bold user@hostname.
+ typeset -g POWERLEVEL9K_CONTEXT_ROOT_TEMPLATE='%B%n@%m'
+ # Context format when in SSH without privileges: user@hostname.
+ typeset -g POWERLEVEL9K_CONTEXT_{REMOTE,REMOTE_SUDO}_TEMPLATE='%n@%m'
+ # Default context format (no privileges, no SSH): user@hostname.
+ typeset -g POWERLEVEL9K_CONTEXT_TEMPLATE='%n@%m'
+
+ # Don't show context unless running with privileges or in SSH.
+ # Tip: Remove the next line to always show context.
+ typeset -g POWERLEVEL9K_CONTEXT_{DEFAULT,SUDO}_{CONTENT,VISUAL_IDENTIFIER}_EXPANSION=
+
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_CONTEXT_VISUAL_IDENTIFIER_EXPANSION='⭐'
+ # Custom prefix.
+ typeset -g POWERLEVEL9K_CONTEXT_PREFIX='%248Fwith '
+
+ ###[ virtualenv: python virtual environment (https://docs.python.org/3/library/venv.html) ]###
+ # Python virtual environment color.
+ typeset -g POWERLEVEL9K_VIRTUALENV_FOREGROUND=37
+ # Don't show Python version next to the virtual environment name.
+ typeset -g POWERLEVEL9K_VIRTUALENV_SHOW_PYTHON_VERSION=false
+ # Separate environment name from Python version only with a space.
+ typeset -g POWERLEVEL9K_VIRTUALENV_{LEFT,RIGHT}_DELIMITER=
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_VIRTUALENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #####################[ anaconda: conda environment (https://conda.io/) ]######################
+ # Anaconda environment color.
+ typeset -g POWERLEVEL9K_ANACONDA_FOREGROUND=37
+ # Don't show Python version next to the anaconda environment name.
+ typeset -g POWERLEVEL9K_ANACONDA_SHOW_PYTHON_VERSION=false
+ # Separate environment name from Python version only with a space.
+ typeset -g POWERLEVEL9K_ANACONDA_{LEFT,RIGHT}_DELIMITER=
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_ANACONDA_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ################[ pyenv: python environment (https://github.com/pyenv/pyenv) ]################
+ # Pyenv color.
+ typeset -g POWERLEVEL9K_PYENV_FOREGROUND=37
+ # Hide python version if it doesn't come from one of these sources.
+ typeset -g POWERLEVEL9K_PYENV_SOURCES=(shell local global)
+ # If set to false, hide python version if it's the same as global:
+ # $(pyenv version-name) == $(pyenv global).
+ typeset -g POWERLEVEL9K_PYENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_PYENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ################[ goenv: go environment (https://github.com/syndbg/goenv) ]################
+ # Goenv color.
+ typeset -g POWERLEVEL9K_GOENV_FOREGROUND=37
+ # Hide go version if it doesn't come from one of these sources.
+ typeset -g POWERLEVEL9K_GOENV_SOURCES=(shell local global)
+ # If set to false, hide go version if it's the same as global:
+ # $(goenv version-name) == $(goenv global).
+ typeset -g POWERLEVEL9K_GOENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_GOENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##########[ nodenv: node.js version from nodenv (https://github.com/nodenv/nodenv) ]##########
+ # Nodenv color.
+ typeset -g POWERLEVEL9K_NODENV_FOREGROUND=70
+ # Don't show node version if it's the same as global: $(nodenv version-name) == $(nodenv global).
+ typeset -g POWERLEVEL9K_NODENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NODENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##############[ nvm: node.js version from nvm (https://github.com/nvm-sh/nvm) ]###############
+ # Nvm color.
+ typeset -g POWERLEVEL9K_NVM_FOREGROUND=70
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NVM_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ############[ nodeenv: node.js environment (https://github.com/ekalinin/nodeenv) ]############
+ # Nodeenv color.
+ typeset -g POWERLEVEL9K_NODEENV_FOREGROUND=70
+ # Don't show Node version next to the environment name.
+ typeset -g POWERLEVEL9K_NODEENV_SHOW_NODE_VERSION=false
+ # Separate environment name from Node version only with a space.
+ typeset -g POWERLEVEL9K_NODEENV_{LEFT,RIGHT}_DELIMITER=
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NODEENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##############################[ node_version: node.js version ]###############################
+ # Node version color.
+ typeset -g POWERLEVEL9K_NODE_VERSION_FOREGROUND=70
+ # Show node version only when in a directory tree containing package.json.
+ typeset -g POWERLEVEL9K_NODE_VERSION_PROJECT_ONLY=true
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_NODE_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #######################[ go_version: go version (https://golang.org) ]########################
+ # Go version color.
+ typeset -g POWERLEVEL9K_GO_VERSION_FOREGROUND=37
+ # Show go version only when in a go project subdirectory.
+ typeset -g POWERLEVEL9K_GO_VERSION_PROJECT_ONLY=true
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_GO_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #################[ rust_version: rustc version (https://www.rust-lang.org) ]##################
+ # Rust version color.
+ typeset -g POWERLEVEL9K_RUST_VERSION_FOREGROUND=37
+ # Show rust version only when in a rust project subdirectory.
+ typeset -g POWERLEVEL9K_RUST_VERSION_PROJECT_ONLY=true
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_RUST_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###############[ dotnet_version: .NET version (https://dotnet.microsoft.com) ]################
+ # .NET version color.
+ typeset -g POWERLEVEL9K_DOTNET_VERSION_FOREGROUND=134
+ # Show .NET version only when in a .NET project subdirectory.
+ typeset -g POWERLEVEL9K_DOTNET_VERSION_PROJECT_ONLY=true
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_DOTNET_VERSION_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #############[ rbenv: ruby version from rbenv (https://github.com/rbenv/rbenv) ]##############
+ # Rbenv color.
+ typeset -g POWERLEVEL9K_RBENV_FOREGROUND=168
+ # Hide ruby version if it doesn't come from one of these sources.
+ typeset -g POWERLEVEL9K_RBENV_SOURCES=(shell local global)
+ # If set to false, hide ruby version if it's the same as global:
+ # $(rbenv version-name) == $(rbenv global).
+ typeset -g POWERLEVEL9K_RBENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_RBENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #######################[ rvm: ruby version from rvm (https://rvm.io) ]########################
+ # Rvm color.
+ typeset -g POWERLEVEL9K_RVM_FOREGROUND=168
+ # Don't show @gemset at the end.
+ typeset -g POWERLEVEL9K_RVM_SHOW_GEMSET=false
+ # Don't show ruby- at the front.
+ typeset -g POWERLEVEL9K_RVM_SHOW_PREFIX=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_RVM_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###########[ fvm: flutter version management (https://github.com/leoafarias/fvm) ]############
+ # Fvm color.
+ typeset -g POWERLEVEL9K_FVM_FOREGROUND=38
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_FVM_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ##########[ luaenv: lua version from luaenv (https://github.com/cehoffman/luaenv) ]###########
+ # Lua color.
+ typeset -g POWERLEVEL9K_LUAENV_FOREGROUND=32
+ # Hide lua version if it doesn't come from one of these sources.
+ typeset -g POWERLEVEL9K_LUAENV_SOURCES=(shell local global)
+ # If set to false, hide lua version if it's the same as global:
+ # $(luaenv version-name) == $(luaenv global).
+ typeset -g POWERLEVEL9K_LUAENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_LUAENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###############[ jenv: java version from jenv (https://github.com/jenv/jenv) ]################
+ # Java color.
+ typeset -g POWERLEVEL9K_JENV_FOREGROUND=32
+ # Hide java version if it doesn't come from one of these sources.
+ typeset -g POWERLEVEL9K_JENV_SOURCES=(shell local global)
+ # If set to false, hide java version if it's the same as global:
+ # $(jenv version-name) == $(jenv global).
+ typeset -g POWERLEVEL9K_JENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_JENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###########[ plenv: perl version from plenv (https://github.com/tokuhirom/plenv) ]############
+ # Perl color.
+ typeset -g POWERLEVEL9K_PLENV_FOREGROUND=67
+ # Hide perl version if it doesn't come from one of these sources.
+ typeset -g POWERLEVEL9K_PLENV_SOURCES=(shell local global)
+ # If set to false, hide perl version if it's the same as global:
+ # $(plenv version-name) == $(plenv global).
+ typeset -g POWERLEVEL9K_PLENV_PROMPT_ALWAYS_SHOW=false
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_PLENV_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ################[ terraform: terraform workspace (https://www.terraform.io) ]#################
+ # POWERLEVEL9K_TERRAFORM_CLASSES is an array with even number of elements. The first element
+ # in each pair defines a pattern against which the current terraform workspace gets matched.
+ # More specifically, it's P9K_CONTENT prior to the application of context expansion (see below)
+ # that gets matched. If you unset all POWERLEVEL9K_TERRAFORM_*CONTENT_EXPANSION parameters,
+ # you'll see this value in your prompt. The second element of each pair in
+ # POWERLEVEL9K_TERRAFORM_CLASSES defines the workspace class. Patterns are tried in order. The
+ # first match wins.
+ #
+ # For example, given these settings:
+ #
+ # typeset -g POWERLEVEL9K_TERRAFORM_CLASSES=(
+ # '*prod*' PROD
+ # '*test*' TEST
+ # '*' DEFAULT)
+ #
+ # If your current terraform workspace is "project_test", its class is TEST because "project_test"
+ # doesn't match the pattern '*prod*' but does match '*test*'.
+ #
+ # You can define different colors, icons and content expansions for different classes:
+ #
+ # typeset -g POWERLEVEL9K_TERRAFORM_TEST_FOREGROUND=28
+ # typeset -g POWERLEVEL9K_TERRAFORM_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
+ # typeset -g POWERLEVEL9K_TERRAFORM_TEST_CONTENT_EXPANSION='> ${P9K_CONTENT} <'
+ typeset -g POWERLEVEL9K_TERRAFORM_CLASSES=(
+ # '*prod*' PROD # These values are examples that are unlikely
+ # '*test*' TEST # to match your needs. Customize them as needed.
+ '*' DEFAULT)
+ typeset -g POWERLEVEL9K_TERRAFORM_DEFAULT_FOREGROUND=38
+ # typeset -g POWERLEVEL9K_TERRAFORM_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #############[ kubecontext: current kubernetes context (https://kubernetes.io/) ]#############
+ # Show kubecontext only when the the command you are typing invokes one of these tools.
+ # Tip: Remove the next line to always show kubecontext.
+ typeset -g POWERLEVEL9K_KUBECONTEXT_SHOW_ON_COMMAND='kubectl|helm|kubens|kubectx|oc'
+
+ # Kubernetes context classes for the purpose of using different colors, icons and expansions with
+ # different contexts.
+ #
+ # POWERLEVEL9K_KUBECONTEXT_CLASSES is an array with even number of elements. The first element
+ # in each pair defines a pattern against which the current kubernetes context gets matched.
+ # More specifically, it's P9K_CONTENT prior to the application of context expansion (see below)
+ # that gets matched. If you unset all POWERLEVEL9K_KUBECONTEXT_*CONTENT_EXPANSION parameters,
+ # you'll see this value in your prompt. The second element of each pair in
+ # POWERLEVEL9K_KUBECONTEXT_CLASSES defines the context class. Patterns are tried in order. The
+ # first match wins.
+ #
+ # For example, given these settings:
+ #
+ # typeset -g POWERLEVEL9K_KUBECONTEXT_CLASSES=(
+ # '*prod*' PROD
+ # '*test*' TEST
+ # '*' DEFAULT)
+ #
+ # If your current kubernetes context is "deathray-testing/default", its class is TEST
+ # because "deathray-testing/default" doesn't match the pattern '*prod*' but does match '*test*'.
+ #
+ # You can define different colors, icons and content expansions for different classes:
+ #
+ # typeset -g POWERLEVEL9K_KUBECONTEXT_TEST_FOREGROUND=28
+ # typeset -g POWERLEVEL9K_KUBECONTEXT_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
+ # typeset -g POWERLEVEL9K_KUBECONTEXT_TEST_CONTENT_EXPANSION='> ${P9K_CONTENT} <'
+ typeset -g POWERLEVEL9K_KUBECONTEXT_CLASSES=(
+ # '*prod*' PROD # These values are examples that are unlikely
+ # '*test*' TEST # to match your needs. Customize them as needed.
+ '*' DEFAULT)
+ typeset -g POWERLEVEL9K_KUBECONTEXT_DEFAULT_FOREGROUND=134
+ typeset -g POWERLEVEL9K_KUBECONTEXT_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='○'
+
+ # Use POWERLEVEL9K_KUBECONTEXT_CONTENT_EXPANSION to specify the content displayed by kubecontext
+ # segment. Parameter expansions are very flexible and fast, too. See reference:
+ # http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion.
+ #
+ # Within the expansion the following parameters are always available:
+ #
+ # - P9K_CONTENT The content that would've been displayed if there was no content
+ # expansion defined.
+ # - P9K_KUBECONTEXT_NAME The current context's name. Corresponds to column NAME in the
+ # output of `kubectl config get-contexts`.
+ # - P9K_KUBECONTEXT_CLUSTER The current context's cluster. Corresponds to column CLUSTER in the
+ # output of `kubectl config get-contexts`.
+ # - P9K_KUBECONTEXT_NAMESPACE The current context's namespace. Corresponds to column NAMESPACE
+ # in the output of `kubectl config get-contexts`. If there is no
+ # namespace, the parameter is set to "default".
+ # - P9K_KUBECONTEXT_USER The current context's user. Corresponds to column AUTHINFO in the
+ # output of `kubectl config get-contexts`.
+ #
+ # If the context points to Google Kubernetes Engine (GKE) or Elastic Kubernetes Service (EKS),
+ # the following extra parameters are available:
+ #
+ # - P9K_KUBECONTEXT_CLOUD_NAME Either "gke" or "eks".
+ # - P9K_KUBECONTEXT_CLOUD_ACCOUNT Account/project ID.
+ # - P9K_KUBECONTEXT_CLOUD_ZONE Availability zone.
+ # - P9K_KUBECONTEXT_CLOUD_CLUSTER Cluster.
+ #
+ # P9K_KUBECONTEXT_CLOUD_* parameters are derived from P9K_KUBECONTEXT_CLUSTER. For example,
+ # if P9K_KUBECONTEXT_CLUSTER is "gke_my-account_us-east1-a_my-cluster-01":
+ #
+ # - P9K_KUBECONTEXT_CLOUD_NAME=gke
+ # - P9K_KUBECONTEXT_CLOUD_ACCOUNT=my-account
+ # - P9K_KUBECONTEXT_CLOUD_ZONE=us-east1-a
+ # - P9K_KUBECONTEXT_CLOUD_CLUSTER=my-cluster-01
+ #
+ # If P9K_KUBECONTEXT_CLUSTER is "arn:aws:eks:us-east-1:123456789012:cluster/my-cluster-01":
+ #
+ # - P9K_KUBECONTEXT_CLOUD_NAME=eks
+ # - P9K_KUBECONTEXT_CLOUD_ACCOUNT=123456789012
+ # - P9K_KUBECONTEXT_CLOUD_ZONE=us-east-1
+ # - P9K_KUBECONTEXT_CLOUD_CLUSTER=my-cluster-01
+ typeset -g POWERLEVEL9K_KUBECONTEXT_DEFAULT_CONTENT_EXPANSION=
+ # Show P9K_KUBECONTEXT_CLOUD_CLUSTER if it's not empty and fall back to P9K_KUBECONTEXT_NAME.
+ POWERLEVEL9K_KUBECONTEXT_DEFAULT_CONTENT_EXPANSION+='${P9K_KUBECONTEXT_CLOUD_CLUSTER:-${P9K_KUBECONTEXT_NAME}}'
+ # Append the current context's namespace if it's not "default".
+ POWERLEVEL9K_KUBECONTEXT_DEFAULT_CONTENT_EXPANSION+='${${:-/$P9K_KUBECONTEXT_NAMESPACE}:#/default}'
+
+ # Custom prefix.
+ typeset -g POWERLEVEL9K_KUBECONTEXT_PREFIX='%248Fat '
+
+ #[ aws: aws profile (https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html) ]#
+ # Show aws only when the the command you are typing invokes one of these tools.
+ # Tip: Remove the next line to always show aws.
+ typeset -g POWERLEVEL9K_AWS_SHOW_ON_COMMAND='aws|awless|terraform|pulumi'
+
+ # POWERLEVEL9K_AWS_CLASSES is an array with even number of elements. The first element
+ # in each pair defines a pattern against which the current AWS profile gets matched.
+ # More specifically, it's P9K_CONTENT prior to the application of context expansion (see below)
+ # that gets matched. If you unset all POWERLEVEL9K_AWS_*CONTENT_EXPANSION parameters,
+ # you'll see this value in your prompt. The second element of each pair in
+ # POWERLEVEL9K_AWS_CLASSES defines the profile class. Patterns are tried in order. The
+ # first match wins.
+ #
+ # For example, given these settings:
+ #
+ # typeset -g POWERLEVEL9K_AWS_CLASSES=(
+ # '*prod*' PROD
+ # '*test*' TEST
+ # '*' DEFAULT)
+ #
+ # If your current AWS profile is "company_test", its class is TEST
+ # because "company_test" doesn't match the pattern '*prod*' but does match '*test*'.
+ #
+ # You can define different colors, icons and content expansions for different classes:
+ #
+ # typeset -g POWERLEVEL9K_AWS_TEST_FOREGROUND=28
+ # typeset -g POWERLEVEL9K_AWS_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
+ # typeset -g POWERLEVEL9K_AWS_TEST_CONTENT_EXPANSION='> ${P9K_CONTENT} <'
+ typeset -g POWERLEVEL9K_AWS_CLASSES=(
+ # '*prod*' PROD # These values are examples that are unlikely
+ # '*test*' TEST # to match your needs. Customize them as needed.
+ '*' DEFAULT)
+ typeset -g POWERLEVEL9K_AWS_DEFAULT_FOREGROUND=208
+ # typeset -g POWERLEVEL9K_AWS_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #[ aws_eb_env: aws elastic beanstalk environment (https://aws.amazon.com/elasticbeanstalk/) ]#
+ # AWS Elastic Beanstalk environment color.
+ typeset -g POWERLEVEL9K_AWS_EB_ENV_FOREGROUND=70
+ # Custom icon.
+ typeset -g POWERLEVEL9K_AWS_EB_ENV_VISUAL_IDENTIFIER_EXPANSION='eb'
+
+ ##########[ azure: azure account name (https://docs.microsoft.com/en-us/cli/azure) ]##########
+ # Show azure only when the the command you are typing invokes one of these tools.
+ # Tip: Remove the next line to always show azure.
+ typeset -g POWERLEVEL9K_AZURE_SHOW_ON_COMMAND='az|terraform|pulumi'
+ # Azure account name color.
+ typeset -g POWERLEVEL9K_AZURE_FOREGROUND=32
+ # Custom icon.
+ typeset -g POWERLEVEL9K_AZURE_VISUAL_IDENTIFIER_EXPANSION='az'
+
+ ##########[ gcloud: google cloud account and project (https://cloud.google.com/) ]###########
+ # Show gcloud only when the the command you are typing invokes one of these tools.
+ # Tip: Remove the next line to always show gcloud.
+ typeset -g POWERLEVEL9K_GCLOUD_SHOW_ON_COMMAND='gcloud|gcs'
+ # Google cloud color.
+ typeset -g POWERLEVEL9K_GCLOUD_FOREGROUND=32
+
+ # Google cloud format. Change the value of POWERLEVEL9K_GCLOUD_CONTENT_EXPANSION if the default
+ # is too verbose or not informative enough.
+ #
+ # P9K_GCLOUD_ACCOUNT: the output of `gcloud config get-value account`
+ # P9K_GCLOUD_PROJECT: the output of `gcloud config get-value project`
+ # ${VARIABLE//\%/%%}: ${VARIABLE} with all occurrences of '%' replaced with '%%'.
+ #
+ typeset -g POWERLEVEL9K_GCLOUD_CONTENT_EXPANSION='${P9K_GCLOUD_PROJECT//\%/%%}'
+
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_GCLOUD_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #[ google_app_cred: google application credentials (https://cloud.google.com/docs/authentication/production) ]#
+ # Show google_app_cred only when the the command you are typing invokes one of these tools.
+ # Tip: Remove the next line to always show google_app_cred.
+ typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_SHOW_ON_COMMAND='terraform|pulumi'
+
+ # Google application credentials classes for the purpose of using different colors, icons and
+ # expansions with different credentials.
+ #
+ # POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES is an array with even number of elements. The first
+ # element in each pair defines a pattern against which the current kubernetes context gets
+ # matched. More specifically, it's P9K_CONTENT prior to the application of context expansion
+ # (see below) that gets matched. If you unset all POWERLEVEL9K_GOOGLE_APP_CRED_*CONTENT_EXPANSION
+ # parameters, you'll see this value in your prompt. The second element of each pair in
+ # POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES defines the context class. Patterns are tried in order.
+ # The first match wins.
+ #
+ # For example, given these settings:
+ #
+ # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES=(
+ # '*:*prod*:*' PROD
+ # '*:*test*:*' TEST
+ # '*' DEFAULT)
+ #
+ # If your current Google application credentials is "service_account deathray-testing x@y.com",
+ # its class is TEST because it doesn't match the pattern '* *prod* *' but does match '* *test* *'.
+ #
+ # You can define different colors, icons and content expansions for different classes:
+ #
+ # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_TEST_FOREGROUND=28
+ # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_TEST_VISUAL_IDENTIFIER_EXPANSION='⭐'
+ # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_TEST_CONTENT_EXPANSION='$P9K_GOOGLE_APP_CRED_PROJECT_ID'
+ typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_CLASSES=(
+ # '*:*prod*:*' PROD # These values are examples that are unlikely
+ # '*:*test*:*' TEST # to match your needs. Customize them as needed.
+ '*' DEFAULT)
+ typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_DEFAULT_FOREGROUND=32
+ # typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_DEFAULT_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Use POWERLEVEL9K_GOOGLE_APP_CRED_CONTENT_EXPANSION to specify the content displayed by
+ # google_app_cred segment. Parameter expansions are very flexible and fast, too. See reference:
+ # http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion.
+ #
+ # You can use the following parameters in the expansion. Each of them corresponds to one of the
+ # fields in the JSON file pointed to by GOOGLE_APPLICATION_CREDENTIALS.
+ #
+ # Parameter | JSON key file field
+ # ---------------------------------+---------------
+ # P9K_GOOGLE_APP_CRED_TYPE | type
+ # P9K_GOOGLE_APP_CRED_PROJECT_ID | project_id
+ # P9K_GOOGLE_APP_CRED_CLIENT_EMAIL | client_email
+ #
+ # Note: ${VARIABLE//\%/%%} expands to ${VARIABLE} with all occurrences of '%' replaced by '%%'.
+ typeset -g POWERLEVEL9K_GOOGLE_APP_CRED_DEFAULT_CONTENT_EXPANSION='${P9K_GOOGLE_APP_CRED_PROJECT_ID//\%/%%}'
+
+ ###############################[ public_ip: public IP address ]###############################
+ # Public IP color.
+ typeset -g POWERLEVEL9K_PUBLIC_IP_FOREGROUND=94
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_PUBLIC_IP_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ########################[ vpn_ip: virtual private network indicator ]#########################
+ # VPN IP color.
+ typeset -g POWERLEVEL9K_VPN_IP_FOREGROUND=81
+ # When on VPN, show just an icon without the IP address.
+ # Tip: To display the private IP address when on VPN, remove the next line.
+ typeset -g POWERLEVEL9K_VPN_IP_CONTENT_EXPANSION=
+ # Regular expression for the VPN network interface. Run `ifconfig` or `ip -4 a show` while on VPN
+ # to see the name of the interface.
+ typeset -g POWERLEVEL9K_VPN_IP_INTERFACE='(wg|(.*tun))[0-9]*'
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_VPN_IP_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ###########[ ip: ip address and bandwidth usage for a specified network interface ]###########
+ # IP color.
+ typeset -g POWERLEVEL9K_IP_FOREGROUND=38
+ # The following parameters are accessible within the expansion:
+ #
+ # Parameter | Meaning
+ # ----------------------+---------------
+ # P9K_IP_IP | IP address
+ # P9K_IP_INTERFACE | network interface
+ # P9K_IP_RX_BYTES | total number of bytes received
+ # P9K_IP_TX_BYTES | total number of bytes sent
+ # P9K_IP_RX_RATE | receive rate (since last prompt)
+ # P9K_IP_TX_RATE | send rate (since last prompt)
+ typeset -g POWERLEVEL9K_IP_CONTENT_EXPANSION='%70F⇣$P9K_IP_RX_RATE %215F⇡$P9K_IP_TX_RATE %38F$P9K_IP_IP'
+ # Show information for the first network interface whose name matches this regular expression.
+ # Run `ifconfig` or `ip -4 a show` to see the names of all network interfaces.
+ typeset -g POWERLEVEL9K_IP_INTERFACE='e.*'
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_IP_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ #########################[ proxy: system-wide http/https/ftp proxy ]##########################
+ # Proxy color.
+ typeset -g POWERLEVEL9K_PROXY_FOREGROUND=68
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_PROXY_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ ################################[ battery: internal battery ]#################################
+ # Show battery in red when it's below this level and not connected to power supply.
+ typeset -g POWERLEVEL9K_BATTERY_LOW_THRESHOLD=20
+ typeset -g POWERLEVEL9K_BATTERY_LOW_FOREGROUND=160
+ # Show battery in green when it's charging or fully charged.
+ typeset -g POWERLEVEL9K_BATTERY_{CHARGING,CHARGED}_FOREGROUND=70
+ # Show battery in yellow when it's discharging.
+ typeset -g POWERLEVEL9K_BATTERY_DISCONNECTED_FOREGROUND=178
+ # Battery pictograms going from low to high level of charge.
+ typeset -g POWERLEVEL9K_BATTERY_STAGES=('%K{232}▁' '%K{232}▂' '%K{232}▃' '%K{232}▄' '%K{232}▅' '%K{232}▆' '%K{232}▇' '%K{232}█')
+ # Don't show the remaining time to charge/discharge.
+ typeset -g POWERLEVEL9K_BATTERY_VERBOSE=false
+
+ #####################################[ wifi: wifi speed ]#####################################
+ # WiFi color.
+ typeset -g POWERLEVEL9K_WIFI_FOREGROUND=68
+ # Custom icon.
+ # typeset -g POWERLEVEL9K_WIFI_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Use different colors and icons depending on signal strength ($P9K_WIFI_BARS).
+ #
+ # # Wifi colors and icons for different signal strength levels (low to high).
+ # typeset -g my_wifi_fg=(68 68 68 68 68) # <-- change these values
+ # typeset -g my_wifi_icon=('WiFi' 'WiFi' 'WiFi' 'WiFi' 'WiFi') # <-- change these values
+ #
+ # typeset -g POWERLEVEL9K_WIFI_CONTENT_EXPANSION='%F{${my_wifi_fg[P9K_WIFI_BARS+1]}}$P9K_WIFI_LAST_TX_RATE Mbps'
+ # typeset -g POWERLEVEL9K_WIFI_VISUAL_IDENTIFIER_EXPANSION='%F{${my_wifi_fg[P9K_WIFI_BARS+1]}}${my_wifi_icon[P9K_WIFI_BARS+1]}'
+ #
+ # The following parameters are accessible within the expansions:
+ #
+ # Parameter | Meaning
+ # ----------------------+---------------
+ # P9K_WIFI_SSID | service set identifier, a.k.a. network name
+ # P9K_WIFI_LINK_AUTH | authentication protocol such as "wpa2-psk" or "none"
+ # P9K_WIFI_LAST_TX_RATE | wireless transmit rate in megabits per second
+ # P9K_WIFI_RSSI | signal strength in dBm, from -120 to 0
+ # P9K_WIFI_NOISE | noise in dBm, from -120 to 0
+ # P9K_WIFI_BARS | signal strength in bars, from 0 to 4 (derived from P9K_WIFI_RSSI and P9K_WIFI_NOISE)
+ #
+ # All parameters except P9K_WIFI_BARS are extracted from the output of the following command:
+ #
+ # /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I
+
+ ####################################[ time: current time ]####################################
+ # Current time color.
+ typeset -g POWERLEVEL9K_TIME_FOREGROUND=66
+ # Format for the current time: 09:51:02. See `man 3 strftime`.
+ typeset -g POWERLEVEL9K_TIME_FORMAT='%D{%H:%M:%S}'
+ # If set to true, time will update when you hit enter. This way prompts for the past
+ # commands will contain the start times of their commands as opposed to the default
+ # behavior where they contain the end times of their preceding commands.
+ typeset -g POWERLEVEL9K_TIME_UPDATE_ON_COMMAND=false
+ # Custom icon.
+ typeset -g POWERLEVEL9K_TIME_VISUAL_IDENTIFIER_EXPANSION=
+ # Custom prefix.
+ typeset -g POWERLEVEL9K_TIME_PREFIX='%248Fat '
+
+ # Example of a user-defined prompt segment. Function prompt_example will be called on every
+ # prompt if `example` prompt segment is added to POWERLEVEL9K_LEFT_PROMPT_ELEMENTS or
+ # POWERLEVEL9K_RIGHT_PROMPT_ELEMENTS. It displays an icon and orange text greeting the user.
+ #
+ # Type `p10k help segment` for documentation and a more sophisticated example.
+ function prompt_example() {
+ p10k segment -f 208 -i '⭐' -t 'hello, %n'
+ }
+
+ # User-defined prompt segments may optionally provide an instant_prompt_* function. Its job
+ # is to generate the prompt segment for display in instant prompt. See
+ # https://github.com/romkatv/powerlevel10k/blob/master/README.md#instant-prompt.
+ #
+ # Powerlevel10k will call instant_prompt_* at the same time as the regular prompt_* function
+ # and will record all `p10k segment` calls it makes. When displaying instant prompt, Powerlevel10k
+ # will replay these calls without actually calling instant_prompt_*. It is imperative that
+ # instant_prompt_* always makes the same `p10k segment` calls regardless of environment. If this
+ # rule is not observed, the content of instant prompt will be incorrect.
+ #
+ # Usually, you should either not define instant_prompt_* or simply call prompt_* from it. If
+ # instant_prompt_* is not defined for a segment, the segment won't be shown in instant prompt.
+ function instant_prompt_example() {
+ # Since prompt_example always makes the same `p10k segment` calls, we can call it from
+ # instant_prompt_example. This will give us the same `example` prompt segment in the instant
+ # and regular prompts.
+ prompt_example
+ }
+
+ # User-defined prompt segments can be customized the same way as built-in segments.
+ # typeset -g POWERLEVEL9K_EXAMPLE_FOREGROUND=208
+ # typeset -g POWERLEVEL9K_EXAMPLE_VISUAL_IDENTIFIER_EXPANSION='⭐'
+
+ # Transient prompt works similarly to the builtin transient_rprompt option. It trims down prompt
+ # when accepting a command line. Supported values:
+ #
+ # - off: Don't change prompt when accepting a command line.
+ # - always: Trim down prompt when accepting a command line.
+ # - same-dir: Trim down prompt when accepting a command line unless this is the first command
+ # typed after changing current working directory.
+ typeset -g POWERLEVEL9K_TRANSIENT_PROMPT=always
+
+ # Instant prompt mode.
+ #
+ # - off: Disable instant prompt. Choose this if you've tried instant prompt and found
+ # it incompatible with your zsh configuration files.
+ # - quiet: Enable instant prompt and don't print warnings when detecting console output
+ # during zsh initialization. Choose this if you've read and understood
+ # https://github.com/romkatv/powerlevel10k/blob/master/README.md#instant-prompt.
+ # - verbose: Enable instant prompt and print a warning when detecting console output during
+ # zsh initialization. Choose this if you've never tried instant prompt, haven't
+ # seen the warning, or if you are unsure what this all means.
+ typeset -g POWERLEVEL9K_INSTANT_PROMPT=verbose
+
+ # Hot reload allows you to change POWERLEVEL9K options after Powerlevel10k has been initialized.
+ # For example, you can type POWERLEVEL9K_BACKGROUND=red and see your prompt turn red. Hot reload
+ # can slow down prompt by 1-2 milliseconds, so it's better to keep it turned off unless you
+ # really need it.
+ typeset -g POWERLEVEL9K_DISABLE_HOT_RELOAD=true
+
+ # If p10k is already loaded, reload configuration.
+ # This works even with POWERLEVEL9K_DISABLE_HOT_RELOAD=true.
+ ((!$+functions[p10k])) || p10k reload
}
-(( ${#p10k_config_opts} )) && setopt ${p10k_config_opts[@]}
+((${#p10k_config_opts})) && setopt ${p10k_config_opts[@]}
'builtin' 'unset' 'p10k_config_opts'
diff --git a/run.py b/run.py
index 69fc7b5..53dc9f8 100755
--- a/run.py
+++ b/run.py
@@ -17,7 +17,7 @@
-----------------
- **Inventory**: Manage the standalone inventory git repository
- **Tool Installation**: Install Python tooling (uv, hatch) with retry logic
-- **Codebase**: Clean build artifacts, run pre-commit hooks, generate Makefile
+- **Codebase**: Clean build artifacts, run prek hooks, generate Makefile
Key Features
-----------
@@ -49,7 +49,7 @@
See Also
-------
- README.md: User-facing documentation
-- .claude/CLAUDE.md: Architecture and development guidance
+- CLAUDE.md: Architecture and development guidance
"""
from __future__ import annotations
@@ -391,12 +391,11 @@ def style(
def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
if isinstance(_color, int):
return f'{38 + offset};5;{_color:d}'
- elif isinstance(_color, (tuple, list)):
+ if isinstance(_color, (tuple, list)):
r, g, b = _color
return f'{38 + offset};2;{r:d};{g:d};{b:d}'
- else:
- _color = cast('str', _color)
- return str(_ansi_colors[_color] + offset)
+ _color = cast('str', _color)
+ return str(_ansi_colors[_color] + offset)
if not isinstance(text, str):
text = str(text)
@@ -467,7 +466,7 @@ def printf(
) -> None:
if debug and not RUN_DEBUG:
return
- elif debug:
+ if debug:
fg = fg if fg is not None else 'bright_white'
dim = dim if dim is not None else True
file = sys.stderr
@@ -644,7 +643,7 @@ def shell_command(
**kwargs,
)
except subprocess.CalledProcessError as e:
- raise ShellCommandError(e.returncode, shlex.join(cmd), output=e.output, stderr=e.stderr)
+ raise ShellCommandError(e.returncode, shlex.join(cmd), output=e.output, stderr=e.stderr) from e
except subprocess.TimeoutExpired as e:
printf(f'❌ Command timed out after {timeout}s', fg='red', bold=True, indent=indent or 0)
printf(f' └─ Command: {shlex.join(cmd)}', fg='red', indent=(indent or 0) + 1)
@@ -700,8 +699,7 @@ def shell_command(
if check and (returncode != 0):
raise ShellCommandError(returncode, shlex.join(cmd), stderr=all_output)
- else:
- return subprocess.CompletedProcess(cmd, returncode, stdout=all_output)
+ return subprocess.CompletedProcess(cmd, returncode, stdout=all_output)
def detect_ssh_error(e: subprocess.CalledProcessError) -> tuple[bool, str | None]:
@@ -745,55 +743,55 @@ def handle_command_error(e: subprocess.CalledProcessError, context: str, *, sugg
:param context: description of what operation failed
:param suggestion: optional helpful suggestion for fixing the issue
"""
- printf(f'❌ {context}', fg='red', bold=True)
- printf(f' └─ Exit code: {e.returncode}', fg='red', indent=1)
- printf(f' └─ Command: {shlex.join(e.cmd)}', fg='red', indent=1)
+ printf(f'❌ {context}', fg='red', bold=True)
+ printf(f' └─ Exit code: {e.returncode}', fg='red')
+ printf(f' └─ Command: {shlex.join(e.cmd)}', fg='red')
if e.stderr:
stderr_text = e.stderr if isinstance(e.stderr, str) else e.stderr.decode()
- printf(' └─ Error output:', fg='red', indent=1)
+ printf(' └─ Error output:', fg='red')
for line in stderr_text.strip().split('\n')[:10]:
- printf(f' {line}', fg='red', indent=1)
+ printf(f' {line}', fg='red')
# Check for SSH-specific errors and provide tailored suggestions
is_ssh_error, ssh_error_type = detect_ssh_error(e)
if is_ssh_error and ssh_error_type:
- printf(' └─ 🔑 SSH Authentication Issue Detected', fg='yellow', bold=True, indent=1)
+ printf(' └─ 🔑 SSH Authentication Issue Detected', fg='yellow', bold=True)
if ssh_error_type == 'permission_denied':
- printf(' └─ 💡 Your SSH key is not authorized for this repository', fg='yellow', indent=1)
- printf(' Try these steps:', fg='yellow', indent=1)
- printf(' 1. Check if SSH key exists: ls -la ~/.ssh/', fg='yellow', indent=1)
- printf(' 2. Generate new key if needed: ssh-keygen -t ed25519 -C "your_email@example.com"', fg='yellow', indent=1)
- printf(' 3. Add key to ssh-agent: ssh-add ~/.ssh/id_ed25519', fg='yellow', indent=1)
- printf(' 4. Add public key to GitHub: https://github.com/settings/keys', fg='yellow', indent=1)
- printf(' 5. Test connection: ssh -T git@github.com', fg='yellow', indent=1)
+ printf(' └─ 💡 Your SSH key is not authorized for this repository', fg='yellow')
+ printf(' Try these steps:', fg='yellow')
+ printf(' 1. Check if SSH key exists: ls -la ~/.ssh/', fg='yellow')
+ printf(' 2. Generate new key if needed: ssh-keygen -t ed25519 -C "email@example.com"', fg='yellow')
+ printf(' 3. Add key to ssh-agent: ssh-add ~/.ssh/id_ed25519', fg='yellow')
+ printf(' 4. Add public key to GitHub: https://github.com/settings/keys', fg='yellow')
+ printf(' 5. Test connection: ssh -T git@github.com', fg='yellow')
elif ssh_error_type == 'host_key_verification':
- printf(' └─ 💡 GitHub host key not recognized', fg='yellow', indent=1)
- printf(' Run: ssh-keyscan github.com >> ~/.ssh/known_hosts', fg='yellow', indent=1)
+ printf(' └─ 💡 GitHub host key not recognized', fg='yellow')
+ printf(' Run: ssh-keyscan github.com >> ~/.ssh/known_hosts', fg='yellow')
elif ssh_error_type in ('no_identities', 'key_load_failed'):
- printf(' └─ 💡 SSH key could not be loaded', fg='yellow', indent=1)
- printf(' Run: ssh-add ~/.ssh/id_ed25519 (or your key path)', fg='yellow', indent=1)
+ printf(' └─ 💡 SSH key could not be loaded', fg='yellow')
+ printf(' Run: ssh-add ~/.ssh/id_ed25519 (or your key path)', fg='yellow')
elif ssh_error_type == 'connection_refused':
- printf(' └─ 💡 SSH connection refused', fg='yellow', indent=1)
- printf(' Check network connectivity and firewall settings', fg='yellow', indent=1)
+ printf(' └─ 💡 SSH connection refused', fg='yellow')
+ printf(' Check network connectivity and firewall settings', fg='yellow')
elif ssh_error_type == 'connection_timeout':
- printf(' └─ 💡 SSH connection timed out', fg='yellow', indent=1)
- printf(' Check network connectivity and try again', fg='yellow', indent=1)
+ printf(' └─ 💡 SSH connection timed out', fg='yellow')
+ printf(' Check network connectivity and try again', fg='yellow')
elif ssh_error_type == 'unknown_host':
- printf(' └─ 💡 Could not resolve hostname', fg='yellow', indent=1)
- printf(' Check your internet connection and DNS settings', fg='yellow', indent=1)
+ printf(' └─ 💡 Could not resolve hostname', fg='yellow')
+ printf(' Check your internet connection and DNS settings', fg='yellow')
else: # generic SSH error
- printf(' └─ 💡 Try testing SSH connection: ssh -T git@github.com', fg='yellow', indent=1)
+ printf(' └─ 💡 Try testing SSH connection: ssh -T git@github.com', fg='yellow')
elif suggestion:
- printf(f' └─ 💡 {suggestion}', fg='yellow', indent=1)
+ printf(f' └─ 💡 {suggestion}', fg='yellow')
"""
@@ -1372,7 +1370,7 @@ def cmd_install_hatch(args: argparse.Namespace) -> None:
General repository maintenance commands:
- clean: Remove build artifacts, caches, and temporary files
-- pre: Run pre-commit hooks on all files
+- pre: Run prek hooks on all files
- makefile: Generate Makefile targets from @command-registered functions
- Parses all registered commands and creates equivalent Make targets
- Organizes targets by command group
@@ -1410,11 +1408,37 @@ def cmd_clean(args: argparse.Namespace) -> None:
@command(group='Codebase')
def cmd_pre(args: argparse.Namespace) -> None:
"""
- Run pre-commit hooks on all project files
+ Run prek (pre-commit) hooks on all project files
+ """
+ printf('🕝 Running prek hooks on all project files...', fg='bright_cyan')
+ shell_command(
+ ['prek', 'run', '--all-files'],
+ indent=3,
+ check=False,
+ )
+
+
+@command(group='Codebase')
+def cmd_install_hooks(args: argparse.Namespace) -> None:
+ """
+ Install prek git hook shims (overwrites any existing shims)
+ """
+ printf('🪝 Installing prek git hook shims...', fg='bright_cyan')
+ shell_command(
+ ['prek', 'install', '-f'],
+ indent=3,
+ check=False,
+ )
+
+
+@command(group='Codebase')
+def cmd_uninstall_hooks(args: argparse.Namespace) -> None:
+ """
+ Uninstall prek git hook shims
"""
- printf('🕝 Running pre-commit hooks on all project files...', fg='bright_cyan')
+ printf('🪝 Uninstalling prek git hook shims...', fg='bright_cyan')
shell_command(
- ['pre-commit', 'run', '--all-files'],
+ ['prek', 'uninstall'],
indent=3,
check=False,
)
diff --git a/scripts/env/README.md b/scripts/env/README.md
new file mode 100644
index 0000000..6b48b38
--- /dev/null
+++ b/scripts/env/README.md
@@ -0,0 +1,266 @@
+[Project Root](../../README.md) > [Scripts](../README.md) > **Env Scripts**
+
+---
+
+# Env Scripts
+
+Standalone Python utilities for inspecting, loading, and entering Python virtual environments. All three scripts are
+zero-dependency where possible (standard library only) or declare their dependencies inline via PEP 723 script metadata,
+making them safe to run without a pre-activated environment.
+
+
+
+- [Scripts](#scripts)
+- [`loadenv.py`](#loadenvpy)
+ - [Synopsis](#synopsis)
+ - [Options](#options)
+ - [Behavior](#behavior)
+ - [Exit Codes](#exit-codes)
+ - [Usage Examples](#usage-examples)
+- [`python_diagnostic.py`](#python_diagnosticpy)
+ - [Synopsis](#synopsis-1)
+ - [Options](#options-1)
+ - [Behavior](#behavior-1)
+ - [Exit Codes](#exit-codes-1)
+ - [Usage Examples](#usage-examples-1)
+- [`venvshell.py`](#venvshellpy)
+ - [Synopsis](#synopsis-2)
+ - [Options](#options-2)
+ - [Behavior](#behavior-2)
+ - [Exit Codes](#exit-codes-2)
+ - [Usage Examples](#usage-examples-2)
+
+
+
+## Scripts
+
+| Script | What it does | Typical usage |
+| ---------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------- |
+| [`loadenv.py`](#loadenvpy) | Parse `.env` files; emit JSON, shell exports, or key-value pairs | `eval "$(python3 loadenv.py --export .env)"` |
+| [`python_diagnostic.py`](#python_diagnosticpy) | Collect a full Python-environment diagnostic report | `python3 python_diagnostic.py` |
+| [`venvshell.py`](#venvshellpy) | Spawn an interactive subshell with a virtualenv activated | `python3 venvshell.py` |
+
+---
+
+## `loadenv.py`
+
+### Synopsis
+
+Standalone `.env` file parser. Parses a dotenv file and writes its contents to stdout in one of three formats. Parser
+logic is vendored from `ezcli.dotenv` v1.6.13 and has no external dependencies.
+
+```
+python3 scripts/env/loadenv.py [OPTIONS] [FILE]
+```
+
+If `FILE` is omitted the script walks from the current working directory upward until it finds a `.env` file.
+
+### Options
+
+| Flag | Description | Default |
+| ------------------ | ---------------------------------------------------------------------- | ----------------------------- |
+| `FILE` | Path to `.env` file | auto-discover from CWD upward |
+| `-j`, `--json` | Output as JSON object | yes (default mode) |
+| `-e`, `--export` | Output as `export KEY="value"` statements | — |
+| `-p`, `--pairs` | Output as `KEY=value` pairs | — |
+| `--override` | `.env` values override existing env vars during `${VAR}` interpolation | off |
+| `--no-interpolate` | Disable `${VAR}` variable expansion | interpolation on |
+| `-q`, `--quiet` | Suppress warnings and errors to stderr | off |
+| `-v`, `--version` | Print version and exit | — |
+| `-h`, `--help` | Show help and exit | — |
+
+### Behavior
+
+1. Resolves the target file (explicit path or CWD-upward walk).
+2. Parses each line into key-value `Binding` objects, handling single-quoted, double-quoted, and unquoted values, inline
+ comments, `export` prefixes, and blank lines.
+3. Optionally resolves `${NAME}` and `${NAME:-default}` variable references against already-seen dotenv values and
+ `os.environ`.
+4. Formats and writes the result to stdout.
+
+### Exit Codes
+
+| Code | Meaning |
+| ---- | -------------------------------------------------- |
+| `0` | Success (no parse errors) |
+| `1` | File not found or could not be opened |
+| `2` | Success, but one or more lines could not be parsed |
+
+### Usage Examples
+
+```bash
+# Source variables into the current shell
+eval "$(python3 scripts/env/loadenv.py --export .env)"
+
+# Inspect parsed values as JSON
+python3 scripts/env/loadenv.py --json .env
+
+# Emit raw KEY=value pairs (useful for piping into other tools)
+python3 scripts/env/loadenv.py --pairs .env
+
+# Disable variable interpolation
+python3 scripts/env/loadenv.py --no-interpolate .env
+
+# Suppress parse warnings while still failing on missing file
+python3 scripts/env/loadenv.py -q .env || echo "file not found"
+```
+
+---
+
+## `python_diagnostic.py`
+
+### Synopsis
+
+Collects and formats a comprehensive Python-environment diagnostic report covering system info, all Python executables
+on `PATH`, virtual-environment state, package managers (`pip`, `uv`, `hatch`), key environment variables, `sys.path`,
+common installation locations, and installed packages.
+
+```
+python3 scripts/env/python_diagnostic.py [OPTIONS]
+```
+
+### Options
+
+| Flag | Description | Default |
+| ---------------------------------- | ----------------------------------------------------- | ----------------------------------- |
+| `--max-installed-packages NUM` | Truncate the installed-packages list to `NUM` entries | no limit |
+| `-s`, `--save` | Save output to a timestamped file instead of printing | off |
+| `-j`, `--json` | Output raw JSON instead of formatted text | off |
+| `-o FILEPATH`, `--output FILEPATH` | Explicit output file path (implies `--save`) | `python_diagnostic_.txt` |
+| `-h`, `--help` | Show help and exit | — |
+
+### Behavior
+
+The script runs a collection phase followed by a formatting/output phase.
+
+**Collection phase** — `collect_diagnostics()` gathers:
+
+- `system` — `platform.platform()`, `sw_vers`, `uname -a`, Python version and implementation
+- `python_executables` — resolves `python`, `python3`, `python3.9`–`python3.13` from `PATH` via `shutil.which`
+- `virtual_env` — `VIRTUAL_ENV`, `CONDA_DEFAULT_ENV`, `sys.prefix` / `sys.base_prefix` / `sys.real_prefix`
+- `package_managers` — pip path/version, `uv` path/version/managed-Python list (`uv python list --json`), hatch
+ path/version/environments (`hatch env show --json`)
+- `environment_variables` — a curated allowlist of non-sensitive vars (e.g. `PYTHONPATH`, `UV_INDEX_URL`, `PATH`)
+- `python_paths` — `sys.executable`, `sys.path`, site-packages entries
+- `common_locations` — existence/symlink check for system Python, Homebrew, pyenv, `~/.local/bin`, etc.
+- `installed_packages` — `uv pip list --format=json` (falls back to `pip list`)
+
+**Output phase** — either `format_diagnostic_output()` (human-readable text) or `json.dumps()` (structured JSON),
+written to stdout or a file.
+
+### Exit Codes
+
+| Code | Meaning |
+| ---- | ------------------------------------------------------------------- |
+| `0` | Always (diagnostic failures are reported inline, not as exit codes) |
+
+### Usage Examples
+
+```bash
+# Print to stdout (default)
+python3 scripts/env/python_diagnostic.py
+
+# Limit installed package listing to avoid wall-of-text
+python3 scripts/env/python_diagnostic.py --max-installed-packages 20
+
+# Save full JSON report for sharing or diffing
+python3 scripts/env/python_diagnostic.py --json --output /tmp/pydiag.json
+
+# Auto-timestamped text file
+python3 scripts/env/python_diagnostic.py --save
+# => Saves to python_diagnostic_20240101_120000.txt
+```
+
+---
+
+## `venvshell.py`
+
+### Synopsis
+
+Spawns an interactive subshell with a Python virtual environment activated. Supports `bash`, `zsh`, and `fish`. Handles
+shell history preservation for zsh (works around `ZDOTDIR` override side effects), temp-dir cleanup via `atexit`, and
+fish `activate.fish` sourcing. Also supports hatch-managed environments via `hatch env find`.
+
+```
+python3 scripts/env/venvshell.py [OPTIONS] [PATH]
+```
+
+### Options
+
+#### Environment selection
+
+| Flag | Description | Env var override | Default |
+| ------------- | -------------------------------------------------------- | --------------------- | ------- |
+| `PATH` | Positional path to venv directory | `VENVSHELL_PATH` | — |
+| `--path PATH` | Path to venv directory (named alternative to positional) | `VENVSHELL_PATH` | — |
+| `--hatch ENV` | Hatch environment name | `VENVSHELL_HATCH_ENV` | — |
+
+#### Environment discovery (when no explicit path given)
+
+| Flag | Description | Env var override | Default |
+| ------------------------------------------ | -------------------------------------------- | -------------------------- | ------- |
+| `--discover` / `--no-discover` | Enable/disable auto-discovery | `VENVSHELL_DISCOVER` | `True` |
+| `--discover-hatch` / `--no-discover-hatch` | Include hatch environments in auto-discovery | `VENVSHELL_DISCOVER_HATCH` | `True` |
+
+#### Output
+
+| Flag | Description |
+| ----------------- | -------------------------------------------------------------------------- |
+| `-v`, `--verbose` | Print debug messages to stderr (discovery steps, shell args, env vars set) |
+| `-h`, `--help` | Show help and exit |
+
+### Behavior
+
+**Resolution order** (first match wins):
+
+1. Explicit `--path PATH` or positional `PATH` argument
+2. Explicit `--hatch ENV` (runs `hatch env find ENV`)
+3. Auto-discovered `.venv/` or `venv/` directory (walks up from CWD)
+4. Auto-discovered default hatch environment (`hatch env find`)
+
+**Shell activation** — launches via `subprocess.run` (not `os.execvpe`) so `atexit` cleanup of temp directories runs on
+exit:
+
+- **bash** — writes a temp `.bashrc` that sources the user's real dotfiles then `source activate`
+- **zsh** — creates a temp `ZDOTDIR`, symlinks all non-`.zshrc` dotfiles from the real `ZDOTDIR`, generates a custom
+ `.zshrc` that sources the real `.zshrc`, activates the venv, then hard-codes `HISTFILE` to the resolved real history
+ file path (prevents history loss when `ZDOTDIR` is overridden)
+- **fish** — passes `-C "source activate.fish"` to the shell process
+- **other** — activates via env vars only (`VIRTUAL_ENV`, `PATH` prepend) with a warning
+
+**Guard rails** — if `VIRTUAL_ENV` is already set, the script exits with a warning rather than nesting environments.
+
+### Exit Codes
+
+| Code | Meaning |
+| ---- | -------------------------------------------------------------------------- |
+| `0` | Venv activated and subshell exited normally, or environment already active |
+| `1` | Existing (different) environment already active |
+| `64` | Invalid arguments (`EX_USAGE`) |
+| `65` | Venv directory is broken/invalid (`EX_DATAERR`) |
+| `69` | No virtual environment found (`EX_UNAVAILABLE`) |
+| `90` | Shell activation failed |
+
+### Usage Examples
+
+```bash
+# Auto-discover venv in current project
+python3 scripts/env/venvshell.py
+
+# Explicitly specify a venv path
+python3 scripts/env/venvshell.py .venv
+python3 scripts/env/venvshell.py --path /path/to/project/.venv
+
+# Use a hatch-managed environment
+python3 scripts/env/venvshell.py --hatch default
+
+# Disable hatch discovery fallback (only look for .venv/ or venv/ dirs)
+python3 scripts/env/venvshell.py --no-discover-hatch
+
+# Debug discovery steps
+python3 scripts/env/venvshell.py --verbose
+
+# Hard-code default venv path via environment variable (e.g. in shell profile)
+export VENVSHELL_PATH=~/projects/myapp/.venv
+python3 scripts/env/venvshell.py
+```
diff --git a/scripts/env/loadenv.py b/scripts/env/loadenv.py
index 3e630b1..bc1287b 100755
--- a/scripts/env/loadenv.py
+++ b/scripts/env/loadenv.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-Standalone dotenv file parser and environment variable loader
+Standalone dotenv file parser and environment variable loader.
Parses .env files and outputs values in JSON, shell export, or key-value pair format
@@ -43,16 +43,9 @@
PathLike = Union[str, Path]
-class Error(Exception):
- """
- Parsing error raised when a regex match or read operation fails within the :class:`Reader`
- """
- ...
-
-
-# =======
-# Regexes
-# =======
+# ==================================================================================================
+# Vendored from ezcli.dotenv v1.6.13 (parser.py)
+# ==================================================================================================
_newline = re.compile(r'(\r\n|\n|\r)', re.UNICODE)
_multiline_whitespace = re.compile(r'\s*', re.UNICODE | re.MULTILINE)
@@ -70,27 +63,17 @@ class Error(Exception):
_double_quote_escapes = re.compile(r"\\[\\'\"abfnrtv]", re.UNICODE)
_single_quote_escapes = re.compile(r"\\[\\']", re.UNICODE)
-_posix_variable = re.compile(
- r"""
- \$\{
- (?P[^\}:]*)
- (?::-
- (?P[^\}]*)
- )?
- \}
- """,
- re.VERBOSE,
-)
-
-# =======
-# Parsing
-# =======
+class Error(Exception):
+ """
+ Parsing error raised when a regex match or read operation fails within the :class:`Reader`.
+ """
+ pass
class Original(NamedTuple):
"""
- The original text of a parsed line and its starting line number
+ The original text of a parsed line and its starting line number.
Preserves the raw string content so that lines can be rewritten verbatim when modifying a `.env` file
"""
@@ -100,7 +83,7 @@ class Original(NamedTuple):
class Binding(NamedTuple):
"""
- A single parsed key-value binding from a `.env` file
+ A single parsed key-value binding from a `.env` file.
Represents one logical line of a dotenv file after parsing. Lines that could not be parsed have `error=True`
with `key` and `value` set to `None`
@@ -113,7 +96,7 @@ class Binding(NamedTuple):
class Position:
"""
- Tracks the current character offset and line number within a dotenv source string
+ Tracks the current character offset and line number within a dotenv source string.
Used by :class:`Reader` to maintain cursor state as the parser advances through the input
@@ -127,7 +110,7 @@ def __init__(self, chars: int, line: int) -> None:
@classmethod
def start(cls) -> Position:
"""
- Create a position representing the beginning of a source string
+ Create a position representing the beginning of a source string.
:return: a new :class:`Position` at character 0, line 1
"""
@@ -135,7 +118,7 @@ def start(cls) -> Position:
def set(self, other: Position) -> None:
"""
- Copy the character offset and line number from another position
+ Copy the character offset and line number from another position.
:param other: the position to copy from
"""
@@ -144,7 +127,7 @@ def set(self, other: Position) -> None:
def advance(self, string: str) -> None:
"""
- Advance the position by the length of the given string, counting newlines
+ Advance the position by the length of the given string, counting newlines.
:param string: the text that was consumed from the source
"""
@@ -154,7 +137,7 @@ def advance(self, string: str) -> None:
class Reader:
"""
- Buffered reader that tokenizes a dotenv source string using regex-based pattern matching
+ Buffered reader that tokenizes a dotenv source string using regex-based pattern matching.
Reads the entire stream into memory, then provides methods to consume characters and match regex patterns while
tracking the current :class:`Position`. Supports marking regions of text so that the original raw content can be
@@ -169,7 +152,7 @@ def __init__(self, stream: IO[str]) -> None:
def has_next(self) -> bool:
"""
- Check if there are remaining characters to read
+ Check if there are remaining characters to read.
:return: ``True`` if the cursor has not reached the end of the string
"""
@@ -177,13 +160,13 @@ def has_next(self) -> bool:
def set_mark(self) -> None:
"""
- Save the current position as a mark for later retrieval via :meth:`get_marked`
+ Save the current position as a mark for later retrieval via :meth:`get_marked`.
"""
self.mark.set(self.position)
def get_marked(self) -> Original:
"""
- Return the text between the last mark and the current position as an :class:`Original`
+ Return the text between the last mark and the current position as an :class:`Original`.
:return: the raw substring and starting line number since :meth:`set_mark` was last called
"""
@@ -194,7 +177,7 @@ def get_marked(self) -> Original:
def peek(self, count: int) -> str:
"""
- Return upcoming characters without advancing the position
+ Return upcoming characters without advancing the position.
:param count: number of characters to peek at
:return: up to `count` characters from the current position
@@ -203,7 +186,7 @@ def peek(self, count: int) -> str:
def read(self, count: int) -> str:
"""
- Consume and return the next `count` characters, advancing the position
+ Consume and return the next `count` characters, advancing the position.
:param count: number of characters to read
:raises Error: if fewer than `count` characters remain
@@ -217,7 +200,7 @@ def read(self, count: int) -> str:
def read_regex(self, regex: re.Pattern[str]) -> Sequence[str]:
"""
- Match a regex at the current position, consume the matched text, and return capture groups
+ Match a regex at the current position, consume the matched text, and return capture groups.
:param regex: compiled regex pattern to match at the current cursor position
:raises Error: if the pattern does not match at the current position
@@ -232,7 +215,7 @@ def read_regex(self, regex: re.Pattern[str]) -> Sequence[str]:
def parse_key(reader: Reader) -> str | None:
"""
- Parse a dotenv key from the current reader position
+ Parse a dotenv key from the current reader position.
Handles both single-quoted keys (e.g. ``"MY KEY"``) and unquoted keys. Lines starting with "#" are treated as
comments and return `None`
@@ -243,16 +226,29 @@ def parse_key(reader: Reader) -> str | None:
char = reader.peek(1)
if char == '#':
return None
- elif char == "'":
+ if char == "'":
key, *_ = reader.read_regex(_single_quoted_key)
else:
key, *_ = reader.read_regex(_unquoted_key)
return key
-def parse_value(reader: Reader) -> str:
+def _decode_escapes(regex: re.Pattern[str], string: str) -> str:
"""
- Parse a dotenv value from the current reader position
+ Replace backslash escape sequences matched by the given regex with their decoded Unicode equivalents.
+
+ :param regex: compiled pattern matching escape sequences to decode
+ :param string: the string containing escape sequences
+ :return: string with matched escape sequences decoded
+ """
+ def decode_match(match: re.Match[str]) -> str:
+ return codecs.decode(match.group(0), 'unicode-escape')
+ return regex.sub(decode_match, string)
+
+
+def parse_value(reader: Reader) -> str:
+ r"""
+ Parse a dotenv value from the current reader position.
Handles single-quoted values (with ``\\'`` escapes), double-quoted values (with standard backslash escapes),
unquoted values (trimming inline comments and trailing whitespace), and empty values
@@ -260,28 +256,22 @@ def parse_value(reader: Reader) -> str:
:param reader: the :class:`Reader` positioned at the start of a value (after the "=")
:return: the parsed and unescaped value string
"""
- def decode_escapes(regex: re.Pattern[str], string: str) -> str:
- def decode_match(match: re.Match[str]) -> str:
- return codecs.decode(match.group(0), 'unicode-escape')
- return regex.sub(decode_match, string)
-
char = reader.peek(1)
if char == "'":
value, *_ = reader.read_regex(_single_quoted_value)
- return decode_escapes(_single_quote_escapes, value)
- elif char == '"':
+ return _decode_escapes(_single_quote_escapes, value)
+ if char == '"':
value, *_ = reader.read_regex(_double_quoted_value)
- return decode_escapes(_double_quote_escapes, value)
- elif char in ('', '\n', '\r'):
+ return _decode_escapes(_double_quote_escapes, value)
+ if char in ('', '\n', '\r'):
return ''
- else:
- part, *_ = reader.read_regex(_unquoted_value)
- return re.sub(r'\s+#.*', '', part).rstrip()
+ part, *_ = reader.read_regex(_unquoted_value)
+ return re.sub(r'\s+#.*', '', part).rstrip()
def parse_binding(reader: Reader) -> Binding:
"""
- Parse a single key-value binding from the current reader position
+ Parse a single key-value binding from the current reader position.
Consumes one logical line of dotenv content including leading whitespace, optional ``export`` prefix, key,
equals sign, value, inline comment, and line ending. If parsing fails, the remainder of the line is consumed
@@ -331,7 +321,7 @@ def parse_binding(reader: Reader) -> Binding:
def parse_stream(stream: IO[str]) -> Iterator[Binding]:
"""
- Parse an entire dotenv stream into a sequence of :class:`Binding` objects
+ Parse an entire dotenv stream into a sequence of :class:`Binding` objects.
Reads the stream and yields one :class:`Binding` per logical line until all content has been consumed
@@ -343,19 +333,32 @@ def parse_stream(stream: IO[str]) -> Iterator[Binding]:
yield parse_binding(reader)
-# ===============
-# Variables (AST)
-# ===============
+# ==================================================================================================
+# Vendored from ezcli.dotenv v1.6.13 (variables.py)
+# ==================================================================================================
+
+
+_posix_variable: re.Pattern[str] = re.compile(
+ r"""
+ \$\{
+ (?P[^\}:]*)
+ (?::-
+ (?P[^\}]*)
+ )?
+ \}
+ """,
+ re.VERBOSE,
+)
class Atom(metaclass=abc.ABCMeta):
"""
- Abstract base class for parsed components of a dotenv value string
+ Abstract base class for parsed components of a dotenv value string.
Each atom represents either a literal text segment or a variable reference that can be resolved against an
environment mapping
"""
- def __ne__(self, other: Any) -> bool:
+ def __ne__(self, other: object) -> bool:
result = self.__eq__(other)
if result is NotImplemented:
return NotImplemented
@@ -364,7 +367,7 @@ def __ne__(self, other: Any) -> bool:
@abc.abstractmethod
def resolve(self, env: Mapping[str, str | None]) -> str:
"""
- Resolve this atom to a concrete string value
+ Resolve this atom to a concrete string value.
:param env: environment mapping of variable names to their values
:return: the resolved string value
@@ -374,7 +377,7 @@ def resolve(self, env: Mapping[str, str | None]) -> str:
class Literal(Atom):
"""
- A literal text segment within a dotenv value
+ A literal text segment within a dotenv value.
Represents a portion of a value string that contains no variable references and resolves to its stored text
verbatim
@@ -387,18 +390,17 @@ def __init__(self, value: str) -> None:
def __repr__(self) -> str:
return f'Literal(value={self.value})'
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return self.value == other.value
+
def __hash__(self) -> int:
return hash((self.__class__, self.value))
- def __eq__(self, other: Any) -> bool:
- if isinstance(other, self.__class__):
- return self.value == other.value
- else:
- return NotImplemented
-
def resolve(self, env: Mapping[str, str | None]) -> str:
"""
- Return the literal value unchanged
+ Return the literal value unchanged.
:param env: environment mapping (unused for literal)
:return: the stored literal text
@@ -408,7 +410,7 @@ def resolve(self, env: Mapping[str, str | None]) -> str:
class Variable(Atom):
"""
- A POSIX-style variable reference within a dotenv value
+ A POSIX-style variable reference within a dotenv value.
Represents a ``${NAME}`` or ``${NAME:-default}`` reference that resolves by looking up the variable name in the
provided environment mapping, falling back to the default value when the variable is not found
@@ -423,21 +425,20 @@ def __init__(self, name: str, default: str | None) -> None:
def __repr__(self) -> str:
return f'Variable(name={self.name}, default={self.default})'
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, self.__class__):
+ return NotImplemented
+ return (self.name, self.default) == (other.name, other.default)
+
def __hash__(self) -> int:
return hash((self.__class__, self.name, self.default))
- def __eq__(self, other: Any) -> bool:
- if isinstance(other, self.__class__):
- return (self.name, self.default) == (other.name, other.default)
- else:
- return NotImplemented
-
def resolve(self, env: Mapping[str, str | None]) -> str:
"""
- Resolve the variable reference against the given environment
+ Resolve the variable reference against the given environment.
- Looks up :attr:`name` in `env`. If not found, or the value is `None`, falls back to :attr:`default`
- (or an empty string if no default was specified)
+ Looks up :attr:`name` in `env`. If not found, or the value is `None`, falls back to :attr:`default` (or an
+ empty string if no default was specified)
:param env: environment mapping of variable names to their values
:return: the resolved value from the environment, the default, or an empty string
@@ -449,7 +450,7 @@ def resolve(self, env: Mapping[str, str | None]) -> str:
def parse_variables(value: str) -> Iterator[Atom]:
"""
- Parse a dotenv value string into a sequence of :class:`Atom` nodes
+ Parse a dotenv value string into a sequence of :class:`Atom` nodes.
Scans for POSIX-style ``${NAME}`` and ``${NAME:-default}`` variable references, yielding :class:`Variable` nodes
for each match and :class:`Literal` nodes for the text between them
@@ -473,19 +474,19 @@ def parse_variables(value: str) -> Iterator[Atom]:
yield Literal(value=value[cursor:length])
-# ==========
-# Resolution
-# ==========
+# ==================================================================================================
+# Vendored from ezcli.dotenv v1.6.13 (main.py)
+# ==================================================================================================
def resolve_variables(values: Iterable[tuple[str, str | None]], override: bool) -> Mapping[str, str | None]:
"""
- Resolve ``${VAR}``-style variable references across a sequence of key-value pairs
+ Resolve ``${VAR}``-style variable references across a sequence of key-value pairs.
Processes values in order, building up a mapping of resolved names. Each value's variable references are resolved
against the combination of previously resolved values and :attr:`os.environ`.
- When `override=True`, :attr:`os.environ` takes lower precedence than already-resolved dotenv values.
+ When `override=True`, :attr:`os.environ` takes lower precedence than already-resolved dotenv values.
When `override=False`, :attr:`os.environ` values take priority
:param values: iterable of ``(key, value)`` tuples with unresolved variable references
@@ -509,13 +510,12 @@ def resolve_variables(values: Iterable[tuple[str, str | None]], override: bool)
result = ''.join(atom.resolve(env) for atom in atoms)
new_values[name] = result
-
return new_values
def _walk_to_root(path: PathLike) -> Iterator[Path]:
"""
- Yield directories starting from the given path up to the filesystem root
+ Yield directories starting from the given path up to the filesystem root.
If `path` points to a file, iteration begins from its parent directory. Each iteration yields the next parent
directory until the root is reached
@@ -541,7 +541,7 @@ def _walk_to_root(path: PathLike) -> Iterator[Path]:
def find_dotenv(filename: str = '.env', *, raise_error_if_not_found: bool = False) -> str:
"""
- Search from CWD upward for the given dotenv file
+ Search from CWD upward for the given dotenv file.
:param filename: the dotenv filename to search for
:param raise_error_if_not_found: whether to raise :class:`OSError` if the file is not found
@@ -566,7 +566,7 @@ def dotenv_values(
quiet: bool = False,
) -> tuple[dict[str, str | None], bool]:
"""
- Parse a .env file and return (values_dict, has_errors)
+ Parse a .env file and return (values_dict, has_errors).
:param dotenv_path: absolute or relative path to the `.env` file
:param interpolate: whether to resolve ``${VAR}``-style variable references in values
@@ -592,13 +592,12 @@ def dotenv_values(
if interpolate:
return dict(resolve_variables(raw_values, override=override)), has_errors
- else:
- return dict(raw_values), has_errors
+ return dict(raw_values), has_errors
-# =================
-# Output formatting
-# =================
+# ==================================================================================================
+# Output formatters
+# ==================================================================================================
OutputFormat = LiteralStr['json', 'export', 'pairs']
@@ -606,18 +605,17 @@ def dotenv_values(
def _shell_escape(value: str) -> str:
"""
- Escape a value for safe use inside double-quoted shell strings
+ Escape a value for safe use inside double-quoted shell strings.
"""
value = value.replace('\\', '\\\\')
value = value.replace('"', '\\"')
value = value.replace('$', '\\$')
- value = value.replace('`', '\\`')
- return value
+ return value.replace('`', '\\`')
def format_json(values: dict[str, str | None], **kwargs: Any) -> str:
"""
- Format parsed values as a JSON object
+ Format parsed values as a JSON object.
"""
return json.dumps(
values,
@@ -628,10 +626,11 @@ def format_json(values: dict[str, str | None], **kwargs: Any) -> str:
def format_export(values: dict[str, str | None]) -> str:
"""
- Format parsed values as shell export statements
+ Format parsed values as shell export statements.
"""
lines: list[str] = [
- f'export {key}="{_shell_escape(val)}"' for key, val in values.items()
+ f'export {key}="{_shell_escape(val)}"'
+ for key, val in values.items()
if val is not None
]
return '\n'.join(lines) + '\n' if lines else ''
@@ -639,10 +638,11 @@ def format_export(values: dict[str, str | None]) -> str:
def format_pairs(values: dict[str, str | None]) -> str:
"""
- Format parsed values as KEY=value pairs
+ Format parsed values as KEY=value pairs.
"""
lines: list[str] = [
- key if val is None else f'{key}={val}' for key, val in values.items()
+ key if val is None else f'{key}={val}'
+ for key, val in values.items()
]
return '\n'.join(lines) + '\n' if lines else ''
@@ -654,25 +654,25 @@ def format_pairs(values: dict[str, str | None]) -> str:
}
-# ============================================
-# Argument parsing and core script entry point
-# ============================================
+# ==================================================================================================
+# Script CLI entry point
+# ==================================================================================================
class Args(argparse.Namespace):
"""
- Annotated :class:`argparse.Namespace` for script command-line arguments, returned by :func:`parse_args`
+ Annotated :class:`argparse.Namespace` returned by :func:`parse_args`.
"""
- file: Path | None # positional script arg
- mode: OutputFormat # --json/--export/--pairs
- override: bool # --override
- no_interpolate: bool # --no-interpolate
- quiet: bool # --quiet
+ file: Path | None # positional script arg
+ mode: OutputFormat # --json/--export/--pairs
+ override: bool # --override
+ interpolate: bool # --no-interpolate
+ quiet: bool # --quiet
def parse_args(argv: Sequence[str] | None = None) -> Args:
"""
- Parse command-line arguments
+ Parse command-line arguments.
:param argv: argument list to parse, defaults to sys.argv[1:]
:return: parsed namespace (:class:`Args`)
@@ -687,10 +687,13 @@ def parse_args(argv: Sequence[str] | None = None) -> Args:
' %(prog)s --pairs .env Output as KEY=value pairs\n'
' eval "$(%(prog)s --export .env)" Source into current shell\n'
),
- add_help=False
+ add_help=False,
)
def add_positional_args() -> None:
+ """
+ Register the positional arguments group on the parser.
+ """
parser.add_argument(
'file',
nargs='?',
@@ -699,10 +702,14 @@ def add_positional_args() -> None:
metavar='FILE',
help='path to .env file (default: auto-discover from current working directory)',
)
+ add_positional_args()
- def add_mode_opts() -> None:
- mode_opts = parser.add_argument_group('output format options')
- mode_opts.add_argument(
+ def add_output_format_opts() -> None:
+ """
+ Register the output format options group on the parser.
+ """
+ output_format_opts = parser.add_argument_group('output format options')
+ output_format_opts.add_argument(
'-j',
'--json',
dest='mode',
@@ -711,7 +718,7 @@ def add_mode_opts() -> None:
default='json',
help='output as JSON object (default)',
)
- mode_opts.add_argument(
+ output_format_opts.add_argument(
'-e',
'--export',
dest='mode',
@@ -719,7 +726,7 @@ def add_mode_opts() -> None:
const='export',
help='output as export KEY="value" statements',
)
- mode_opts.add_argument(
+ output_format_opts.add_argument(
'-p',
'--pairs',
dest='mode',
@@ -727,8 +734,12 @@ def add_mode_opts() -> None:
const='pairs',
help='output as KEY=value pairs',
)
+ add_output_format_opts()
def add_resolution_opts() -> None:
+ """
+ Register the variable resolution options group on the parser.
+ """
resolution_opts = parser.add_argument_group('variable resolution options')
resolution_opts.add_argument(
'--override',
@@ -743,8 +754,12 @@ def add_resolution_opts() -> None:
default=True,
help='disable ${VAR} variable expansion',
)
+ add_resolution_opts()
def add_other_opts() -> None:
+ """
+ Register the miscellaneous options group on the parser.
+ """
other_opts = parser.add_argument_group('other options')
other_opts.add_argument(
'-q',
@@ -767,10 +782,6 @@ def add_other_opts() -> None:
default=argparse.SUPPRESS,
help='show this help message and exit',
)
-
- add_positional_args()
- add_mode_opts()
- add_resolution_opts()
add_other_opts()
return parser.parse_args(argv, namespace=Args())
@@ -778,13 +789,15 @@ def add_other_opts() -> None:
def main(argv: Sequence[str] | None = None) -> int:
"""
- Standalone dotenv file parser and environment variable loader
+ Standalone dotenv file parser and environment variable loader.
Parses .env files and outputs values in JSON, shell export, or key-value pair format
:param argv: argument list to parse, defaults to sys.argv[1:]
:return: int script exit code
"""
+
+ # Parse command-line arguments
args = parse_args(argv)
# Resolve file path
diff --git a/scripts/env/python_diagnostic.py b/scripts/env/python_diagnostic.py
index 2d2667c..e1030b1 100755
--- a/scripts/env/python_diagnostic.py
+++ b/scripts/env/python_diagnostic.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-Python Environment Diagnostic Tool
+Python Environment Diagnostic Tool.
This script collects comprehensive information about a user's Python environment to help diagnose installation and
configuration issues.
@@ -58,10 +58,16 @@
class ErrorDict(TypedDict):
+ """
+ Error information from a failed command.
+ """
error: str
class RunCommandResult(TypedDict, total=False):
+ """
+ Result of running a subprocess command.
+ """
stdout: str
stderr: str
returncode: int
@@ -71,7 +77,7 @@ class RunCommandResult(TypedDict, total=False):
def run_command(cmd: list[str], **kwargs: Any) -> RunCommandResult:
"""
- Run a command and return output, error, and return code
+ Run a command and return output, error, and return code.
:param cmd: command to run as list of arguments
:return: dict with stdout, stderr, returncode, and error flag
@@ -106,6 +112,9 @@ def run_command(cmd: list[str], **kwargs: Any) -> RunCommandResult:
class PythonExecutable(TypedDict):
+ """
+ Information about a discovered Python executable.
+ """
path: str
real_path: str
version: str
@@ -114,7 +123,7 @@ class PythonExecutable(TypedDict):
def get_python_executables() -> dict[str, PythonExecutable]:
"""
- Find all Python executables in PATH
+ Find all Python executables in PATH.
:return: mapping from executable name -> path/version info (:class:`PythonExecutable`)
"""
@@ -138,24 +147,35 @@ def get_python_executables() -> dict[str, PythonExecutable]:
class VersionParts(TypedDict):
+ """
+ Parsed semantic version components.
+ """
major: int
minor: int
patch: int
-""" `pip` info types """
+# `pip` info types
+# ----------------
class PipInfo(TypedDict):
+ """
+ Pip installation paths and version.
+ """
pip_path: str | None
pip3_path: str | None
version: str
-""" `uv` info types """
+# `uv` info types
+# ---------------
class UvInstalledPython(TypedDict):
+ """
+ Details of a single Python installation managed by uv.
+ """
key: str
version: str
version_parts: VersionParts
@@ -170,6 +190,9 @@ class UvInstalledPython(TypedDict):
class UvInstalledPythons(TypedDict):
+ """
+ Collection of Python installations managed by uv.
+ """
installed: list[UvInstalledPython]
@@ -177,15 +200,22 @@ class UvInstalledPythons(TypedDict):
class UvInfo(TypedDict):
+ """
+ Uv tool path, version, and managed Python installations.
+ """
path: str
version: str
pythons: UvPythons
-""" `hatch` info types """
+# `hatch` info types
+# ------------------
class HatchEnvironment(TypedDict):
+ """
+ Configuration for a single Hatch virtual environment.
+ """
type: Literal['virtual']
python: str
installer: str
@@ -195,6 +225,9 @@ class HatchEnvironment(TypedDict):
class HatchInfo(TypedDict):
+ """
+ Hatch tool path, version, and environment configuration.
+ """
path: str
version: str
environments: HatchEnvironments
@@ -202,7 +235,7 @@ class HatchInfo(TypedDict):
class PackageManagerInfo(TypedDict, total=False):
"""
- Dict schema with info about a package manager (values in dict returned by :func:`get_package_manager_info`)
+ Dict schema with info about a package manager (values in dict returned by :func:`get_package_manager_info`).
"""
pip: PipInfo
uv: UvInfo
@@ -211,7 +244,7 @@ class PackageManagerInfo(TypedDict, total=False):
def get_package_manager_info() -> PackageManagerInfo:
"""
- Get information about package managers (pip, uv, hatch)
+ Get information about package managers (pip, uv, hatch).
:return: mapping from package manager name -> path/version info (:class:`PackageManagerInfo`)
"""
@@ -290,6 +323,9 @@ def get_package_manager_info() -> PackageManagerInfo:
class VirtualenvInfo(TypedDict):
+ """
+ Current virtual environment state and prefix paths.
+ """
in_virtualenv: bool
virtual_env: str | None
conda_env: str | None
@@ -300,7 +336,7 @@ class VirtualenvInfo(TypedDict):
def get_virtual_env_info() -> VirtualenvInfo:
"""
- Get information about current virtual environment
+ Get information about current virtual environment.
:return: dict with virtual environment details (:class:`VirtualenvInfo`)
"""
@@ -323,7 +359,7 @@ def get_virtual_env_info() -> VirtualenvInfo:
def get_environment_variables() -> dict[str, str | None]:
"""
- Get relevant environment variables (non-sensitive)
+ Get relevant environment variables (non-sensitive).
:return: dict of environment variables
"""
@@ -355,6 +391,9 @@ def get_environment_variables() -> dict[str, str | None]:
class PythonPaths(TypedDict):
+ """
+ Python interpreter paths and site-packages locations.
+ """
sys_executable: str
sys_path: list[str]
site_packages: list[str]
@@ -364,7 +403,7 @@ class PythonPaths(TypedDict):
def get_python_paths() -> PythonPaths:
"""
- Get Python-specific paths and configuration
+ Get Python-specific paths and configuration.
:return: dict with Python paths (:class;`PythonPaths`)
"""
@@ -383,6 +422,9 @@ def get_python_paths() -> PythonPaths:
class SystemInfo(TypedDict):
+ """
+ Host system platform, architecture, and OS version details.
+ """
platform: str
machine: str
processor: str
@@ -394,7 +436,7 @@ class SystemInfo(TypedDict):
def get_system_info() -> SystemInfo:
"""
- Get macOS system information
+ Get macOS system information.
:return: dict with system details (:class:`SystemInfo`)
"""
@@ -417,6 +459,9 @@ def get_system_info() -> SystemInfo:
class PythonInstallation(TypedDict):
+ """
+ Existence and symlink info for a Python installation at a known path.
+ """
exists: bool
description: str
is_symlink: bool | None
@@ -425,7 +470,7 @@ class PythonInstallation(TypedDict):
def check_common_locations() -> dict[str, PythonInstallation]:
"""
- Check for Python installations in common locations
+ Check for Python installations in common locations.
:return: mapping from paths -> python installation info/existence info (:class:`PythonInstallation`)
"""
@@ -456,6 +501,9 @@ def check_common_locations() -> dict[str, PythonInstallation]:
class InstalledPythonVersions(TypedDict):
+ """
+ Installed Python package versions and editable project locations.
+ """
versions: dict[str, str] # package name -> version
editable_locations: dict[str, str] # package name -> editable project location (path)
@@ -468,7 +516,7 @@ class InstalledPythonVersions(TypedDict):
def get_installed_packages() -> InstalledPythonPackages:
"""
- Get list of installed packages in current environment
+ Get list of installed packages in current environment.
:return: dict containing "versions" (mapping from package name -> version) and "editable_locations" (mapping from
package name -> editable project location), or dict with single "error" entry if `pip` command fails
@@ -480,20 +528,19 @@ def get_installed_packages() -> InstalledPythonPackages:
if pip_list.get('error'):
return ErrorDict(error=pip_list.get('message', 'Unknown error'))
- elif pip_list.get('returncode') != 0:
+ if pip_list.get('returncode') != 0:
return ErrorDict(error=f"`pip list` failed: {pip_list.get('stderr', 'Unknown error')}")
+ try:
+ pip_list_packages = json.loads(pip_list['stdout'])
+ except (json.JSONDecodeError, KeyError) as e:
+ return ErrorDict(error=f'Failed to parse `pip list` output: {e}')
else:
- try:
- pip_list_packages = json.loads(pip_list['stdout'])
- except (json.JSONDecodeError, KeyError) as e:
- return ErrorDict(error=f'Failed to parse `pip list` output: {e}')
- else:
- packages: InstalledPythonVersions = InstalledPythonVersions(versions={}, editable_locations={})
- for pkg in pip_list_packages:
- packages['versions'][pkg['name']] = pkg['version']
- if editable_location := pkg.get('editable_project_location'):
- packages['editable_locations'][pkg['name']] = editable_location
- return packages
+ packages: InstalledPythonVersions = InstalledPythonVersions(versions={}, editable_locations={})
+ for pkg in pip_list_packages:
+ packages['versions'][pkg['name']] = pkg['version']
+ if editable_location := pkg.get('editable_project_location'):
+ packages['editable_locations'][pkg['name']] = editable_location
+ return packages
# ============================================
@@ -502,6 +549,9 @@ def get_installed_packages() -> InstalledPythonPackages:
class Diagnostics(TypedDict):
+ """
+ Complete Python environment diagnostic report.
+ """
timestamp: str
system: SystemInfo
python_executables: dict[str, PythonExecutable]
@@ -515,7 +565,7 @@ class Diagnostics(TypedDict):
def collect_diagnostics() -> Diagnostics:
"""
- Collect all diagnostic information
+ Collect all diagnostic information.
:return: complete diagnostic data dict (:class:`Diagnostics`)
"""
@@ -534,11 +584,11 @@ def collect_diagnostics() -> Diagnostics:
def format_diagnostic_output(data: Diagnostics, *, max_installed_packages: int | None = None) -> str:
"""
- Format diagnostic data for human-readable output
+ Format diagnostic data for human-readable output.
:param data: diagnostic data dict
- :param max_installed_packages: maximum number of installed python packages to list in "INSTALLED PACKAGES" section before
- truncating list. If None, all installed packages will be outputted
+ :param max_installed_packages: maximum number of installed python packages to list in "INSTALLED PACKAGES" section
+ before truncating list. If None, all installed packages will be outputted
:return: formatted string output
"""
@@ -661,9 +711,10 @@ def format_diagnostic_output(data: Diagnostics, *, max_installed_packages: int |
lines.append(f'{var_ljust} {value}')
if path_envvar := data['environment_variables'].get('PATH'):
- lines.append('\nPATH:')
- for path in path_envvar.split(':'):
- lines.append(f' {path}')
+ lines.extend([
+ '\nPATH:',
+ *[f' {path}' for path in path_envvar.split(os.pathsep)],
+ ])
""" Python paths from :func:`get_python_paths` """
@@ -673,11 +724,9 @@ def format_diagnostic_output(data: Diagnostics, *, max_installed_packages: int |
'-' * 80,
f"sys.executable: {data['python_paths']['sys_executable']}",
'\nsys.path:',
+ *[f' {path}' for path in data['python_paths']['sys_path']],
])
- for path in data['python_paths']['sys_path']:
- lines.append(f' {path}')
-
""" Common installation locations from :func:`check_common_locations` """
lines.extend([
@@ -717,8 +766,8 @@ def format_diagnostic_output(data: Diagnostics, *, max_installed_packages: int |
name_vers = f'{name_vers.ljust(col1_width)} {loc}'
lines.append(f' {name_vers}')
- if max_installed_packages and (len(packages) > max_installed_packages):
- lines.append(f' ... and {len(packages) - max_installed_packages} more packages')
+ if max_installed_packages and (len(pkg_versions) > max_installed_packages):
+ lines.append(f' ... and {len(pkg_versions) - max_installed_packages} more packages')
else:
lines.append(f" Error: {packages.get('error', 'Unknown error')}")
@@ -739,7 +788,7 @@ def format_diagnostic_output(data: Diagnostics, *, max_installed_packages: int |
class Args(argparse.Namespace):
"""
- Annotated :class:`argparse.Namespace` for script command-line arguments
+ Annotated :class:`argparse.Namespace` for script command-line arguments.
"""
max_installed_packages: int | None # --max-installed-packages
save: bool # -s/--save
@@ -749,7 +798,7 @@ class Args(argparse.Namespace):
def build_parser() -> argparse.ArgumentParser:
"""
- Build argument parser for script
+ Build argument parser for script.
:return: :class:`argparse.ArgumentParser`
"""
@@ -759,50 +808,65 @@ def build_parser() -> argparse.ArgumentParser:
add_help=False,
)
- console_out_opts = parser.add_argument_group('Console output options')
- console_out_opts.add_argument(
- '--max-installed-packages',
- metavar='NUM',
- type=int,
- help='Max number of installed python packages to list under "INSTALLED PACKAGES"',
- )
-
- file_out_opts = parser.add_argument_group('File output options')
- file_out_opts.add_argument(
- '-s',
- '--save',
- action='store_true',
- help='Save output to file instead of printing',
- )
- file_out_opts.add_argument(
- '-j',
- '--json',
- action='store_true',
- help='Output as JSON format',
- )
- file_out_opts.add_argument(
- '-o',
- '--output',
- type=Path,
- metavar='FILEPATH',
- help='Output file path (default: python_diagnostic_.txt)',
- )
-
- other_group = parser.add_argument_group('Other options')
- other_group.add_argument(
- '-h',
- '--help',
- action='help',
- default=argparse.SUPPRESS,
- help='Show help message and exit',
- )
+ def add_console_out_opts() -> None:
+ """
+ Add console output options group to the argument parser.
+ """
+ console_out_opts = parser.add_argument_group('Console output options')
+ console_out_opts.add_argument(
+ '--max-installed-packages',
+ metavar='NUM',
+ type=int,
+ help='Max number of installed python packages to list under "INSTALLED PACKAGES"',
+ )
+ add_console_out_opts()
+
+ def add_file_out_opts() -> None:
+ """
+ Add file output options group to the argument parser.
+ """
+ file_out_opts = parser.add_argument_group('File output options')
+ file_out_opts.add_argument(
+ '-s',
+ '--save',
+ action='store_true',
+ help='Save output to file instead of printing',
+ )
+ file_out_opts.add_argument(
+ '-j',
+ '--json',
+ action='store_true',
+ help='Output as JSON format',
+ )
+ file_out_opts.add_argument(
+ '-o',
+ '--output',
+ type=Path,
+ metavar='FILEPATH',
+ help='Output file path (default: python_diagnostic_.txt)',
+ )
+ add_file_out_opts()
+
+ def add_other_opts() -> None:
+ """
+ Add miscellaneous options group to the argument parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show help message and exit',
+ )
+ add_other_opts()
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""
- Main entry point
+ Main entry point.
:param argv: argument list to parse, defaults to sys.argv[1:]
:return: int script exit code
@@ -829,7 +893,7 @@ def main(argv: Sequence[str] | None = None) -> int:
output_path = Path(f'python_diagnostic_{timestamp}.{extension}')
output_path.write_text(output)
- print('')
+ print()
print(f'Diagnostic report saved to: {output_path}', file=sys.stderr)
print(f'File size: {output_path.stat().st_size} bytes', file=sys.stderr)
else:
diff --git a/scripts/env/venvshell.py b/scripts/env/venvshell.py
index 8bd40a7..8cf30d8 100755
--- a/scripts/env/venvshell.py
+++ b/scripts/env/venvshell.py
@@ -16,6 +16,7 @@
import sys
import tempfile
import textwrap
+from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
@@ -27,7 +28,6 @@
if TYPE_CHECKING:
from collections.abc import Iterable
- from collections.abc import Sequence
# ====================
@@ -144,6 +144,37 @@ def exit(self) -> None:
# ==================
+ANSI_COLORS: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+}
+ANSI_RESET_ALL = '\033[0m'
+
+
+def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
+ """
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
+
+ :param _color: color as a name string, 256-color int, or (r, g, b) tuple
+ :param offset: offset to add for background colors (10 for bg, 0 for fg)
+ :return: ANSI SGR parameter string (e.g. ``'38;5;196'``)
+ """
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(ANSI_COLORS[_color] + offset)
+
+
def style(
text: Any,
*,
@@ -172,36 +203,6 @@ def style(
"""
if os.environ.get('NO_COLOR') is not None:
return str(text)
-
- _ansi_colors: dict[str, int] = {
- 'black': 30, 'bright_black': 90,
- 'red': 31, 'bright_red': 91,
- 'green': 32, 'bright_green': 92,
- 'yellow': 33, 'bright_yellow': 93,
- 'blue': 34, 'bright_blue': 94,
- 'magenta': 35, 'bright_magenta': 95,
- 'cyan': 36, 'bright_cyan': 96,
- 'white': 37, 'bright_white': 97,
- 'reset': 39,
- }
- _ansi_reset_all = '\033[0m'
-
- def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
- """
- Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
-
- :param _color: color as a name string, 256-color int, or (r, g, b) tuple
- :param offset: offset to add for background colors (10 for bg, 0 for fg)
- :return: ANSI SGR parameter string (e.g. ``'38;5;196'``)
- """
- if isinstance(_color, int):
- return f'{38 + offset};5;{_color:d}'
- if isinstance(_color, (tuple, list)):
- r, g, b = _color
- return f'{38 + offset};2;{r:d};{g:d};{b:d}'
- _color = cast('str', _color)
- return str(_ansi_colors[_color] + offset)
-
if not isinstance(text, str):
text = str(text)
@@ -227,7 +228,7 @@ def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
bits.append(text)
if reset:
- bits.append(_ansi_reset_all)
+ bits.append(ANSI_RESET_ALL)
return ''.join(bits)
@@ -298,7 +299,13 @@ def typestyle(val: Any, **opts: Any) -> str:
return style(repr(val), fg='bright_cyan', bold=True, **opts)
if isinstance(val, str):
return style(repr(val), fg='green', **opts)
- return str(val)
+ if isinstance(val, Sequence):
+ return ''.join([
+ style('[', fg='bright_white'),
+ ', '.join(typestyle(item, **opts) for item in val),
+ style(']', fg='bright_white'),
+ ])
+ return style(val, **opts)
def dim_paren(s: str, *, fg: StyleColor | None = None) -> str:
@@ -318,26 +325,35 @@ def dim_paren(s: str, *, fg: StyleColor | None = None) -> str:
def annotated_opt_help(
opt_help: str,
- *,
+ *extra_help_lines: str,
default: Any | None = None,
default_fg: StyleColor | None = None,
envvar: str | None = None,
+ extra_line: bool = True,
) -> str:
"""
Build a styled argparse help string with optional default value and environment variable annotations.
- :param opt_help: base help text for the option
+ :param opt_help: core help text for the option (will be styled "bright_white")
+ :param extra_help_lines: additional help text lines (joined by newlines) for the option. Outputted with no styling
+ after `opt_help` and before `default`/`envvar`
:param default: default value to display below the help text
:param default_fg: explicit foreground color for the default value (overrides :func:`typestyle`)
:param envvar: environment variable name that can override this option
+ :param extra_line: if True (default), extra blank line included at the end of the option help text (visually spaces
+ sequential options apart from each other)
:return: multi-line styled help string with annotations appended
"""
help_lines: list[str] = style(opt_help, fg='bright_white').splitlines()
+ for extra_help_line in extra_help_lines:
+ help_lines.extend(extra_help_line.splitlines())
if default is not None:
help_lines.append(f' default: {typestyle(default) if default_fg is None else style(default, fg=default_fg)}')
if envvar is not None:
help_lines.append(f' env var: {style(envvar, fg="cyan")}')
- return '\n'.join([*help_lines, ' '])
+ if extra_line:
+ help_lines.append(' ')
+ return '\n'.join(help_lines)
def printf(
@@ -1010,7 +1026,7 @@ def main(argv: list[str] | None = None) -> None:
cwd = Path.cwd()
printf(f'No virtual environment found in {pathstyle(cwd)}', error=True)
printf(' '.join([
- style('Use', dim=True, fg='bright_white'), optstyle('--path PATH', dim=True),
+ style('Use', dim=True, fg='bright_white'), optstyle('--path PATH', dim=True),
*([style('or', dim=True, fg='bright_white'), optstyle('--hatch ENV', dim=True)] if HATCH_ENABLED else []),
style('to specify an environment', dim=True, fg='bright_white'),
]), file=sys.stderr, indent=10)
diff --git a/scripts/install/README.md b/scripts/install/README.md
index 7d58980..3468db4 100644
--- a/scripts/install/README.md
+++ b/scripts/install/README.md
@@ -4,9 +4,9 @@ Bulletproof installer scripts for bootstrapping development environments with `u
## Overview
-These scripts provide robust, cross-platform installation of essential Python development tools with comprehensive
-error handling, retry logic, and helpful troubleshooting guidance. Perfect for bootstrapping new systems or onboarding
-new developers.
+These scripts provide robust, cross-platform installation of essential Python development tools with comprehensive error
+handling, retry logic, and helpful troubleshooting guidance. Perfect for bootstrapping new systems or onboarding new
+developers.
## Scripts
@@ -15,6 +15,7 @@ new developers.
Installs [uv](https://docs.astral.sh/uv/) - the blazing-fast Python package manager and installer from Astral.
**Features:**
+
- Cross-platform support (Linux, macOS, Windows)
- Smart executable detection (checks PATH and common install locations)
- Configurable network retry logic with exponential backoff
@@ -24,6 +25,7 @@ Installs [uv](https://docs.astral.sh/uv/) - the blazing-fast Python package mana
- Force reinstall option
**Usage:**
+
```bash
# Simple installation
./scripts/install/install_uv.py
@@ -45,12 +47,14 @@ Installs [uv](https://docs.astral.sh/uv/) - the blazing-fast Python package mana
```
**Options:**
+
- `--force` - Force reinstall even if UV is already installed
- `-v, --verbose` - Enable verbose output for debugging
- `--retries N` - Maximum number of download retry attempts (default: 3)
- `--retry-delay SECONDS` - Initial delay in seconds between retries with exponential backoff (default: 2)
**What it does:**
+
1. Checks Python version (requires 3.8+)
2. Searches for existing `uv` installation in PATH and common locations
3. Downloads the official installer from https://astral.sh/uv/install.sh
@@ -59,6 +63,7 @@ Installs [uv](https://docs.astral.sh/uv/) - the blazing-fast Python package mana
6. Cleans up temporary files
**Exit codes:**
+
- `0` - Success or already installed
- `2` - Download failed
- `3` - Installation failed
@@ -71,6 +76,7 @@ Installs [uv](https://docs.astral.sh/uv/) - the blazing-fast Python package mana
Installs [Hatch](https://hatch.pypa.io/) - the modern Python project manager and build system.
**Features:**
+
- Cross-platform support (Linux, macOS, Windows)
- Uses official Hatch universal installer
- Smart executable detection in multiple common locations
@@ -80,6 +86,7 @@ Installs [Hatch](https://hatch.pypa.io/) - the modern Python project manager and
- Verbose and force options
**Usage:**
+
```bash
# Simple installation
./scripts/install/install_hatch.py
@@ -101,12 +108,14 @@ Installs [Hatch](https://hatch.pypa.io/) - the modern Python project manager and
```
**Options:**
+
- `--force` - Force reinstall even if Hatch is already installed
- `-v, --verbose` - Enable verbose output for debugging
- `--retries N` - Maximum number of download retry attempts (default: 3)
- `--retry-delay SECONDS` - Initial delay in seconds between retries with exponential backoff (default: 2)
**What it does:**
+
1. Validates Python version (3.8+ required)
2. Checks for existing Hatch installation
3. Downloads universal installer from GitHub releases
@@ -115,6 +124,7 @@ Installs [Hatch](https://hatch.pypa.io/) - the modern Python project manager and
6. Provides shell-specific PATH setup instructions if needed
**Exit codes:**
+
- `0` - Success or already installed
- `2` - Download failed
- `3` - Installation failed
@@ -127,6 +137,7 @@ Installs [Hatch](https://hatch.pypa.io/) - the modern Python project manager and
Both scripts share a robust architecture:
### Smart Detection
+
```python
# Checks PATH first
$ which uv
@@ -139,12 +150,14 @@ $ which uv
```
### Network Resilience
+
- Configurable retry attempts (default: 3, customize with `--retries`)
- Configurable initial delay with exponential backoff (default: 2s, customize with `--retry-delay`)
- 30-second timeout per request
- Helpful troubleshooting if all retries fail
**Retry behavior:**
+
```bash
# Default: 3 attempts with 2s, 4s, 8s delays
./scripts/install/install_uv.py
@@ -157,6 +170,7 @@ $ which uv
```
The exponential backoff formula is: `delay = retry_delay * (2 ** (attempt - 2))`
+
- Attempt 1: No delay
- Attempt 2: Initial delay (e.g., 2s)
- Attempt 3: 2× initial delay (e.g., 4s)
@@ -164,7 +178,9 @@ The exponential backoff formula is: `delay = retry_delay * (2 ** (attempt - 2))`
- And so on...
### Shell Intelligence
+
Detects your shell and provides appropriate PATH commands:
+
- **bash/zsh**: `export PATH="..."`
- **fish**: `set -gx PATH "..." $PATH`
- **tcsh/csh**: `setenv PATH "..."`
@@ -172,10 +188,10 @@ Detects your shell and provides appropriate PATH commands:
### Platform Support
| Platform | install_uv.py | install_hatch.py |
-|----------|---------------|------------------|
-| macOS | ✓ | ✓ |
-| Linux | ✓ | ✓ |
-| Windows | ✓ | ✓ |
+| -------- | ------------- | ---------------- |
+| macOS | ✓ | ✓ |
+| Linux | ✓ | ✓ |
+| Windows | ✓ | ✓ |
## Troubleshooting
@@ -203,11 +219,13 @@ To make UV available globally, add it to your PATH:
### Download failures
Scripts provide actionable troubleshooting:
+
1. Check internet connection
2. Verify access to installer URLs
3. Check if proxy configuration is needed
**For unreliable networks:**
+
```bash
# Increase retry attempts and delay
./scripts/install/install_uv.py --retries 10 --retry-delay 5
@@ -219,6 +237,7 @@ Scripts provide actionable troubleshooting:
### Verification failures
If installation succeeds but verification fails:
+
1. Close and reopen terminal
2. Try `--force` to reinstall
3. Check official documentation links
diff --git a/scripts/install/install_hatch.py b/scripts/install/install_hatch.py
index 2e73d28..84a89f2 100755
--- a/scripts/install/install_hatch.py
+++ b/scripts/install/install_hatch.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-Programmatically install Hatch using the official installer script
+Programmatically install Hatch using the official installer script.
"""
from __future__ import annotations
@@ -9,17 +9,28 @@
import functools
import os
import platform
+import re
+import shutil
+import ssl
import subprocess
import sys
import textwrap
import time
import urllib.error
import urllib.request
+from collections.abc import Sequence
from pathlib import Path
from typing import Any
+from typing import Callable
from typing import Protocol
from typing import TypeVar
from typing import Union
+from typing import cast
+
+# ==================================================================================================
+# Constants/defaults/globals
+# ==================================================================================================
+
# Configuration
MIN_PYTHON: tuple[int, int] = (3, 8) # Minimum supported python version. Script exits with error if not met
@@ -35,17 +46,28 @@
VERBOSE: bool = False
-PathLike = Union[Path, str]
+# ==================================================================================================
+# Types/protocols
+# ==================================================================================================
+
+
+PathLike = Union[Path, str]
+StyleColor = Union[int, tuple[int, int, int], str]
+
+T_contra = TypeVar('T_contra', contravariant=True)
-T_contra = TypeVar('T_contra', contravariant=True)
class SupportsWrite(Protocol[T_contra]):
- def write(self, s: T_contra, /) -> object: ...
+ """
+ Protocol for writable things (like :attr:`sys.stdout` and :attr:`sys.stderr`).
+ """
+ def write(self, s: T_contra, /) -> object:
+ ...
class ExitCode(int, enum.Enum):
"""
- Script exit codes
+ Script exit codes.
"""
SUCCESS = 0
ALREADY_INSTALLED = 0
@@ -57,136 +79,418 @@ class ExitCode(int, enum.Enum):
PYTHON_VERSION = 6
-def vprint(*args: Any, **kwargs: Any) -> None:
+class DownloadMethod(str, enum.Enum):
"""
- Print only if verbose mode is enabled
+ Installer download methods, ordered by the priority in which they may be attempted.
+
+ ``curl`` is preferred over ``urllib`` by default because curl uses the platform's native trust store
+ (e.g. SecureTransport on macOS)
"""
- if VERBOSE:
- print(*args, file=kwargs.pop('file', sys.stderr), **kwargs)
+ CURL = 'curl'
+ URLLIB = 'urllib'
+
+ def __str__(self) -> str:
+ return self.value
+
+
+# Default ordered list of download methods to attempt. curl is tried before urllib (see :class:`DownloadMethod`)
+DEFAULT_DOWNLOAD_METHODS: tuple[DownloadMethod, ...] = (DownloadMethod.CURL, DownloadMethod.URLLIB)
+
+
+# ==================================================================================================
+# Formatting/logging
+# ==================================================================================================
+
+ANSI_COLORS: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+}
+ANSI_RESET_ALL = '\033[0m'
-def indent(s: str, num: int) -> str:
+
+def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
"""
- Indent string by given number of spaces
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
- :param s: string to indent
- :param num: number of spaces to indent each line in string `s` with
- :return: indented string
+ :param _color: color as a name string, 256-color int, or (r, g, b) tuple
+ :param offset: offset to add for background colors (10 for bg, 0 for fg)
+ :return: ANSI SGR parameter string (e.g. ``'38;5;196'``)
"""
- return textwrap.indent(s, ' ' * num)
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(ANSI_COLORS[_color] + offset)
-def check_python_version() -> bool:
+def style(
+ text: Any,
+ *,
+ fg: StyleColor | None = None,
+ bg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ underline: bool | None = None,
+ overline: bool | None = None,
+ italic: bool | None = None,
+ blink: bool | None = None,
+ reverse: bool | None = None,
+ strikethrough: bool | None = None,
+ reset: bool = True,
+) -> str:
+ """
+ Style text with ANSI escape codes.
+
+ :param text: the string to style with ansi codes
+ :param fg: foreground color
+ :param bg: background color
+ :param bold: enable or disable bold mode
+ :param dim: enable or disable dim mode
+ :param underline: enable or disable underline
+ :param overline: enable or disable overline
+ :param italic: enable or disable italic
+ :param blink: enable or disable blinking
+ :param reverse: enable or disable inverse rendering
+ :param strikethrough: enable or disable striking through text
+ :param reset: add a reset-all code at the end of the string
+ :return: styled text
"""
- Check minimum Python version requirement (:attr:`MAX_PYTHON` above)
+ if not isinstance(text, str):
+ text = str(text)
- :return: True if minimum python version requirement met, False if not
+ bits: list[str] = []
+ if fg:
+ try:
+ bits.append(f'\033[{_interpret_color(fg)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {fg!r}') from None
+ if bg:
+ try:
+ bits.append(f'\033[{_interpret_color(bg, 10)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {bg!r}') from None
+ if bold is not None:
+ bits.append(f'\033[{1 if bold else 22}m')
+ if dim is not None:
+ bits.append(f'\033[{2 if dim else 22}m')
+ if underline is not None:
+ bits.append(f'\033[{4 if underline else 24}m')
+ if overline is not None:
+ bits.append(f'\033[{53 if overline else 55}m')
+ if italic is not None:
+ bits.append(f'\033[{3 if italic else 23}m')
+ if blink is not None:
+ bits.append(f'\033[{5 if blink else 25}m')
+ if reverse is not None:
+ bits.append(f'\033[{7 if reverse else 27}m')
+ if strikethrough is not None:
+ bits.append(f'\033[{9 if strikethrough else 29}m')
+
+ bits.append(text)
+ if reset:
+ bits.append(ANSI_RESET_ALL)
+ return ''.join(bits)
+
+
+def unstyle(text: str) -> str:
"""
- if sys.version_info < MIN_PYTHON:
- print(
- f'❌ Error: '
- f'Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ required, '
- f'but you have Python {sys.version_info.major}.{sys.version_info.minor}',
- file=sys.stderr,
- )
- return False
- else:
- vprint(f'✅ Python version {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')
- return True
+ Remove ANSI styling information from a string.
+
+ :param text: the text to remove style information from
+ :return: string with ANSI styling characters removed
+ """
+ return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text)
-def detect_shell() -> str | None:
+def folduser(path: PathLike) -> str:
"""
- Detect the user's shell
+ Does the opposite of :meth:`pathlib.Path.expanduser`, replacing the user's home directory with a "~".
- :return: current shell
+ :param path: path
+ :return: str folded path
"""
- if shell := os.environ.get('SHELL', ''):
- return Path(shell).name
- else:
- return None
+ home = str(Path.home())
+ return str(path).replace(home, '~')
-def get_shell_rc_file() -> Path | None:
+def pathstyle(path: PathLike, **kwargs: Any) -> str:
"""
- Get the appropriate shell RC file path
+ Style a file path with magenta foreground and home directory condensed to ``~``.
- :return: path to shell RC file for current shell
+ :param path: path to style
+ :return: styled path string
"""
- shell = detect_shell()
- home = Path.home()
+ return style(folduser(path), fg='magenta', **kwargs)
- shell_rc_map: dict[str, Path] = {
- 'bash': home / '.bashrc',
- 'zsh': home / '.zshrc',
- 'fish': home / '.config' / 'fish' / 'config.fish',
- 'tcsh': home / '.tcshrc',
- 'csh': home / '.cshrc',
- }
- # For macOS, bash uses .bash_profile instead of .bashrc
- if (shell == 'bash') and (platform.system() == 'Darwin'):
- bash_profile = home / '.bash_profile'
- if bash_profile.exists():
- return bash_profile
+def optstyle(text: Any, **opts: Any) -> str:
+ """
+ Style a CLI option name in yellow.
- return shell_rc_map.get(shell) if shell else None
+ :param text: the option name to style
+ :param opts: additional keyword arguments forwarded to :func:`style`
+ :return: ANSI-styled string
+ """
+ return style(text, fg='yellow', **opts)
-def get_path_export_command(install_dir: Path) -> str | None:
+def typestyle(val: Any, **opts: Any) -> str:
"""
- Get the command to add directory to PATH based on shell
+ Style a Python value by its type using semantic colors (roughly matching default repr styling provided by rich).
+
+ Color mapping:
- :param install_dir: directory path
- :return: str shell command
+ - ``None`` = magenta italic
+ - ``bool`` = green (True) / red (False) italic
+ - ``Path`` = magenta with folduser
+ - ``int``/``float`` = bright cyan bold
+ - ``str`` = green
+
+ Other types fall through to ``str(val)`` unstyled
+
+ :param val: the Python value to style
+ :return: the ANSI-styled representation
"""
- shell = detect_shell()
- if shell in ('bash', 'zsh'):
- return f'export PATH="{install_dir}:$PATH"'
- elif shell == 'fish':
- return f'set -gx PATH "{install_dir}" $PATH'
- elif shell in ('tcsh', 'csh'):
- return f'setenv PATH "{install_dir}:$PATH"'
- else:
+ if val is None:
+ return style(str(val), fg='magenta', italic=True, **opts)
+ if isinstance(val, bool):
+ return style(str(val), fg=f'bright_{"green" if val else "red"}', italic=True, **opts)
+ if isinstance(val, Path):
+ return style(folduser(val), fg='magenta', **opts)
+ if isinstance(val, (int, float)):
+ return style(repr(val), fg='bright_cyan', bold=True, **opts)
+ if isinstance(val, str):
+ return style(repr(val), fg='green', **opts)
+ if isinstance(val, Sequence):
+ return ''.join([
+ style('[', fg='bright_white'),
+ ', '.join(typestyle(item, **opts) for item in val),
+ style(']', fg='bright_white'),
+ ])
+ return style(val, **opts)
+
+
+def annotated_opt_help(
+ opt_help: str,
+ *extra_help_lines: str,
+ choices: Sequence[Any] | None = None,
+ default: Any | None = None,
+ default_fg: StyleColor | None = None,
+ envvar: str | None = None,
+ extra_line: bool = True,
+) -> str:
+ """
+ Build a styled argparse help string with optional default value and environment variable annotations.
+
+ :param opt_help: core help text for the option (will be styled "bright_white")
+ :param extra_help_lines: additional help text lines (joined by newlines) for the option. Outputted with no styling
+ after `opt_help` and before `default`/`envvar`
+ :param choices: valid option value choices to display below the help text
+ :param default: default value to display below the help text
+ :param default_fg: explicit foreground color for the default value (overrides :func:`typestyle`)
+ :param envvar: environment variable name that can override this option
+ :param extra_line: if True (default), extra blank line included at the end of the option help text (visually spaces
+ sequential options apart from each other)
+ :return: multi-line styled help string with annotations appended
+ """
+ help_lines: list[str] = style(opt_help, fg='bright_white').splitlines()
+ for extra_help_line in extra_help_lines:
+ help_lines.extend(extra_help_line.splitlines())
+ if choices is not None:
+ help_lines.append(f' choices: {typestyle(choices)}')
+ if default is not None:
+ help_lines.append(f' default: {typestyle(default) if default_fg is None else style(default, fg=default_fg)}')
+ if envvar is not None:
+ help_lines.append(f' env var: {style(envvar, fg="cyan")}')
+ if extra_line:
+ help_lines.append(' ')
+ return '\n'.join(help_lines)
+
+
+def printf(
+ text: str = '',
+ *,
+ fg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ indent: int = 0,
+ file: SupportsWrite[str] | None = None,
+ verbose: bool = False,
+) -> None:
+ """
+ Print styled text with optional indent and verbose gating.
+
+ :param text: text to print
+ :param fg: foreground color
+ :param bold: bold mode
+ :param dim: dim mode
+ :param indent: number of spaces to indent output by
+ :param file: output file (defaults to stdout, or stderr for verbose output)
+ :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr
+ """
+ if verbose and not VERBOSE:
+ return
+ if verbose:
+ fg = fg or 'bright_white'
+ dim = True if dim is None else dim
+ file = file or sys.stderr
+
+ print(
+ style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim),
+ file=file,
+ )
+
+
+# ==================================================================================================
+# Python checks
+# ==================================================================================================
+
+
+def check_python_version() -> bool:
+ """
+ Check minimum Python version requirement (:attr:`MIN_PYTHON` above).
+
+ :return: True if minimum python version requirement met, False if not
+ """
+ if sys.version_info < MIN_PYTHON:
+ printf(
+ f'❌ Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ required, '
+ f'but you have Python {sys.version_info.major}.{sys.version_info.minor}',
+ fg='bright_red',
+ file=sys.stderr,
+ )
+ return False
+ vers_str = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
+ printf(f'✅ Python version {vers_str}', verbose=True)
+ return True
+
+
+# ==================================================================================================
+# Shell detection & path mapping
+# ==================================================================================================
+
+
+class Shell(enum.Enum):
+ """
+ Known shells with their associated RC file paths and PATH export syntax.
+ """
+ BASH = 'bash'
+ ZSH = 'zsh'
+ FISH = 'fish'
+ TCSH = 'tcsh'
+ CSH = 'csh'
+
+ @classmethod
+ def detect(cls) -> Shell | None:
+ """
+ Detect the user's shell from the ``$SHELL`` environment variable.
+
+ :return: detected :class:`Shell` member, or ``None`` if unrecognized or unset
+ """
+ if shell := os.environ.get('SHELL', ''):
+ name = Path(shell).name
+ try:
+ return cls(name)
+ except ValueError:
+ return None
return None
+ @property
+ def rc_file(self) -> Path:
+ """
+ Get the appropriate RC file path for this shell.
+
+ On macOS, bash prefers ``~/.bash_profile`` over ``~/.bashrc`` when the file exists.
+
+ :return: path to the shell's RC file
+ """
+ home = Path.home()
+ rc_map: dict[Shell, Path] = {
+ Shell.BASH: home / '.bashrc',
+ Shell.ZSH: home / '.zshrc',
+ Shell.FISH: home / '.config' / 'fish' / 'config.fish',
+ Shell.TCSH: home / '.tcshrc',
+ Shell.CSH: home / '.cshrc',
+ }
+ if (self is Shell.BASH) and (platform.system() == 'Darwin'):
+ bash_profile = home / '.bash_profile'
+ if bash_profile.exists():
+ return bash_profile
+ return rc_map[self]
+
+ def path_export_command(self, directory: PathLike) -> str:
+ """
+ Get the command to add a directory to ``PATH`` for this shell.
+
+ :param directory: directory to prepend to ``PATH``
+ :return: shell-specific export command string
+ """
+ directory_path = Path(directory).expanduser().resolve()
+ if self in (Shell.BASH, Shell.ZSH):
+ return f'export PATH="{directory_path}:$PATH"'
+ if self is Shell.FISH:
+ return f'set -gx PATH "{directory_path}" $PATH'
+ # TCSH, CSH
+ return f'setenv PATH "{directory_path}:$PATH"'
+
+
+# ==================================================================================================
+# Local executable checks
+# ==================================================================================================
+
# TODO: support brew install location as well
# TODO: allow installation from current environment as well (from `pip install`)
def get_common_install_locations() -> list[Path]:
"""
- Get common installation locations for Hatch based on platform
+ Get common installation locations for Hatch on macOS.
:return: list of filepaths (:class:`pathlib.Path`)
"""
- home = Path.home()
- system = platform.system().lower()
-
- locations: list[Path] = []
- if system in ('linux', 'darwin'):
- locations = [
- home / '.local' / 'bin' / 'hatch',
- home / '.cargo' / 'bin' / 'hatch',
- Path('/usr/local/bin/hatch'),
- Path('/usr/bin/hatch'),
- home / '.hatch' / 'bin' / 'hatch',
- ]
- elif system == 'windows':
- locations = [
- home / '.local' / 'bin' / 'hatch.exe',
- home / '.cargo' / 'bin' / 'hatch.exe',
- home / 'AppData' / 'Local' / 'hatch' / 'hatch.exe',
- Path('C:/Program Files/hatch/hatch.exe'),
- ]
+ home = Path.home()
+ locations: list[Path] = [
+ home / '.local' / 'bin' / 'hatch',
+ home / '.cargo' / 'bin' / 'hatch',
+ Path('/usr/local/bin/hatch'),
+ Path('/usr/local/hatch/bin/hatch'),
+ Path('/usr/bin/hatch'),
+ home / '.hatch' / 'bin' / 'hatch',
+ ]
+
+ # macOS pip ``--user`` installs land in ``~/Library/Python//bin/hatch`` (one entry per framework Python).
+ # Scanning catches hatch installed by ``install_via_pip`` even when the system python3 falls back to PEP 370
+ # user-base, plus any pre-existing pip --user installs.
+ library_python = home / 'Library' / 'Python'
+ if library_python.is_dir():
+ for child in sorted(library_python.iterdir()):
+ hatch_path = child / 'bin' / 'hatch'
+ if hatch_path not in locations:
+ locations.append(hatch_path)
+
+ # Locations registered dynamically by ``install_via_pip`` (e.g. system python3 scripts dir)
+ for path in PIP_INSTALL_LOCATIONS:
+ if path not in locations:
+ locations.append(path)
+
return locations
def find_executable(*, path_only: bool = False) -> tuple[str | None, str | None]:
"""
- Try to find the Hatch executable in common locations
+ Try to find the Hatch executable in common locations.
:param path_only: if True, only look for executable in PATH. If False, look for executable in other common locations
- where UV might be installed
+ where hatch might be installed
:return: tuple (executable path, version)
"""
@@ -224,7 +528,7 @@ def find_executable(*, path_only: bool = False) -> tuple[str | None, str | None]
def is_installed(*, quiet: bool = False) -> bool:
"""
- Check if Hatch is already installed
+ Check if Hatch is already installed.
:param quiet: suppress console output if True
:return: True if `hatch` already installed, False if not
@@ -232,32 +536,62 @@ def is_installed(*, quiet: bool = False) -> bool:
executable, version = find_executable()
if executable:
if not quiet:
- print(f'✅ Hatch is already installed: {version}')
+ printf(f'✅ Already installed: {style(version, fg="bright_white")}', fg='bright_green')
if executable != 'hatch':
- print(f' 📍 Found at: {executable}')
- print(' 💡 Note: Consider adding this location to your PATH')
+ printf(f'Found at: {pathstyle(executable)}', indent=3)
+ printf(
+ style('NOTE: ', fg='magenta', bold=True) +
+ style('consider adding this to your PATH', fg='bright_white'),
+ indent=3,
+ )
return True
- else:
- return False
+ return False
+
+
+# ==================================================================================================
+# Installer retrieval/execution
+# ==================================================================================================
def get_installer_url() -> str:
"""
- Get the appropriate Hatch installer URL based on the platform
+ Get the Hatch installer URL for macOS.
- :raises OSError: if current platform/operating system is not supported
+ On macOS, Hatch is distributed as a ``.pkg`` installer.
+
+ :raises OSError: if current platform is not macOS
:return: installer URL
"""
- system = platform.system().lower()
- if system in ('linux', 'darwin'):
- return 'https://github.com/pypa/hatch/releases/latest/download/hatch-universal-installer.py'
- elif system == 'windows':
- return 'https://github.com/pypa/hatch/releases/latest/download/hatch-universal-installer.py'
+ if platform.system().lower() != 'darwin':
+ raise OSError(f'This installer only supports macOS (got: {platform.system()})')
+ return 'https://github.com/pypa/hatch/releases/latest/download/hatch-universal.pkg'
+
+
+def write_installer(dest: PathLike, content: bytes) -> bool:
+ """
+ Write downloaded installer bytes to disk, surfacing helpful troubleshooting tips on failure.
+
+ :param dest: destination file path
+ :param content: bytes to write
+ :return: True if successful, False if not
+ """
+ try:
+ with Path(dest).expanduser().open('wb') as f:
+ f.write(content)
+ except (PermissionError, OSError) as e:
+ printf(f'❌ Failed to write installer to disk: {e!s}', fg='bright_red', file=sys.stderr)
+ print(file=sys.stderr)
+ printf('🔧 Troubleshooting:', fg='bright_white', file=sys.stderr)
+ printf(f'1. Check write permissions for: {pathstyle(dest)}', indent=3, file=sys.stderr)
+ printf('2. Verify sufficient disk space is available', indent=3, file=sys.stderr)
+ printf('3. Ensure the parent directory exists', indent=3, file=sys.stderr)
+ return False
else:
- raise OSError(f'Unsupported operating system: {system}')
+ printf(f'✅ Downloaded to {pathstyle(dest)}', fg='bright_green')
+ return True
-def download_installer(
+def download_with_urllib(
url: str,
dest: PathLike,
*,
@@ -266,262 +600,547 @@ def download_installer(
retry_delay: float = DOWNLOAD_RETRY_DELAY,
) -> bool:
"""
- Download the installer script with retry logic
+ Download a file using Python's :mod:`urllib`.
- :param url: installer URL
- :param dest: where to download installer to
- :param timeout: download timeout in seconds
- :param retries: maximum number of download retry attempts
+ Aborts early on SSL verification errors so the curl fallback can be tried sooner.
+ SSL errors are not transient and additional retries will not recover.
+
+ :param url: source URL
+ :param dest: destination file path
+ :param timeout: per-request timeout in seconds
+ :param retries: maximum number of retry attempts
:param retry_delay: initial delay in seconds between retries, before exponential backoff
:return: True if successful, False if not
"""
+ content: bytes | None = None
for attempt in range(1, retries + 1):
try:
if attempt > 1:
delay = retry_delay * (2 ** (attempt - 2)) # Exponential backoff
- print(f'⏱️ Retrying in {delay} seconds... (attempt {attempt}/{retries})')
+ printf(f'⏱️ Retrying in {delay} seconds... (attempt {attempt}/{retries})', fg='bright_yellow')
time.sleep(delay)
- print(f'⏬ Downloading installer from {url}...')
- vprint(f' Attempt {attempt}/{retries}')
+ printf(f'⏬ Downloading installer from {url} (urllib)...', bold=True)
+ printf(f'Attempt {attempt}/{retries}', indent=3, verbose=True)
- with urllib.request.urlopen(url, timeout=timeout) as response:
+ with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310
content = response.read()
- with Path(dest).expanduser().open('wb') as f:
- f.write(content)
except urllib.error.URLError as e:
- print(f'❌ Download failed: {e}', file=sys.stderr)
+ printf(f'❌ urllib download failed: {e!s}', fg='bright_red', file=sys.stderr)
+ if isinstance(e.reason, ssl.SSLError):
+ printf('SSL verification failed; aborting urllib retries', indent=3, verbose=True, file=sys.stderr)
+ return False
if attempt == retries:
- print('', file=sys.stderr)
- print('🔧 Troubleshooting:', file=sys.stderr)
- print(' 1. Check your internet connection', file=sys.stderr)
- print(' 2. Verify you can access https://github.com', file=sys.stderr)
- print(' 3. Check if a proxy is required in your network', file=sys.stderr)
return False
except Exception as e:
- print(f'❌ Unexpected error during download: {e}', file=sys.stderr)
+ printf(f'❌ Unexpected error during urllib download: {e!s}', fg='bright_red', file=sys.stderr)
if attempt == retries:
return False
+ # Download succeeded, break out of retry loop
+ else:
+ break
+
+ if content is None:
+ return False
+ return write_installer(dest, content)
+
+
+def download_with_curl(
+ url: str,
+ dest: PathLike,
+ *,
+ timeout: float = DOWNLOAD_TIMEOUT,
+ retries: int = DOWNLOAD_RETRIES,
+ retry_delay: float = DOWNLOAD_RETRY_DELAY,
+) -> bool:
+ """
+ Download a file using the system ``curl`` executable.
+
+ Useful as a fallback when :mod:`urllib` fails — particularly with SSL certificate verification errors — since curl
+ uses the platform's native trust store (e.g. SecureTransport on macOS)
+
+ :param url: source URL
+ :param dest: destination file path
+ :param timeout: per-request timeout in seconds
+ :param retries: maximum number of retry attempts
+ :param retry_delay: initial delay in seconds between retries, before exponential backoff
+ :return: True if successful, False if not
+ """
+ curl = shutil.which('curl')
+ if curl is None:
+ printf('curl not available, skipping curl fallback', verbose=True, file=sys.stderr)
+ return False
+
+ dest_path = Path(dest).expanduser()
+ cmd: list[str] = [
+ curl,
+ '--fail',
+ '--silent',
+ '--show-error',
+ '--location',
+ '--max-time', str(int(timeout)),
+ '--output', str(dest_path),
+ url,
+ ]
+
+ for attempt in range(1, retries + 1):
+ if attempt > 1:
+ delay = retry_delay * (2 ** (attempt - 2)) # Exponential backoff
+ printf(f'⏱️ Retrying in {delay} seconds... (attempt {attempt}/{retries})', fg='bright_yellow')
+ time.sleep(delay)
+
+ printf(f'⏬ Downloading installer from {url} (curl)...', bold=True)
+ printf(f'Attempt {attempt}/{retries}', indent=3, verbose=True)
+ printf(f'Running: {" ".join(cmd)}', indent=3, verbose=True)
+ try:
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
+ except subprocess.CalledProcessError as e:
+ err = (e.stderr or '').strip() or str(e)
+ printf(f'❌ curl download failed: {err}', fg='bright_red', file=sys.stderr)
+ if attempt == retries:
+ return False
else:
- print(f'✅ Downloaded to {dest}')
+ printf(f'✅ Downloaded to {pathstyle(dest_path)}', fg='bright_green')
return True
return False
+# Dispatch table mapping each download method to its implementation.
+# Both functions share the same signature ``(url, dest, *, timeout, retries, retry_delay) -> bool``
+DOWNLOAD_FUNCS: dict[DownloadMethod, Callable[..., bool]] = {
+ DownloadMethod.CURL: download_with_curl,
+ DownloadMethod.URLLIB: download_with_urllib,
+}
+
+
+def download_installer(
+ url: str,
+ dest: PathLike,
+ *,
+ methods: Sequence[DownloadMethod] = DEFAULT_DOWNLOAD_METHODS,
+ timeout: float = DOWNLOAD_TIMEOUT,
+ retries: int = DOWNLOAD_RETRIES,
+ retry_delay: float = DOWNLOAD_RETRY_DELAY,
+) -> bool:
+ """
+ Download the installer script, trying each configured download method in order until one succeeds.
+
+ The default order (see :attr:`DEFAULT_DOWNLOAD_METHODS`) tries ``curl`` before :mod:`urllib`. curl handles
+ environments where Python's bundled OpenSSL CA list cannot validate the TLS chain to ``url``
+
+ :param url: installer URL
+ :param dest: where to download installer to
+ :param methods: ordered download methods to attempt; each is tried until one succeeds
+ :param timeout: download timeout in seconds
+ :param retries: maximum number of download retry attempts
+ :param retry_delay: initial delay in seconds between retries, before exponential backoff
+ :raises ValueError: if `url` does not start with "http:" or "https:"
+ :return: True if successful, False if not
+ """
+ if not url.startswith(('http:', 'https:')):
+ raise ValueError("URL must start with 'http:' or 'https:'")
+
+ ordered_methods = list(methods) or list(DEFAULT_DOWNLOAD_METHODS)
+ for index, method in enumerate(ordered_methods):
+ download_func = DOWNLOAD_FUNCS[method]
+ if download_func(url, dest, timeout=timeout, retries=retries, retry_delay=retry_delay):
+ return True
+
+ # Announce the fallback to the next method, if any remain
+ if index < len(ordered_methods) - 1:
+ next_method = ordered_methods[index + 1]
+ printf(f'⚠️ {method} download failed; falling back to {next_method}', fg='bright_yellow')
+ printf('')
+
+ return False
+
+
def run_installer(installer_path: PathLike) -> bool:
"""
- Execute the Hatch installer script
+ Execute the Hatch ``.pkg`` installer via ``sudo installer``.
- :param installer_path: path to installer script
+ :param installer_path: path to ``.pkg`` installer
:return: True on success, False on failure
"""
- print('💿 Running Hatch installer...')
+ printf('💿 Running Hatch .pkg installer (requires sudo)...', bold=True)
+ try:
+ result = subprocess.run(
+ ['sudo', 'installer', '-pkg', str(installer_path), '-target', '/'],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.CalledProcessError as e:
+ printf(f'❌ Installation failed: {e!s}', fg='bright_red', file=sys.stderr)
+ printf(e.stderr, indent=3, file=sys.stderr)
+ return False
+ else:
+ printf(result.stdout, indent=3)
+ return True
+
+
+# ==================================================================================================
+# `pip`-based installation (last-resort fallback)
+# ==================================================================================================
+
+
+# Locations registered dynamically by :func:`install_via_pip` so :func:`find_executable` can find hatch after a
+# successful pip install on this run.
+PIP_INSTALL_LOCATIONS: list[Path] = []
+
+
+def resolve_system_python() -> str | None:
+ """
+ Locate a system ``python3`` executable suitable for the pip-install fallback.
+
+ Prefers the macOS system interpreter at ``/usr/bin/python3``, then anything named ``python3`` on ``PATH``.
+
+ :return: absolute path to a python3 executable, or ``None`` if none can be found
+ """
+ system_python = Path('/usr/bin/python3')
+ if system_python.exists():
+ return str(system_python)
+ return shutil.which('python3')
+
+
+def register_pip_install_location(python_exe: str) -> None:
+ """
+ Register the scripts directory of ``python_exe`` so :func:`find_executable` can find ``hatch``.
+
+ Asks the interpreter for its ``sysconfig`` scripts path and appends ``/hatch`` to the module-level
+ :attr:`PIP_INSTALL_LOCATIONS` list.
+
+ :param python_exe: path to a python interpreter that just ran ``pip install hatch``
+ """
try:
result = subprocess.run(
- [sys.executable, str(installer_path)],
+ [python_exe, '-c', 'import sysconfig; print(sysconfig.get_path("scripts"))'],
check=True,
capture_output=True,
text=True,
)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return
+ else:
+ candidate = Path(result.stdout.strip()) / 'hatch'
+ if candidate not in PIP_INSTALL_LOCATIONS:
+ PIP_INSTALL_LOCATIONS.append(candidate)
+ printf(f'Registered pip install location: {pathstyle(candidate)}', indent=3, verbose=True)
+
+
+def install_via_pip() -> bool:
+ """
+ Install Hatch via ``python3 -m pip install hatch`` using the system ``python3`` executable.
+
+ Used as a last-resort fallback when neither :mod:`urllib` nor ``curl`` could fetch the official ``.pkg`` installer
+ (or when the user cannot run ``sudo`` to execute the ``.pkg``). Installs into the system Python's site-packages so
+ the resulting ``hatch`` executable lands in that interpreter's scripts directory.
+
+ :return: True on success, False on failure
+ """
+ printf('')
+ printf('💿 Falling back to pip install hatch (system python3)...', bold=True)
+
+ python3 = resolve_system_python()
+ if python3 is None:
+ printf('❌ Could not find a python3 executable for pip fallback', fg='bright_red', file=sys.stderr)
+ return False
+ printf(f'Using python: {pathstyle(python3)}', indent=3, verbose=True)
+
+ cmd: list[str] = [python3, '-m', 'pip', 'install', 'hatch']
+ printf(f'Running: {" ".join(cmd)}', indent=3, verbose=True)
+
+ try:
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
+
except subprocess.CalledProcessError as e:
- print(f'❌ Installation failed: {e}', file=sys.stderr)
- print(indent(e.stderr, 3), file=sys.stderr)
+ printf(f'❌ pip install hatch failed (exit {e.returncode})', fg='bright_red', file=sys.stderr)
+ if e.stdout:
+ printf(e.stdout.rstrip(), indent=3, file=sys.stderr)
+ if e.stderr:
+ printf(e.stderr.rstrip(), indent=3, file=sys.stderr)
return False
+
+ except FileNotFoundError as e:
+ printf(f'❌ pip install hatch failed: {e!s}', fg='bright_red', file=sys.stderr)
+ return False
+
else:
- print(indent(result.stdout, 3))
+ if VERBOSE and result.stdout:
+ printf(result.stdout.rstrip(), indent=3, verbose=True)
+ printf('✅ Installed hatch via pip', fg='bright_green')
+ register_pip_install_location(python3)
return True
def verify_installation() -> bool:
"""
- Verify that Hatch was installed successfully. Provides detailed guidance if tool is installed but not in PATH
+ Verify that Hatch was installed successfully. Provides detailed guidance if tool is installed but not in PATH.
:return: True on success, False on failure
"""
- print('')
- print('🔍 Verifying installation...')
- vprint('Checking all known installation locations...')
+ printf('')
+ printf('🔍 Verifying installation...', bold=True)
+ printf('Checking all known installation locations...', indent=3, verbose=True)
# Use find_executable to check all locations
executable, version = find_executable()
if not executable:
- print('❌ Hatch installation could not be verified\n', file=sys.stderr)
- print('🔧 Troubleshooting:', file=sys.stderr)
- print(' 1. Close and reopen your terminal', file=sys.stderr)
- print(' 2. Try running the installer again with --force', file=sys.stderr)
- print(' 3. Check the official Hatch installation guide:', file=sys.stderr)
- print(f' {INSTALLATION_GUIDE_URL}', file=sys.stderr)
+ printf('❌ Hatch installation could not be verified\n', fg='bright_red', file=sys.stderr)
+ printf('🔧 Troubleshooting:', fg='bright_white', file=sys.stderr)
+ printf('1. Close and reopen your terminal', indent=3, file=sys.stderr)
+ printf(f'2. Try running the installer again with {optstyle("--force")}', indent=3, file=sys.stderr)
+ printf('3. Check the official Hatch installation guide:', indent=3, file=sys.stderr)
+ printf(INSTALLATION_GUIDE_URL, dim=True, indent=6, file=sys.stderr)
return False
# Tool found!
- print(f'✅ Hatch installed successfully: {version}')
+ printf('✅ Installed successfully', fg='bright_green')
+ printf(version or '', indent=3)
# Check if it's in PATH
if executable == 'hatch':
- print('✅ Hatch is in your PATH and ready to use')
- vprint(f' Executable: {executable}')
+ printf('✅ In your PATH and ready to use', fg='bright_green')
+ printf(f'Executable: {executable}', indent=3, verbose=True)
return True
# Tool is installed but not in PATH
+ printf(f'📍 Found at: {pathstyle(executable)}\n')
+ printf('⚠️ Installed but not in your PATH', fg='bright_yellow')
+
+ install_dir = Path(executable).parent
+ shell = Shell.detect()
+
+ printf('')
+ printf('💡 To make it available globally, add it to your PATH:\n', fg='bright_white')
+ if shell:
+ rc_file = shell.rc_file
+ export_cmd = shell.path_export_command(install_dir)
+ printf(f'For {shell.value}, add this line to {pathstyle(rc_file)}:', indent=3)
+ printf(style(export_cmd, fg='bright_magenta'), indent=3)
+ printf('')
+ printf('Then run:', indent=3)
+ printf(style(f'source {folduser(rc_file)}', fg='bright_magenta'), indent=3)
else:
- print(f'📍 Found at: {executable}\n')
- print('⚠️ Hatch is installed but not in your PATH')
-
- install_dir = Path(executable).parent
- shell = detect_shell()
- rc_file = get_shell_rc_file()
- export_cmd = get_path_export_command(install_dir)
-
- print('')
- print('💡 To make Hatch available globally, add it to your PATH:\n')
- if rc_file and export_cmd and shell:
- print(f' For {shell}, add this line to {rc_file}:')
- print(f' {export_cmd}\n')
- print(' Then run:')
- print(f' source {rc_file}')
- else:
- print(f' Add {install_dir} to your PATH environment variable')
- print(" Consult your shell's documentation for instructions")
+ printf(f'Add {pathstyle(install_dir)} to your PATH environment variable', indent=3)
+ printf("Consult your shell's documentation for instructions", indent=3, dim=True)
- print('')
- print(f' Or use the full path: {executable}')
+ printf('')
+ printf(f'Or use the full path: {pathstyle(executable)}', indent=3)
- # Still consider this a success since the tool is installed
- return True
+ # Still consider this a success since the tool is installed
+ return True
+
+
+# ==================================================================================================
+# Command-line arguments
+# ==================================================================================================
+
+
+class Args(argparse.Namespace):
+ """
+ Annotated :class:`argparse.Namespace` returned by :func:`parse_args`.
+ """
+ force: bool # -f/--force
+ ensure: bool # -e/--ensure
+ verbose: bool # -v/--verbose
+ timeout: float # -t/--timeout
+ retries: int # -r/--retries
+ retry_delay: float # --retry-delay
+ download_methods: list[DownloadMethod] # -m/--download-method
-def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+def parse_args(argv: Sequence[str] | None = None) -> Args:
"""
- Build parser and parse command-line arguments
+ Build parser and parse command-line arguments.
- :param argv: argument list of parse, defaults to ``sys.argv[1:]``
- :return: :class:`argparse.Namespace` with parsed argument values
+ :param argv: argument list of parse. Defaults to ``sys.argv[1:]``
+ :return: annotated namespace of parsed args (:class:`Args`)
"""
global VERBOSE
+ PROG = style('%(prog)s', fg='bright_magenta')
+
+ def dim(text: Any, **opts: Any) -> str:
+ return style(text, dim=opts.pop('dim', True), **opts)
+
# Build argument parser
parser = argparse.ArgumentParser(
- description='Install Hatch build tool using official installer script',
+ description=style('Install Hatch build tool using official installer script', fg='bright_yellow'),
formatter_class=functools.partial(
- argparse.RawDescriptionHelpFormatter,
- max_help_position=50,
+ argparse.RawTextHelpFormatter,
+ max_help_position=80,
),
epilog='\n'.join([
- 'Examples:',
+ style('Examples:', fg='bright_blue', bold=True),
'',
- ' %(prog)s # Normal installation',
- ' %(prog)s --force # Force reinstall even if present',
- ' %(prog)s --verbose # Show detailed progress',
- ' %(prog)s --force -v # Force reinstall with verbose output',
- ' %(prog)s --retries 5 # Increase download retry attempts to 5',
- ' %(prog)s --retry-delay 3 # Set initial retry delay to 3 seconds',
- ' %(prog)s --retries 5 --retry-delay 3 # Custom retry configuration',
+ f' {PROG} {dim("# Normal installation")}',
+ f' {PROG} --force {dim("# Force reinstall even if present")}',
+ f' {PROG} --verbose {dim("# Show detailed progress")}',
+ f' {PROG} --force -v {dim("# Force reinstall with verbose output")}',
+ f' {PROG} --retries 5 {dim("# Increase download retry attempts to 5")}',
+ f' {PROG} --retry-delay 3 {dim("# Set initial retry delay to 3 seconds")}',
+ f' {PROG} --retries 5 --retry-delay 3 {dim("# Custom retry configuration")}',
+ f' {PROG} --download-method curl {dim("# Only use curl to download the installer")}',
+ f' {PROG} --download-method urllib curl {dim("# Try urllib first, then fall back to curl")}',
]),
add_help=False,
)
- execution_opts = parser.add_argument_group('Execution options')
- execution_opts.add_argument(
- '-f',
- '--force',
- action='store_true',
- help='Force installation even if Hatch is already installed',
- )
- execution_opts.add_argument(
- '-e',
- '--ensure',
- action='store_true',
- help='Install Hatch if it does not exist, otherwise quietly exit with no console output',
- )
-
- download_opts = parser.add_argument_group('Download options')
- download_opts.add_argument(
- '-t',
- '--timeout',
- type=float,
- default=DOWNLOAD_TIMEOUT,
- metavar='SECONDS',
- help=f'Download timeout in seconds (default: {DOWNLOAD_TIMEOUT})',
- )
- download_opts.add_argument(
- '-r',
- '--retries',
- type=int,
- default=DOWNLOAD_RETRIES,
- metavar='N',
- help=f'Maximum number of download retry attempts (default: {DOWNLOAD_RETRIES})',
- )
- download_opts.add_argument(
- '--retry-delay',
- type=float,
- default=DOWNLOAD_RETRY_DELAY,
- metavar='SECONDS',
- help=f'Initial delay in seconds between retries, uses exponential backoff (default: {DOWNLOAD_RETRY_DELAY})',
- )
-
- logging_opts = parser.add_argument_group('Logging options')
- logging_opts.add_argument(
- '-v',
- '--verbose',
- action='store_true',
- help='Enable verbose output for debugging',
- )
-
- other_opts = parser.add_argument_group('Other options')
- other_opts.add_argument(
- '-h',
- '--help',
- action='help',
- default=argparse.SUPPRESS,
- help='Show this help message and exit',
- )
+ def add_execution_opts() -> None:
+ """
+ Add execution option arguments (``--force``, ``--ensure``) to the parser.
+ """
+ execution_opts = parser.add_argument_group(style('Execution options', fg='bright_blue', bold=True))
+ execution_opts.add_argument(
+ '-f',
+ '--force',
+ action='store_true',
+ help=annotated_opt_help('Force installation even if Hatch is already installed'),
+ )
+ execution_opts.add_argument(
+ '-e',
+ '--ensure',
+ action='store_true',
+ help=annotated_opt_help(
+ 'Install Hatch if it does not exist, otherwise quietly exit with no console output'
+ ),
+ )
+ add_execution_opts()
+
+ def add_download_opts() -> None:
+ """
+ Add download option arguments (``--timeout``, ``--retries``, ``--retry-delay``) to the parser.
+ """
+ download_opts = parser.add_argument_group(style('Download options', fg='bright_blue', bold=True))
+ download_opts.add_argument(
+ '-t',
+ '--timeout',
+ type=float,
+ default=DOWNLOAD_TIMEOUT,
+ metavar='SECONDS',
+ help=annotated_opt_help(
+ 'Download timeout in seconds',
+ default=DOWNLOAD_TIMEOUT,
+ ),
+ )
+ download_opts.add_argument(
+ '-r',
+ '--retries',
+ type=int,
+ default=DOWNLOAD_RETRIES,
+ metavar='NUM',
+ help=annotated_opt_help(
+ 'Maximum number of download retry attempts',
+ default=DOWNLOAD_RETRIES,
+ ),
+ )
+ download_opts.add_argument(
+ '-d',
+ '--retry-delay',
+ type=float,
+ default=DOWNLOAD_RETRY_DELAY,
+ metavar='SECONDS',
+ help=annotated_opt_help(
+ 'Initial delay in seconds between retries, uses exponential backoff',
+ default=DOWNLOAD_RETRY_DELAY,
+ ),
+ )
+ download_opts.add_argument(
+ '-m',
+ '--download-method',
+ dest='download_methods',
+ nargs='+',
+ type=DownloadMethod,
+ choices=list(DownloadMethod),
+ default=list(DEFAULT_DOWNLOAD_METHODS),
+ metavar='METHOD',
+ help=annotated_opt_help(
+ 'Ordered download methods to try, highest priority first',
+ choices=[m.value for m in DownloadMethod],
+ default=[m.value for m in DEFAULT_DOWNLOAD_METHODS],
+ ),
+ )
+ add_download_opts()
+
+ def add_other_opts() -> None:
+ """
+ Add miscellaneous option arguments (``--help``) to the parser.
+ """
+ other_opts = parser.add_argument_group(style('Other options', fg='bright_blue', bold=True))
+ other_opts.add_argument(
+ '-v',
+ '--verbose',
+ action='store_true',
+ help='Enable verbose output for debugging',
+ )
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show this help message and exit',
+ )
+ add_other_opts()
# Parse arguments
- args = parser.parse_args(argv)
+ args = parser.parse_args(argv, namespace=Args())
# Set global config variables with parsed command-line option values
VERBOSE = args.verbose
# Validate retry parameters
if args.timeout < 0:
- print('❌ Error: --timeout cannot be negative', file=sys.stderr)
+ printf(f'❌ {optstyle("--timeout")} cannot be negative', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.INVALID_PARAMETERS)
if args.retries < 1:
- print('❌ Error: --retries must be at least 1', file=sys.stderr)
+ printf(f'❌ {optstyle("--retries")} must be at least 1', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.INVALID_PARAMETERS)
if args.retry_delay < 0:
- print('❌ Error: --retry-delay cannot be negative', file=sys.stderr)
+ printf(f'❌ {optstyle("--retry-delay")} cannot be negative', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.INVALID_PARAMETERS)
# Enforce mutual exclusivity of --force and --ensure
# Using ``add_mutually_exclusive_group()`` prevents us from adding a help text title to the execution options group
if args.force and args.ensure:
- print('❌ Error: --force and --ensure are mutually exclusive', file=sys.stderr)
+ printf(
+ f'❌ {optstyle("--force")} and {optstyle("--ensure")} are mutually exclusive',
+ fg='bright_red',
+ file=sys.stderr,
+ )
sys.exit(ExitCode.INVALID_PARAMETERS)
return args
-def main(argv: list[str] | None = None) -> None:
+# ==================================================================================================
+# Core script entry point
+# ==================================================================================================
+
+
+def main(argv: Sequence[str] | None = None) -> None:
"""
- Programmatically install Hatch using the official installer script
+ Programmatically install Hatch using the official installer script.
- :param argv: argument list of parse, defaults to ``sys.argv[1:]``
+ :param argv: argument list of parse. Defaults to ``sys.argv[1:]``
"""
# Parse command-line arguments
- args = parse_args(argv=argv)
+ args = parse_args(argv)
if args.verbose or not args.ensure:
- print('╔═══════════════════════════════╗')
- print('║ Hatch Installation Script ║')
- print('╚═══════════════════════════════╝')
- print()
- vprint(f'Download retry configuration: {args.retries} attempts, {args.retry_delay}s initial delay\n')
+ printf()
+ printf('Hatch installation', fg='bright_cyan', bold=True)
+ printf('─' * 77, dim=True)
+ printf(
+ f'Download configuration: methods={" → ".join(str(m) for m in args.download_methods)}, '
+ f'{args.retries} attempts, {args.retry_delay}s initial delay\n',
+ verbose=True,
+ )
# Check Python version
if not check_python_version():
@@ -531,52 +1150,74 @@ def main(argv: list[str] | None = None) -> None:
if not args.force:
if is_installed(quiet=args.ensure and not args.verbose):
if not args.ensure:
- print('⏭️ Skipping installation\n')
- print('💡 Tip: Use --force to reinstall')
+ printf('⏭️ Skipping installation')
+ printf(
+ f'{style("Use", fg="bright_white")} {optstyle("--force")} '
+ f'{style("to reinstall", fg="bright_white")}',
+ indent=3,
+ )
sys.exit(ExitCode.ALREADY_INSTALLED)
else:
- print('🔄 --force specified, proceeding with installation...\n')
+ printf(f'🔄 {optstyle("--force")} specified, proceeding with installation...\n', fg='bright_cyan')
# Get installer URL
try:
url = get_installer_url()
- vprint(f'Installer URL: {url}')
+ printf(f'Installer URL: {url}', verbose=True)
except OSError as e:
- print(f'❌ Error: {e}', file=sys.stderr)
+ printf(f'❌ {e!s}', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.UNSUPPORTED_PLATFORM)
# Download installer to temp location
- installer_path = Path.home() / '.hatch_installer.py'
- vprint(f'Temporary installer path: {installer_path}')
- if not download_installer(
+ installer_path = Path.home() / '.hatch-universal.pkg'
+ printf(f'Temporary installer path: {installer_path}', verbose=True)
+
+ # Try the official .pkg installer first; fall back to ``pip install hatch`` if either the download or the installer
+ # execution fails. Network/SSL errors typically affect the download step. Missing ``sudo`` or platform mismatches
+ # typically affect the installer step.
+ installed: bool = False
+ if download_installer(
url,
installer_path,
+ methods=args.download_methods,
timeout=args.timeout,
retries=args.retries,
retry_delay=args.retry_delay,
):
- sys.exit(ExitCode.DOWNLOAD_FAILED)
- print('')
-
- # Run the installer
- if not run_installer(installer_path):
+ printf('')
+ installed = run_installer(installer_path)
installer_path.unlink(missing_ok=True)
+ if not installed:
+ printf('⚠️ .pkg installer failed; trying pip fallback', fg='bright_yellow')
+
+ if not installed:
+ installed = install_via_pip()
+
+ if not installed:
+ printf('', file=sys.stderr)
+ printf('🔧 Troubleshooting:', fg='bright_white', file=sys.stderr)
+ printf('1. Check your internet connection', indent=3, file=sys.stderr)
+ printf('2. Verify you can access https://github.com', indent=3, file=sys.stderr)
+ printf('3. Check if a proxy is required in your network', indent=3, file=sys.stderr)
+ printf('4. For SSL errors, try: export SSL_CERT_FILE=$(python3 -m certifi)', indent=3, file=sys.stderr)
sys.exit(ExitCode.INSTALL_FAILED)
- # Clean up installer
- vprint(f'Cleaning up {installer_path}')
- installer_path.unlink(missing_ok=True)
-
# Verify installation
if not verify_installation():
sys.exit(ExitCode.VERIFICATION_FAILED)
- print('')
- print('🎉 Installation complete!\n')
- print('📋 Next steps:')
- print(' 1. Close and reopen your terminal (or run: source ~/.bashrc)')
- print(' 2. Verify with: hatch --version')
- print(f' 3. View documentation: {DOCS_URL}')
+ # We did it!
+ source_shell_rc_msg: str = ''
+ if shell := Shell.detect():
+ source_shell_rc_msg = f' (or run: {style("source", fg="bright_magenta")} {pathstyle(shell.rc_file)})'
+
+ printf('')
+ printf('🎉 Installation complete!\n', fg='bright_green', bold=True)
+ if not args.ensure:
+ printf('📋 Next steps:', fg='bright_cyan')
+ printf(f'1. Close and reopen your terminal{source_shell_rc_msg}', indent=3)
+ printf(f'2. Verify with: {style("hatch --version", fg="bright_magenta")}', indent=3)
+ printf(f'3. View documentation: {style(DOCS_URL, dim=True)}', indent=3)
sys.exit(ExitCode.SUCCESS)
diff --git a/scripts/install/install_uv.py b/scripts/install/install_uv.py
index b2f326d..96bc998 100755
--- a/scripts/install/install_uv.py
+++ b/scripts/install/install_uv.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
-Programmatically install UV using the official installer script
+Programmatically install uv using the official installer script.
"""
from __future__ import annotations
@@ -9,17 +9,28 @@
import functools
import os
import platform
+import re
+import shutil
+import ssl
import subprocess
import sys
import textwrap
import time
import urllib.error
import urllib.request
+from collections.abc import Sequence
from pathlib import Path
from typing import Any
+from typing import Callable
from typing import Protocol
from typing import TypeVar
from typing import Union
+from typing import cast
+
+# ==================================================================================================
+# Constants/defaults/globals
+# ==================================================================================================
+
# Configuration
MIN_PYTHON: tuple[int, int] = (3, 8) # Minimum supported python version. Script exits with error if not met
@@ -35,17 +46,28 @@
VERBOSE: bool = False
-PathLike = Union[Path, str]
+# ==================================================================================================
+# Types/protocols
+# ==================================================================================================
+
+
+PathLike = Union[Path, str]
+StyleColor = Union[int, tuple[int, int, int], str]
+
+T_contra = TypeVar('T_contra', contravariant=True)
-T_contra = TypeVar('T_contra', contravariant=True)
class SupportsWrite(Protocol[T_contra]):
- def write(self, s: T_contra, /) -> object: ...
+ """
+ Protocol for writable things (like :attr:`sys.stdout` and :attr:`sys.stderr`).
+ """
+ def write(self, s: T_contra, /) -> object:
+ ...
class ExitCode(int, enum.Enum):
"""
- Script exit codes
+ Script exit codes.
"""
SUCCESS = 0
ALREADY_INSTALLED = 0
@@ -57,135 +79,416 @@ class ExitCode(int, enum.Enum):
PYTHON_VERSION = 6
-def vprint(*args: Any, **kwargs: Any) -> None:
+class DownloadMethod(str, enum.Enum):
"""
- Print only if verbose mode is enabled
+ Installer download methods, ordered by the priority in which they may be attempted.
+
+ ``curl`` is preferred over ``urllib`` by default because curl uses the platform's native trust store
+ (e.g. SecureTransport on macOS)
"""
- if VERBOSE:
- print(*args, file=kwargs.pop('file', sys.stderr), **kwargs)
+ CURL = 'curl'
+ URLLIB = 'urllib'
+
+ def __str__(self) -> str:
+ return self.value
+
+
+# Default ordered list of download methods to attempt. curl is tried before urllib (see :class:`DownloadMethod`)
+DEFAULT_DOWNLOAD_METHODS: tuple[DownloadMethod, ...] = (DownloadMethod.CURL, DownloadMethod.URLLIB)
+
+
+# ==================================================================================================
+# Formatting/logging
+# ==================================================================================================
+
+ANSI_COLORS: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+}
+ANSI_RESET_ALL = '\033[0m'
-def indent(s: str, num: int) -> str:
+
+def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
"""
- Indent string by given number of spaces
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
- :param s: string to indent
- :param num: number of spaces to indent each line in string `s` with
- :return: indented string
+ :param _color: color as a name string, 256-color int, or (r, g, b) tuple
+ :param offset: offset to add for background colors (10 for bg, 0 for fg)
+ :return: ANSI SGR parameter string (e.g. ``'38;5;196'``)
"""
- return textwrap.indent(s, ' ' * num)
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(ANSI_COLORS[_color] + offset)
-def check_python_version() -> bool:
+def style(
+ text: Any,
+ *,
+ fg: StyleColor | None = None,
+ bg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ underline: bool | None = None,
+ overline: bool | None = None,
+ italic: bool | None = None,
+ blink: bool | None = None,
+ reverse: bool | None = None,
+ strikethrough: bool | None = None,
+ reset: bool = True,
+) -> str:
+ """
+ Style text with ANSI escape codes.
+
+ :param text: the string to style with ansi codes
+ :param fg: foreground color
+ :param bg: background color
+ :param bold: enable or disable bold mode
+ :param dim: enable or disable dim mode
+ :param underline: enable or disable underline
+ :param overline: enable or disable overline
+ :param italic: enable or disable italic
+ :param blink: enable or disable blinking
+ :param reverse: enable or disable inverse rendering
+ :param strikethrough: enable or disable striking through text
+ :param reset: add a reset-all code at the end of the string
+ :return: styled text
"""
- Check minimum Python version requirement (:attr:`MAX_PYTHON` above)
+ if not isinstance(text, str):
+ text = str(text)
- :return: True if minimum python version requirement met, False if not
+ bits: list[str] = []
+ if fg:
+ try:
+ bits.append(f'\033[{_interpret_color(fg)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {fg!r}') from None
+ if bg:
+ try:
+ bits.append(f'\033[{_interpret_color(bg, 10)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {bg!r}') from None
+ if bold is not None:
+ bits.append(f'\033[{1 if bold else 22}m')
+ if dim is not None:
+ bits.append(f'\033[{2 if dim else 22}m')
+ if underline is not None:
+ bits.append(f'\033[{4 if underline else 24}m')
+ if overline is not None:
+ bits.append(f'\033[{53 if overline else 55}m')
+ if italic is not None:
+ bits.append(f'\033[{3 if italic else 23}m')
+ if blink is not None:
+ bits.append(f'\033[{5 if blink else 25}m')
+ if reverse is not None:
+ bits.append(f'\033[{7 if reverse else 27}m')
+ if strikethrough is not None:
+ bits.append(f'\033[{9 if strikethrough else 29}m')
+
+ bits.append(text)
+ if reset:
+ bits.append(ANSI_RESET_ALL)
+ return ''.join(bits)
+
+
+def unstyle(text: str) -> str:
"""
- if sys.version_info < MIN_PYTHON:
- print(
- f'❌ Error: '
- f'Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ required, '
- f'but you have Python {sys.version_info.major}.{sys.version_info.minor}',
- file=sys.stderr,
- )
- return False
- else:
- vprint(f'✅ Python version {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')
- return True
+ Remove ANSI styling information from a string.
+
+ :param text: the text to remove style information from
+ :return: string with ANSI styling characters removed
+ """
+ return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text)
-def detect_shell() -> str | None:
+def folduser(path: PathLike) -> str:
"""
- Detect the user's shell
+ Does the opposite of :meth:`pathlib.Path.expanduser`, replacing the user's home directory with a "~".
- :return: current shell
+ :param path: path
+ :return: str folded path
"""
- if shell := os.environ.get('SHELL', ''):
- return Path(shell).name
- else:
- return None
+ home = str(Path.home())
+ return str(path).replace(home, '~')
-def get_shell_rc_file() -> Path | None:
+def pathstyle(path: PathLike, **kwargs: Any) -> str:
"""
- Get the appropriate shell RC file path
+ Style a file path with magenta foreground and home directory condensed to ``~``.
- :return: path to shell RC file for current shell
+ :param path: path to style
+ :return: styled path string
"""
- shell = detect_shell()
- home = Path.home()
+ return style(folduser(path), fg='magenta', **kwargs)
- shell_rc_map: dict[str, Path] = {
- 'bash': home / '.bashrc',
- 'zsh': home / '.zshrc',
- 'fish': home / '.config' / 'fish' / 'config.fish',
- 'tcsh': home / '.tcshrc',
- 'csh': home / '.cshrc',
- }
- # For macOS, bash uses .bash_profile instead of .bashrc
- if (shell == 'bash') and (platform.system() == 'Darwin'):
- bash_profile = home / '.bash_profile'
- if bash_profile.exists():
- return bash_profile
+def optstyle(text: Any, **opts: Any) -> str:
+ """
+ Style a CLI option name in yellow.
- return shell_rc_map.get(shell) if shell else None
+ :param text: the option name to style
+ :param opts: additional keyword arguments forwarded to :func:`style`
+ :return: ANSI-styled string
+ """
+ return style(text, fg='yellow', **opts)
-def get_path_export_command(install_dir: Path) -> str | None:
+def typestyle(val: Any, **opts: Any) -> str:
"""
- Get the command to add directory to PATH based on shell
+ Style a Python value by its type using semantic colors (roughly matching default repr styling provided by rich).
- :param install_dir: directory path
- :return: str shell command
+ Color mapping:
+
+ - ``None`` = magenta italic
+ - ``bool`` = green (True) / red (False) italic
+ - ``Path`` = magenta with folduser
+ - ``int``/``float`` = bright cyan bold
+ - ``str`` = green
+
+ Other types fall through to ``str(val)`` unstyled
+
+ :param val: the Python value to style
+ :return: the ANSI-styled representation
"""
- shell = detect_shell()
- if shell in ('bash', 'zsh'):
- return f'export PATH="{install_dir}:$PATH"'
- elif shell == 'fish':
- return f'set -gx PATH "{install_dir}" $PATH'
- elif shell in ('tcsh', 'csh'):
- return f'setenv PATH "{install_dir}:$PATH"'
- else:
+ if val is None:
+ return style(str(val), fg='magenta', italic=True, **opts)
+ if isinstance(val, bool):
+ return style(str(val), fg=f'bright_{"green" if val else "red"}', italic=True, **opts)
+ if isinstance(val, Path):
+ return style(folduser(val), fg='magenta', **opts)
+ if isinstance(val, (int, float)):
+ return style(repr(val), fg='bright_cyan', bold=True, **opts)
+ if isinstance(val, str):
+ return style(repr(val), fg='green', **opts)
+ if isinstance(val, Sequence):
+ return ''.join([
+ style('[', fg='bright_white'),
+ ', '.join(typestyle(item, **opts) for item in val),
+ style(']', fg='bright_white'),
+ ])
+ return style(val, **opts)
+
+
+def annotated_opt_help(
+ opt_help: str,
+ *extra_help_lines: str,
+ choices: Sequence[Any] | None = None,
+ default: Any | None = None,
+ default_fg: StyleColor | None = None,
+ envvar: str | None = None,
+ extra_line: bool = True,
+) -> str:
+ """
+ Build a styled argparse help string with optional default value and environment variable annotations.
+
+ :param opt_help: core help text for the option (will be styled "bright_white")
+ :param extra_help_lines: additional help text lines (joined by newlines) for the option. Outputted with no styling
+ after `opt_help` and before `default`/`envvar`
+ :param choices: valid option value choices to display below the help text
+ :param default: default value to display below the help text
+ :param default_fg: explicit foreground color for the default value (overrides :func:`typestyle`)
+ :param envvar: environment variable name that can override this option
+ :param extra_line: if True (default), extra blank line included at the end of the option help text (visually spaces
+ sequential options apart from each other)
+ :return: multi-line styled help string with annotations appended
+ """
+ help_lines: list[str] = style(opt_help, fg='bright_white').splitlines()
+ for extra_help_line in extra_help_lines:
+ help_lines.extend(extra_help_line.splitlines())
+ if choices is not None:
+ help_lines.append(f' choices: {typestyle(choices)}')
+ if default is not None:
+ help_lines.append(f' default: {typestyle(default) if default_fg is None else style(default, fg=default_fg)}')
+ if envvar is not None:
+ help_lines.append(f' env var: {style(envvar, fg="cyan")}')
+ if extra_line:
+ help_lines.append(' ')
+ return '\n'.join(help_lines)
+
+
+def printf(
+ text: str = '',
+ *,
+ fg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ indent: int = 0,
+ file: SupportsWrite[str] | None = None,
+ verbose: bool = False,
+) -> None:
+ """
+ Print styled text with optional indent and verbose gating.
+
+ :param text: text to print
+ :param fg: foreground color
+ :param bold: bold mode
+ :param dim: dim mode
+ :param indent: number of spaces to indent output by
+ :param file: output file (defaults to stdout, or stderr for verbose output)
+ :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr
+ """
+ if verbose and not VERBOSE:
+ return
+ if verbose:
+ fg = fg or 'bright_white'
+ dim = True if dim is None else dim
+ file = file or sys.stderr
+
+ print(
+ style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim),
+ file=file,
+ )
+
+
+# ==================================================================================================
+# Python checks
+# ==================================================================================================
+
+
+def check_python_version() -> bool:
+ """
+ Check minimum Python version requirement (:attr:`MIN_PYTHON` above).
+
+ :return: True if minimum python version requirement met, False if not
+ """
+ if sys.version_info < MIN_PYTHON:
+ printf(
+ f'❌ Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ required, '
+ f'but you have Python {sys.version_info.major}.{sys.version_info.minor}',
+ fg='bright_red',
+ file=sys.stderr,
+ )
+ return False
+ vers_str = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
+ printf(f'✅ Python version {vers_str}', verbose=True)
+ return True
+
+
+# ==================================================================================================
+# Shell detection & path mapping
+# ==================================================================================================
+
+
+class Shell(enum.Enum):
+ """
+ Known shells with their associated RC file paths and PATH export syntax.
+ """
+ BASH = 'bash'
+ ZSH = 'zsh'
+ FISH = 'fish'
+ TCSH = 'tcsh'
+ CSH = 'csh'
+
+ @classmethod
+ def detect(cls) -> Shell | None:
+ """
+ Detect the user's shell from the ``$SHELL`` environment variable.
+
+ :return: detected :class:`Shell` member, or ``None`` if unrecognized or unset
+ """
+ if shell := os.environ.get('SHELL', ''):
+ name = Path(shell).name
+ try:
+ return cls(name)
+ except ValueError:
+ return None
return None
+ @property
+ def rc_file(self) -> Path:
+ """
+ Get the appropriate RC file path for this shell.
+
+ On macOS, bash prefers ``~/.bash_profile`` over ``~/.bashrc`` when the file exists.
+
+ :return: path to the shell's RC file
+ """
+ home = Path.home()
+ rc_map: dict[Shell, Path] = {
+ Shell.BASH: home / '.bashrc',
+ Shell.ZSH: home / '.zshrc',
+ Shell.FISH: home / '.config' / 'fish' / 'config.fish',
+ Shell.TCSH: home / '.tcshrc',
+ Shell.CSH: home / '.cshrc',
+ }
+ if (self is Shell.BASH) and (platform.system() == 'Darwin'):
+ bash_profile = home / '.bash_profile'
+ if bash_profile.exists():
+ return bash_profile
+ return rc_map[self]
+
+ def path_export_command(self, directory: PathLike) -> str:
+ """
+ Get the command to add a directory to ``PATH`` for this shell.
+
+ :param directory: directory to prepend to ``PATH``
+ :return: shell-specific export command string
+ """
+ directory_path = Path(directory).expanduser().resolve()
+ if self in (Shell.BASH, Shell.ZSH):
+ return f'export PATH="{directory_path}:$PATH"'
+ if self is Shell.FISH:
+ return f'set -gx PATH "{directory_path}" $PATH'
+ # TCSH, CSH
+ return f'setenv PATH "{directory_path}:$PATH"'
+
+
+# ==================================================================================================
+# `uv` local executable checks
+# ==================================================================================================
+
# TODO: support brew install location as well
# TODO: allow installation from current environment as well (from `pip install`)
def get_common_install_locations() -> list[Path]:
"""
- Get common installation locations for UV based on platform
+ Get common installation locations for uv on macOS.
:return: list of filepaths (:class:`pathlib.Path`)
"""
- home = Path.home()
- system = platform.system().lower()
-
- locations: list[Path] = []
- if system in ('linux', 'darwin'):
- locations = [
- home / '.cargo' / 'bin' / 'uv',
- home / '.local' / 'bin' / 'uv',
- Path('/usr/local/bin/uv'),
- Path('/usr/bin/uv'),
- ]
- elif system == 'windows':
- locations = [
- home / '.cargo' / 'bin' / 'uv.exe',
- home / '.local' / 'bin' / 'uv.exe',
- home / 'AppData' / 'Local' / 'uv' / 'uv.exe',
- Path('C:/Program Files/uv/uv.exe'),
- ]
+ home = Path.home()
+ locations: list[Path] = [
+ home / '.cargo' / 'bin' / 'uv',
+ home / '.local' / 'bin' / 'uv',
+ Path('/usr/local/bin/uv'),
+ Path('/usr/bin/uv'),
+ ]
+
+ # macOS pip ``--user`` installs land in ``~/Library/Python//bin/uv`` (one entry per framework Python).
+ # Scanning catches uv installed by ``install_via_pip`` even when the system python3 falls back to PEP 370 user-base,
+ # plus any pre-existing pip --user installs.
+ library_python = home / 'Library' / 'Python'
+ if library_python.is_dir():
+ for child in sorted(library_python.iterdir()):
+ uv_path = child / 'bin' / 'uv'
+ if uv_path not in locations:
+ locations.append(uv_path)
+
+ # Locations registered dynamically by ``install_via_pip`` (e.g. system python3 scripts dir)
+ for path in PIP_INSTALL_LOCATIONS:
+ if path not in locations:
+ locations.append(path)
+
return locations
def find_executable(*, path_only: bool = False) -> tuple[str | None, str | None]:
"""
- Try to find the UV executable in common locations
+ Try to find the uv executable in common locations.
:param path_only: if True, only look for executable in PATH. If False, look for executable in other common locations
- where UV might be installed
+ where uv might be installed
:return: tuple (executable path, version)
"""
@@ -223,7 +526,7 @@ def find_executable(*, path_only: bool = False) -> tuple[str | None, str | None]
def is_installed(*, quiet: bool = False) -> bool:
"""
- Check if UV is already installed
+ Check if uv is already installed.
:param quiet: suppress console output if True
:return: True if `uv` already installed, False if not
@@ -231,32 +534,60 @@ def is_installed(*, quiet: bool = False) -> bool:
executable, version = find_executable()
if executable:
if not quiet:
- print(f'✅ UV is already installed: {version}')
+ printf(f'✅ Already installed: {style(version, fg="bright_white")}', fg='bright_green')
if executable != 'uv':
- print(f' 📍 Found at: {executable}')
- print(' 💡 Note: Consider adding this location to your PATH')
+ printf(f'Found at: {pathstyle(executable)}', indent=3)
+ printf(
+ style('NOTE: ', fg='magenta', bold=True) +
+ style('consider adding this to your PATH', fg='bright_white'),
+ indent=3,
+ )
return True
- else:
- return False
+ return False
+
+
+# ==================================================================================================
+# `uv` installer retrieval/execution
+# ==================================================================================================
def get_installer_url() -> str:
"""
- Get the appropriate UV installer URL based on the platform
+ Get the uv installer URL for macOS.
- :raises OSError: if current platform/operating system is not supported
+ :raises OSError: if current platform is not macOS
:return: installer URL
"""
- system = platform.system().lower()
- if system in ('linux', 'darwin'):
- return 'https://astral.sh/uv/install.sh'
- elif system == 'windows':
- return 'https://astral.sh/uv/install.ps1'
+ if platform.system().lower() != 'darwin':
+ raise OSError(f'This installer only supports macOS (got: {platform.system()})')
+ return 'https://astral.sh/uv/install.sh'
+
+
+def write_installer(dest: PathLike, content: bytes) -> bool:
+ """
+ Write downloaded installer bytes to disk, surfacing helpful troubleshooting tips on failure.
+
+ :param dest: destination file path
+ :param content: bytes to write
+ :return: True if successful, False if not
+ """
+ try:
+ with Path(dest).expanduser().open('wb') as f:
+ f.write(content)
+ except (PermissionError, OSError) as e:
+ printf(f'❌ Failed to write installer to disk: {e!s}', fg='bright_red', file=sys.stderr)
+ printf('', file=sys.stderr)
+ printf('🔧 Troubleshooting:', fg='bright_white', file=sys.stderr)
+ printf(f'1. Check write permissions for: {pathstyle(dest)}', indent=3, file=sys.stderr)
+ printf('2. Verify sufficient disk space is available', indent=3, file=sys.stderr)
+ printf('3. Ensure the parent directory exists', indent=3, file=sys.stderr)
+ return False
else:
- raise OSError(f'Unsupported operating system: {system}')
+ printf(f'✅ Downloaded to {pathstyle(dest)}', fg='bright_green')
+ return True
-def download_installer(
+def download_with_urllib(
url: str,
dest: PathLike,
*,
@@ -265,60 +596,177 @@ def download_installer(
retry_delay: float = DOWNLOAD_RETRY_DELAY,
) -> bool:
"""
- Download the installer script with retry logic
+ Download a file using Python's :mod:`urllib`.
- :param url: installer URL
- :param dest: where to download installer to
- :param timeout: download timeout in seconds
- :param retries: maximum number of download retry attempts
+ Aborts early on SSL verification errors so the curl fallback can be tried sooner.
+ SSL errors are not transient and additional retries will not recover.
+
+ :param url: source URL
+ :param dest: destination file path
+ :param timeout: per-request timeout in seconds
+ :param retries: maximum number of retry attempts
:param retry_delay: initial delay in seconds between retries, before exponential backoff
:return: True if successful, False if not
"""
+ content: bytes | None = None
for attempt in range(1, retries + 1):
try:
if attempt > 1:
delay = retry_delay * (2 ** (attempt - 2)) # Exponential backoff
- print(f'⏱️ Retrying in {delay} seconds... (attempt {attempt}/{retries})')
+ printf(f'⏱️ Retrying in {delay} seconds... (attempt {attempt}/{retries})', fg='bright_yellow')
time.sleep(delay)
- print(f'⏬ Downloading installer from {url}...')
- vprint(f' Attempt {attempt}/{retries}')
+ printf(f'⏬ Downloading installer from {url} (urllib)...', bold=True)
+ printf(f'Attempt {attempt}/{retries}', indent=3, verbose=True)
- with urllib.request.urlopen(url, timeout=timeout) as response:
+ # A real-browser User-Agent is required: the default ``Python-urllib/X.Y`` is rejected (403) by the
+ # bot-protection in front of ``releases.astral.sh`` (where ``astral.sh/uv/install.sh`` redirects to)
+ # rdar://177045555 ([ezcli] `install_uv.py` fails to download installer with "HTTP Error 403: Forbidden")
+ request = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) # noqa: S310
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
content = response.read()
- with Path(dest).expanduser().open('wb') as f:
- f.write(content)
except urllib.error.URLError as e:
- print(f'❌ Download failed: {e}', file=sys.stderr)
+ printf(f'❌ urllib download failed: {e!s}', fg='bright_red', file=sys.stderr)
+ if isinstance(e.reason, ssl.SSLError):
+ printf('SSL verification failed; aborting urllib retries', indent=3, verbose=True, file=sys.stderr)
+ return False
if attempt == retries:
- print('', file=sys.stderr)
- print('🔧 Troubleshooting:', file=sys.stderr)
- print(' 1. Check your internet connection', file=sys.stderr)
- print(' 2. Verify you can access https://astral.sh', file=sys.stderr)
- print(' 3. Check if a proxy is required in your network', file=sys.stderr)
return False
except Exception as e:
- print(f'❌ Unexpected error during download: {e}', file=sys.stderr)
+ printf(f'❌ Unexpected error during urllib download: {e!s}', fg='bright_red', file=sys.stderr)
if attempt == retries:
return False
+ # Download succeeded, break out of retry loop
else:
- print(f'✅ Downloaded to {dest}')
+ break
+
+ if content is None:
+ return False
+ return write_installer(dest, content)
+
+
+def download_with_curl(
+ url: str,
+ dest: PathLike,
+ *,
+ timeout: float = DOWNLOAD_TIMEOUT,
+ retries: int = DOWNLOAD_RETRIES,
+ retry_delay: float = DOWNLOAD_RETRY_DELAY,
+) -> bool:
+ """
+ Download a file using the system ``curl`` executable.
+
+ Useful as a fallback when :mod:`urllib` fails with SSL certificate verification errors — since curl uses the
+ platform's native trust store (e.g. SecureTransport on macOS)
+
+ :param url: source URL
+ :param dest: destination file path
+ :param timeout: per-request timeout in seconds
+ :param retries: maximum number of retry attempts
+ :param retry_delay: initial delay in seconds between retries, before exponential backoff
+ :return: True if successful, False if not
+ """
+ curl = shutil.which('curl')
+ if curl is None:
+ printf('curl not available, skipping curl fallback', verbose=True, file=sys.stderr)
+ return False
+
+ dest_path = Path(dest).expanduser()
+ curl_cmd: list[str] = [
+ curl,
+ '--fail',
+ '--silent',
+ '--show-error',
+ '--location',
+ '--max-time', str(int(timeout)),
+ '--output', str(dest_path),
+ url,
+ ]
+
+ for attempt in range(1, retries + 1):
+ if attempt > 1:
+ delay = retry_delay * (2 ** (attempt - 2)) # Exponential backoff
+ printf(f'⏱️ Retrying in {delay} seconds... (attempt {attempt}/{retries})', fg='bright_yellow')
+ time.sleep(delay)
+
+ printf(f'⏬ Downloading installer from {url} (curl)...', bold=True)
+ printf(f'Attempt {attempt}/{retries}', indent=3, verbose=True)
+ printf(f'Running: {" ".join(curl_cmd)}', indent=3, verbose=True)
+ try:
+ subprocess.run(curl_cmd, check=True, capture_output=True, text=True)
+ except subprocess.CalledProcessError as e:
+ err = (e.stderr or '').strip() or str(e)
+ printf(f'❌ curl download failed: {err}', fg='bright_red', file=sys.stderr)
+ if attempt == retries:
+ return False
+ else:
+ printf(f'✅ Downloaded to {pathstyle(dest_path)}', fg='bright_green')
+ return True
+
+ return False
+
+
+# Dispatch table mapping each download method to its implementation.
+# Both functions share the same signature ``(url, dest, *, timeout, retries, retry_delay) -> bool``
+DOWNLOAD_FUNCS: dict[DownloadMethod, Callable[..., bool]] = {
+ DownloadMethod.CURL: download_with_curl,
+ DownloadMethod.URLLIB: download_with_urllib,
+}
+
+
+def download_installer(
+ url: str,
+ dest: PathLike,
+ *,
+ methods: Sequence[DownloadMethod] = DEFAULT_DOWNLOAD_METHODS,
+ timeout: float = DOWNLOAD_TIMEOUT,
+ retries: int = DOWNLOAD_RETRIES,
+ retry_delay: float = DOWNLOAD_RETRY_DELAY,
+) -> bool:
+ """
+ Download the installer script, trying each configured download method in order until one succeeds.
+
+ The default order (see :attr:`DEFAULT_DOWNLOAD_METHODS`) tries ``curl`` before :mod:`urllib`. curl handles
+ environments where Python's bundled OpenSSL CA list cannot validate the TLS chain to ``url``
+
+ :param url: installer URL
+ :param dest: where to download installer to
+ :param methods: ordered download methods to attempt; each is tried until one succeeds
+ :param timeout: download timeout in seconds
+ :param retries: maximum number of download retry attempts
+ :param retry_delay: initial delay in seconds between retries, before exponential backoff
+ :raises ValueError: if `url` does not start with "http:" or "https:"
+ :return: True if successful, False if not
+ """
+ if not url.startswith(('http:', 'https:')):
+ raise ValueError("URL must start with 'http:' or 'https:'")
+
+ ordered_methods = list(methods) or list(DEFAULT_DOWNLOAD_METHODS)
+ for index, method in enumerate(ordered_methods):
+ download_func = DOWNLOAD_FUNCS[method]
+ if download_func(url, dest, timeout=timeout, retries=retries, retry_delay=retry_delay):
return True
+ # Announce the fallback to the next method, if any remain
+ if index < len(ordered_methods) - 1:
+ next_method = ordered_methods[index + 1]
+ printf(f'⚠️ {method} download failed; falling back to {next_method}', fg='bright_yellow')
+ printf('')
+
return False
-def run_installer_unix(installer_path: PathLike) -> bool:
+def run_installer(installer_path: PathLike) -> bool:
"""
- Execute the UV installer script on Unix-like systems
+ Execute the uv installer shell script.
:param installer_path: path to installer script
:return: True on success, False on failure
"""
- print('💿 Running UV installer...')
+ printf('💿 Running uv installer...', bold=True)
try:
result = subprocess.run(
['sh', str(installer_path)],
@@ -327,245 +775,371 @@ def run_installer_unix(installer_path: PathLike) -> bool:
text=True,
)
except subprocess.CalledProcessError as e:
- print(f'❌ Installation failed: {e}', file=sys.stderr)
- print(indent(e.stderr, 3), file=sys.stderr)
+ printf(f'❌ Installation failed: {e!s}', fg='bright_red', file=sys.stderr)
+ printf(e.stderr, indent=3, file=sys.stderr)
return False
else:
- print(indent(result.stdout, 3))
+ printf(result.stdout, indent=3)
if result.stderr:
- print(indent(result.stderr, 3))
+ printf(result.stderr, indent=3)
return True
-def run_installer_windows(installer_path: PathLike) -> bool:
+# ==================================================================================================
+# `pip`-based installation (last-resort fallback)
+# ==================================================================================================
+
+
+# Locations registered dynamically by :func:`install_via_pip` so :func:`find_executable` can find uv after a
+# successful pip install on this run.
+PIP_INSTALL_LOCATIONS: list[Path] = []
+
+
+def resolve_system_python() -> str | None:
"""
- Execute the UV installer script on Windows
+ Locate a system ``python3`` executable suitable for the pip-install fallback.
- :param installer_path: path to installer script
- :return: True on success, False on failure
+ Prefers the macOS system interpreter at ``/usr/bin/python3``, then anything named ``python3`` on ``PATH``.
+
+ :return: absolute path to a python3 executable, or ``None`` if none can be found
+ """
+ system_python = Path('/usr/bin/python3')
+ if system_python.exists():
+ return str(system_python)
+ return shutil.which('python3')
+
+
+def register_pip_install_location(python_exe: str) -> None:
+ """
+ Register the scripts directory of ``python_exe`` so :func:`find_executable` can find ``uv``.
+
+ Asks the interpreter for its ``sysconfig`` scripts path and appends ``/uv`` to the module-level
+ :attr:`PIP_INSTALL_LOCATIONS` list.
+
+ :param python_exe: path to a python interpreter that just ran ``pip install uv``
"""
- print('💿 Running UV installer...')
try:
result = subprocess.run(
- ['powershell', '-ExecutionPolicy', 'Bypass', '-File', str(installer_path)],
+ [python_exe, '-c', 'import sysconfig; print(sysconfig.get_path("scripts"))'],
check=True,
capture_output=True,
text=True,
)
- except subprocess.CalledProcessError as e:
- print(f'❌ Installation failed: {e}', file=sys.stderr)
- print(indent(e.stderr, 3), file=sys.stderr)
- return False
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return
else:
- print(indent(result.stdout, 3))
- if result.stderr:
- print(indent(result.stderr, 3))
- return True
+ candidate = Path(result.stdout.strip()) / 'uv'
+ if candidate not in PIP_INSTALL_LOCATIONS:
+ PIP_INSTALL_LOCATIONS.append(candidate)
+ printf(f'Registered pip install location: {pathstyle(candidate)}', indent=3, verbose=True)
-def run_installer(installer_path: PathLike) -> bool:
+def install_via_pip() -> bool:
"""
- Execute the UV installer script based on platform
+ Install uv via ``python3 -m pip install uv`` using the system ``python3`` executable.
+
+ Used as a last-resort fallback when neither :mod:`urllib` nor ``curl`` could fetch the official installer script.
+ Installs into the system Python's site-packages so the resulting ``uv`` executable lands in that interpreter's
+ scripts directory.
- :param installer_path: path to installer script
:return: True on success, False on failure
"""
- system = platform.system().lower()
- if system in ('linux', 'darwin'):
- return run_installer_unix(installer_path)
- elif system == 'windows':
- return run_installer_windows(installer_path)
- else:
- print(f'❌ Unsupported platform: {system}', file=sys.stderr)
+ printf('')
+ printf('💿 Falling back to pip install uv (system python3)...', bold=True)
+
+ python3 = resolve_system_python()
+ if python3 is None:
+ printf('❌ Could not find a python3 executable for pip fallback', fg='bright_red', file=sys.stderr)
+ return False
+ printf(f'Using python: {pathstyle(python3)}', indent=3, verbose=True)
+
+ cmd: list[str] = [python3, '-m', 'pip', 'install', 'uv']
+ printf(f'Running: {" ".join(cmd)}', indent=3, verbose=True)
+ try:
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
+
+ except subprocess.CalledProcessError as e:
+ printf(f'❌ pip install uv failed (exit {e.returncode})', fg='bright_red', file=sys.stderr)
+ if e.stdout:
+ printf(e.stdout.rstrip(), indent=3, file=sys.stderr)
+ if e.stderr:
+ printf(e.stderr.rstrip(), indent=3, file=sys.stderr)
+ return False
+
+ except FileNotFoundError as e:
+ printf(f'❌ pip install uv failed: {e!s}', fg='bright_red', file=sys.stderr)
return False
+ else:
+ if VERBOSE and result.stdout:
+ printf(result.stdout.rstrip(), indent=3, verbose=True)
+ printf('✅ Installed uv via pip', fg='bright_green')
+ register_pip_install_location(python3)
+ return True
+
def verify_installation() -> bool:
"""
- Verify that UV was installed successfully. Provides detailed guidance if tool is installed but not in PATH
+ Verify that uv was installed successfully. Provides detailed guidance if tool is installed but not in PATH.
:return: True on success, False on failure
"""
- print('')
- print('🔍 Verifying installation...')
- vprint('Checking all known installation locations...')
+ printf('')
+ printf('🔍 Verifying installation...', bold=True)
+ printf('Checking all known installation locations...', indent=3, verbose=True)
# Use find_executable to check all locations
executable, version = find_executable()
if not executable:
- print('❌ UV installation could not be verified\n', file=sys.stderr)
- print('🔧 Troubleshooting:', file=sys.stderr)
- print(' 1. Close and reopen your terminal', file=sys.stderr)
- print(' 2. Try running the installer again with --force', file=sys.stderr)
- print(' 3. Check the official UV installation guide:', file=sys.stderr)
- print(f' {INSTALLATION_GUIDE_URL}', file=sys.stderr)
+ printf('❌ uv installation could not be verified\n', fg='bright_red', file=sys.stderr)
+ printf('🔧 Troubleshooting:', fg='bright_white', file=sys.stderr)
+ printf('1. Close and reopen your terminal', indent=3, file=sys.stderr)
+ printf(f'2. Try running the installer again with {optstyle("--force")}', indent=3, file=sys.stderr)
+ printf('3. Check the official uv installation guide:', indent=3, file=sys.stderr)
+ printf(INSTALLATION_GUIDE_URL, dim=True, indent=6, file=sys.stderr)
return False
# Tool found!
- print(f'✅ UV installed successfully: {version}')
+ printf('✅ Installed successfully', fg='bright_green')
+ printf(version or '', indent=3)
# Check if it's in PATH
if executable == 'uv':
- print('✅ UV is in your PATH and ready to use')
- vprint(f' Executable: {executable}')
+ printf('✅ In your PATH and ready to use', fg='bright_green')
+ printf(f'Executable: {executable}', indent=3, verbose=True)
return True
# Tool is installed but not in PATH
+ printf(f'📍 Found at: {pathstyle(executable)}\n')
+ printf('⚠️ Installed but not in your PATH', fg='bright_yellow')
+
+ install_dir = Path(executable).parent
+ shell = Shell.detect()
+
+ printf('')
+ printf('💡 To make it available globally, add it to your PATH:\n', fg='bright_white')
+ if shell:
+ rc_file = shell.rc_file
+ export_cmd = shell.path_export_command(install_dir)
+ printf(f'For {shell.value}, add this line to {pathstyle(rc_file)}:', indent=3)
+ printf(style(export_cmd, fg='bright_magenta'), indent=3)
+ printf('')
+ printf('Then run:', indent=3)
+ printf(style(f'source {folduser(rc_file)}', fg='bright_magenta'), indent=3)
else:
- print(f'📍 Found at: {executable}\n')
- print('⚠️ UV is installed but not in your PATH')
-
- install_dir = Path(executable).parent
- shell = detect_shell()
- rc_file = get_shell_rc_file()
- export_cmd = get_path_export_command(install_dir)
-
- print('')
- print('💡 To make UV available globally, add it to your PATH:\n')
- if rc_file and export_cmd and shell:
- print(f' For {shell}, add this line to {rc_file}:')
- print(f' {export_cmd}\n')
- print(' Then run:')
- print(f' source {rc_file}')
- else:
- print(f' Add {install_dir} to your PATH environment variable')
- print(" Consult your shell's documentation for instructions")
+ printf(f'Add {pathstyle(install_dir)} to your PATH environment variable', indent=3)
+ printf("Consult your shell's documentation for instructions", indent=3, dim=True)
- print('')
- print(f' Or use the full path: {executable}')
+ printf('')
+ printf(f'Or use the full path: {pathstyle(executable)}', indent=3)
+
+ # Still consider this a success since the tool is installed
+ return True
- # Still consider this a success since the tool is installed
- return True
+# ==================================================================================================
+# Command-line arguments
+# ==================================================================================================
-def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+
+class Args(argparse.Namespace):
+ """
+ Annotated :class:`argparse.Namespace` returned by :func:`parse_args`.
+ """
+ force: bool # -f/--force
+ ensure: bool # -e/--ensure
+ verbose: bool # -v/--verbose
+ timeout: float # -t/--timeout
+ retries: int # -r/--retries
+ retry_delay: float # --retry-delay
+ download_methods: list[DownloadMethod] # -m/--download-method
+
+
+def parse_args(argv: Sequence[str] | None = None) -> Args:
"""
- Build parser and parse command-line arguments
+ Build parser and parse command-line arguments.
- :param argv: argument list of parse, defaults to ``sys.argv[1:]``
- :return: :class:`argparse.Namespace` with parsed argument values
+ :param argv: argument list of parse. Defaults to ``sys.argv[1:]``
+ :return: annotated namespace of parsed args (:class:`Args`)
"""
global VERBOSE
+ PROG = style('%(prog)s', fg='bright_magenta')
+
+ def dim(text: Any, **opts: Any) -> str:
+ return style(text, dim=opts.pop('dim', True), **opts)
+
# Build argument parser
parser = argparse.ArgumentParser(
- description='Install UV package manager using official installer script',
+ description=style('Install uv package manager using official installer script', fg='bright_yellow'),
formatter_class=functools.partial(
- argparse.RawDescriptionHelpFormatter,
- max_help_position=50,
+ argparse.RawTextHelpFormatter,
+ max_help_position=80,
),
epilog='\n'.join([
- 'Examples:',
+ style('Examples:', fg='bright_blue', bold=True),
'',
- ' %(prog)s # Normal installation',
- ' %(prog)s --force # Force reinstall even if present',
- ' %(prog)s --verbose # Show detailed progress',
- ' %(prog)s --force -v # Force reinstall with verbose output',
- ' %(prog)s --retries 5 # Increase download retry attempts to 5',
- ' %(prog)s --retry-delay 3 # Set initial retry delay to 3 seconds',
- ' %(prog)s --retries 5 --retry-delay 3 # Custom retry configuration',
+ f' {PROG} {dim("# Normal installation")}',
+ f' {PROG} --force {dim("# Force reinstall even if present")}',
+ f' {PROG} --verbose {dim("# Show detailed progress")}',
+ f' {PROG} --force -v {dim("# Force reinstall with verbose output")}',
+ f' {PROG} --retries 5 {dim("# Increase download retry attempts to 5")}',
+ f' {PROG} --retry-delay 3 {dim("# Set initial retry delay to 3 seconds")}',
+ f' {PROG} --retries 5 --retry-delay 3 {dim("# Custom retry configuration")}',
+ f' {PROG} --download-method curl {dim("# Only use curl to download the installer")}',
+ f' {PROG} --download-method urllib curl {dim("# Try urllib first, then fall back to curl")}',
]),
add_help=False,
)
- execution_opts = parser.add_argument_group('Execution options')
- execution_opts.add_argument(
- '-f',
- '--force',
- action='store_true',
- help='Force installation even if UV is already installed',
- )
- execution_opts.add_argument(
- '-e',
- '--ensure',
- action='store_true',
- help='Install UV if it does not exist, otherwise quietly exit with no console output',
- )
-
- download_opts = parser.add_argument_group('Download options')
- download_opts.add_argument(
- '-t',
- '--timeout',
- type=float,
- default=DOWNLOAD_TIMEOUT,
- metavar='SECONDS',
- help=f'Download timeout in seconds (default: {DOWNLOAD_TIMEOUT})',
- )
- download_opts.add_argument(
- '-r',
- '--retries',
- type=int,
- default=DOWNLOAD_RETRIES,
- metavar='N',
- help=f'Maximum number of download retry attempts (default: {DOWNLOAD_RETRIES})',
- )
- download_opts.add_argument(
- '--retry-delay',
- type=float,
- default=DOWNLOAD_RETRY_DELAY,
- metavar='SECONDS',
- help=f'Initial delay in seconds between retries, uses exponential backoff (default: {DOWNLOAD_RETRY_DELAY})',
- )
-
- logging_opts = parser.add_argument_group('Logging options')
- logging_opts.add_argument(
- '-v',
- '--verbose',
- action='store_true',
- help='Enable verbose output for debugging',
- )
-
- other_opts = parser.add_argument_group('Other options')
- other_opts.add_argument(
- '-h',
- '--help',
- action='help',
- default=argparse.SUPPRESS,
- help='Show this help message and exit',
- )
+ def add_execution_opts() -> None:
+ """
+ Add execution option arguments (``--force``, ``--ensure``) to the parser.
+ """
+ execution_opts = parser.add_argument_group(style('Execution options', fg='bright_blue', bold=True))
+ execution_opts.add_argument(
+ '-f',
+ '--force',
+ action='store_true',
+ help=annotated_opt_help('Force installation even if uv is already installed'),
+ )
+ execution_opts.add_argument(
+ '-e',
+ '--ensure',
+ action='store_true',
+ help=annotated_opt_help('Install uv if it does not exist, otherwise quietly exit with no console output'),
+ )
+ add_execution_opts()
+
+ def add_download_opts() -> None:
+ """
+ Add download option arguments (``--timeout``, ``--retries``, ``--retry-delay``) to the parser.
+ """
+ download_opts = parser.add_argument_group(style('Download options', fg='bright_blue', bold=True))
+ download_opts.add_argument(
+ '-t',
+ '--timeout',
+ type=float,
+ default=DOWNLOAD_TIMEOUT,
+ metavar='SECONDS',
+ help=annotated_opt_help(
+ 'Download timeout in seconds',
+ default=DOWNLOAD_TIMEOUT,
+ ),
+ )
+ download_opts.add_argument(
+ '-r',
+ '--retries',
+ type=int,
+ default=DOWNLOAD_RETRIES,
+ metavar='NUM',
+ help=annotated_opt_help(
+ 'Maximum number of download retry attempts',
+ default=DOWNLOAD_RETRIES,
+ )
+ )
+ download_opts.add_argument(
+ '-d',
+ '--retry-delay',
+ type=float,
+ default=DOWNLOAD_RETRY_DELAY,
+ metavar='SECONDS',
+ help=annotated_opt_help(
+ 'Initial delay in seconds between retries, uses exponential backoff',
+ default=DOWNLOAD_RETRY_DELAY,
+ ),
+ )
+ download_opts.add_argument(
+ '-m',
+ '--download-method',
+ dest='download_methods',
+ nargs='+',
+ type=DownloadMethod,
+ choices=list(DownloadMethod),
+ default=list(DEFAULT_DOWNLOAD_METHODS),
+ metavar='METHOD',
+ help=annotated_opt_help(
+ 'Ordered download methods to try, highest priority first',
+ choices=[m.value for m in DownloadMethod],
+ default=[m.value for m in DEFAULT_DOWNLOAD_METHODS],
+ ),
+ )
+ add_download_opts()
+
+ def add_other_opts() -> None:
+ """
+ Add miscellaneous option arguments (``--help``) to the parser.
+ """
+ other_opts = parser.add_argument_group(style('Other options', fg='bright_blue', bold=True))
+ other_opts.add_argument(
+ '-v',
+ '--verbose',
+ action='store_true',
+ help='Enable verbose output for debugging',
+ )
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show this help message and exit',
+ )
+ add_other_opts()
# Parse arguments
- args = parser.parse_args(argv)
+ args = parser.parse_args(argv, namespace=Args())
# Set global config variables with parsed command-line option values
VERBOSE = args.verbose
# Validate download parameters
if args.timeout < 0:
- print('❌ Error: --timeout cannot be negative', file=sys.stderr)
+ printf(f'❌ {optstyle("--timeout")} cannot be negative', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.INVALID_PARAMETERS)
if args.retries < 1:
- print('❌ Error: --retries must be at least 1', file=sys.stderr)
+ printf(f'❌ {optstyle("--retries")} must be at least 1', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.INVALID_PARAMETERS)
if args.retry_delay < 0:
- print('❌ Error: --retry-delay cannot be negative', file=sys.stderr)
+ printf(f'❌ {optstyle("--retry-delay")} cannot be negative', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.INVALID_PARAMETERS)
# Enforce mutual exclusivity of --force and --ensure
# Using ``add_mutually_exclusive_group()`` prevents us from adding a help text title to the execution options group
if args.force and args.ensure:
- print('❌ Error: --force and --ensure are mutually exclusive', file=sys.stderr)
+ printf(
+ f'❌ {optstyle("--force")} and {optstyle("--ensure")} are mutually exclusive',
+ fg='bright_red',
+ file=sys.stderr,
+ )
sys.exit(ExitCode.INVALID_PARAMETERS)
return args
-def main(argv: list[str] | None = None) -> None:
+# ==================================================================================================
+# Core script entry point
+# ==================================================================================================
+
+
+def main(argv: Sequence[str] | None = None) -> None:
"""
- Programmatically install UV using the official installer script
+ Programmatically install uv using the official installer script.
- :param argv: argument list of parse, defaults to ``sys.argv[1:]``
+ :param argv: argument list of parse. Defaults to ``sys.argv[1:]``
"""
# Parse command-line arguments
args = parse_args(argv=argv)
if args.verbose or not args.ensure:
- print('╔════════════════════════════╗')
- print('║ UV Installation Script ║')
- print('╚════════════════════════════╝')
- print()
- vprint(f'Download retry configuration: {args.retries} attempts, {args.retry_delay}s initial delay\n')
+ printf()
+ printf('uv installation', fg='bright_cyan', bold=True)
+ printf('─' * 77, dim=True)
+ printf(
+ f'Download configuration: methods={" → ".join(str(m) for m in args.download_methods)}, '
+ f'{args.retries} attempts, {args.retry_delay}s initial delay\n',
+ verbose=True,
+ )
# Check Python version
if not check_python_version():
@@ -575,56 +1149,74 @@ def main(argv: list[str] | None = None) -> None:
if not args.force:
if is_installed(quiet=args.ensure and not args.verbose):
if not args.ensure:
- print('⏭️ Skipping installation\n')
- print('💡 Tip: Use --force to reinstall')
+ printf('⏭️ Skipping installation')
+ printf(
+ f'{style("Use", fg="bright_white")} {optstyle("--force")} '
+ f'{style("to reinstall", fg="bright_white")}',
+ indent=3,
+ )
sys.exit(ExitCode.ALREADY_INSTALLED)
else:
- print('🔄 --force specified, proceeding with installation...\n')
+ printf(f'🔄 {optstyle("--force")} specified, proceeding with installation...\n', fg='bright_cyan')
# Get installer URL
try:
url = get_installer_url()
- vprint(f'Installer URL: {url}')
+ printf(f'Installer URL: {url}', verbose=True)
except OSError as e:
- print(f'❌ Error: {e}', file=sys.stderr)
+ printf(f'❌ {e!s}', fg='bright_red', file=sys.stderr)
sys.exit(ExitCode.UNSUPPORTED_PLATFORM)
- # Determine installer file extension
- system = platform.system().lower()
- extension = '.ps1' if system == 'windows' else '.sh'
- installer_path = Path.home() / f'.uv_installer{extension}'
- vprint(f'Temporary installer path: {installer_path}')
+ # Download installer to temp location
+ installer_path = Path.home() / '.uv_installer.sh'
+ printf(f'Temporary installer path: {installer_path}', verbose=True)
- # Download installer
- if not download_installer(
+ # Try the official installer first; fall back to ``pip install uv`` if either the download or the installer
+ # execution fails. Network/SSL errors typically affect the download step; broken shells or platform mismatches
+ # typically affect the installer step
+ installed: bool = False
+ if download_installer(
url,
installer_path,
+ methods=args.download_methods,
timeout=args.timeout,
retries=args.retries,
retry_delay=args.retry_delay,
):
- sys.exit(ExitCode.DOWNLOAD_FAILED)
-
- # Run the installer
- print()
- if not run_installer(installer_path):
+ printf('')
+ installed = run_installer(installer_path)
installer_path.unlink(missing_ok=True)
+ if not installed:
+ printf('⚠️ Installer script failed; trying pip fallback', fg='bright_yellow')
+
+ if not installed:
+ installed = install_via_pip()
+
+ if not installed:
+ printf('', file=sys.stderr)
+ printf('🔧 Troubleshooting:', fg='bright_white', file=sys.stderr)
+ printf('1. Check your internet connection', indent=3, file=sys.stderr)
+ printf('2. Verify you can access https://astral.sh', indent=3, file=sys.stderr)
+ printf('3. Check if a proxy is required in your network', indent=3, file=sys.stderr)
+ printf('4. For SSL errors, try: export SSL_CERT_FILE=$(python3 -m certifi)', indent=3, file=sys.stderr)
sys.exit(ExitCode.INSTALL_FAILED)
- # Clean up installer
- vprint(f'Cleaning up {installer_path}')
- installer_path.unlink(missing_ok=True)
-
# Verify installation
if not verify_installation():
sys.exit(ExitCode.VERIFICATION_FAILED)
- print('')
- print('🎉 Installation complete!\n')
- print('📋 Next steps:')
- print(' 1. Close and reopen your terminal (or run: source ~/.bashrc)')
- print(' 2. Verify with: uv --version')
- print(f' 3. View documentation: {DOCS_URL}')
+ # We did it!
+ source_shell_rc_msg: str = ''
+ if shell := Shell.detect():
+ source_shell_rc_msg = f' (or run: {style("source", fg="bright_magenta")} {pathstyle(shell.rc_file)})'
+
+ printf('')
+ printf('🎉 Installation complete!\n', fg='bright_green', bold=True)
+ if not args.ensure:
+ printf('📋 Next steps:', fg='bright_cyan')
+ printf(f'1. Close and reopen your terminal{source_shell_rc_msg}', indent=3)
+ printf(f'2. Verify with: {style("uv --version", fg="bright_magenta")}', indent=3)
+ printf(f'3. View documentation: {style(DOCS_URL, dim=True)}', indent=3)
sys.exit(ExitCode.SUCCESS)
diff --git a/scripts/maintain/README.md b/scripts/maintain/README.md
new file mode 100644
index 0000000..445034e
--- /dev/null
+++ b/scripts/maintain/README.md
@@ -0,0 +1,139 @@
+[Project Root](../../README.md) > [Scripts](../README.md) > **Maintain Scripts**
+
+---
+
+# Maintain Scripts
+
+Python scripts for automating common codebase maintenance tasks: keeping `.gitignore` patterns current and prek hook
+versions up to date.
+
+## Scripts
+
+| Script | Language | Description |
+| --------------------- | -------- | --------------------------------------------------------------------------------------------- |
+| `update_gitignore.py` | Python | Refresh `.gitignore` with latest patterns from gitignore.io while preserving custom additions |
+| `update_prek.py` | Python | Bump prek hook versions via `prek auto-update` |
+
+---
+
+## update_gitignore.py
+
+Fetches fresh ignore patterns for the template list embedded in the current `.gitignore`, splices them in, and preserves
+any custom patterns that appear after the generated block. Uses three fetch methods in order: Python `urllib`, `curl`,
+and `git-ignore-io`.
+
+**Usage:**
+
+```bash
+# Update .gitignore with latest patterns
+uv run scripts/maintain/update_gitignore.py
+
+# Preview the updated content without writing the file
+uv run scripts/maintain/update_gitignore.py --dry-run
+
+# Force rewrite even when no content has changed
+uv run scripts/maintain/update_gitignore.py --force
+
+# Target a different .gitignore file
+uv run scripts/maintain/update_gitignore.py --gitignore-path path/to/.gitignore
+```
+
+**Options:**
+
+| Flag | Default | Description |
+| ----------------------- | ------------ | ------------------------------------------------ |
+| `--dry-run` | off | Print the updated content; do not write the file |
+| `--force` | off | Write even when content is unchanged |
+| `--gitignore-path PATH` | `.gitignore` | Path to the `.gitignore` file to update |
+
+**Behavior:**
+
+1. Reads the current `.gitignore` and extracts the template list from the embedded gitignore.io URL.
+2. Extracts any custom patterns that appear after the `# End of https://www.toptal.com/developers/gitignore/api/`
+ marker.
+3. Fetches fresh content from `toptal.com/developers/gitignore/api/{templates}` (falls back to `curl`, then
+ `git-ignore-io`).
+4. Combines the fresh content with the preserved custom patterns.
+5. Skips the write if content is unchanged (unless `--force`).
+
+**Public API (`__all__`):**
+
+| Symbol | Signature | Description |
+| ---------------------------------- | --------------------------------------------------------- | ------------------------------------------------------ |
+| `extract_templates_from_gitignore` | `(gitignore_content: str) -> list[str]` | Parse template names from the gitignore.io URL |
+| `extract_custom_patterns` | `(gitignore_content: str) -> list[str]` | Extract lines below the generated-content end marker |
+| `fetch_gitignore_content` | `(templates: list[str]) -> str` | Fetch fresh content from gitignore.io (with fallbacks) |
+| `generate_updated_gitignore` | `(fresh_content: str, custom_patterns: list[str]) -> str` | Combine fresh content with preserved custom patterns |
+
+**Dependencies:** Python standard library only (`argparse`, `re`, `urllib`, `subprocess`).
+
+---
+
+## update_prek.py
+
+Thin wrapper around `prek auto-update` that adds dry-run support, targeted repo filtering, tag/cooldown constraints, and
+a configurable timeout. In dry-run mode the config file is reverted to its original state after auto-update runs, so the
+diff is visible without any permanent changes.
+
+**Usage:**
+
+```bash
+# Update all hooks in .pre-commit-config.yaml
+uv run scripts/maintain/update_prek.py
+
+# Preview what would change without persisting the update
+uv run scripts/maintain/update_prek.py --dry-run
+
+# Update only a specific repo
+uv run scripts/maintain/update_prek.py --repo https://github.com/astral-sh/ruff-pre-commit
+
+# Update multiple repos
+uv run scripts/maintain/update_prek.py \
+ --repo https://github.com/astral-sh/ruff-pre-commit \
+ --repo https://github.com/pre-commit/pre-commit-hooks
+
+# Only bump versions released at least 7 days ago, using a longer timeout
+uv run scripts/maintain/update_prek.py --cooldown-days 7 --timeout 240
+```
+
+**Options:**
+
+| Flag | Default | Description |
+| ------------------------- | ------------------------- | ------------------------------------------------------------- |
+| `-c, --config PATH` | `.pre-commit-config.yaml` | Path to the prek/pre-commit config file |
+| `-r, --repo URL` | (all repos) | Restrict update to this repo URL; repeat for multiple repos |
+| `--exclude-repo URL` | (none) | Skip the given repo URL; repeat for multiple repos |
+| `--include-tag PATTERN` | (all tags) | Only consider tags matching this glob pattern; repeatable |
+| `--exclude-tag PATTERN` | (none) | Ignore tags matching this glob pattern; repeatable |
+| `--repo-include-tag SPEC` | (none) | Per-repo tag include filter as `=`; repeatable |
+| `--repo-exclude-tag SPEC` | (none) | Per-repo tag exclude filter as `=`; repeatable |
+| `--bleeding-edge` | off | Update to the default branch head instead of the latest tag |
+| `--freeze` | off | Store frozen hashes in `rev` instead of tag names |
+| `--cooldown-days DAYS` | (none) | Minimum release age (in days) for a version to be eligible |
+| `-j, --jobs N` | (prek default) | Number of threads prek should use (`0` lets prek decide) |
+| `--refresh` | off | Refresh all cached prek data before running |
+| `-t, --timeout SECONDS` | `120` | Timeout for the `prek auto-update` subprocess |
+| `--dry-run` | off | Run auto-update, show changes, then revert the file |
+| `-h, --help` | — | Show help and exit |
+
+**Behavior:**
+
+1. Reads the current config file content for later comparison / revert.
+2. Runs `prek auto-update` (optionally scoped to specific repos and tag/cooldown constraints).
+3. Prints stdout from auto-update (`Updating ... -> ` lines).
+4. If `--dry-run` and changes were made, reverts the file to its original state.
+5. Reports whether any hook versions changed.
+
+**Public API (`__all__`):**
+
+| Symbol | Signature | Description |
+| ---------------- | -------------------------------------------------------------------- | --------------------------------------------- |
+| `run_autoupdate` | `(config_path: Path, *, repo_urls=None, ..., timeout=120, **kwargs)` | Run `prek auto-update` and return the process |
+
+**Dependencies:** Python standard library only (`argparse`, `subprocess`, `pathlib`). Requires `prek` on PATH.
+
+---
+
+## See Also
+
+- [Scripts Overview](../README.md) — Parent directory documentation
diff --git a/scripts/maintain/update_gitignore.py b/scripts/maintain/update_gitignore.py
new file mode 100755
index 0000000..2ff41df
--- /dev/null
+++ b/scripts/maintain/update_gitignore.py
@@ -0,0 +1,287 @@
+#!/usr/bin/env python3
+"""
+Script to update .gitignore file with latest patterns from gitignore.io while preserving custom patterns.
+
+Usage:
+
+ python scripts/maintain/update_gitignore.py [--dry-run] [--force]
+
+"""
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+from functools import partial
+from pathlib import Path
+from typing import TYPE_CHECKING
+from urllib.error import URLError
+from urllib.request import urlopen
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+__all__ = [
+ 'extract_custom_patterns',
+ 'extract_templates_from_gitignore',
+ 'fetch_gitignore_content',
+ 'generate_updated_gitignore',
+]
+
+
+def extract_templates_from_gitignore(gitignore_content: str) -> list[str]:
+ """
+ Extract template list from gitignore.io URL in the current .gitignore file.
+
+ :param gitignore_content: Content of the current .gitignore file
+ :return: list of template names used
+ """
+ if m := re.search(r'https://www\.toptal\.com/developers/gitignore/api/([a-zA-Z0-9+,]+)', gitignore_content):
+ templates_string = m.group(1)
+ return templates_string.split(',')
+ raise ValueError('Could not find gitignore.io URL in current .gitignore file')
+
+
+def extract_custom_patterns(gitignore_content: str) -> list[str]:
+ """
+ Extract custom patterns added after the gitignore.io generated content.
+
+ :param gitignore_content: context of the current .gitignore file
+ :return: list of custom pattern lines
+ """
+ lines = gitignore_content.splitlines()
+
+ # Find the end marker for gitignore.io content
+ end_marker_idx: int | None = None
+ for i, line in enumerate(lines):
+ if re.match(r'# End of https://www\.toptal\.com/developers/gitignore/api/', line):
+ end_marker_idx = i
+ break
+
+ if end_marker_idx is None:
+ print('Warning: Could not find end marker for gitignore.io content', file=sys.stderr)
+ return []
+
+ # Extract everything after the end marker, ignoring empty lines
+ custom_lines: list[str] = []
+ for line in lines[end_marker_idx + 1:]:
+ if (not custom_lines) and (not line.strip()): # Skip empty lines at the beginning
+ continue
+ custom_lines.append(line)
+
+ # Remove trailing empty lines
+ while custom_lines and not custom_lines[-1].strip():
+ custom_lines.pop()
+ return custom_lines
+
+
+def fetch_gitignore_content(templates: list[str]) -> str:
+ """
+ Fetch fresh .gitignore content from gitignore.io for the given templates. Tries multiple methods as fallbacks.
+
+ :param templates: list of template names
+ :return: fresh gitignore content from gitignore.io
+ """
+ templates_str = ','.join(templates)
+ url = f'https://www.toptal.com/developers/gitignore/api/{templates_str}'
+
+ # Method 1: Try Python's urllib first
+ try:
+ with urlopen(url) as response: # noqa: S310
+ content = response.read().decode('utf-8')
+ print('✅ Successfully fetched content using Python urllib')
+ return content
+
+ except URLError as e:
+ print(f'⚠️ urllib failed ({e}), trying curl...')
+
+ # Method 2: Try curl as fallback
+ try:
+ result = subprocess.run(
+ ['curl', '-sSL', url],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30
+ )
+ if (result.returncode == 0) and result.stdout:
+ print('✅ Successfully fetched content using curl')
+ return result.stdout
+ print(f'⚠️ curl failed (exit code {result.returncode}), trying git-ignore-io...')
+
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+ print(f'⚠️ curl failed ({e}), trying git-ignore-io...')
+
+ # Method 3: Try git-ignore-io as final fallback
+ try:
+ result = subprocess.run(
+ ['git-ignore-io', templates_str],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode == 0 and result.stdout:
+ print('✅ Successfully fetched content using git-ignore-io')
+ return result.stdout
+ raise RuntimeError(f'git-ignore-io failed with exit code {result.returncode}')
+
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+ raise RuntimeError(f'All methods failed. Final attempt (git-ignore-io): {e}') from e
+
+
+def generate_updated_gitignore(fresh_content: str, custom_patterns: list[str]) -> str:
+ """
+ Combine fresh gitignore.io content with custom patterns.
+
+ :param fresh_content: fresh content from gitignore.io
+ :param custom_patterns: custom patterns to preserve
+ :return: combined gitignore content
+ """
+
+ # Ensure fresh content ends with exactly one newline
+ fresh_content = fresh_content.rstrip('\n') + '\n'
+ if not custom_patterns:
+ return fresh_content
+
+ # Add custom patterns after a blank line
+ result = fresh_content + '\n'
+ for pattern in custom_patterns:
+ result += pattern + '\n'
+ return result
+
+
+class Args(argparse.Namespace):
+ """
+ Annotated :class:`argparse.Namespace` returned by :func:`parse_args`.
+ """
+ gitignore_path: Path # --gitignore-path
+ dry_run: bool # --dry-run
+ force: bool # --force
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """
+ Build argument parser with auto-generated help from command docstrings.
+
+ :return: :class:`argparse.ArgumentParser`
+ """
+ parser = argparse.ArgumentParser(
+ description='Update .gitignore file with latest patterns from gitignore.io',
+ formatter_class=partial(argparse.HelpFormatter, max_help_position=80),
+ add_help=False,
+ )
+
+ def add_file_opts() -> None:
+ """
+ Add file path options to the argument parser.
+ """
+ file_opts = parser.add_argument_group('File options')
+ file_opts.add_argument(
+ '--gitignore-path',
+ type=Path,
+ default=Path('.gitignore'),
+ help='Path to .gitignore file (default: .gitignore)',
+ )
+ add_file_opts()
+
+ def add_execution_opts() -> None:
+ """
+ Add execution control options to the argument parser.
+ """
+ execution_opts = parser.add_argument_group('Execution opts')
+ execution_opts.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Show what would be changed without actually updating the file',
+ )
+ execution_opts.add_argument(
+ '--force',
+ action='store_true',
+ help='Update the file even if no changes are detected',
+ )
+ add_execution_opts()
+
+ def add_other_opts() -> None:
+ """
+ Add help and miscellaneous options to the argument parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show help message and exit',
+ )
+ add_other_opts()
+
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """
+ Main entry point for the script.
+
+ :param argv: argument list of parse. Defaults to ``sys.argv[1:]``
+ :return: int script exit code
+ """
+ parser = build_parser()
+ args = parser.parse_args(argv, namespace=Args())
+
+ # Resolve gitignore path
+ if not args.gitignore_path.exists():
+ print(f'Error: .gitignore file not found at {args.gitignore_path}', file=sys.stderr)
+ return 1
+
+ try:
+
+ # Read current .gitignore
+ current_content = args.gitignore_path.read_text(encoding='utf-8')
+
+ # Extract templates and custom patterns
+ print('Extracting templates and custom patterns...')
+ templates = extract_templates_from_gitignore(current_content)
+ custom_patterns = extract_custom_patterns(current_content)
+
+ print(f"Found templates: {', '.join(templates)}")
+ if custom_patterns:
+ print(f'Found {len(custom_patterns)} custom pattern(s)')
+ else:
+ print('No custom patterns found')
+
+ # Fetch fresh content from gitignore.io
+ print('Fetching fresh content from gitignore.io...')
+ fresh_content = fetch_gitignore_content(templates)
+
+ # Generate updated content
+ updated_content = generate_updated_gitignore(fresh_content, custom_patterns)
+
+ # Check if content has actually changed
+ if (not args.force) and (updated_content == current_content):
+ print('✅ .gitignore file is already up to date')
+ return 0
+ if args.dry_run:
+ print('🔍 Dry run - would update .gitignore file with the following content:')
+ print('=' * 50)
+ print(updated_content)
+ print('=' * 50)
+ return 0
+
+ # Write updated content
+ args.gitignore_path.write_text(updated_content, encoding='utf-8')
+ print(f'✅ Successfully updated {args.gitignore_path}')
+ return 0
+
+ except KeyboardInterrupt:
+ print('Cancelled')
+ return 1
+
+ except Exception as e:
+ print(f'Error: {e}', file=sys.stderr)
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/maintain/update_prek.py b/scripts/maintain/update_prek.py
new file mode 100755
index 0000000..8ceedc9
--- /dev/null
+++ b/scripts/maintain/update_prek.py
@@ -0,0 +1,356 @@
+#!/usr/bin/env python3
+"""
+Script to update pre-commit hook versions using ``prek auto-update``.
+
+Usage::
+
+ python scripts/maintain/update_prek.py [--dry-run] [--repo URL] [--config PATH]
+
+"""
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from functools import partial
+from pathlib import Path
+from typing import TYPE_CHECKING
+from typing import Any
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+__all__ = [
+ 'run_autoupdate',
+]
+
+
+def run_autoupdate(
+ config_path: Path,
+ *,
+ repo_urls: list[str] | None = None,
+ exclude_repos: list[str] | None = None,
+ include_tags: list[str] | None = None,
+ exclude_tags: list[str] | None = None,
+ repo_include_tags: list[str] | None = None,
+ repo_exclude_tags: list[str] | None = None,
+ bleeding_edge: bool = False,
+ freeze: bool = False,
+ jobs: int | None = None,
+ cooldown_days: int | None = None,
+ refresh: bool = False,
+ timeout: float | None = 120,
+ **kwargs: Any,
+) -> subprocess.CompletedProcess[str]:
+ """
+ Run ``prek auto-update`` and return the completed process.
+
+ :param config_path: path to the pre-commit config file
+ :param repo_urls: optional list of repo URLs to filter updates
+ :param exclude_repos: optional list of repo URLs to exclude from updates
+ :param include_tags: optional list of glob patterns for tags to consider
+ :param exclude_tags: optional list of glob patterns for tags to ignore
+ :param repo_include_tags: optional list of ``=`` per-repo tag include filters
+ :param repo_exclude_tags: optional list of ``=`` per-repo tag exclude filters
+ :param bleeding_edge: update to the bleeding edge of the default branch instead of latest tag
+ :param freeze: store frozen hashes in ``rev`` instead of tag names
+ :param jobs: number of threads prek should use (``0`` lets prek decide)
+ :param cooldown_days: minimum release age (in days) for a version to be eligible
+ :param refresh: refresh all cached prek data before running
+ :param timeout: command timeout
+ :return: completed process from the auto-update command
+ """
+ cmd: list[str] = ['prek', 'auto-update', '--config', str(config_path)]
+ if refresh:
+ cmd.append('--refresh')
+ if bleeding_edge:
+ cmd.append('--bleeding-edge')
+ if freeze:
+ cmd.append('--freeze')
+ if jobs is not None:
+ cmd.extend(['--jobs', str(jobs)])
+ if cooldown_days is not None:
+ cmd.extend(['--cooldown-days', str(cooldown_days)])
+ for url in repo_urls or []:
+ cmd.extend(['--repo', url])
+ for url in exclude_repos or []:
+ cmd.extend(['--exclude-repo', url])
+ for pattern in include_tags or []:
+ cmd.extend(['--include-tag', pattern])
+ for pattern in exclude_tags or []:
+ cmd.extend(['--exclude-tag', pattern])
+ for spec in repo_include_tags or []:
+ cmd.extend(['--repo-include-tag', spec])
+ for spec in repo_exclude_tags or []:
+ cmd.extend(['--repo-exclude-tag', spec])
+
+ return subprocess.run(
+ cmd,
+ capture_output=kwargs.pop('capture_output', True),
+ text=kwargs.pop('text', True),
+ check=kwargs.pop('check', False),
+ timeout=timeout,
+ **kwargs,
+ )
+
+
+class Args(argparse.Namespace):
+ """
+ Annotated :class:`argparse.Namespace` for script command-line arguments.
+ """
+ config: Path # --config
+ repos: list[str] | None # --repo
+ exclude_repos: list[str] | None # --exclude-repo
+ include_tags: list[str] | None # --include-tag
+ exclude_tags: list[str] | None # --exclude-tag
+ repo_include_tags: list[str] | None # --repo-include-tag
+ repo_exclude_tags: list[str] | None # --repo-exclude-tag
+ bleeding_edge: bool # --bleeding-edge
+ freeze: bool # --freeze
+ cooldown_days: int | None # --cooldown-days
+ jobs: int | None # --jobs
+ refresh: bool # --refresh
+ timeout: float # --timeout
+ dry_run: bool # --dry-run
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """
+ Build argument parser for script.
+
+ :return: :class:`argparse.ArgumentParser`
+ """
+ parser = argparse.ArgumentParser(
+ description='Update pre-commit hook versions using prek auto-update',
+ formatter_class=partial(argparse.HelpFormatter, max_help_position=50),
+ add_help=False,
+ )
+
+ def add_target_opts() -> None:
+ """
+ Add config file and repo target options to the argument parser.
+ """
+ target_opts = parser.add_argument_group('Target options')
+ target_opts.add_argument(
+ '-c',
+ '--config',
+ type=Path,
+ metavar='PATH',
+ default='.pre-commit-config.yaml',
+ help='Path to pre-commit config file (default: .pre-commit-config.yaml)',
+ )
+ target_opts.add_argument(
+ '-r',
+ '--repo',
+ action='append',
+ dest='repos',
+ metavar='URL',
+ help='Only update the given repo (can be repeated)',
+ )
+ target_opts.add_argument(
+ '--exclude-repo',
+ action='append',
+ dest='exclude_repos',
+ metavar='URL',
+ help='Skip the given repo (can be repeated)',
+ )
+ target_opts.add_argument(
+ '--include-tag',
+ action='append',
+ dest='include_tags',
+ metavar='PATTERN',
+ help='Only consider tags matching this glob pattern (can be repeated)',
+ )
+ target_opts.add_argument(
+ '--exclude-tag',
+ action='append',
+ dest='exclude_tags',
+ metavar='PATTERN',
+ help='Ignore tags matching this glob pattern (can be repeated)',
+ )
+ target_opts.add_argument(
+ '--repo-include-tag',
+ action='append',
+ dest='repo_include_tags',
+ metavar='REPO=PATTERN',
+ help='Per-repo tag include filter as = (can be repeated)',
+ )
+ target_opts.add_argument(
+ '--repo-exclude-tag',
+ action='append',
+ dest='repo_exclude_tags',
+ metavar='REPO=PATTERN',
+ help='Per-repo tag exclude filter as = (can be repeated)',
+ )
+ add_target_opts()
+
+ def add_update_opts() -> None:
+ """
+ Add update behavior options to the argument parser.
+ """
+ update_opts = parser.add_argument_group('Update options')
+ update_opts.add_argument(
+ '--bleeding-edge',
+ action='store_true',
+ help='Update to the bleeding edge of the default branch instead of latest tag',
+ )
+ update_opts.add_argument(
+ '--freeze',
+ action='store_true',
+ help='Store frozen hashes in `rev` instead of tag names',
+ )
+ update_opts.add_argument(
+ '--cooldown-days',
+ type=int,
+ metavar='DAYS',
+ default=None,
+ help='Minimum release age (in days) required for a version to be eligible',
+ )
+ add_update_opts()
+
+ def add_execution_opts() -> None:
+ """
+ Add execution control options to the argument parser.
+ """
+ execution_opts = parser.add_argument_group('Execution options')
+ execution_opts.add_argument(
+ '-j',
+ '--jobs',
+ type=int,
+ metavar='N',
+ default=None,
+ help='Number of threads prek should use (0 lets prek decide)',
+ )
+ execution_opts.add_argument(
+ '--refresh',
+ action='store_true',
+ help='Refresh all cached prek data before running',
+ )
+ execution_opts.add_argument(
+ '-t',
+ '--timeout',
+ metavar='SECONDS',
+ type=float,
+ default=120,
+ help='`prek auto-update` command timeout (in seconds)',
+ )
+ execution_opts.add_argument(
+ '--dry-run',
+ action='store_true',
+ help='Run auto-update, show what changed, but revert the file',
+ )
+ add_execution_opts()
+
+ def add_other_opts() -> None:
+ """
+ Add help and miscellaneous options to the argument parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show help message and exit',
+ )
+ add_other_opts()
+
+ return parser
+
+
+# TODO: ensure prek is installed (or install it if not?)
+def main(argv: Sequence[str] | None = None) -> int:
+ """
+ Main entry point for the script.
+
+ :param argv: argument list to parse, defaults to sys.argv[1:]
+ :return: int script exit code
+ """
+ parser = build_parser()
+ args = parser.parse_args(argv, namespace=Args())
+
+ if args.timeout < 0:
+ print('Error: --timeout cannot be negative', file=sys.stderr)
+ return 1
+ if (args.cooldown_days is not None) and (args.cooldown_days < 0):
+ print('Error: --cooldown-days cannot be negative', file=sys.stderr)
+ return 1
+ if (args.jobs is not None) and (args.jobs < 0):
+ print('Error: --jobs cannot be negative', file=sys.stderr)
+ return 1
+
+ config_path = Path(args.config)
+ if not config_path.exists():
+ print(f'Error: config file not found at {config_path}', file=sys.stderr)
+ return 1
+
+ try:
+
+ # Read current config content
+ original_content = config_path.read_text(encoding='utf-8')
+
+ # Run prek auto-update
+ print('Running prek auto-update...')
+ result = run_autoupdate(
+ config_path,
+ repo_urls=args.repos,
+ exclude_repos=args.exclude_repos,
+ include_tags=args.include_tags,
+ exclude_tags=args.exclude_tags,
+ repo_include_tags=args.repo_include_tags,
+ repo_exclude_tags=args.repo_exclude_tags,
+ bleeding_edge=args.bleeding_edge,
+ freeze=args.freeze,
+ jobs=args.jobs,
+ cooldown_days=args.cooldown_days,
+ refresh=args.refresh,
+ timeout=args.timeout,
+ )
+
+ # Show stdout from auto-update (contains "Updating ... -> " lines)
+ if result.stdout:
+ print(result.stdout.rstrip())
+ if result.stderr:
+ print(result.stderr.rstrip(), file=sys.stderr)
+
+ if result.returncode != 0:
+ print(f'Error: prek auto-update exited with code {result.returncode}', file=sys.stderr)
+ return 1
+
+ # Read the (possibly updated) config
+ updated_content = config_path.read_text(encoding='utf-8')
+
+ # Detect changes
+ if has_changes := original_content != updated_content:
+ print('Changes detected in pre-commit config')
+ else:
+ print('No changes detected - hooks are already up to date')
+
+ if args.dry_run:
+
+ # Restore original content if updated but --dry-run requested
+ if has_changes:
+ config_path.write_text(original_content, encoding='utf-8')
+ print('Dry run - reverted config file to original content')
+
+ return 0
+
+ if has_changes:
+ print(f'Successfully updated {config_path}')
+ return 0
+
+ except subprocess.TimeoutExpired:
+ print('Error: prek auto-update timed out', file=sys.stderr)
+ return 1
+
+ except KeyboardInterrupt:
+ print('Cancelled')
+ return 1
+
+ except Exception as e:
+ print(f'Error: {e}', file=sys.stderr)
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/utility/generate_completions.py b/scripts/utility/generate_completions.py
new file mode 100755
index 0000000..69d79c5
--- /dev/null
+++ b/scripts/utility/generate_completions.py
@@ -0,0 +1,782 @@
+#!/usr/bin/env python3
+"""
+Generate shell completion scripts for argparse-based CLI tools.
+
+Supports bash, zsh, and fish. Can auto-detect an :class:`argparse.ArgumentParser` from a target script by looking for
+well-known factory functions (``build_parser``, ``get_parser``, etc.) or module-level parser instances.
+
+Usage::
+
+ # Auto-detect parser factory in a script
+ python scripts/utility/generate_completions.py scripts/maintain/update_prek.py zsh
+
+ # Explicit parser factory function name
+ python scripts/utility/generate_completions.py scripts/maintain/update_prek.py zsh --parser build_parser
+
+ # Override the command name used in the completion script
+ python scripts/utility/generate_completions.py scripts/maintain/update_prek.py zsh --name update_prek
+
+"""
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import TYPE_CHECKING
+from typing import ClassVar
+from typing import Literal
+from typing import get_args
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from collections.abc import Sequence
+ from types import ModuleType
+
+
+SupportedShell = Literal['bash', 'zsh', 'fish']
+
+# Repo-relative path to this generator, used in the header comments of generated completion scripts.
+GENERATOR_PATH: str = 'scripts/utility/generate_completions.py'
+
+PARSER_FACTORY_NAMES: list[str] = [
+ 'build_parser',
+ 'get_parser',
+ 'create_parser',
+ 'make_parser',
+ 'parser',
+]
+
+# =========================
+# String manipulation utils
+# =========================
+
+
+ANSI_PATTERN: re.Pattern[str] = re.compile(r'\033\[[;?0-9]*[a-zA-Z]')
+
+
+def strip_ansi(text: str) -> str:
+ """
+ Remove ANSI escape sequences from a string.
+
+ :param text: text potentially containing ANSI codes
+ :return: cleaned text
+ """
+ return ANSI_PATTERN.sub('', text)
+
+
+# ==========================
+# Parser metadata extraction
+# ==========================
+
+
+@dataclass(frozen=True)
+class CompletionOption:
+ """
+ Extracted argument metadata for shell completion generation.
+ """
+ flags: tuple[str, ...]
+ help: str
+ takes_value: bool
+ metavar: str | None = None
+ choices: tuple[str, ...] | None = None
+
+
+def extract_parser_options(parser: argparse.ArgumentParser) -> list[CompletionOption]:
+ """
+ Extract option metadata from an :class:`argparse.ArgumentParser` for completion generation.
+
+ Skips subparser actions and positional arguments. Splits :class:`argparse.BooleanOptionalAction` into separate
+ entries for ``--flag`` and ``--no-flag``.
+
+ :param parser: the parser to introspect
+ :return: list of extracted option info
+ """
+ options: list[CompletionOption] = []
+ for action in parser._actions:
+ if isinstance(action, argparse._SubParsersAction):
+ continue
+ if not action.option_strings:
+ continue
+
+ help_text = ''
+ if action.help and (action.help != argparse.SUPPRESS):
+ help_text = strip_ansi(str(action.help))
+ help_text = help_text.split('[')[0].strip()
+ help_text = help_text.splitlines()[0].strip() if help_text else ''
+
+ takes_value = not isinstance(
+ action,
+ (
+ argparse._StoreTrueAction,
+ argparse._StoreFalseAction,
+ argparse._StoreConstAction,
+ argparse._CountAction,
+ argparse._HelpAction,
+ argparse.BooleanOptionalAction,
+ ),
+ )
+
+ choices = tuple(str(c) for c in action.choices) if action.choices else None
+ metavar = action.metavar if isinstance(action.metavar, str) else None
+
+ if isinstance(action, argparse.BooleanOptionalAction):
+ options.extend(
+ CompletionOption(
+ flags=(opt_str,),
+ help=help_text if not opt_str.startswith('--no-') else '',
+ takes_value=False,
+ )
+ for opt_str in action.option_strings
+ )
+
+ else:
+ options.append(
+ CompletionOption(
+ flags=tuple(action.option_strings),
+ help=help_text,
+ takes_value=takes_value,
+ metavar=metavar,
+ choices=choices,
+ )
+ )
+
+ return options
+
+
+def extract_subparser_choices(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]:
+ """
+ Extract the subcommand-name-to-subparser mapping from a parser.
+
+ :param parser: the root parser
+ :return: mapping of command name to its subparser
+ """
+ for action in parser._actions:
+ if isinstance(action, argparse._SubParsersAction):
+ return dict(action.choices)
+ return {}
+
+
+def extract_positional_choices(parser: argparse.ArgumentParser) -> list[tuple[str, tuple[str, ...]]]:
+ """
+ Extract positional arguments with constrained choices from a parser.
+
+ :param parser: the parser to introspect
+ :return: list of ``(help_text, choices)`` tuples for positional arguments that define choices
+ """
+ positionals: list[tuple[str, tuple[str, ...]]] = []
+ for action in parser._actions:
+ if action.option_strings or isinstance(action, (argparse._SubParsersAction, argparse._HelpAction)):
+ continue
+ if action.choices:
+ help_text = ''
+ if action.help and (action.help != argparse.SUPPRESS):
+ help_text = strip_ansi(str(action.help)).split('[')[0].strip()
+ positionals.append((help_text, tuple(str(c) for c in action.choices)))
+ return positionals
+
+
+def get_subparser_description(subparser: argparse.ArgumentParser) -> str:
+ """
+ Get the first line of a subparser's description, with ANSI codes stripped.
+
+ :param subparser: the subparser
+ :return: first line of description, or empty string
+ """
+ desc = subparser.description or ''
+ desc = strip_ansi(desc)
+ return desc.splitlines()[0].strip() if desc else ''
+
+
+# ===========
+# zsh helpers
+# ===========
+
+
+def zsh_escape(text: str) -> str:
+ """
+ Escape text for zsh completion spec strings.
+
+ :param text: raw description text
+ :return: text safe for use inside single-quoted zsh specs
+ """
+ return text.replace("'", "'\\''").replace('[', '\\[').replace(']', '\\]').replace(':', '\\:')
+
+
+def zsh_option_specs(opt: CompletionOption) -> list[str]:
+ """
+ Format a :class:`CompletionOption` as one or more zsh ``_arguments`` spec strings.
+
+ Generates a separate spec for each flag string (short and long variants).
+
+ :param opt: the option to format
+ :return: list of single-quoted zsh _arguments specs
+ """
+ desc = zsh_escape(opt.help) if opt.help else ''
+
+ specs: list[str] = []
+ for flag in opt.flags:
+ if opt.takes_value:
+ eq = '=' if flag.startswith('--') else ''
+ argname = opt.metavar or 'value'
+ choices_str = f'({" ".join(opt.choices)})' if opt.choices else ''
+ specs.append(f"'{flag}{eq}[{desc}]:{argname}:{choices_str}'")
+ else:
+ specs.append(f"'{flag}[{desc}]'")
+ return specs
+
+
+# =====================
+# Completion generators
+# =====================
+
+
+@dataclass
+class CompletionConfig:
+ """
+ Configuration for completion script generation.
+
+ :param command_name: command name for the completion function (e.g. ``update_prek.py``)
+ :param script_path: display path to the script (for header comments)
+ :param project_name: project name (for header comments)
+ """
+ command_name: str
+ script_path: str = ''
+ project_name: str = ''
+
+ FUNC_NAME_CHARS: ClassVar[re.Pattern[str]] = re.compile(r'[^a-zA-Z0-9_]')
+
+ @property
+ def func_name(self) -> str:
+ """
+ Shell-safe function name derived from the command name.
+
+ :return: function name prefixed with ``_``
+ """
+ return '_' + self.FUNC_NAME_CHARS.sub('_', self.command_name)
+
+
+def generate_zsh_completions(parser: argparse.ArgumentParser, config: CompletionConfig) -> str:
+ """
+ Generate a zsh completion script from an argument parser.
+
+ :param parser: the fully built root argument parser (with subparsers)
+ :param config: completion configuration
+ :return: the complete zsh completion script
+ """
+ cmd = config.command_name
+ func = config.func_name
+
+ lines: list[str] = [
+ f'#compdef {cmd}',
+ '',
+ f'# Shell completions for {cmd}' + (f' ({config.project_name})' if config.project_name else ''),
+ ]
+ if config.script_path:
+ lines.extend([
+ f'# Generated by: python {GENERATOR_PATH} {config.script_path} zsh',
+ '#',
+ '# Installation:',
+ f'# eval "$(python {GENERATOR_PATH} {config.script_path} zsh)"',
+ '#',
+ '# Or save to a file in your fpath:',
+ f'# python {GENERATOR_PATH} {config.script_path} zsh > ~/.zsh/completions/_{cmd}',
+ '#',
+ f"# For aliases (e.g. alias myalias='python {config.script_path}'):",
+ f'# compdef {func} myalias',
+ ])
+ lines.extend(['', f'{func}() {{', ' local state', ''])
+
+ # Build _arguments spec with root options + subcommand dispatch
+ root_options = extract_parser_options(parser)
+
+ specs: list[str] = [spec for opt in root_options for spec in zsh_option_specs(opt)]
+ specs.append("'1:command:->commands'")
+ specs.append("'*::arg:->args'")
+
+ lines.append(' _arguments -C \\')
+ for i, spec in enumerate(specs):
+ sep = ' \\' if i < len(specs) - 1 else ''
+ lines.append(f' {spec}{sep}')
+
+ # Command completion
+ subparser_choices = extract_subparser_choices(parser)
+ lines.extend([
+ '',
+ ' case $state in',
+ ' commands)',
+ ' local -a commands=(',
+ ])
+ for cmd_name, subparser in subparser_choices.items():
+ desc = zsh_escape(get_subparser_description(subparser))
+ lines.append(f" '{cmd_name}:{desc}'")
+
+ lines.extend([
+ ' )',
+ " _describe 'command' commands",
+ ' ;;',
+ ])
+
+ # Per-command argument completion
+ lines.extend([' args)', ' case $words[1] in'])
+ for cmd_name, subparser in subparser_choices.items():
+ cmd_options = extract_parser_options(subparser)
+ cmd_positionals = extract_positional_choices(subparser)
+ if (not cmd_options) and (not cmd_positionals):
+ continue
+
+ cmd_specs = [spec for opt in cmd_options for spec in zsh_option_specs(opt)]
+ for pos_help, pos_choices in cmd_positionals:
+ desc = zsh_escape(pos_help) if pos_help else ''
+ cmd_specs.append(f"':{desc}:({' '.join(pos_choices)})'")
+
+ lines.extend([
+ f' {cmd_name})',
+ ' _arguments \\'
+ ])
+ for i, spec in enumerate(cmd_specs):
+ sep = ' \\' if i < len(cmd_specs) - 1 else ''
+ lines.append(f' {spec}{sep}')
+ lines.append(' ;;')
+
+ lines.extend([
+ ' esac',
+ ' ;;',
+ ' esac',
+ '}',
+ '',
+ f'{func} "$@"',
+ '',
+ ])
+ return '\n'.join(lines)
+
+
+def generate_bash_completions(parser: argparse.ArgumentParser, config: CompletionConfig) -> str:
+ """
+ Generate a bash completion script from an argument parser.
+
+ :param parser: the fully built root argument parser (with subparsers)
+ :param config: completion configuration
+ :return: the complete bash completion script
+ """
+ cmd = config.command_name
+ func = config.func_name
+ subparser_choices = extract_subparser_choices(parser)
+
+ # Collect all command names
+ cmd_names = list(subparser_choices.keys())
+
+ # Collect global option strings
+ root_options = extract_parser_options(parser)
+ global_opts = ' '.join(flag for opt in root_options for flag in opt.flags)
+
+ lines: list[str] = [
+ '#!/usr/bin/env bash',
+ '',
+ f'# Shell completions for {cmd}' + (f' ({config.project_name})' if config.project_name else ''),
+ ]
+ if config.script_path:
+ lines.extend([
+ f'# Generated by: python {GENERATOR_PATH} {config.script_path} bash',
+ '#',
+ '# Installation:',
+ f'# eval "$(python {GENERATOR_PATH} {config.script_path} bash)"',
+ '#',
+ '# Or save to a file:',
+ f'# python {GENERATOR_PATH} {config.script_path} bash > ~/.bash_completions/{cmd}',
+ '#',
+ f"# For aliases (e.g. alias myalias='python {config.script_path}'):",
+ f'# complete -F {func} myalias',
+ ])
+ lines.extend([
+ '',
+ f'{func}() {{',
+ ' local cur cmd',
+ ' COMPREPLY=()',
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
+ '',
+ ' # Find the subcommand',
+ ' cmd=""',
+ ' local i',
+ ' for ((i=1; i str:
+ """
+ Generate a fish completion script from an argument parser.
+
+ :param parser: the fully built root argument parser (with subparsers)
+ :param config: completion configuration
+ :return: the complete fish completion script
+ """
+ cmd = config.command_name
+ subparser_choices = extract_subparser_choices(parser)
+
+ lines: list[str] = [
+ f'# Shell completions for {cmd}' + (f' ({config.project_name})' if config.project_name else ''),
+ ]
+ if config.script_path:
+ lines.extend([
+ f'# Generated by: python {GENERATOR_PATH} {config.script_path} fish',
+ '#',
+ '# Installation:',
+ f'# python {GENERATOR_PATH} {config.script_path} fish > ~/.config/fish/completions/{cmd}.fish',
+ '#',
+ '# For aliases, add:',
+ f"# alias myalias='python {config.script_path}'",
+ f'# complete -c myalias --wraps {cmd}',
+ ])
+ lines.extend([
+ '',
+ '# Disable file completions by default',
+ f'complete -c {cmd} -f',
+ '',
+ '# Commands',
+ ])
+
+ for cmd_name, subparser in subparser_choices.items():
+ desc = get_subparser_description(subparser).replace("'", "\\'")
+ lines.append(f"complete -c {cmd} -n '__fish_use_subcommand' -a '{cmd_name}' -d '{desc}'")
+
+ # Global options
+ if root_options := extract_parser_options(parser):
+ lines.extend([
+ '',
+ '# Global options',
+ ])
+ for opt in root_options:
+ desc = opt.help.replace("'", "\\'") if opt.help else ''
+ for flag in opt.flags:
+ if flag.startswith('--'):
+ long_name = flag[2:]
+ req_arg = ' -r' if opt.takes_value else ''
+ lines.append(f"complete -c {cmd} -l '{long_name}'{req_arg} -d '{desc}'")
+ elif flag.startswith('-') and len(flag) == 2:
+ short_name = flag[1]
+ req_arg = ' -r' if opt.takes_value else ''
+ lines.append(f"complete -c {cmd} -s '{short_name}'{req_arg} -d '{desc}'")
+
+ # Per-command options
+ for cmd_name, subparser in subparser_choices.items():
+ cmd_options = extract_parser_options(subparser)
+ cmd_positionals = extract_positional_choices(subparser)
+ if (not cmd_options) and (not cmd_positionals):
+ continue
+
+ lines.extend(['', f'# Options for: {cmd_name}'])
+ for opt in cmd_options:
+ desc = opt.help.replace("'", "\\'") if opt.help else ''
+ for flag in opt.flags:
+ if flag.startswith('--'):
+ long_name = flag[2:]
+ req_arg = ' -r' if opt.takes_value else ''
+ lines.append(
+ f"complete -c {cmd} -n '__fish_seen_subcommand_from {cmd_name}'"
+ f" -l '{long_name}'{req_arg} -d '{desc}'"
+ )
+ elif flag.startswith('-') and (len(flag) == 2):
+ short_name = flag[1]
+ req_arg = ' -r' if opt.takes_value else ''
+ lines.append(
+ f"complete -c {cmd} -n '__fish_seen_subcommand_from {cmd_name}'"
+ f" -s '{short_name}'{req_arg} -d '{desc}'"
+ )
+ for pos_help, pos_choices in cmd_positionals:
+ desc = pos_help.replace("'", "\\'") if pos_help else ''
+ lines.append(
+ f"complete -c {cmd} -n '__fish_seen_subcommand_from {cmd_name}'"
+ f" -a '{' '.join(pos_choices)}' -d '{desc}'"
+ )
+
+ lines.append('')
+ return '\n'.join(lines)
+
+
+COMPLETION_GENERATORS: dict[SupportedShell, Callable[[argparse.ArgumentParser, CompletionConfig], str]] = {
+ 'bash': generate_bash_completions,
+ 'zsh': generate_zsh_completions,
+ 'fish': generate_fish_completions,
+}
+
+
+def generate_completions(
+ shell: SupportedShell,
+ parser: argparse.ArgumentParser,
+ *,
+ command_name: str | None = None,
+ script_path: str | None = None,
+ project_name: str | None = None,
+) -> str:
+ """
+ Generate a shell completion script for the given shell.
+
+ :param shell: target shell (bash, zsh, or fish)
+ :param parser: the fully built root argument parser
+ :param command_name: command name for the completion function (defaults to "cli")
+ :param script_path: display path to the script (for header comments)
+ :param project_name: project name (for header comments)
+ :raises ValueError: if *shell* is not a supported shell name
+ :return: the complete shell completion script
+ """
+ generator = COMPLETION_GENERATORS.get(shell)
+ if generator is None:
+ raise ValueError(
+ f'Unsupported shell: {shell!r}. '
+ f'Supported shells: {", ".join(sorted(get_args(SupportedShell)))}'
+ )
+
+ return generator(
+ parser,
+ CompletionConfig(
+ command_name=command_name or 'cli',
+ script_path=script_path or '',
+ project_name=project_name or '',
+ ),
+ )
+
+
+# ==============
+# Auto-detection
+# ==============
+
+
+def import_module_from_path(script_path: Path) -> ModuleType:
+ """
+ Dynamically import a Python script as a module.
+
+ :param script_path: path to the script file
+ :raises FileNotFoundError: if the script does not exist
+ :raises ImportError: if the script cannot be loaded
+ :return: the imported module
+ """
+ if not script_path.is_file():
+ raise FileNotFoundError(f'Script not found: {script_path}')
+
+ spec = importlib.util.spec_from_file_location(script_path.stem, script_path)
+ if (spec is None) or (spec.loader is None):
+ raise ImportError(f'Cannot load module spec from: {script_path}')
+
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def detect_parser(module: ModuleType) -> argparse.ArgumentParser:
+ """
+ Auto-detect an :class:`argparse.ArgumentParser` from a loaded module.
+
+ Tries calling well-known factory functions first (``build_parser``, ``get_parser``, ``create_parser``,
+ ``make_parser``, ``parser``), then falls back to searching for module-level ``ArgumentParser`` instances.
+
+ :param module: the imported module to inspect
+ :raises RuntimeError: if no parser can be detected
+ :return: the detected argument parser
+ """
+
+ # Try well-known factory function names
+ for name in PARSER_FACTORY_NAMES:
+ attr = getattr(module, name, None)
+ if not callable(attr):
+ continue
+
+ try:
+ result = attr()
+ except Exception: # noqa: S112 — intentional: probing unknown callables during auto-detection
+ continue
+ if isinstance(result, argparse.ArgumentParser):
+ return result
+
+ # Fall back to module-level ArgumentParser instances
+ for name in dir(module):
+ if name.startswith('_'):
+ continue
+ attr = getattr(module, name, None)
+ if isinstance(attr, argparse.ArgumentParser):
+ return attr
+
+ raise RuntimeError(
+ f'Could not auto-detect an ArgumentParser in {module.__name__!r}. '
+ f'Looked for factory functions ({", ".join(PARSER_FACTORY_NAMES)}) and module-level ArgumentParser instances. '
+ f'Use --parser to specify the factory function name explicitly.'
+ )
+
+
+def resolve_parser(script_path: Path, *, parser_name: str | None = None) -> argparse.ArgumentParser:
+ """
+ Import a script and resolve its argument parser.
+
+ When *parser_name* is provided, it is looked up as an attribute on the imported module and called (if callable).
+ Otherwise, :func:`detect_parser` is used for auto-detection.
+
+ :param script_path: path to the target script
+ :param parser_name: optional name of a callable or attribute that provides the parser
+ :raises RuntimeError: if the parser cannot be resolved
+ :return: the resolved argument parser
+ """
+ module = import_module_from_path(script_path)
+
+ if parser_name is not None:
+ attr = getattr(module, parser_name, None)
+ if attr is None:
+ raise RuntimeError(f'Attribute {parser_name!r} not found in {script_path}')
+
+ if callable(attr):
+ result = attr()
+ if not isinstance(result, argparse.ArgumentParser):
+ raise RuntimeError(f'{parser_name}() returned {type(result).__name__}, expected ArgumentParser')
+ return result
+
+ if isinstance(attr, argparse.ArgumentParser):
+ return attr
+
+ raise RuntimeError(f'{parser_name!r} is {type(attr).__name__}, expected a callable or ArgumentParser instance')
+
+ return detect_parser(module)
+
+
+# ===============
+# CLI entry point
+# ===============
+
+
+class Args(argparse.Namespace):
+ """
+ Annotated :class:`argparse.Namespace` returned by :func:`parse_args`.
+ """
+ script: Path # first positional arg
+ shell: SupportedShell # second positional arg
+ parser: str # --parser
+ name: str # --name
+ project: str # --project
+
+
+def parse_args(argv: Sequence[str] | None = None) -> Args:
+ """
+ Parse command-line arguments.
+
+ :param argv: argument list to parse, defaults to sys.argv[1:]
+ :return: annotated namespace of parsed args (:class:`Args`)
+ """
+ parser = argparse.ArgumentParser(
+ description='Generate shell completion scripts for argparse-based CLI tools.',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ add_help=False,
+ epilog='\n'.join([
+ 'examples:',
+ ' %(prog)s scripts/maintain/update_prek.py zsh',
+ ' %(prog)s scripts/maintain/update_prek.py bash --parser build_parser',
+ ' %(prog)s scripts/maintain/update_prek.py fish --name update_prek',
+ ]),
+ )
+
+ def add_positional_args() -> None:
+ parser.add_argument(
+ 'script',
+ type=Path,
+ help='path to the target argparse-based script',
+ )
+ parser.add_argument(
+ 'shell',
+ choices=get_args(SupportedShell),
+ help=f'target shell ({", ".join(get_args(SupportedShell))})',
+ )
+ add_positional_args()
+
+ def add_options() -> None:
+ parser.add_argument(
+ '--parser',
+ metavar='NAME',
+ default=None,
+ help='name of the function or attribute that provides the ArgumentParser (auto-detected if omitted)',
+ )
+ parser.add_argument(
+ '--name',
+ metavar='CMD',
+ default=None,
+ help='command name for the completion script (defaults to the script filename)',
+ )
+ parser.add_argument(
+ '--project',
+ metavar='NAME',
+ default=None,
+ help='project name for header comments',
+ )
+ parser.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='show this help message and exit',
+ )
+ add_options()
+
+ return parser.parse_args(argv, namespace=Args())
+
+
+def main(argv: Sequence[str] | None = None) -> None:
+ """
+ CLI entry point for generating shell completions from an argparse-based script.
+
+ :param argv: argument list to parse (defaults to ``sys.argv[1:]``)
+ """
+ args = parse_args(argv)
+ try:
+ parser = resolve_parser(args.script, parser_name=args.parser)
+ except (FileNotFoundError, ImportError, RuntimeError) as e:
+ print(f'error: {e}', file=sys.stderr)
+ sys.exit(1)
+ else:
+ print(
+ generate_completions(
+ args.shell,
+ parser,
+ command_name=args.name or args.script.name,
+ script_path=str(args.script),
+ project_name=args.project or '',
+ )
+ )
+
+
+if __name__ == '__main__':
+ main()
diff --git a/services/README.md b/services/README.md
index 0d44bd7..569dd8c 100644
--- a/services/README.md
+++ b/services/README.md
@@ -1,101 +1,107 @@
# Services
-Docker Compose definitions for the home NAS stack. Each subdirectory contains a self-contained
-`compose.yml` for a single service; `compose.base.yml` provides the shared project name and the
-`nas-network` bridge that ties them together.
+Docker Compose definitions for the home NAS stack. Each subdirectory contains a self-contained `compose.yml` for a
+single service; `compose.base.yml` provides the shared project name and the `nas-network` bridge that ties them
+together.
-These files are deployed to the NAS at `~/docker/` by the `roles/docker` Ansible role and brought
-up by `roles/docker/files/deploy.sh`, which discovers every subdirectory containing a `compose.yml`
-and runs `docker compose -f compose.base.yml -f /compose.yml up -d `.
+These files are deployed to the NAS at `~/docker/` by the `roles/docker` Ansible role and brought up by
+`roles/docker/files/deploy.sh`, which discovers every subdirectory containing a `compose.yml` and runs
+`docker compose -f compose.base.yml -f /compose.yml up -d `.
## Quick Reference
-All host ports below are bound on the NAS itself. Services without a host port are reachable only
-from inside the `nas-network` bridge (or, for Home Assistant, the host network namespace).
-
-| Container | Image | Host Port(s) | Purpose |
-|------------------|------------------------------------|-----------------------------------------------|-----------------------------------------------|
-| `caddy` | `caddy:2-alpine` | `443/tcp`, `32443/tcp` | Tailscale HTTPS reverse proxy (see below) |
-| `glance` | `glanceapp/glance` | `8080/tcp` | Dashboard / homepage |
-| `home-assistant` | `homeassistant/home-assistant` | host networking (default `8123/tcp`) | Home automation hub |
-| `pihole` | `pihole/pihole` | `53/tcp`, `53/udp`, `67/udp`, `8053/tcp` → 80 | DNS, ad-blocking, DHCP |
-| `plex` | `linuxserver/plex` | `32400/tcp` | Media streaming server |
-| `jellyfin` | `linuxserver/jellyfin` | `8096/tcp`, `7359/udp` | Media streaming server (Plex alternative) |
-| `prowlarr` | `linuxserver/prowlarr` | `9696/tcp` | Indexer aggregator for Sonarr/Radarr |
-| `radarr` | `linuxserver/radarr` | `7878/tcp` | Movie library management |
-| `sonarr` | `linuxserver/sonarr` | `8989/tcp` | TV library management |
-| `transmission` | `linuxserver/transmission` | `9091/tcp` | BitTorrent client (web UI) |
-| `flaresolverr` | `ghcr.io/flaresolverr/flaresolverr`| internal only (`8191/tcp` on the bridge) | Cloudflare challenge solver for Prowlarr |
-| `autoplex` | `danielmmetz/autoplex` (built) | none | Copies completed downloads into Plex layout |
-
-LAN access is `http://:` (e.g. `http://nas.local:8989` for Sonarr). The PiHole
-admin UI is `http://:8053/admin`. Home Assistant listens directly on the host network at
-port `8123`.
+All host ports below are bound on the NAS itself. Services without a host port are reachable only from inside the
+`nas-network` bridge (or, for Home Assistant, the host network namespace).
+
+| Container | Image | Host Port(s) | Purpose |
+| ---------------- | ----------------------------------- | --------------------------------------------- | ------------------------------------------- |
+| `caddy` | `caddy:2-alpine` | `443/tcp`, `32443/tcp` | Tailscale HTTPS reverse proxy (see below) |
+| `glance` | `glanceapp/glance` | `8080/tcp` | Dashboard / homepage |
+| `home-assistant` | `homeassistant/home-assistant` | host networking (default `8123/tcp`) | Home automation hub |
+| `pihole` | `pihole/pihole` | `53/tcp`, `53/udp`, `67/udp`, `8053/tcp` → 80 | DNS, ad-blocking, DHCP |
+| `plex` | `linuxserver/plex` | `32400/tcp` | Media streaming server |
+| `jellyfin` | `linuxserver/jellyfin` | `8096/tcp`, `7359/udp` | Media streaming server (Plex alternative) |
+| `prowlarr` | `linuxserver/prowlarr` | `9696/tcp` | Indexer aggregator for Sonarr/Radarr |
+| `radarr` | `linuxserver/radarr` | `7878/tcp` | Movie library management |
+| `sonarr` | `linuxserver/sonarr` | `8989/tcp` | TV library management |
+| `transmission` | `linuxserver/transmission` | `9091/tcp` | BitTorrent client (web UI) |
+| `flaresolverr` | `ghcr.io/flaresolverr/flaresolverr` | internal only (`8191/tcp` on the bridge) | Cloudflare challenge solver for Prowlarr |
+| `autoplex` | `danielmmetz/autoplex` (built) | none | Copies completed downloads into Plex layout |
+
+LAN access is `http://:` (e.g. `http://nas.local:8989` for Sonarr). The PiHole admin UI is
+`http://:8053/admin`. Home Assistant listens directly on the host network at port `8123`.
## Tailscale / HTTPS
-`caddy` terminates TLS using certs issued by `tailscale cert` and proxies a subset of the services
-under a single hostname. Configuration is templated by `roles/tailscale/templates/Caddyfile.j2`
-from the `tailscale_services` variable in `inventory/host_vars/nas/vars.yml`.
+`caddy` terminates TLS using certs issued by `tailscale cert` and proxies a subset of the services under a single
+hostname. Configuration is templated by `roles/tailscale/templates/Caddyfile.j2` from the `tailscale_services` variable
+in `inventory/host_vars/nas/vars.yml`.
-| Service | External URL | Notes |
-|---------------|------------------------------------------------|----------------------------------------------------|
-| Sonarr | `https://nas..ts.net/sonarr` | Requires `UrlBase = /sonarr` in app settings |
-| Radarr | `https://nas..ts.net/radarr` | Requires `UrlBase = /radarr` in app settings |
-| Prowlarr | `https://nas..ts.net/prowlarr` | Requires `UrlBase = /prowlarr` in app settings |
-| Transmission | `https://nas..ts.net/transmission` | Requires `rpc-url = /transmission/` in settings |
-| PiHole | `https://nas..ts.net/pihole` | Caddy rewrites `/pihole` → `/admin` |
-| Glance | `https://nas..ts.net/glance` | Prefix stripped before proxying |
-| Plex | `https://nas..ts.net:32443` | Dedicated port; `ADVERTISE_IP` must match |
+| Service | External URL | Notes |
+| ------------ | ------------------------------------------- | ----------------------------------------------- |
+| Sonarr | `https://nas..ts.net/sonarr` | Requires `UrlBase = /sonarr` in app settings |
+| Radarr | `https://nas..ts.net/radarr` | Requires `UrlBase = /radarr` in app settings |
+| Prowlarr | `https://nas..ts.net/prowlarr` | Requires `UrlBase = /prowlarr` in app settings |
+| Transmission | `https://nas..ts.net/transmission` | Requires `rpc-url = /transmission/` in settings |
+| PiHole | `https://nas..ts.net/pihole` | Caddy rewrites `/pihole` → `/admin` |
+| Glance | `https://nas..ts.net/glance` | Prefix stripped before proxying |
+| Plex | `https://nas..ts.net:32443` | Dedicated port; `ADVERTISE_IP` must match |
## Services
### `caddy`
-Reverse proxy fronting the stack on the Tailscale interface. Reads `Caddyfile` and TLS material
-from `/etc/caddy/` and `/etc/tailscale/certs/` on the host, both populated by the `tailscale` role.
+
+Reverse proxy fronting the stack on the Tailscale interface. Reads `Caddyfile` and TLS material from `/etc/caddy/` and
+`/etc/tailscale/certs/` on the host, both populated by the `tailscale` role.
### `glance`
-Web dashboard. Mounts `glance/config` and `glance/assets`, plus the Docker socket (read-only) so it
-can display container status. Loads environment from a sibling `.env` file rendered by Ansible.
+
+Web dashboard. Mounts `glance/config` and `glance/assets`, plus the Docker socket (read-only) so it can display
+container status. Loads environment from a sibling `.env` file rendered by Ansible.
### `home-assistant`
-Runs on the host network (`network_mode: host`) so it can discover devices via mDNS/SSDP and reach
-the Lutron Caseta bridge directly. Secrets, certificates, and YAML config live under
-`homeassistant/config/`, populated during the `docker` role's `homeassistant` task.
+
+Runs on the host network (`network_mode: host`) so it can discover devices via mDNS/SSDP and reach the Lutron Caseta
+bridge directly. Secrets, certificates, and YAML config live under `homeassistant/config/`, populated during the
+`docker` role's `homeassistant` task.
### `pihole`
-DNS server and DHCP-capable ad-blocker. Binds privileged DNS/DHCP ports on the host; the admin UI
-is remapped to host port `8053` to avoid colliding with other web services. `FTLCONF_webserver_api_password`
-is sourced from the environment (the deploy `.env` file).
+
+DNS server and DHCP-capable ad-blocker. Binds privileged DNS/DHCP ports on the host; the admin UI is remapped to host
+port `8053` to avoid colliding with other web services. `FTLCONF_webserver_api_password` is sourced from the environment
+(the deploy `.env` file).
### `plex`
-Media server. Uses the LinuxServer.io image with `PUID=1000/PGID=1000`. `ADVERTISE_IP` is set in the
-deploy `.env` to the Tailscale HTTPS URL (`https://nas..ts.net:32443`) so remote clients
-get a working external address.
+
+Media server. Uses the LinuxServer.io image with `PUID=1000/PGID=1000`. `ADVERTISE_IP` is set in the deploy `.env` to
+the Tailscale HTTPS URL (`https://nas..ts.net:32443`) so remote clients get a working external address.
### `jellyfin`
-Media server running alongside Plex for evaluation. Uses the LinuxServer.io image with
-`PUID=1000/PGID=1000` and shares the same `/storage/media/{tv,movies,music}` libraries as Plex.
-LAN-only HTTP on port `8096`; client auto-discovery on `7359/udp`. Config persists at
-`/storage/media/config/jellyfin`.
+
+Media server running alongside Plex for evaluation. Uses the LinuxServer.io image with `PUID=1000/PGID=1000` and shares
+the same `/storage/media/{tv,movies,music}` libraries as Plex. LAN-only HTTP on port `8096`; client auto-discovery on
+`7359/udp`. Config persists at `/storage/media/config/jellyfin`.
### `prowlarr`, `radarr`, `sonarr`, `transmission`
-Standard *arr / download stack. All share the `/storage/media` tree on the host so completed
-downloads can be picked up by Radarr/Sonarr and reorganized by autoplex.
+
+Standard \*arr / download stack. All share the `/storage/media` tree on the host so completed downloads can be picked up
+by Radarr/Sonarr and reorganized by autoplex.
### `flaresolverr`
-Headless-browser sidecar used by Prowlarr to solve Cloudflare challenges. Not exposed to the host;
-other containers reach it as `http://flaresolverr:8191` over the `nas-network` bridge.
+
+Headless-browser sidecar used by Prowlarr to solve Cloudflare challenges. Not exposed to the host; other containers
+reach it as `http://flaresolverr:8191` over the `nas-network` bridge.
### `autoplex`
+
Built from source (`danielmmetz/autoplex`) by the `docker` Ansible role. Watches
-`/storage/media/downloads/complete/{tv,movies}` and copies new files into the `tv/` and `movies/`
-trees consumed by Plex and Sonarr/Radarr. Talks to `transmission` for completion status.
+`/storage/media/downloads/complete/{tv,movies}` and copies new files into the `tv/` and `movies/` trees consumed by Plex
+and Sonarr/Radarr. Talks to `transmission` for completion status.
## Deploying
-The Ansible bootstrap installs `~/docker/deploy.sh` on the NAS. It is idempotent and used by both
-the initial provision and the GitHub Actions CI workflow on push to `main`.
+The Ansible bootstrap installs `~/docker/deploy.sh` on the NAS. It is idempotent and used by both the initial provision
+and the GitHub Actions CI workflow on push to `main`.
```bash
# Deploy everything
diff --git a/services/homeassistant/config/README.md b/services/homeassistant/config/README.md
index 1eb2fbb..7240a52 100644
--- a/services/homeassistant/config/README.md
+++ b/services/homeassistant/config/README.md
@@ -1,11 +1,13 @@
# homeassistant
+
bae5hau5 HomeAssisant config
### Necessary Workarounds
-- pylutron appears to have a bug of some sort, preventing the Lutron component from authenticating even after
-generating a cert with [scripts/get_lutron_cert.py](scripts/get_lutron_cert.py). Found a workaround in an
-[open homeassistant issue](https://github.com/home-assistant/home-assistant/issues/15421#issuecomment-459453030),
-which requires changing a line in the pylutron python package:
+
+- pylutron appears to have a bug of some sort, preventing the Lutron component from authenticating even after generating
+ a cert with [scripts/get_lutron_cert.py](scripts/get_lutron_cert.py). Found a workaround in an
+ [open homeassistant issue](https://github.com/home-assistant/home-assistant/issues/15421#issuecomment-459453030),
+ which requires changing a line in the pylutron python package:
```
quick fix :
diff --git a/services/homeassistant/config/scripts/get_lutron_cert.py b/services/homeassistant/config/scripts/get_lutron_cert.py
index 955e0d1..a33998f 100644
--- a/services/homeassistant/config/scripts/get_lutron_cert.py
+++ b/services/homeassistant/config/scripts/get_lutron_cert.py
@@ -97,7 +97,7 @@
oauth_code = re.sub(r'^(.*?code=){0,1}([0-9a-f]*)\s*$', r'\2', redirected_url)
if oauth_code == '':
- raise Exception('Invalid code')
+ raise Exception('Invalid code') from None
token = requests.post(
f'{BASE_URL}oauth/token',
@@ -110,7 +110,7 @@
},
).json()
if token['token_type'] != 'bearer':
- raise Exception(f'Received invalid token {token}. Try generating a new code (one time use)')
+ raise Exception(f'Received invalid token {token}. Try generating a new code (one time use)') from None
pairing_response = requests.post(
f'{BASE_URL}api/v1/remotepairing/application/user',
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/uv.lock b/uv.lock
index e66e332..dbf0bbf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,545 +1,1590 @@
version = 1
revision = 3
-requires-python = ">=3.10"
+requires-python = ">=3.12"
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15'",
+]
+
+[[package]]
+name = "ansible-core"
+version = "2.21.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "resolvelib" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/26/6d/38d7eaea1df4a95c3b137e780f6ad2208d7886334a291cadde5d645f08a1/ansible_core-2.21.1.tar.gz", hash = "sha256:a5536ece95be84de15212b3644cdbbe9cbd9efd62e4e8a544cd6b0b27a083039", size = 3384534, upload-time = "2026-06-18T19:35:02.887Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/90/55/894f2cad10ef650bf33a0e64e242805de2d1a8835f539d04d52053fe08a6/ansible_core-2.21.1-py3-none-any.whl", hash = "sha256:1403ff56bb69484ef90788126d06c65b4e28e8d2dcdcfb7f027d4c1495052d7e", size = 2454057, upload-time = "2026-06-18T19:35:00.741Z" },
+]
+
+[[package]]
+name = "ast-serialize"
+version = "0.6.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
+ { url = "https://pypi.apple.com/packages/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" },
+ { url = "https://pypi.apple.com/packages/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" },
+ { url = "https://pypi.apple.com/packages/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" },
+ { url = "https://pypi.apple.com/packages/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" },
+ { url = "https://pypi.apple.com/packages/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" },
+ { url = "https://pypi.apple.com/packages/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" },
+ { url = "https://pypi.apple.com/packages/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" },
+ { url = "https://pypi.apple.com/packages/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" },
+ { url = "https://pypi.apple.com/packages/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" },
+ { url = "https://pypi.apple.com/packages/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" },
+ { url = "https://pypi.apple.com/packages/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" },
+ { url = "https://pypi.apple.com/packages/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" },
+ { url = "https://pypi.apple.com/packages/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" },
+ { url = "https://pypi.apple.com/packages/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" },
+ { url = "https://pypi.apple.com/packages/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" },
+ { url = "https://pypi.apple.com/packages/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" },
+ { url = "https://pypi.apple.com/packages/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" },
+ { url = "https://pypi.apple.com/packages/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" },
+ { url = "https://pypi.apple.com/packages/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" },
+ { url = "https://pypi.apple.com/packages/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" },
+ { url = "https://pypi.apple.com/packages/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" },
+ { url = "https://pypi.apple.com/packages/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" },
+ { url = "https://pypi.apple.com/packages/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" },
+ { url = "https://pypi.apple.com/packages/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" },
+ { url = "https://pypi.apple.com/packages/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" },
+ { url = "https://pypi.apple.com/packages/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" },
+ { url = "https://pypi.apple.com/packages/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" },
+ { url = "https://pypi.apple.com/packages/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" },
+ { url = "https://pypi.apple.com/packages/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" },
+ { url = "https://pypi.apple.com/packages/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" },
+ { url = "https://pypi.apple.com/packages/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" },
+ { url = "https://pypi.apple.com/packages/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" },
+]
+
+[[package]]
+name = "astatine"
+version = "0.3.3"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "domdf-python-tools" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/a4/8a/544e058e5ed049cffd67006944872216c48e86b9004ceeefbb33a9759d3d/astatine-0.3.3.tar.gz", hash = "sha256:0c58a7844b5890ff16da07dbfeb187341d8324cb4378940f89d795cbebebce08", size = 6687, upload-time = "2023-08-15T10:38:28.151Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/1e/72/58e5d9b9965f55a1b652c7ea73dceedf4f8cea08d1310aa2d4102202acb8/astatine-0.3.3-py3-none-any.whl", hash = "sha256:6d8c914f01fbea252cb8f31563f2e766a9ab03c02b9bcc37d18f7d9138828401", size = 17376, upload-time = "2023-08-15T10:38:26.735Z" },
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
[[package]]
name = "attrs"
version = "26.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+ { url = "https://pypi.apple.com/packages/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
-name = "cfgv"
-version = "3.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+name = "cffi"
+version = "2.1.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+ { url = "https://pypi.apple.com/packages/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
+ { url = "https://pypi.apple.com/packages/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
+ { url = "https://pypi.apple.com/packages/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
+ { url = "https://pypi.apple.com/packages/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
+ { url = "https://pypi.apple.com/packages/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
+ { url = "https://pypi.apple.com/packages/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
+ { url = "https://pypi.apple.com/packages/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
+ { url = "https://pypi.apple.com/packages/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
+ { url = "https://pypi.apple.com/packages/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
+ { url = "https://pypi.apple.com/packages/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
+ { url = "https://pypi.apple.com/packages/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
+ { url = "https://pypi.apple.com/packages/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
+ { url = "https://pypi.apple.com/packages/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
+ { url = "https://pypi.apple.com/packages/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
+ { url = "https://pypi.apple.com/packages/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
+ { url = "https://pypi.apple.com/packages/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
+ { url = "https://pypi.apple.com/packages/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
+ { url = "https://pypi.apple.com/packages/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
+ { url = "https://pypi.apple.com/packages/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
+ { url = "https://pypi.apple.com/packages/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
+ { url = "https://pypi.apple.com/packages/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
+ { url = "https://pypi.apple.com/packages/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
+ { url = "https://pypi.apple.com/packages/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
+ { url = "https://pypi.apple.com/packages/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
+ { url = "https://pypi.apple.com/packages/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
+ { url = "https://pypi.apple.com/packages/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
+ { url = "https://pypi.apple.com/packages/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
+ { url = "https://pypi.apple.com/packages/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
+ { url = "https://pypi.apple.com/packages/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
+ { url = "https://pypi.apple.com/packages/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
+ { url = "https://pypi.apple.com/packages/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
+ { url = "https://pypi.apple.com/packages/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
+ { url = "https://pypi.apple.com/packages/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
+ { url = "https://pypi.apple.com/packages/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
+ { url = "https://pypi.apple.com/packages/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
+ { url = "https://pypi.apple.com/packages/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
+ { url = "https://pypi.apple.com/packages/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
+ { url = "https://pypi.apple.com/packages/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
+ { url = "https://pypi.apple.com/packages/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
+ { url = "https://pypi.apple.com/packages/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
+ { url = "https://pypi.apple.com/packages/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
+ { url = "https://pypi.apple.com/packages/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
+ { url = "https://pypi.apple.com/packages/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
+ { url = "https://pypi.apple.com/packages/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
+ { url = "https://pypi.apple.com/packages/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
+ { url = "https://pypi.apple.com/packages/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
+ { url = "https://pypi.apple.com/packages/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
+ { url = "https://pypi.apple.com/packages/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
+ { url = "https://pypi.apple.com/packages/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
+ { url = "https://pypi.apple.com/packages/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
+ { url = "https://pypi.apple.com/packages/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
+ { url = "https://pypi.apple.com/packages/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
+ { url = "https://pypi.apple.com/packages/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
+ { url = "https://pypi.apple.com/packages/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
+ { url = "https://pypi.apple.com/packages/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
+ { url = "https://pypi.apple.com/packages/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
+ { url = "https://pypi.apple.com/packages/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
+ { url = "https://pypi.apple.com/packages/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
+ { url = "https://pypi.apple.com/packages/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
+ { url = "https://pypi.apple.com/packages/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
+ { url = "https://pypi.apple.com/packages/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
+ { url = "https://pypi.apple.com/packages/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
+ { url = "https://pypi.apple.com/packages/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
+ { url = "https://pypi.apple.com/packages/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
+ { url = "https://pypi.apple.com/packages/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
+ { url = "https://pypi.apple.com/packages/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
+ { url = "https://pypi.apple.com/packages/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
+ { url = "https://pypi.apple.com/packages/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
+ { url = "https://pypi.apple.com/packages/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
+ { url = "https://pypi.apple.com/packages/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
+ { url = "https://pypi.apple.com/packages/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
+ { url = "https://pypi.apple.com/packages/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
+ { url = "https://pypi.apple.com/packages/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
+ { url = "https://pypi.apple.com/packages/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
]
[[package]]
name = "click"
-version = "8.3.1"
-source = { registry = "https://pypi.org/simple" }
+version = "8.4.2"
+source = { registry = "https://pypi.apple.com/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+sdist = { url = "https://pypi.apple.com/packages/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "codespell"
+version = "2.4.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+ { url = "https://pypi.apple.com/packages/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
]
[[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" }
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/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://pypi.apple.com/packages/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 = "consolekit"
+version = "1.13.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "deprecation-alias" },
+ { name = "domdf-python-tools" },
+ { name = "mistletoe" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/60/1f/b1745cfe7b1c32d0cfe76b09a184c0d9360851a92516da0ea3647e39c759/consolekit-1.13.0.tar.gz", hash = "sha256:6c28df284ec86fb395fbe39493ddf9f8dfc8b181a6156abfd50c3f2156ad2b20", size = 32316, upload-time = "2026-02-19T16:12:14.026Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/a8/63/e08e7d4a2a89ab7769ca7e8066d04cbf6620ff4065ee0ea9a47b4cdabae6/consolekit-1.13.0-py3-none-any.whl", hash = "sha256:5b274587d32344dbb7a7aae29df6478e1bbbf6842494f74fe6126fada5ded3ea", size = 46437, upload-time = "2026-02-19T16:12:11.475Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.15.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
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" },
+ { url = "https://pypi.apple.com/packages/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" },
+ { url = "https://pypi.apple.com/packages/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" },
+ { url = "https://pypi.apple.com/packages/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" },
+ { url = "https://pypi.apple.com/packages/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" },
+ { url = "https://pypi.apple.com/packages/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" },
+ { url = "https://pypi.apple.com/packages/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" },
+ { url = "https://pypi.apple.com/packages/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" },
+ { url = "https://pypi.apple.com/packages/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" },
+ { url = "https://pypi.apple.com/packages/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" },
+ { url = "https://pypi.apple.com/packages/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" },
+ { url = "https://pypi.apple.com/packages/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" },
+ { url = "https://pypi.apple.com/packages/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" },
+ { url = "https://pypi.apple.com/packages/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" },
+ { url = "https://pypi.apple.com/packages/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" },
+ { url = "https://pypi.apple.com/packages/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" },
+ { url = "https://pypi.apple.com/packages/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" },
+ { url = "https://pypi.apple.com/packages/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" },
+ { url = "https://pypi.apple.com/packages/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" },
+ { url = "https://pypi.apple.com/packages/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" },
+ { url = "https://pypi.apple.com/packages/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" },
+ { url = "https://pypi.apple.com/packages/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" },
+ { url = "https://pypi.apple.com/packages/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" },
+ { url = "https://pypi.apple.com/packages/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" },
+ { url = "https://pypi.apple.com/packages/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" },
+ { url = "https://pypi.apple.com/packages/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" },
+ { url = "https://pypi.apple.com/packages/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" },
+ { url = "https://pypi.apple.com/packages/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" },
+ { url = "https://pypi.apple.com/packages/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" },
+ { url = "https://pypi.apple.com/packages/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" },
+ { url = "https://pypi.apple.com/packages/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" },
+ { url = "https://pypi.apple.com/packages/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
+ { url = "https://pypi.apple.com/packages/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
+ { url = "https://pypi.apple.com/packages/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
+ { url = "https://pypi.apple.com/packages/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
+ { url = "https://pypi.apple.com/packages/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
+ { url = "https://pypi.apple.com/packages/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
+ { url = "https://pypi.apple.com/packages/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
+ { url = "https://pypi.apple.com/packages/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
+ { url = "https://pypi.apple.com/packages/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
+ { url = "https://pypi.apple.com/packages/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
+ { url = "https://pypi.apple.com/packages/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
+ { url = "https://pypi.apple.com/packages/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
+ { url = "https://pypi.apple.com/packages/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
+ { url = "https://pypi.apple.com/packages/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
+ { url = "https://pypi.apple.com/packages/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
+ { url = "https://pypi.apple.com/packages/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
+ { url = "https://pypi.apple.com/packages/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
+ { url = "https://pypi.apple.com/packages/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
+ { url = "https://pypi.apple.com/packages/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
+ { url = "https://pypi.apple.com/packages/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
+ { url = "https://pypi.apple.com/packages/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
+ { url = "https://pypi.apple.com/packages/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
+ { url = "https://pypi.apple.com/packages/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
+ { url = "https://pypi.apple.com/packages/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
+ { url = "https://pypi.apple.com/packages/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
+ { url = "https://pypi.apple.com/packages/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
+ { url = "https://pypi.apple.com/packages/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
+ { url = "https://pypi.apple.com/packages/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
+ { url = "https://pypi.apple.com/packages/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
+ { url = "https://pypi.apple.com/packages/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
+ { url = "https://pypi.apple.com/packages/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
]
[[package]]
-name = "connor-mason-dotfiles"
+name = "cryptography"
+version = "49.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
+ { url = "https://pypi.apple.com/packages/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
+ { url = "https://pypi.apple.com/packages/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
+ { url = "https://pypi.apple.com/packages/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
+ { url = "https://pypi.apple.com/packages/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
+ { url = "https://pypi.apple.com/packages/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
+ { url = "https://pypi.apple.com/packages/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
+ { url = "https://pypi.apple.com/packages/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
+ { url = "https://pypi.apple.com/packages/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
+ { url = "https://pypi.apple.com/packages/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
+ { url = "https://pypi.apple.com/packages/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
+ { url = "https://pypi.apple.com/packages/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
+ { url = "https://pypi.apple.com/packages/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
+ { url = "https://pypi.apple.com/packages/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
+ { url = "https://pypi.apple.com/packages/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
+ { url = "https://pypi.apple.com/packages/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
+ { url = "https://pypi.apple.com/packages/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
+ { url = "https://pypi.apple.com/packages/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
+ { url = "https://pypi.apple.com/packages/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
+ { url = "https://pypi.apple.com/packages/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
+ { url = "https://pypi.apple.com/packages/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
+ { url = "https://pypi.apple.com/packages/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
+ { url = "https://pypi.apple.com/packages/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
+ { url = "https://pypi.apple.com/packages/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
+ { url = "https://pypi.apple.com/packages/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
+ { url = "https://pypi.apple.com/packages/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
+ { url = "https://pypi.apple.com/packages/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
+ { url = "https://pypi.apple.com/packages/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
+ { url = "https://pypi.apple.com/packages/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
+ { url = "https://pypi.apple.com/packages/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
+ { url = "https://pypi.apple.com/packages/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
+ { url = "https://pypi.apple.com/packages/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
+ { url = "https://pypi.apple.com/packages/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
+ { url = "https://pypi.apple.com/packages/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
+ { url = "https://pypi.apple.com/packages/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
+ { url = "https://pypi.apple.com/packages/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
+ { url = "https://pypi.apple.com/packages/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
+ { url = "https://pypi.apple.com/packages/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
+ { url = "https://pypi.apple.com/packages/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
+]
+
+[[package]]
+name = "deprecation"
+version = "2.1.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" },
+]
+
+[[package]]
+name = "deprecation-alias"
+version = "0.4.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "deprecation" },
+ { name = "packaging" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/33/e0/2484747bc4936baf2737bb539b9e5b7f6d3a4c47f22d8b137c0a1fde356b/deprecation_alias-0.4.0.tar.gz", hash = "sha256:a58d2e74491c7834e9d318788da60272fa2f81aeb814b4055a4ba90a0a41740f", size = 10898, upload-time = "2025-02-11T15:53:59.001Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/a2/00/a0514192a926e2c077a170f9f58653a0b3dd2ff034fdee48da843ea01d6c/deprecation_alias-0.4.0-py3-none-any.whl", hash = "sha256:a2d3cb08705d81bcc845ebbeff8e981ca7e5ef6c51478f0c771c38a54d7d7811", size = 13768, upload-time = "2025-02-11T15:53:57.83Z" },
+]
+
+[[package]]
+name = "domdf-python-tools"
+version = "3.10.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "natsort" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/36/8b/ab2d8a292bba8fe3135cacc8bfd3576710a14b8f2d0a8cde19130d5c9d21/domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298", size = 100458, upload-time = "2025-02-12T17:34:05.747Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/5b/11/208f72084084d3f6a2ed5ebfdfc846692c3f7ad6dce65e400194924f7eed/domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36", size = 126860, upload-time = "2025-02-12T17:34:04.093Z" },
+]
+
+[[package]]
+name = "dotfiles"
version = "0.0.1"
source = { virtual = "." }
dependencies = [
+ { name = "ansible-core" },
+ { name = "pyyaml" },
{ name = "typing-extensions" },
]
[package.dev-dependencies]
dev = [
+ { name = "codespell" },
+ { name = "coverage" },
+ { name = "flake8-dunder-all" },
{ name = "interrogate" },
+ { name = "mdformat" },
+ { name = "mdformat-config" },
+ { name = "mdformat-footnote" },
+ { name = "mdformat-front-matters" },
+ { name = "mdformat-gfm" },
+ { name = "mdformat-gfm-alerts" },
+ { name = "mdformat-pyproject" },
+ { name = "mdformat-simple-breaks" },
+ { name = "mdformat-toc" },
{ name = "mypy" },
- { name = "pre-commit" },
+ { name = "prek" },
+ { name = "pyjson5" },
+ { name = "pytest" },
+ { name = "pytest-clarity" },
+ { name = "pytest-html" },
+ { name = "pytest-mock" },
+ { name = "pytest-sugar" },
+ { name = "pytest-timeout" },
+ { name = "pytest-xdist" },
+ { name = "pyyaml" },
{ name = "ruff" },
+ { name = "shellcheck-py" },
+ { name = "shfmt-py" },
+ { name = "taplo" },
{ name = "types-pyyaml" },
{ name = "types-requests" },
+ { name = "types-toml" },
+ { name = "validate-pyproject", extra = ["all"] },
+ { name = "validate-pyproject-schema-store" },
+ { name = "yamllint" },
]
lint = [
+ { name = "codespell" },
+ { name = "flake8-dunder-all" },
{ name = "interrogate" },
+ { name = "mdformat" },
+ { name = "mdformat-config" },
+ { name = "mdformat-footnote" },
+ { name = "mdformat-front-matters" },
+ { name = "mdformat-gfm" },
+ { name = "mdformat-gfm-alerts" },
+ { name = "mdformat-pyproject" },
+ { name = "mdformat-simple-breaks" },
+ { name = "mdformat-toc" },
{ name = "mypy" },
- { name = "pre-commit" },
+ { name = "prek" },
+ { name = "pyjson5" },
{ name = "ruff" },
+ { name = "shellcheck-py" },
+ { name = "shfmt-py" },
+ { name = "taplo" },
{ name = "types-pyyaml" },
{ name = "types-requests" },
+ { name = "types-toml" },
+ { name = "validate-pyproject", extra = ["all"] },
+ { name = "validate-pyproject-schema-store" },
+ { name = "yamllint" },
+]
+test = [
+ { name = "coverage" },
+ { name = "pytest" },
+ { name = "pytest-clarity" },
+ { name = "pytest-html" },
+ { name = "pytest-mock" },
+ { name = "pytest-sugar" },
+ { name = "pytest-timeout" },
+ { name = "pytest-xdist" },
+ { name = "pyyaml" },
+ { name = "yamllint" },
]
[package.metadata]
-requires-dist = [{ name = "typing-extensions" }]
+requires-dist = [
+ { name = "ansible-core" },
+ { name = "pyyaml", specifier = ">=6.0" },
+ { name = "typing-extensions" },
+]
[package.metadata.requires-dev]
dev = [
+ { name = "codespell" },
+ { name = "coverage", extras = ["toml"], specifier = ">=7.11" },
+ { name = "flake8-dunder-all" },
{ name = "interrogate", specifier = ">=1.7.0" },
- { name = "mypy", specifier = ">=1.19.1" },
- { name = "pre-commit" },
+ { name = "mdformat" },
+ { name = "mdformat-config" },
+ { name = "mdformat-footnote" },
+ { name = "mdformat-front-matters" },
+ { name = "mdformat-gfm" },
+ { name = "mdformat-gfm-alerts" },
+ { name = "mdformat-pyproject" },
+ { name = "mdformat-simple-breaks" },
+ { name = "mdformat-toc" },
+ { name = "mypy", marker = "python_full_version < '3.10'", specifier = ">=1.19.1,<1.20" },
+ { name = "mypy", marker = "python_full_version >= '3.10'", specifier = ">=2.0" },
+ { name = "prek" },
+ { name = "pyjson5" },
+ { name = "pytest", specifier = ">=9.0" },
+ { name = "pytest-clarity", specifier = ">=1.0.1" },
+ { name = "pytest-html", specifier = ">=3.2.0" },
+ { name = "pytest-mock", specifier = ">=3.11.1" },
+ { name = "pytest-sugar", specifier = ">=0.9.7" },
+ { name = "pytest-timeout", specifier = ">=2.1.0" },
+ { name = "pytest-xdist", specifier = ">=3.3.0" },
+ { name = "pyyaml", specifier = ">=6.0.1" },
{ name = "ruff", specifier = ">=0.15.8" },
+ { name = "shellcheck-py" },
+ { name = "shfmt-py" },
+ { name = "taplo" },
{ name = "types-pyyaml" },
{ name = "types-requests" },
+ { name = "types-toml" },
+ { name = "validate-pyproject", extras = ["all"] },
+ { name = "validate-pyproject-schema-store" },
+ { name = "yamllint" },
+ { name = "yamllint", specifier = ">=1.32.0" },
]
lint = [
+ { name = "codespell" },
+ { name = "flake8-dunder-all" },
{ name = "interrogate", specifier = ">=1.7.0" },
- { name = "mypy", specifier = ">=1.19.1" },
- { name = "pre-commit" },
+ { name = "mdformat" },
+ { name = "mdformat-config" },
+ { name = "mdformat-footnote" },
+ { name = "mdformat-front-matters" },
+ { name = "mdformat-gfm" },
+ { name = "mdformat-gfm-alerts" },
+ { name = "mdformat-pyproject" },
+ { name = "mdformat-simple-breaks" },
+ { name = "mdformat-toc" },
+ { name = "mypy", marker = "python_full_version < '3.10'", specifier = ">=1.19.1,<1.20" },
+ { name = "mypy", marker = "python_full_version >= '3.10'", specifier = ">=2.0" },
+ { name = "prek" },
+ { name = "pyjson5" },
{ name = "ruff", specifier = ">=0.15.8" },
+ { name = "shellcheck-py" },
+ { name = "shfmt-py" },
+ { name = "taplo" },
{ name = "types-pyyaml" },
{ name = "types-requests" },
+ { name = "types-toml" },
+ { name = "validate-pyproject", extras = ["all"] },
+ { name = "validate-pyproject-schema-store" },
+ { name = "yamllint" },
+]
+test = [
+ { name = "coverage", extras = ["toml"], specifier = ">=7.11" },
+ { name = "pytest", specifier = ">=9.0" },
+ { name = "pytest-clarity", specifier = ">=1.0.1" },
+ { name = "pytest-html", specifier = ">=3.2.0" },
+ { name = "pytest-mock", specifier = ">=3.11.1" },
+ { name = "pytest-sugar", specifier = ">=0.9.7" },
+ { name = "pytest-timeout", specifier = ">=2.1.0" },
+ { name = "pytest-xdist", specifier = ">=3.3.0" },
+ { name = "pyyaml", specifier = ">=6.0.1" },
+ { name = "yamllint", specifier = ">=1.32.0" },
]
[[package]]
-name = "distlib"
-version = "0.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+name = "execnet"
+version = "2.1.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+ { url = "https://pypi.apple.com/packages/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]]
-name = "filelock"
-version = "3.25.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
+name = "fastjsonschema"
+version = "2.21.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
+ { url = "https://pypi.apple.com/packages/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
]
[[package]]
-name = "identify"
-version = "2.6.18"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
+name = "flake8"
+version = "7.3.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mccabe" },
+ { name = "pycodestyle" },
+ { name = "pyflakes" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
+ { url = "https://pypi.apple.com/packages/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" },
+]
+
+[[package]]
+name = "flake8-dunder-all"
+version = "0.5.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "astatine" },
+ { name = "click" },
+ { name = "consolekit" },
+ { name = "domdf-python-tools" },
+ { name = "flake8" },
+ { name = "natsort" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/69/94/c6fe6c1ea239d6e732beb38324bcecf0132008260183caf2a2d31bc9565a/flake8_dunder_all-0.5.0.tar.gz", hash = "sha256:4ea582eb77cc7333df223608cb4d6ae813f07b167c19d4d65a6a52e388e89398", size = 9716, upload-time = "2025-05-21T16:26:40.723Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/e4/6c/e7c3401ce8c5515b80bd121f89bb0fa241cc3f7a0c0a83b38be2a56d2d83/flake8_dunder_all-0.5.0-py3-none-any.whl", hash = "sha256:fcd7d4ac9cbb3a1bca4c41221a6eb8bcd3c0965269e98772802cdd333beb7adc", size = 12153, upload-time = "2025-05-21T16:26:39.332Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/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://pypi.apple.com/packages/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 = "interrogate"
version = "1.7.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.apple.com/simple" }
dependencies = [
{ name = "attrs" },
{ name = "click" },
{ name = "colorama" },
{ name = "py" },
{ name = "tabulate" },
- { name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" }
+sdist = { url = "https://pypi.apple.com/packages/packages/8b/22/74f7fcc96280eea46cf2bcbfa1354ac31de0e60a4be6f7966f12cef20893/interrogate-1.7.0.tar.gz", hash = "sha256:a320d6ec644dfd887cc58247a345054fc4d9f981100c45184470068f4b3719b0", size = 159636, upload-time = "2024-04-07T22:30:46.217Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://pypi.apple.com/packages/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/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" },
+ { url = "https://pypi.apple.com/packages/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 = "librt"
-version = "0.8.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" },
- { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" },
- { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" },
- { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" },
- { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" },
- { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" },
- { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" },
- { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" },
- { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" },
- { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" },
- { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" },
- { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" },
- { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" },
- { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" },
- { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" },
- { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" },
- { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" },
- { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" },
- { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" },
- { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" },
- { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" },
- { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" },
- { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" },
- { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" },
- { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" },
- { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
- { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
- { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
- { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
- { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
- { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
- { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
- { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
- { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
- { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
- { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
- { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
- { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
- { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
- { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
- { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
- { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
- { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
- { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
- { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
- { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
- { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
- { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
- { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
- { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
- { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
- { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
- { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
- { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
- { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
- { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
- { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
- { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
- { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
- { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
- { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
- { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
- { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
- { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
- { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
- { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
- { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
- { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
- { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
- { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
- { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
- { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
- { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
- { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
- { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
- { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
- { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
+version = "0.12.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" },
+ { url = "https://pypi.apple.com/packages/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" },
+ { url = "https://pypi.apple.com/packages/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" },
+ { url = "https://pypi.apple.com/packages/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" },
+ { url = "https://pypi.apple.com/packages/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" },
+ { url = "https://pypi.apple.com/packages/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" },
+ { url = "https://pypi.apple.com/packages/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" },
+ { url = "https://pypi.apple.com/packages/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" },
+ { url = "https://pypi.apple.com/packages/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" },
+ { url = "https://pypi.apple.com/packages/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" },
+ { url = "https://pypi.apple.com/packages/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" },
+ { url = "https://pypi.apple.com/packages/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" },
+ { url = "https://pypi.apple.com/packages/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" },
+ { url = "https://pypi.apple.com/packages/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" },
+ { url = "https://pypi.apple.com/packages/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" },
+ { url = "https://pypi.apple.com/packages/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" },
+ { url = "https://pypi.apple.com/packages/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" },
+ { url = "https://pypi.apple.com/packages/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" },
+ { url = "https://pypi.apple.com/packages/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" },
+ { url = "https://pypi.apple.com/packages/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" },
+ { url = "https://pypi.apple.com/packages/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" },
+ { url = "https://pypi.apple.com/packages/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" },
+ { url = "https://pypi.apple.com/packages/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" },
+ { url = "https://pypi.apple.com/packages/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" },
+ { url = "https://pypi.apple.com/packages/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" },
+ { url = "https://pypi.apple.com/packages/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" },
+ { url = "https://pypi.apple.com/packages/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" },
+ { url = "https://pypi.apple.com/packages/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" },
+ { url = "https://pypi.apple.com/packages/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" },
+ { url = "https://pypi.apple.com/packages/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" },
+ { url = "https://pypi.apple.com/packages/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" },
+ { url = "https://pypi.apple.com/packages/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" },
+ { url = "https://pypi.apple.com/packages/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" },
+ { url = "https://pypi.apple.com/packages/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" },
+ { url = "https://pypi.apple.com/packages/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" },
+ { url = "https://pypi.apple.com/packages/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" },
+ { url = "https://pypi.apple.com/packages/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" },
+ { url = "https://pypi.apple.com/packages/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" },
+ { url = "https://pypi.apple.com/packages/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" },
+ { url = "https://pypi.apple.com/packages/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" },
+ { url = "https://pypi.apple.com/packages/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" },
+ { url = "https://pypi.apple.com/packages/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" },
+ { url = "https://pypi.apple.com/packages/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" },
+ { url = "https://pypi.apple.com/packages/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" },
+ { url = "https://pypi.apple.com/packages/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" },
+ { url = "https://pypi.apple.com/packages/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" },
+ { url = "https://pypi.apple.com/packages/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" },
+ { url = "https://pypi.apple.com/packages/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" },
+ { url = "https://pypi.apple.com/packages/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" },
+ { url = "https://pypi.apple.com/packages/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" },
+ { url = "https://pypi.apple.com/packages/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" },
+ { url = "https://pypi.apple.com/packages/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" },
+ { url = "https://pypi.apple.com/packages/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" },
+ { url = "https://pypi.apple.com/packages/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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 = "mccabe"
+version = "0.7.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
+]
+
+[[package]]
+name = "mdformat"
+version = "1.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" },
+]
+
+[[package]]
+name = "mdformat-config"
+version = "0.2.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "ruamel-yaml" },
+ { name = "taplo" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/ed/c0/49baad1d5121b1b7f30b5166f668d70d2a16ce5d2c267ebe7f210f575b59/mdformat_config-0.2.1.tar.gz", hash = "sha256:753aa1179198c791e4791099a0e9b1684a649fbbcd52db7c4d2e911a4c2c69ae", size = 2829, upload-time = "2024-10-18T19:42:53.522Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/4e/73/e989491953da2cb446253258d0f3694ea021c277ca0cfbefe0a79ca5eef1/mdformat_config-0.2.1-py3-none-any.whl", hash = "sha256:484a867c067366f85c9e4b91e36d8a92c80141490db59e481befe0ec8055f02d", size = 3473, upload-time = "2024-10-18T19:42:51.998Z" },
+]
+
+[[package]]
+name = "mdformat-footnote"
+version = "0.1.3"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/3f/c0/0fe461e3c53eb72b35b642add6c3eaf1f90e43a574a18633955f9e89791d/mdformat_footnote-0.1.3.tar.gz", hash = "sha256:70617e61af87f59d7dea93a392c5093089a6d4551126fa18025c6fddb18bf742", size = 6430, upload-time = "2026-01-30T14:13:05.983Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/7e/ac/60d47237a0e37fe991cbf311f10f096a28cfdbbe25d1932dadf8e9abdac8/mdformat_footnote-0.1.3-py3-none-any.whl", hash = "sha256:3033184237cbaf2a71cc02a4c37f043a97c1f16d3ed6939bc104964e77aad0b3", size = 7767, upload-time = "2026-01-30T14:13:04.944Z" },
+]
+
+[[package]]
+name = "mdformat-front-matters"
+version = "2.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "ruamel-yaml" },
+ { name = "toml" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/2c/53/008805510d3006d9aec6c602fa40305750cb96daba8ea9abae477149909b/mdformat_front_matters-2.0.0.tar.gz", hash = "sha256:46efabe93707699120c67d4b33b51fdb4cfe0e040acfb0f8212eb33c9a3e8a9a", size = 12576, upload-time = "2025-12-04T11:44:52.265Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/00/05/4c197c000dab41f1f58290221a4c02bd57804ff42e9374d3dd0f5408aa40/mdformat_front_matters-2.0.0-py3-none-any.whl", hash = "sha256:8ea92d23d1e9427fe6548b9044e5fbf7bcf6c217941f1a9442e0efd8b0a76e61", size = 13411, upload-time = "2025-12-04T11:44:51.209Z" },
+]
+
+[[package]]
+name = "mdformat-gfm"
+version = "1.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "wcwidth" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" },
+]
+
+[[package]]
+name = "mdformat-gfm-alerts"
+version = "2.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/c7/33/70619cf9e2b9e35857ebcf7e947e8f5370df2ff2a4ab666895783f8fab6c/mdformat_gfm_alerts-2.0.0.tar.gz", hash = "sha256:eb2b3189ad44ae28a6b6b714609dd3a30d6e3b898f02b1e6473d7b08df8bb3c0", size = 9687, upload-time = "2025-06-05T20:49:46.414Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/89/ea/22e093bb2ef3e25dc3ccc9d8392c14b069a007ae394a12e058d9e1f51c00/mdformat_gfm_alerts-2.0.0-py3-none-any.whl", hash = "sha256:e003422cc003bc6e936d0797553f23201095a1d1e8602c5062296d223f2ae516", size = 6752, upload-time = "2025-06-05T20:49:45.539Z" },
+]
+
+[[package]]
+name = "mdformat-pyproject"
+version = "0.1.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/a7/33/4c7400f8d2ed273d675e72f55c33e72e08e6224a35f184ffca8ae6d40d53/mdformat_pyproject-0.1.1.tar.gz", hash = "sha256:be16ed754d42db5f5e376ccb9cdbdfefc7750f7f77eadddde37f99ab87272b54", size = 5654, upload-time = "2025-10-29T16:10:24.651Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/5a/31/113382823075056a46de42b21ed31372908f30d353c11ca01ec447ca2829/mdformat_pyproject-0.1.1-py3-none-any.whl", hash = "sha256:92d849f5c587ec7dc74a8d240139d3a9ecb65ea553cdb11e01f927fc55f248ce", size = 5102, upload-time = "2025-10-29T16:10:23.124Z" },
+]
+
+[[package]]
+name = "mdformat-simple-breaks"
+version = "0.1.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/97/cf/21faa8db8ac7548fc58424a82a48b4e6c53dcd45d4cf86494586e8eb4329/mdformat_simple_breaks-0.1.0.tar.gz", hash = "sha256:0f1d57edba17d6ca7175031a3f2edb2e69ea5800c7c406c905e81e75a325470d", size = 6684, upload-time = "2025-10-28T07:06:14.34Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/c1/61/ae77d87133bd32c86631dcfb595a24432cec45d868c99b7e9b6b50597bf0/mdformat_simple_breaks-0.1.0-py3-none-any.whl", hash = "sha256:c81c3d2ee65f0a0d9a968fb02347d1d34a14a6b0f3655c01e2a05bba10e8211f", size = 4265, upload-time = "2025-10-28T07:06:13.481Z" },
+]
+
+[[package]]
+name = "mdformat-toc"
+version = "0.5.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "mdformat" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/e8/64/023dac7c53aa2c915838a4ce52fbb724db41635b559024a943cb2f036586/mdformat_toc-0.5.0.tar.gz", hash = "sha256:28e87519fe58d5f25133ba27998bfcba46f5710a40df9e4a19da787e03088d52", size = 7934, upload-time = "2025-10-15T21:30:33.633Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/71/c9/8bce9c6a97c4968d0381d09ae8b8e5a9d8dea292896f37c898fe87da4a71/mdformat_toc-0.5.0-py3-none-any.whl", hash = "sha256:0d26e222db39e3fe7527644cf3d99f83cc8f66295156855e46b054bc4636b328", size = 9711, upload-time = "2025-10-15T21:30:31.495Z" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/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://pypi.apple.com/packages/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 = "mistletoe"
+version = "1.5.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/31/ae/d33647e2a26a8899224f36afc5e7b7a670af30f1fd87231e9f07ca19d673/mistletoe-1.5.1.tar.gz", hash = "sha256:c5571ce6ca9cfdc7ce9151c3ae79acb418e067812000907616427197648030a3", size = 111769, upload-time = "2025-12-07T16:19:01.066Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/20/60/0980fefdc4d12c18c1bbab9d62852f27aded8839233c7b0a9827aaf395f5/mistletoe-1.5.1-py3-none-any.whl", hash = "sha256:d3e97664798261503f685f6a6281b092628367cf3128fc68a015a993b0c4feb3", size = 55331, upload-time = "2025-12-07T16:18:59.65Z" },
]
[[package]]
name = "mypy"
-version = "1.19.1"
-source = { registry = "https://pypi.org/simple" }
+version = "2.2.0"
+source = { registry = "https://pypi.apple.com/simple" }
dependencies = [
+ { name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
- { name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" },
- { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" },
- { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" },
- { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" },
- { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" },
- { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" },
- { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
- { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
- { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
- { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
- { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
- { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
- { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
- { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
- { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
- { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
- { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
- { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
- { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
- { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
- { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
- { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
- { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
- { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
- { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
- { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
- { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
- { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
- { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
+sdist = { url = "https://pypi.apple.com/packages/packages/e9/7e/be536678c6ae49ef058aba4b483d8c7bc104f471479016066f345bc1f5f8/mypy-2.2.0.tar.gz", hash = "sha256:2cdd99d48590dce6f6b7f1961eda75386364398fcdaad86923bc0f0231bf9baf", size = 3950939, upload-time = "2026-07-08T01:37:27.335Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/d1/be/fbaba7b0ee89874fb11668416dec9e5585c190b676b0796cff26a9290fe8/mypy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:484a2be712b245ac6e89847141f1f50c612b0a924aa25917e63e6cfcf4da07cd", size = 14928025, upload-time = "2026-07-08T01:37:24.376Z" },
+ { url = "https://pypi.apple.com/packages/packages/c5/8f/f79a7c5a76671b0f563d4beaa7d99fe90df4500d2c1d2ba1be0432121bcf/mypy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb0a020dc68480d40e484675558ed637140df1ccbf896a81ba68bca85f2b50a0", size = 13793027, upload-time = "2026-07-08T01:34:33.937Z" },
+ { url = "https://pypi.apple.com/packages/packages/b2/b9/3db0086bab611d34e26061b86189e6f71de6d22a9b81699a93b006eabcf6/mypy-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc361340732ce7108fa0308812caf02bb6868f16112f1efd35bcad88badf3327", size = 14108322, upload-time = "2026-07-08T01:36:08.316Z" },
+ { url = "https://pypi.apple.com/packages/packages/58/29/4f1e13979a848de2a0fd385462354b58358b6e8b3d9661663e308f6e3d5d/mypy-2.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0179a3a0b833f724a65f22613607cf7ea941ab17ec34fa283f8d6dfe21d9fa9", size = 15190198, upload-time = "2026-07-08T01:36:20.168Z" },
+ { url = "https://pypi.apple.com/packages/packages/14/f7/7759f6294d9d25d86671957d0974a215a2a24d429526e26a2f603de951c5/mypy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e0899b13da1e4ba44b880550f247402ce90ffecc71c54b220bcbe7ecb34f394", size = 15424222, upload-time = "2026-07-08T01:33:39.398Z" },
+ { url = "https://pypi.apple.com/packages/packages/d7/1b/05b212bef4d2234b5f0b551ea53ce0680d8075b2e79861c765f70b590945/mypy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:511320b17467402e2906130e185abffffa3d7648aff1444fc2abb61f4c8a087d", size = 11135191, upload-time = "2026-07-08T01:35:03.019Z" },
+ { url = "https://pypi.apple.com/packages/packages/92/51/495e7122f6589948b36d3820a046461906756a0eb1b6dedc13ebfec7815e/mypy-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:7589f370b33dcdd95708f5340f13a67c2c49140957f934b42ef63064d343cca0", size = 10132502, upload-time = "2026-07-08T01:34:04.508Z" },
+ { url = "https://pypi.apple.com/packages/packages/a3/5f/2d7a9ac5646274cd6e77ce3abcc2a9ece760c2b21f4c4b9f301711e07855/mypy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6968f27347ef539c443ddfd6897e79db525ddb8c856aa8fbf14c34f310ca5193", size = 14931618, upload-time = "2026-07-08T01:33:51.702Z" },
+ { url = "https://pypi.apple.com/packages/packages/b2/8a/1adaa7caaa104f87021b1ac71252d62e646e9b623d77900ac7a0ae252bf3/mypy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3df226d2a0ae2c3b03845af217800a68e2965d4b14914c99b78d3a2c8ae23299", size = 13857718, upload-time = "2026-07-08T01:35:27.555Z" },
+ { url = "https://pypi.apple.com/packages/packages/1d/15/b11586b5aebbb82213e297fc30a6fcf3bed6a9deea3739cd8dd87621f3fd/mypy-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc53553996aca2094216ad9306a6f06c5265d206c1bcb54dd367560bd3557825", size = 14059704, upload-time = "2026-07-08T01:33:57.363Z" },
+ { url = "https://pypi.apple.com/packages/packages/03/db/071e05ab442596bdf7a845e830d5ef7128a0175281038245b171a6b16873/mypy-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:996abf2f0bebf572556c60720e8dc0cf5292b64060fa68d7f2bc9caa48e01b6f", size = 15128719, upload-time = "2026-07-08T01:33:33.855Z" },
+ { url = "https://pypi.apple.com/packages/packages/f3/1c/c4b84eafb85ee315da72471523cc1bf7d7c42164085c42333601da7a8817/mypy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ec287c2381898c652bf8ff79448627fe1a9ee76d22005181fac7315a485c7108", size = 15378692, upload-time = "2026-07-08T01:34:10.468Z" },
+ { url = "https://pypi.apple.com/packages/packages/68/a4/59a0ee94877fdfe2958cec9b6add72a75393063c79cb60ab4026dd5e10c2/mypy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2c967a7685fa93fcf1a778b3ebe76e756b28ba14655f539d7b61ff3da69352a", size = 11150911, upload-time = "2026-07-08T01:35:21.603Z" },
+ { url = "https://pypi.apple.com/packages/packages/90/e4/6a9144be50180ed43d8c92de9b03dff504daa92b5bcc0353e8960799a23b/mypy-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:d59a4b80351ec92e5f7415fcdd008bd77fcbefc7adf9cbc7ffe4eb9f71617734", size = 10125389, upload-time = "2026-07-08T01:36:36.164Z" },
+ { url = "https://pypi.apple.com/packages/packages/73/32/0aa8d8d197023ca6040f7b25a486cb47037b6350b0d3bae657c8f85fb43f/mypy-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1f6c3d76853071409ac58fc0aadfb276a22af5f190fdaa02152a858088a39ebd", size = 14926083, upload-time = "2026-07-08T01:36:02.365Z" },
+ { url = "https://pypi.apple.com/packages/packages/e2/7c/35bbe0cb10e6699f90e988e537aaf4282a6c16e37f58848a242eb0a98bde/mypy-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bdaa177e80cc3292824d4ef3670b5b58771ee8d57c290e0c9c89e7968212332c", size = 13879985, upload-time = "2026-07-08T01:36:31.093Z" },
+ { url = "https://pypi.apple.com/packages/packages/f9/0c/1597fbebd873e9b63452317740ae3dd32692cec5da180cc65acd96cd28cf/mypy-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80923e6d6e7878291f537ee11052f974954c20cb569798429a5dc265eb780b47", size = 14076883, upload-time = "2026-07-08T01:34:21.789Z" },
+ { url = "https://pypi.apple.com/packages/packages/68/35/2ec021a83ec01b5d522639f78d8b36adade7fa4821db0f48fd6d82e861f3/mypy-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f24bd465a09077c8d64be8f19a6646db467a55490fd315fe7871afe6bb9645", size = 15103567, upload-time = "2026-07-08T01:36:47.717Z" },
+ { url = "https://pypi.apple.com/packages/packages/53/3a/8cb3529f6d6800c7d069935e5c83a05d80263847b8a947cf6b0b16a9e958/mypy-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:db34595464869f474708e769413d1d739fc33a69850f253757b9a4cc20bc1fec", size = 15354641, upload-time = "2026-07-08T01:36:41.592Z" },
+ { url = "https://pypi.apple.com/packages/packages/d6/4d/320bc9a9553f8a9db5e847ec5ded762ef7ed7403c76c4ba2e8181c80e2f0/mypy-2.2.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b48092132c7b0ef4322773fecae62fc5b0bc339be348badeec8af502122a4a51", size = 7694355, upload-time = "2026-07-08T01:37:03.291Z" },
+ { url = "https://pypi.apple.com/packages/packages/90/05/bf3b349e2f885cd3aab488111bb9049439c28bc028dac5073350d3df8fbe/mypy-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:6fc0e98b95e31755ca06d89f75fafa7820fbb3ea2caace6d83cba17625cd0acb", size = 11329146, upload-time = "2026-07-08T01:35:38.318Z" },
+ { url = "https://pypi.apple.com/packages/packages/41/a5/558b06e6cfe17ab88bb38f7b370b6bc68a74ba177c9e138db9748e422d2d/mypy-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc73a5b4d40e8a3e6b12ef82eb0c90430964e34016a36c2aff4e3bfe37ba41f0", size = 10316586, upload-time = "2026-07-08T01:36:25.458Z" },
+ { url = "https://pypi.apple.com/packages/packages/0b/21/f0b96f19a9b8ba111a45ffbe9508e818b7f6990469b38f6888943f7bfd3a/mypy-2.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6257bd4b4c0ae2148548b869a1ff3758e38645b92c8fe65eca401866c3c551c1", size = 15922565, upload-time = "2026-07-08T01:35:56.282Z" },
+ { url = "https://pypi.apple.com/packages/packages/18/f2/1dbcb20b0865d5e992541450a8c73f2fcc90f8bd7d8a4b81313e16934870/mypy-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dfa22b3ae862ac1ce76f5976ddd402651b5f090bcfd49c6d0484b8983a29eaf8", size = 14816515, upload-time = "2026-07-08T01:34:58.167Z" },
+ { url = "https://pypi.apple.com/packages/packages/84/a2/18cce9c7d5b4d14010d1f13836da11b234dda917b17ca8671fc32c136997/mypy-2.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30a430bf26fe8cf372f3933fbd83e633d6561868803645a20e4e6d4523f52a3b", size = 15272246, upload-time = "2026-07-08T01:37:08.72Z" },
+ { url = "https://pypi.apple.com/packages/packages/ce/71/24d720c7924829bd675cbde2d0fa779f50abf676ca617f53d6a8bfef5fa7/mypy-2.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d58655a60e823b1a4c9ebcda072897fb0193c2f3e6f8e7c433e152aa4cb00233", size = 16226295, upload-time = "2026-07-08T01:34:51.861Z" },
+ { url = "https://pypi.apple.com/packages/packages/b7/5e/785730990fc863ad8340b4ab44ac4ca23270aecff92c180ccdf27f9f5869/mypy-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d4452c955caf14e28bb046cbd0c3671272e6381630a8b81b0da9713558148890", size = 16493275, upload-time = "2026-07-08T01:35:09.337Z" },
+ { url = "https://pypi.apple.com/packages/packages/93/33/55b1edf16f639f153972380d6977b81f65509c5b8f9c86b58b94b7990b03/mypy-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:68f5b7f7f755200f68c7181e3dfb28be9858162257690e539759c9f57721e388", size = 11749038, upload-time = "2026-07-08T01:35:50.071Z" },
+ { url = "https://pypi.apple.com/packages/packages/61/36/67424748a4e65e97f0e05bf00df379dfb6c2d817f82cc3a4ce5c96d99beb/mypy-2.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:78d201accfafce3801d978f2b8dbbd473a9ce364cc0a0dfd9192fe47d977e129", size = 10704254, upload-time = "2026-07-08T01:37:17.971Z" },
+ { url = "https://pypi.apple.com/packages/packages/28/cb/142c2097ca02c0d295b00625ff946808bdda65acda17d163c680d8a6a474/mypy-2.2.0-py3-none-any.whl", hash = "sha256:ecc138da861e932d1344214da4bae866b21900a9c2778824b51fe2fb47f5180e", size = 2726094, upload-time = "2026-07-08T01:34:00.075Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+ { url = "https://pypi.apple.com/packages/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
-name = "nodeenv"
-version = "1.10.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+name = "natsort"
+version = "8.4.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+ { url = "https://pypi.apple.com/packages/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pathspec"
-version = "1.0.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
+version = "1.1.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
+ { url = "https://pypi.apple.com/packages/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
-name = "platformdirs"
-version = "4.9.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/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/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
+ { url = "https://pypi.apple.com/packages/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 = "pre-commit"
-version = "4.5.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cfgv" },
- { name = "identify" },
- { name = "nodeenv" },
- { name = "pyyaml" },
- { name = "virtualenv" },
+name = "pprintpp"
+version = "0.4.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/06/1a/7737e7a0774da3c3824d654993cf57adc915cb04660212f03406334d8c0b/pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403", size = 17995, upload-time = "2018-07-01T01:42:34.87Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/4e/d1/e4ed95fdd3ef13b78630280d9e9e240aeb65cc7c544ec57106149c3942fb/pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d", size = 16952, upload-time = "2018-07-01T01:42:36.496Z" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
+
+[[package]]
+name = "prek"
+version = "0.4.8"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
+ { url = "https://pypi.apple.com/packages/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" },
+ { url = "https://pypi.apple.com/packages/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" },
+ { url = "https://pypi.apple.com/packages/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" },
+ { url = "https://pypi.apple.com/packages/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" },
+ { url = "https://pypi.apple.com/packages/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" },
+ { url = "https://pypi.apple.com/packages/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" },
+ { url = "https://pypi.apple.com/packages/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" },
+ { url = "https://pypi.apple.com/packages/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" },
+ { url = "https://pypi.apple.com/packages/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" },
+ { url = "https://pypi.apple.com/packages/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" },
+ { url = "https://pypi.apple.com/packages/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" },
+ { url = "https://pypi.apple.com/packages/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" },
+ { url = "https://pypi.apple.com/packages/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" },
+ { url = "https://pypi.apple.com/packages/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" },
+ { url = "https://pypi.apple.com/packages/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" },
+ { url = "https://pypi.apple.com/packages/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" },
]
[[package]]
name = "py"
version = "1.11.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" }
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" },
+ { url = "https://pypi.apple.com/packages/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" },
]
[[package]]
-name = "python-discovery"
-version = "1.2.1"
-source = { registry = "https://pypi.org/simple" }
+name = "pycodestyle"
+version = "2.14.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pyflakes"
+version = "3.4.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyjson5"
+version = "2.0.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/f1/9a/3db19560e968d6e85b2a4ddf4b949c6ebf9dd1dcfb5a9f37736f8adeb927/pyjson5-2.0.1.tar.gz", hash = "sha256:a5b0e322e847b198a50d8a1ef16d6b2b19129644dc018d76773e81ef1487ca39", size = 352242, upload-time = "2026-05-15T16:12:54.931Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/68/41/be622b742fe4749fefa9d8a923b1cf5e51a1e8e4b4930fa8ea8c531b243d/pyjson5-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62394660d54e76ff8a968c8194b252be23d184f05a00238d4def9624454ea6", size = 302636, upload-time = "2026-05-15T16:06:08.006Z" },
+ { url = "https://pypi.apple.com/packages/packages/4b/e2/50378c3bec9164c1bb983ca15ed59f8be498981b790a90d1410a1016ffe2/pyjson5-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:58dfa4cf5c327e14c530b16c23e812c021fe53b19e6f9cdfce257d8c02244895", size = 158588, upload-time = "2026-05-15T16:06:09.684Z" },
+ { url = "https://pypi.apple.com/packages/packages/cb/7d/5de0f82c144b6fc2fa2e3fde5517257733a91d24734b5d2415b7e991e8fd/pyjson5-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07cb90a31f962a6de9e49592a306046ee015da73510c6d215e9481a2a21c9ac2", size = 153741, upload-time = "2026-05-15T16:06:11.114Z" },
+ { url = "https://pypi.apple.com/packages/packages/f5/ac/174ca02e5f3ed8245dc48247e534880bbec1528c0c4eaa768fafa9417dda/pyjson5-2.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ef4026a90a68cd2bcb64d89ca064a1615b60a61c7694714e2b8ef30434f365b5", size = 185450, upload-time = "2026-05-15T16:06:12.883Z" },
+ { url = "https://pypi.apple.com/packages/packages/8f/0d/bfc553176b7e109b72f23697ab1fd1a77d7fd5c6626d6e23936f13d5884e/pyjson5-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04b4929a7adf0e0720da92ac54c92d408e8057158be3ccc34da17c7767a9af78", size = 163331, upload-time = "2026-05-15T16:06:14.314Z" },
+ { url = "https://pypi.apple.com/packages/packages/b3/05/b0e67106f78c8ea5ffcf6ce0b8421f38a04d5f37ea88ac70123199755bcb/pyjson5-2.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e889b039d6bffda9a6b8d822073bbdb4a60166af22f075e7faa0c03c2dfcf17b", size = 166429, upload-time = "2026-05-15T16:06:16.076Z" },
+ { url = "https://pypi.apple.com/packages/packages/81/3c/2366f8ecf053434470eafe1f120f2e235d13015fc50d7f06e55e39b550ab/pyjson5-2.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a08e99d0a33463728fb016f5eec680504499c4be280dac016de2da70ec55b0db", size = 180499, upload-time = "2026-05-15T16:06:17.544Z" },
+ { url = "https://pypi.apple.com/packages/packages/76/65/9a70876d4dd09ce17678024266406b94b8f1edf9db6c2e3bf76cfa653a13/pyjson5-2.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:035c094feb6a9d4312183f9f34228d976102eefb971e9e3dd972d8c7900a466a", size = 187469, upload-time = "2026-05-15T16:06:19.212Z" },
+ { url = "https://pypi.apple.com/packages/packages/5b/43/0c0bcbf9a9ef7fdcd49dc0992a3a206244c2c2baa7b409932464cf26f1a5/pyjson5-2.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d178025a5317db44663dc87aab90f34674991b3da40dbedc3c108e7b03b6466", size = 176032, upload-time = "2026-05-15T16:06:21.153Z" },
+ { url = "https://pypi.apple.com/packages/packages/82/2c/32213674010c44db265d45e977a431cbde971c45342b483f7af337811403/pyjson5-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:381ca5b3172bccf9c29c5823a0173ab9118d579d0f5bb4fca20b803daa6e72d5", size = 171477, upload-time = "2026-05-15T16:06:23.017Z" },
+ { url = "https://pypi.apple.com/packages/packages/f1/49/da5417d592ac75a23d1ca2dfda846391ccf137edd89054b780866c592b65/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e158caf0dd7fbf7afe12f92fcc9cba26bceac4b38615c087fd592e9e3240d9f", size = 1145571, upload-time = "2026-05-15T16:06:24.757Z" },
+ { url = "https://pypi.apple.com/packages/packages/60/f8/1f0c326b3a3267f28b7b9e2ac07121386ec20ee8f9dd49955af4faa17ba7/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c8010e9a02b6a0719234f8d074639f9051afe579ff0f74d3367af1d8b4c7d839", size = 1010388, upload-time = "2026-05-15T16:06:26.574Z" },
+ { url = "https://pypi.apple.com/packages/packages/de/c8/76f2739a8715061755f12f4d2795f6c4079aeadd166c56d0992521442d5b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3543e067d1d8ab6cb1cd41add38a42eaf82e9a7e802e8a08f1acda9f22199b00", size = 1323111, upload-time = "2026-05-15T16:06:28.805Z" },
+ { url = "https://pypi.apple.com/packages/packages/50/7a/683e1586fa076fb344aec23a647d67f427847b6bffb1001d56e86aa8da33/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e496938e7e0defc33bfbd0ff581d4ffdc428c598cc5d679232f34df27f7330a", size = 1240671, upload-time = "2026-05-15T16:06:30.473Z" },
+ { url = "https://pypi.apple.com/packages/packages/c0/8e/6638b0b22344b23cae1271b0211668be54b3f76183bd8d4f89a2edd8e562/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2badbc101908123905a38789cc552b99ab896a7ec0ce4fa6ee2bb7b613c85c1b", size = 1178013, upload-time = "2026-05-15T16:06:32.212Z" },
+ { url = "https://pypi.apple.com/packages/packages/7a/21/d44540d3cdc670e5c6e665471361f557ef8c7d56964cd433524208b3707b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1b0df4898e2e046fa1e9b5df436fe11d9b681b1e5fc9877508d982d2059aaf1", size = 1354907, upload-time = "2026-05-15T16:06:33.922Z" },
+ { url = "https://pypi.apple.com/packages/packages/3c/74/fceedd2709760f5c85e75b508002af9b4c2da8af20a80e62b2d0e2358a15/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fcc96c429888f2293f672e33d01da8a0847cd5116a773c9a1903b8bc2f12bc1", size = 1208847, upload-time = "2026-05-15T16:06:35.883Z" },
+ { url = "https://pypi.apple.com/packages/packages/b8/3a/d2f062b5801b3034ecb16ffb0801d7b4a1d6abae4cd4615482dbf62f1ed8/pyjson5-2.0.1-cp312-cp312-win32.whl", hash = "sha256:48c97e2f7f02171948f8a7ed6c9b2b2faea00653a3348d9e810238a688f07b6c", size = 115562, upload-time = "2026-05-15T16:09:24.807Z" },
+ { url = "https://pypi.apple.com/packages/packages/26/13/2265cf16720defdc87672b68ff5edf83820bd76e59e32f8384c9cb1ae619/pyjson5-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8dea9976eea0aa7af8b5ad49df3fa831d6bda08ffd7bb11409d6a81961491219", size = 136176, upload-time = "2026-05-15T16:09:25.931Z" },
+ { url = "https://pypi.apple.com/packages/packages/9c/92/a8947e646ce642555ac22b31f44b7168c81b0e364d0f2711d571955229b6/pyjson5-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:264ab65fb7d68a763567c2302151f1f074bf053c8479939aa8e75f5f9518fe31", size = 116752, upload-time = "2026-05-15T16:09:27.274Z" },
+ { url = "https://pypi.apple.com/packages/packages/93/2c/0619f89a9576f335ba63eb851e73ba507480a8c7f28a2e7de2501ed3d303/pyjson5-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8a661d292801b38434d5288bf7329f9b50b3a693b1d2941c7595175842692eba", size = 301821, upload-time = "2026-05-15T16:09:28.721Z" },
+ { url = "https://pypi.apple.com/packages/packages/91/31/7824913ec71e7421d6a57bc06228f3e2d946d8e8f738f898572dded0dc57/pyjson5-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cb5c1c066038ce6e1922d9d5be23f5cd14d5b5a3c96e6358aa5d0e379014a93", size = 158201, upload-time = "2026-05-15T16:09:30.261Z" },
+ { url = "https://pypi.apple.com/packages/packages/bc/7d/4ddb249563a838425242342d2cf67976ccc292a831b543a92fa1a8a83b11/pyjson5-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7417eab751817fa5f070e41975d9df4aec3532d21389073318161f63cd96d636", size = 153244, upload-time = "2026-05-15T16:09:32.04Z" },
+ { url = "https://pypi.apple.com/packages/packages/02/39/7622416ac0570d9ce377447bd5b2ec9383c1282e63cc6d0b65779f1336fa/pyjson5-2.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cc4802093b4ab9039367774988486fd04754cf416c32aebf94a94bafd8b8a478", size = 185568, upload-time = "2026-05-15T16:09:33.719Z" },
+ { url = "https://pypi.apple.com/packages/packages/d8/90/2f317da231477b77481020d79c73bbe10625e4925ceeae77603726ef1763/pyjson5-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc3181274ed19ecb2315cf85a499bf83002554f42c72453666c616059d665a7b", size = 163169, upload-time = "2026-05-15T16:09:34.865Z" },
+ { url = "https://pypi.apple.com/packages/packages/02/b3/20023c3cfe2f2c2523007d7031b5b7ad7ccd8856d2665547683cebaa6f8e/pyjson5-2.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5398306b8aa253e620af9ed8085767de9cd28d6846da45e535e80fcffa9f33d7", size = 166514, upload-time = "2026-05-15T16:09:36.383Z" },
+ { url = "https://pypi.apple.com/packages/packages/e2/1c/b863e502153477b2845a54b688b72436457aca5107b300fca95e98184551/pyjson5-2.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b97b592287d52a6ec5af84dca94aab02b185f72f8ddb6fc99c58efb8891be5cd", size = 179907, upload-time = "2026-05-15T16:09:38.166Z" },
+ { url = "https://pypi.apple.com/packages/packages/fc/b2/413fcb76632e5f6fa89a2ec83976755b5bb4696c6d9eca9ff3c70cd717b6/pyjson5-2.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4efbf5e910a3aa6f330a92d1ae940dda5ed662976ed5056526baaf2e1821f95d", size = 187171, upload-time = "2026-05-15T16:09:39.624Z" },
+ { url = "https://pypi.apple.com/packages/packages/d6/11/66151b819407ab589aef36582038257f1ef42dc065e3423b3d8274f4fcd2/pyjson5-2.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e042f9b869e7f21d5f53a2638e5d0cf1daaba2342127c8777c502daf972602e", size = 175564, upload-time = "2026-05-15T16:09:41.356Z" },
+ { url = "https://pypi.apple.com/packages/packages/f2/98/deb70690dab994474ea527cba91f43ae55928bcff498c441e812b60fc1e2/pyjson5-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26074370d7a6dd38b6e8c28a2f10c790fc6dec938ec22ec3de6c93c97d195f10", size = 171530, upload-time = "2026-05-15T16:09:42.78Z" },
+ { url = "https://pypi.apple.com/packages/packages/a9/be/969752a3a052d00698b6e1dd926c627d7a6475a7c8dd390db148363544f8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cb163a495096716a7a01d052b60bda0308ecfaeddcb2d50a247ba9d707e3e40", size = 1145510, upload-time = "2026-05-15T16:09:45.005Z" },
+ { url = "https://pypi.apple.com/packages/packages/d0/be/4c5c92cdda5a911ee4450a74281782335e7ce6715638308322beb96be639/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5fdd66bdddc2d53421ab7427bedcd2ca9b2f1b4b3b464381aa60d305c94be38c", size = 1010114, upload-time = "2026-05-15T16:09:47.081Z" },
+ { url = "https://pypi.apple.com/packages/packages/89/1e/72283bc505d77dcdab7eaeb0020cf0fd79a05d2991e886d1fcfa73e88ae1/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4ad222a3eff1cc93f9b70894c5f3d2ff5e46531e88feaa4818e329d5f3ae4307", size = 1322915, upload-time = "2026-05-15T16:09:48.885Z" },
+ { url = "https://pypi.apple.com/packages/packages/06/b4/94a09e744a6bb6e76108b61a28a7cc5ecab1b8105cacde7548359b3b74a3/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:63f5cf25113dd1bf3bfeda211d99413e58ec6b9036ff34f7b69f85f23695e9c4", size = 1240126, upload-time = "2026-05-15T16:09:50.638Z" },
+ { url = "https://pypi.apple.com/packages/packages/af/29/4549380cae425ee112f3c154606c35f5211c12ddf415f079c2b23f2d493b/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b0001eecce9080e6c170e978786501e8931e2075a3d15f5260e1bdd6a45e8976", size = 1178022, upload-time = "2026-05-15T16:09:52.639Z" },
+ { url = "https://pypi.apple.com/packages/packages/1c/1a/5a8a869e855645858e45dbd077204cec3a85b2f2e669bc3c8cefe9c4a70e/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40371c73cc83ed81914028786a6cc5950bdf300ae82443df83a5dfa80c582fca", size = 1355161, upload-time = "2026-05-15T16:09:55.177Z" },
+ { url = "https://pypi.apple.com/packages/packages/82/32/827066cd946447648275a892f494f4572d78e6a79d877fa6b6c14af599d8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9dca542eeb6e8fbbc1fe3373bfd18ae6250f1aedc78f39242ac12c8837091b8", size = 1208864, upload-time = "2026-05-15T16:09:56.919Z" },
+ { url = "https://pypi.apple.com/packages/packages/2d/cc/a9bc12aff47d8bfd3074a21e3fb056ec0028fac28f8ec7e0fe4449d050f5/pyjson5-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d30c8b2c91d530be475a8fab6dde8c3bcd3e89b999c9e0f05aec2bf2c24b0638", size = 115461, upload-time = "2026-05-15T16:10:30.711Z" },
+ { url = "https://pypi.apple.com/packages/packages/c9/c7/fa3fc956fbc3fa6250c8b99ef75a8f52b691780fca1bfb368340283bd898/pyjson5-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:67f8f5b8d3e3b2ca5f618c928729986aa00fb588c476c0d0d3a151633ff41e0d", size = 135836, upload-time = "2026-05-15T16:10:32.062Z" },
+ { url = "https://pypi.apple.com/packages/packages/f0/0a/e457b20d1e36a766d3ccd09c418b7ef41acabb679ff2248bb1cd38247ce6/pyjson5-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:bdf30ce5b1242f63254d936b30af75cae237fab0ee71cc2544163c82b6bc9201", size = 116652, upload-time = "2026-05-15T16:10:33.39Z" },
+ { url = "https://pypi.apple.com/packages/packages/2b/4c/6266789e615576b62d2db6de26a3b4b8f4cc7a8ff23fe24e48363bca1682/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:954c14d40022fa40e0d36e0ebd337e9e90fd71145592d25a0e2868344e3daca0", size = 320487, upload-time = "2026-05-15T16:09:58.38Z" },
+ { url = "https://pypi.apple.com/packages/packages/dc/75/c4a563034805e20b274fe4db963e1d480001931d88fe70b7d082033b7dcb/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f57529b98d21e8b76f8bc51af9d3c3057dd04a92287fd21ef7ec55255ca6f5c5", size = 166879, upload-time = "2026-05-15T16:09:59.849Z" },
+ { url = "https://pypi.apple.com/packages/packages/f3/d2/5e6ec3580379794e9a99a402465f74586e93e93814974172202d1254fa89/pyjson5-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:545b655ef0b59f39fc29e2b63b4dadd45e447ad77bad2fdca859ff4db69e21f5", size = 162235, upload-time = "2026-05-15T16:10:01.147Z" },
+ { url = "https://pypi.apple.com/packages/packages/5b/48/b2d0e868ef8375eb0696ddc74f71028fc4fcd26cdddaeb34485214f2dc87/pyjson5-2.0.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b519de49dd4cbf254179749e5c6bfe9bd8fd1f97e7755bff2e17cc6f79f85aa5", size = 182162, upload-time = "2026-05-15T16:10:02.523Z" },
+ { url = "https://pypi.apple.com/packages/packages/ce/18/fed02a3d68c3badb87394e8420b72d7cb165ade208c1e02561637409aa99/pyjson5-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de48fbd0466c114c17f408be7bcbc5b3aedeb29ff5f73187022915e6d5eb7817", size = 169487, upload-time = "2026-05-15T16:10:03.876Z" },
+ { url = "https://pypi.apple.com/packages/packages/8b/90/dc897332dda24e949828616e8e74d7937b587a56789f3aca148fcadddde2/pyjson5-2.0.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fe6516ff9ab94368c18ff8ac0ebdb53fac9e047da093d43e67d5e7cd98cdd0bf", size = 164128, upload-time = "2026-05-15T16:10:05.588Z" },
+ { url = "https://pypi.apple.com/packages/packages/39/09/90104baeab58adfe63bda804174ef1806b1cabb1692e047fabc2f4aa1799/pyjson5-2.0.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea7aadacac7ac6661117b708175f6beff75e132322c08bbf3794d8ec4ba572b4", size = 184306, upload-time = "2026-05-15T16:10:06.986Z" },
+ { url = "https://pypi.apple.com/packages/packages/89/73/613efc3cdab5cf9ec399e6c5a0463b672e5f5cbd693a3a4d82cbdda19476/pyjson5-2.0.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5c339bcac113dfc30a1f88f23af273d67bb6169abee1684c0dd27daa71a32f9", size = 192239, upload-time = "2026-05-15T16:10:08.727Z" },
+ { url = "https://pypi.apple.com/packages/packages/47/2a/17b97e02b37ab9353fc9713e56470be3d2539501ed18d6c676cfbcd0a57c/pyjson5-2.0.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:075de0f1e5e0ffee174a8941b5d9d8af632772a6e5eebe92fb0899db7f6ab7fa", size = 177652, upload-time = "2026-05-15T16:10:10.19Z" },
+ { url = "https://pypi.apple.com/packages/packages/1c/43/a05331ba88fd1aa09ca418548c6b06692e34b2a64b109bb363e01ecafdeb/pyjson5-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c463c9bf508c1d316191898a3c0bfc03dd3de67bdc02f480b0742ac5c859eaeb", size = 174860, upload-time = "2026-05-15T16:10:11.976Z" },
+ { url = "https://pypi.apple.com/packages/packages/2e/00/5d253751f4d27b7e63fdfd765041593fa588395f45b9cea496878d964448/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:54427c8cad0c1a516bad5b4457167cde1e7ef7ee563e28a944f59b3c7413b6a0", size = 1151556, upload-time = "2026-05-15T16:10:13.608Z" },
+ { url = "https://pypi.apple.com/packages/packages/9c/f7/a30eed477295e92dfd59553063cf82bf510a7052efc7720c9c50d0082fdb/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca04839cecd364cb53de7ebcd81fd5915193707d968730ea1c8d4ff5c9c2f1a8", size = 1012330, upload-time = "2026-05-15T16:10:15.171Z" },
+ { url = "https://pypi.apple.com/packages/packages/9c/60/80818fad90a99336f8bfc686d76d0b2d7c8bb51e8de5d031620fa1a2d7a1/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13a2de8b14aeb576b1623c8af419510bf871b8d9cc2308db41a46bcc94d00f2f", size = 1320970, upload-time = "2026-05-15T16:10:16.863Z" },
+ { url = "https://pypi.apple.com/packages/packages/7c/3f/090eb3e0971d067defb58bbfd738f74a09be495c6f4f00d5d76a10755bca/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8a689f5252e9791bfbc8fa916999202556a1e8c7b24dfd72de1ad84c493e10a", size = 1244775, upload-time = "2026-05-15T16:10:19.416Z" },
+ { url = "https://pypi.apple.com/packages/packages/61/38/e7546ad733affe51a5462bad21bceb4fd659baf24930bd141627ce56279b/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc0c2c80fd9d1e4c26f8b6c91a54d38b48648b93ae3f09355856c75fda3e460", size = 1182149, upload-time = "2026-05-15T16:10:21.696Z" },
+ { url = "https://pypi.apple.com/packages/packages/90/2c/016647580d8a82ee53b4cb9ddb96deb4b157ccea9624bba61de4aa2cd25c/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:13d78b3e2c60b81bd7e6b00e6ab24a664ba1fd64b5d86baa65c4cdfae1fff7ae", size = 1358579, upload-time = "2026-05-15T16:10:23.649Z" },
+ { url = "https://pypi.apple.com/packages/packages/01/45/8b84ff7a0c4d8c12a76e097c67af62abe986a18d1ab2540764dd6eaa0253/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a7b640dbeabbd7d975170f793f90a7500cc5d510aeae9364a1bc558bcc5336ed", size = 1211015, upload-time = "2026-05-15T16:10:25.4Z" },
+ { url = "https://pypi.apple.com/packages/packages/cf/48/d8f34de7a7319f5966bdfd10f133e689ba01f138d2040bf792f1ca8b18e1/pyjson5-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:46e7c5525034fde8abf3aaf100ed7660c1b618ccd3205544ed7c99e4c55cf201", size = 131660, upload-time = "2026-05-15T16:10:26.747Z" },
+ { url = "https://pypi.apple.com/packages/packages/06/aa/0bd437252134115a846ffc061078c85f8e8c325c86abcaa862c134d3c57e/pyjson5-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:207c00e7f9641e0358bdc848a1c0610f26f2e8bd51d73bd64e5a0ab3b725f1d8", size = 157717, upload-time = "2026-05-15T16:10:28.053Z" },
+ { url = "https://pypi.apple.com/packages/packages/24/61/7849b04a0dc78f73905189400ac99c45de8eec2ec451ce9c6990fb3588e5/pyjson5-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4917d5b6186bfa48ec4d85326e2a09769a5f7844963307ab2710429e1f743264", size = 126281, upload-time = "2026-05-15T16:10:29.378Z" },
+ { url = "https://pypi.apple.com/packages/packages/89/41/70aa1cb1fb0a3ac4c9b8cd405c0c85ba935c53fdfae6271b8eae364b92d1/pyjson5-2.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:573fecd7cad4e24d232053f9ebf331c80fe1b0ed6e2064f7614797a7f691e7ca", size = 303706, upload-time = "2026-05-15T16:10:35.163Z" },
+ { url = "https://pypi.apple.com/packages/packages/0c/d9/caf44bf3d33b9502dc4b8ed5d0c7a8af8fbe33001e82414c0407f39f18bd/pyjson5-2.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8ade508a046a5407a322c098e3b7e6033216b158a7746eb8962b7a4cdbf9248", size = 158915, upload-time = "2026-05-15T16:10:36.496Z" },
+ { url = "https://pypi.apple.com/packages/packages/3f/94/f2bab1ce2eac8f77e16dcd0a1ba39de20733a84b9961c3504af0dd68bceb/pyjson5-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4635e349bb1d1f1e854dc790f14be31b6cb198d6453848e8d86818104074f805", size = 154199, upload-time = "2026-05-15T16:10:37.902Z" },
+ { url = "https://pypi.apple.com/packages/packages/82/c4/d94573ca37486af3d6a72f2582844d7d490d8e55639e0cfba371ce91442f/pyjson5-2.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d274697f81f143abac12ce256a06b61635c30d0f8cc11eefae7182655dac9a5", size = 186060, upload-time = "2026-05-15T16:10:39.603Z" },
+ { url = "https://pypi.apple.com/packages/packages/03/cc/c42e697def319b286fdcb912939c044cc94bd1cfc7338b6dbb566f817f29/pyjson5-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4e2242e55a2651fd8696cfd67ec8e04f54f4ed6c089cceea2b63763a7516d8", size = 165796, upload-time = "2026-05-15T16:10:40.967Z" },
+ { url = "https://pypi.apple.com/packages/packages/3f/dd/f22a5f0e619ef22b8e32520a91bd92f815d50f9ec2d67cfe5974eab9476c/pyjson5-2.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af855c680feaa39cae4a44914ee2863eeae1549f37ce58069c747b3d4803999a", size = 165262, upload-time = "2026-05-15T16:10:42.391Z" },
+ { url = "https://pypi.apple.com/packages/packages/99/8b/90e22ecb12d51ccdc68325b6b051002e6103cc4600a335d9ed13828acdd9/pyjson5-2.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb769d90516da904e6cf0ba58f3d5830f4a5804486b5e85c5ca42ba3146d187a", size = 181608, upload-time = "2026-05-15T16:10:43.914Z" },
+ { url = "https://pypi.apple.com/packages/packages/e0/ab/237f9036eed73e08cf8861466d3d1182ef1c88506bd29955740dcadb24ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6484c14e07aa46abeb3cb2ff0204a765260763ad7cf3f87f601f26b20ed607f3", size = 189582, upload-time = "2026-05-15T16:10:45.367Z" },
+ { url = "https://pypi.apple.com/packages/packages/c4/3d/3df8d5f003910a9291e5f04fa178f626e3c8553a5986dc62589ca74195ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc03673adb544324500d79b2acfe2ced038998b2aceb564d335de9cc238cbdb0", size = 176321, upload-time = "2026-05-15T16:10:47.134Z" },
+ { url = "https://pypi.apple.com/packages/packages/c0/d9/a458a54f780bafec1b44ee3c27cb3007a83fb7bd4f1c17ac6bf519fd6151/pyjson5-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d714daf784bec2e14fc06c0c26e4a373a6157eaeddaed2d5a7b7b6ae2aabc33", size = 171956, upload-time = "2026-05-15T16:10:49.174Z" },
+ { url = "https://pypi.apple.com/packages/packages/78/d8/1010e0147c8862dc0881cf3656364d3d87a5e336d290fe4cb0b92223e14c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cf3ded356730e08b5941a16db6525efc0de26f35468ab09ad573056c4efc6e5", size = 1147679, upload-time = "2026-05-15T16:10:51.005Z" },
+ { url = "https://pypi.apple.com/packages/packages/19/2f/91c8d8cdb4a2e4bbe8f14b9b57d717988e14830f2ed4a4f08d503cb5edde/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cc00d4669fb4170c28b2c9ed3cd1c2af9f21727c55d6866030cc154f31f68747", size = 1008216, upload-time = "2026-05-15T16:10:53.252Z" },
+ { url = "https://pypi.apple.com/packages/packages/d7/d7/25c3f8693660a35be7bb4eb71b0d10d9a6981e986723a4881d607f0c6462/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a77a91c2e019476345c03cde105da44100daaabc3fa56f82f2bd9eb2ebbcb698", size = 1323856, upload-time = "2026-05-15T16:10:55.517Z" },
+ { url = "https://pypi.apple.com/packages/packages/c8/25/82f0f83556ff68ec0ca1129ba9afeb14149cd421dc74823cdf10c209c95c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9ae54e5d717982cb7a082c700ab1f7f965c6aedea6fcb003ec4bdeac4f02bf52", size = 1241860, upload-time = "2026-05-15T16:10:57.635Z" },
+ { url = "https://pypi.apple.com/packages/packages/c1/01/2c4695fc06a0f3f29041a875d3bb24f904c9ae7b3d8dbfa1f8db17ce995c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ce4f5b67b3a6fab623a31d83416b51a5b2c97cb172194f214078484bbb4783a6", size = 1178443, upload-time = "2026-05-15T16:10:59.398Z" },
+ { url = "https://pypi.apple.com/packages/packages/9a/ae/c10f534037b096aca21b90017ac9d41f791572dc3ec31fcd92db8ddcd256/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f8d2e61f7bed0b40cfe5f62375581bf56d7b5b9707c9d74747b5931149d7d376", size = 1357739, upload-time = "2026-05-15T16:11:01.189Z" },
+ { url = "https://pypi.apple.com/packages/packages/0c/f0/655224f78ede087aa6a295c4745423e0104dd31f67928675b1a4cea72a0a/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2930140edd1a64eb42689993c5ae317bca2e6bc785b5dc5053bd5e9d184c98ad", size = 1209542, upload-time = "2026-05-15T16:11:03.364Z" },
+ { url = "https://pypi.apple.com/packages/packages/73/52/a7b2a26625136fcd0f92e17beaaae51e04923cc2e28502db354d4de061b4/pyjson5-2.0.1-cp314-cp314-win32.whl", hash = "sha256:51733f91dda897239ca10928f363ce6f4b5eadaf797e74477f6c1c3222c185a7", size = 118342, upload-time = "2026-05-15T16:11:40.009Z" },
+ { url = "https://pypi.apple.com/packages/packages/3a/8e/79db426fff3a41610076989d4060cc18a6508ebd3c7877155f6709eea102/pyjson5-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:698c73aacff49ea35bbbf7f700a97785626201ea7aa2e1f1e0a4fe23788c3be2", size = 138408, upload-time = "2026-05-15T16:11:41.757Z" },
+ { url = "https://pypi.apple.com/packages/packages/c7/1b/385da14c05412bca15e86551dde91959686c8bddf7e78722bdc627fa6815/pyjson5-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d447e2e5756f89abfd0ad82d1438075bcbc701c1cc9279b43d24825aaac67356", size = 120905, upload-time = "2026-05-15T16:11:43.146Z" },
+ { url = "https://pypi.apple.com/packages/packages/dd/04/cd69739556d304d3ea48064ea7d47e5ff7321b0032d7ad78864bceaa0cae/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5603be4f0cb9685d7c2cbd408ddec21b33f415252bee00ac7969f20b5789a1d1", size = 321150, upload-time = "2026-05-15T16:11:05.07Z" },
+ { url = "https://pypi.apple.com/packages/packages/84/d4/6d98268ed07a2cac1e79634fcd3dc280d2503ade5f3c465f7aa095951444/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87267520a256b3f2dba7f2995b745e8b3f2dd72bbf8436010a5191f7809f8ab6", size = 167540, upload-time = "2026-05-15T16:11:06.822Z" },
+ { url = "https://pypi.apple.com/packages/packages/c8/c6/8855a9462bdbc8306cba62793e23cc2952dfcbcbcab8e0413f570c891361/pyjson5-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77e58307b74019d90aff9f2781bcffbbea3315b15fc814f8fa9b8d334b956ff4", size = 162533, upload-time = "2026-05-15T16:11:08.218Z" },
+ { url = "https://pypi.apple.com/packages/packages/a6/a3/1092f68538ee71ed8027393ab5e71aa9c4a114e493e30a55e46e34c2ffb9/pyjson5-2.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd1712038837342dea73ed526883e53c6e85a158242b6f574b9ad9cdd3199c7f", size = 184165, upload-time = "2026-05-15T16:11:09.639Z" },
+ { url = "https://pypi.apple.com/packages/packages/46/63/bb9f42a4047284ea59064c134c88dfac3b7f5273e40fe548d6a086cea452/pyjson5-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dba36677c77aed5d680827c65478933705ca156a34838f1250ea191ffc27662", size = 170075, upload-time = "2026-05-15T16:11:11.105Z" },
+ { url = "https://pypi.apple.com/packages/packages/55/2a/0379aa5184e986d0f9af2919712c148d81928ce7d2d7950407e271b1df3b/pyjson5-2.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:04356d0c09e58907f60675d33859f269473479b90add5026231ef2fadba5207e", size = 164100, upload-time = "2026-05-15T16:11:12.909Z" },
+ { url = "https://pypi.apple.com/packages/packages/15/d5/2087af69695c28c7fe796afc73dec7f0b0b9bc123ec329e45928d58d4705/pyjson5-2.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b331a0fbe2ae26f4ccb88045ebaecc00aeeeab23ff858f9a2223cedd969ec6d6", size = 184674, upload-time = "2026-05-15T16:11:14.448Z" },
+ { url = "https://pypi.apple.com/packages/packages/2a/06/cf1b2744e07f1689cb0ac663e162a060b81cd358474754fe9f3bf02d5398/pyjson5-2.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ca55878d923ba5764254ad1c020c22b33e6161b3555d1f457b8060f2b3c6c17", size = 193047, upload-time = "2026-05-15T16:11:15.969Z" },
+ { url = "https://pypi.apple.com/packages/packages/54/e6/a6b0deb6a3f393907c6d9115a7ac8e27557109204b56b05d3b57c51fc2ca/pyjson5-2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35e18d11e4c2034b78a664affe18c153a8efbf8872cf6bed7f12dc4fb819d440", size = 178712, upload-time = "2026-05-15T16:11:17.364Z" },
+ { url = "https://pypi.apple.com/packages/packages/6e/48/9e5c16daba56dfdb481baab85d1197e14ae3f403902fc23cdca14d14351b/pyjson5-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b710a7489b6e134890eaa1fdb285e9644a1e730c1cfcf1cd90a87859b87a84a9", size = 175642, upload-time = "2026-05-15T16:11:18.781Z" },
+ { url = "https://pypi.apple.com/packages/packages/c3/32/f07d4dbd8cf733f9e3f3e9c9abedfbc23da1e5cfa7fed46aebae07ca6f25/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:08920c0dd6aba3fb6bc6c849e6f05731c5fb2716cd651de424ff60cdd12eae65", size = 1152219, upload-time = "2026-05-15T16:11:20.54Z" },
+ { url = "https://pypi.apple.com/packages/packages/01/9d/0ddbb89d381bc27f3740850d5d664c1f6f76620ad879ff5eb2506a4dfd6c/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e166dadb3275025cff5ee8602131372a3ea171c6727b3fc6e44697348e3972c5", size = 1013305, upload-time = "2026-05-15T16:11:22.702Z" },
+ { url = "https://pypi.apple.com/packages/packages/bb/93/0f44886391dd2249ad9cf98c2deb3f26d1a115dd5a215629af7455765968/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea3c2b4e7b8e209e7f59bd6a925d79b69cd0a4ffa12f2df0c6af55514c8b2227", size = 1322840, upload-time = "2026-05-15T16:11:24.918Z" },
+ { url = "https://pypi.apple.com/packages/packages/d4/29/c478dc24dfcfc07e407ff8669df265ecc51fed5e7d47e51a01f17fd1c53f/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b710fc8ce00c984c8865131f05a8536163e1d3caffb9d081647879eca7424670", size = 1245221, upload-time = "2026-05-15T16:11:26.873Z" },
+ { url = "https://pypi.apple.com/packages/packages/e2/f0/5fef8c47c5b7052da79af6425a021040402a9a09b37c5687d91f98696a27/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9784d79275d1b06d95f2920c3f260ce5d3f6671f65a1fae1464f83fbfc834a0", size = 1182991, upload-time = "2026-05-15T16:11:29.258Z" },
+ { url = "https://pypi.apple.com/packages/packages/90/33/4d3a9d3159cdff1f1886d39f1991e6c8d5927f9b606cfc9743b5bef98c2a/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0ef86c53d0e14991b5b0fe58f225e3d8faec437aa0a70da4762525b6bc2fca10", size = 1359104, upload-time = "2026-05-15T16:11:31.651Z" },
+ { url = "https://pypi.apple.com/packages/packages/0f/04/20eb16c52453aea0af68a697f4058378c9ff871bd2f0bea28eb76ffe12f8/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d9faccf5a9e86f14104eec31c13428ef8baeb867182ce107e0c68fcc5c62477", size = 1212847, upload-time = "2026-05-15T16:11:33.767Z" },
+ { url = "https://pypi.apple.com/packages/packages/b9/a3/69bbe93275eafb7802a5b29230cc5c28f9b924c68d15b3ab2d43cfe19825/pyjson5-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:2df497af7ab03cf82d9278a4707a83d0ae4e6d727f919a883b5ccec3f7d92650", size = 139137, upload-time = "2026-05-15T16:11:35.487Z" },
+ { url = "https://pypi.apple.com/packages/packages/48/45/b865ca6e0ae6887bc2b6b17cd123470ae43ac0fa04a2362d77d4b9f5bd43/pyjson5-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b31cf4f4a4f01800812865af3b02f6700cefb9377e4d9a9c6b78fdcf41fd973", size = 169412, upload-time = "2026-05-15T16:11:36.887Z" },
+ { url = "https://pypi.apple.com/packages/packages/68/55/02c734459fef0a955ab3e7e745df415d5f9fc873a4c288765f60f22fbc13/pyjson5-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:15f0d8baea89d35c6c01c60b944c778a79cac10f77f71b727f1b244e2c7a8ccb", size = 129061, upload-time = "2026-05-15T16:11:38.712Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "pytest-clarity"
+version = "1.0.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pprintpp" },
+ { name = "pytest" },
+ { name = "rich" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/52/5c/cafa97944de55738a6a2c5a7cee00d073cb80495032d2b112c4546525eca/pytest-clarity-1.0.1.tar.gz", hash = "sha256:505fe345fad4fe11c6a4187fe683f2c7c52c077caa1e135f3e483fe112db7772", size = 4891, upload-time = "2021-06-11T18:16:18.372Z" }
+
+[[package]]
+name = "pytest-html"
+version = "4.2.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "pytest" },
+ { name = "pytest-metadata" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/c4/08/2076aa09507e51c1119d16a84c6307354d16270558f1a44fc9a2c99fdf1d/pytest_html-4.2.0.tar.gz", hash = "sha256:b6a88cba507500d8709959201e2e757d3941e859fd17cfd4ed87b16fc0c67912", size = 108634, upload-time = "2026-01-19T11:25:26.471Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl", hash = "sha256:ff5caf3e17a974008e5816edda61168e6c3da442b078a44f8744865862a85636", size = 23801, upload-time = "2026-01-19T11:25:25.008Z" },
+]
+
+[[package]]
+name = "pytest-metadata"
+version = "3.1.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
+]
+
+[[package]]
+name = "pytest-sugar"
+version = "1.1.1"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "termcolor" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" },
+]
+
+[[package]]
+name = "pytest-timeout"
+version = "2.4.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
+]
+
+[[package]]
+name = "pytest-xdist"
+version = "3.8.0"
+source = { registry = "https://pypi.apple.com/simple" }
dependencies = [
- { name = "filelock" },
- { name = "platformdirs" },
+ { name = "execnet" },
+ { name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" }
+sdist = { url = "https://pypi.apple.com/packages/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" },
+ { url = "https://pypi.apple.com/packages/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
- { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
- { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
- { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
- { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
- { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
- { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
- { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
- { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
- { 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" },
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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://pypi.apple.com/packages/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 = "resolvelib"
+version = "1.2.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/1d/14/4669927e06631070edb968c78fdb6ce8992e27c9ab2cde4b3993e22ac7af/resolvelib-1.2.1.tar.gz", hash = "sha256:7d08a2022f6e16ce405d60b68c390f054efcfd0477d4b9bd019cc941c28fad1c", size = 24575, upload-time = "2025-10-11T01:07:44.582Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/e2/23/c941a0d0353681ca138489983c4309e0f5095dfd902e1357004f2357ddf2/resolvelib-1.2.1-py3-none-any.whl", hash = "sha256:fb06b66c8da04172d9e72a21d7d06186d8919e32ae5ab5cdf5b9d920be805ac2", size = 18737, upload-time = "2025-10-11T01:07:43.081Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "ruamel-yaml"
+version = "0.19.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
]
[[package]]
name = "ruff"
-version = "0.15.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
- { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
- { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
- { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
- { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
- { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
- { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
- { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
- { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
- { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
- { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
- { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
- { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
- { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
- { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
- { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
- { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
+version = "0.15.20"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
+ { url = "https://pypi.apple.com/packages/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
+ { url = "https://pypi.apple.com/packages/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
+ { url = "https://pypi.apple.com/packages/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
+ { url = "https://pypi.apple.com/packages/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
+ { url = "https://pypi.apple.com/packages/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
+ { url = "https://pypi.apple.com/packages/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
+ { url = "https://pypi.apple.com/packages/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
+ { url = "https://pypi.apple.com/packages/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
+ { url = "https://pypi.apple.com/packages/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
+ { url = "https://pypi.apple.com/packages/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
+ { url = "https://pypi.apple.com/packages/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
+ { url = "https://pypi.apple.com/packages/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
+ { url = "https://pypi.apple.com/packages/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
+ { url = "https://pypi.apple.com/packages/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
+ { url = "https://pypi.apple.com/packages/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
+ { url = "https://pypi.apple.com/packages/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
+]
+
+[[package]]
+name = "shellcheck-py"
+version = "0.11.0.1"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/df/55/455b097417b3df3d330eff029c72c32f08b25739e3010acb30ad06d268ef/shellcheck_py-0.11.0.1.tar.gz", hash = "sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc", size = 3139, upload-time = "2025-08-09T17:53:42.492Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/54/27/d75b03e5458cefdb6d3b674566cd20476c3e4d3fe6cc9d68b7e3b854b296/shellcheck_py-0.11.0.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8", size = 6774472, upload-time = "2025-08-09T17:53:34.573Z" },
+ { url = "https://pypi.apple.com/packages/packages/61/ac/2a84c37171c0cf5a10ea4b0a27d43eb0a1d29bd98b49c2c5ffe17ad24bbe/shellcheck_py-0.11.0.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0", size = 11381835, upload-time = "2025-08-09T17:53:36.852Z" },
+ { url = "https://pypi.apple.com/packages/packages/96/55/250e0e3367613a5c22bd82e33b16b889287d81ab0f7dda67e6514a4cccf4/shellcheck_py-0.11.0.1-py2.py3-none-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05", size = 3800600, upload-time = "2025-08-09T17:53:38.643Z" },
+ { url = "https://pypi.apple.com/packages/packages/15/5b/bb14c0a7474463b1aa3c09e866cb172dffc66ed2993b7ea8f1db581e86ee/shellcheck_py-0.11.0.1-py2.py3-none-win_amd64.whl", hash = "sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759", size = 8027541, upload-time = "2025-08-09T17:53:40.889Z" },
+]
+
+[[package]]
+name = "shfmt-py"
+version = "4.0.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/06/d5/c2ad5c6593a34da7344cf39bde65763e8cda752589074ba1619e55b317ad/shfmt_py-4.0.0.tar.gz", hash = "sha256:1e5fdacf40aabaa77a97639d52a6220df0893b46658d82b7f136f4e66e2b2fb0", size = 11947, upload-time = "2026-05-13T09:25:50.153Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/5a/1d/8f72824e2a0e06dc0bc2687baacaba0573be7d2e93c01d1e895fddd8c13e/shfmt_py-4.0.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:75a4919a03fb3bcff9795e3cc7b971e37e74905654d2f11605001cab42e5f92f", size = 1343695, upload-time = "2026-05-13T09:25:42.969Z" },
+ { url = "https://pypi.apple.com/packages/packages/a8/82/9564a2c2a76fbec94db1b3a3c37a9a1d00e7eafca2cdd2e0d19082618d7e/shfmt_py-4.0.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb3d236163ff39c7790953e069938caf247e7646399f7a059f00f65d4e6916d6", size = 1237947, upload-time = "2026-05-13T09:25:44.767Z" },
+ { url = "https://pypi.apple.com/packages/packages/9a/a9/6fce944efa530db941edd11388d70dc7384aaf12169a3b0847b6a6c987b0/shfmt_py-4.0.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:4701336c3cb5f3959a5e85481b14f02054ea094b3c666f3d04649bbe10de3c25", size = 1218771, upload-time = "2026-05-13T09:25:46.225Z" },
+ { url = "https://pypi.apple.com/packages/packages/64/43/e3965a25bb39555f2791c6860214f62b6f976f9ac7e9786073364bcdd9a6/shfmt_py-4.0.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:e57877abe0177a9da7bbb5390fe7e96aa19b00958189a025634039aef8834d44", size = 1350939, upload-time = "2026-05-13T09:25:47.584Z" },
+ { url = "https://pypi.apple.com/packages/packages/95/20/db2430d9262d2cffadcad2b330441e13031f1ab849ec069659edb7f23257/shfmt_py-4.0.0-py2.py3-none-win_amd64.whl", hash = "sha256:bd4f3d36264d4ba8b014ff73e5e702aaa2345845c021f563480128de3705135b", size = 1427721, upload-time = "2026-05-13T09:25:48.865Z" },
]
[[package]]
name = "tabulate"
version = "0.10.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" },
-]
-
-[[package]]
-name = "tomli"
-version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
- { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
- { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
- { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
- { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
- { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
- { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
- { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
- { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
- { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
- { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
- { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
- { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
- { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
- { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
- { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
- { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
- { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
- { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
- { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
- { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
- { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
- { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
- { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
- { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
- { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
- { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
- { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
- { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
- { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
- { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
- { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
- { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
- { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
- { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
- { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
- { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
- { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
- { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
- { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
- { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
- { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
- { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
- { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
- { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
- { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" },
+]
+
+[[package]]
+name = "taplo"
+version = "0.9.3"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/71/79/513513960377e1212a28446acb323cf77dfce162e825a822f035b02a422d/taplo-0.9.3.tar.gz", hash = "sha256:6b73b45b9adbd20189d8981ac9055d5465227c58bbe1b0646a7588a1a5c07a1a", size = 102556, upload-time = "2024-08-19T10:22:15.005Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/61/42/a93c18ebb7cf3ee2a7a30dd2fda654aca458956c3b64bdfb9d82b2c42679/taplo-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1c3db689406d538420c64aa779ac8694cf44c13a46e158d6df406de65980b9c7", size = 4248497, upload-time = "2024-08-19T10:21:59.954Z" },
+ { url = "https://pypi.apple.com/packages/packages/82/d2/f5b6e4a4f474f9fe613b5b91012520c3f62e46748a6ce9fd61fc2fb52fa2/taplo-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1e7782f33f97e7aa658d18788748bce5cf3ce440eeb419cf5861cf542740e610", size = 4044421, upload-time = "2024-08-19T10:22:03.06Z" },
+ { url = "https://pypi.apple.com/packages/packages/7d/32/4ac46ff15bb9d060f50ad31fb3a80aa8ee1e6ca500104ca8569f6fbdee3d/taplo-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29d4d7abfcc10bd536e5a43fe6ec2c1931507c1433e79df03ea22e1030611cb6", size = 4334420, upload-time = "2024-08-19T10:22:05.68Z" },
+ { url = "https://pypi.apple.com/packages/packages/ef/cc/656aed22a59cf4c50dcaaa66aaa570d4a1412acdd8ea429120a6bb00f336/taplo-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f12648f273478d7330cb3529c82f48f388501e1122e0bea78bce5ff5972b8b", size = 4468935, upload-time = "2024-08-19T10:22:08.106Z" },
+ { url = "https://pypi.apple.com/packages/packages/21/15/d8db1db6382b444122fa1a66fe5fe0dd5b04bfbe68c74bdb5345aec11eb2/taplo-0.9.3-py3-none-win32.whl", hash = "sha256:9ab7df76a3facc6d0dd2fe2dae3e8eb52fa458d31d27878d5eac14f5cbc0abac", size = 3482612, upload-time = "2024-08-19T10:22:10.904Z" },
+ { url = "https://pypi.apple.com/packages/packages/42/3c/df6641d7e2e84a6dd4de3b3a4426db7f6a7270c05bbdeadd523645c9c45f/taplo-0.9.3-py3-none-win_amd64.whl", hash = "sha256:7d80b630b93fb43cee99d1e1ee07b616236dc5615efaf7cd51074b4cffc33bab", size = 3985843, upload-time = "2024-08-19T10:22:13.446Z" },
+]
+
+[[package]]
+name = "termcolor"
+version = "3.3.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
+]
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
+]
+
+[[package]]
+name = "trove-classifiers"
+version = "2026.6.1.19"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" },
]
[[package]]
name = "types-pyyaml"
-version = "6.0.12.20250915"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" }
+version = "6.0.12.20260518"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
+ { url = "https://pypi.apple.com/packages/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" },
]
[[package]]
name = "types-requests"
-version = "2.33.0.20260327"
-source = { registry = "https://pypi.org/simple" }
+version = "2.33.0.20260518"
+source = { registry = "https://pypi.apple.com/simple" }
dependencies = [
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/02/5f/2e3dbae6e21be6ae026563bad96cbf76602d73aa85ea09f13419ddbdabb4/types_requests-2.33.0.20260327.tar.gz", hash = "sha256:f4f74f0b44f059e3db420ff17bd1966e3587cdd34062fe38a23cda97868f8dd8", size = 23804, upload-time = "2026-03-27T04:23:38.737Z" }
+sdist = { url = "https://pypi.apple.com/packages/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8c/55/951e733616c92cb96b57554746d2f65f4464d080cc2cc093605f897aba89/types_requests-2.33.0.20260327-py3-none-any.whl", hash = "sha256:fde0712be6d7c9a4d490042d6323115baf872d9a71a22900809d0432de15776e", size = 20737, upload-time = "2026-03-27T04:23:37.813Z" },
+ { url = "https://pypi.apple.com/packages/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" },
+]
+
+[[package]]
+name = "types-toml"
+version = "0.10.8.20260518"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/4b/11/6ece999e91f2ccb848ab4420f3f4816e78ac0541f739e6864affdaaa5737/types_toml-0.10.8.20260518.tar.gz", hash = "sha256:80e10facd24fdeda9d5c672187d72be3ac284843788d67f5aae59e3e016db6fe", size = 9419, upload-time = "2026-05-18T06:02:16.719Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/91/25/489751806bf5c95e4007f8e17409199c54d31e49ffbea07c5729b1286c8e/types_toml-0.10.8.20260518-py3-none-any.whl", hash = "sha256:0e564ab05f6fde62a315b3b5a9b6624fda569399795d30a37e64705a70459303", size = 9669, upload-time = "2026-05-18T06:02:15.86Z" },
]
[[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" }
+version = "4.16.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/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" },
+ { url = "https://pypi.apple.com/packages/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "urllib3"
-version = "2.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" }
+version = "2.7.0"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+ { url = "https://pypi.apple.com/packages/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
-name = "virtualenv"
-version = "21.2.0"
-source = { registry = "https://pypi.org/simple" }
+name = "validate-pyproject"
+version = "0.25"
+source = { registry = "https://pypi.apple.com/simple" }
dependencies = [
- { name = "distlib" },
- { name = "filelock" },
- { name = "platformdirs" },
- { name = "python-discovery" },
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+ { name = "fastjsonschema" },
+]
+sdist = { url = "https://pypi.apple.com/packages/packages/e7/d8/45a82408619c306d0083afcf6142617201b2647a82e9a7d4c902e837274c/validate_pyproject-0.25.tar.gz", hash = "sha256:e68c12d1cb0d8ddc269ffc42875a81727ddb7865000aa6d2f77d833b55c53f0b", size = 118662, upload-time = "2026-02-02T17:31:07.089Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/b7/ee/e9c95cda829131f71a8dff5ce0406059fd16e591c074414e31ada19ba7c3/validate_pyproject-0.25-py3-none-any.whl", hash = "sha256:f9d05e2686beff82f9ea954f582306b036ced3d3feb258c1110f2c2a495b1981", size = 54942, upload-time = "2026-02-02T17:31:05.489Z" },
+]
+
+[package.optional-dependencies]
+all = [
+ { name = "packaging" },
+ { name = "trove-classifiers" },
+]
+
+[[package]]
+name = "validate-pyproject-schema-store"
+version = "2026.7.8"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/37/1a/56d4924eed352c36828358f33e814f2a8bc9dcf28d41a9136a9af3462f9b/validate_pyproject_schema_store-2026.7.8.tar.gz", hash = "sha256:08eb8624b4a020e3bffe9eba2fc1e3123b97e6a159ef38c76d36d33cb45508ca", size = 191745, upload-time = "2026-07-08T10:32:05.15Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/77/9a/881842d94b6ba4ce9e56e5771b7b8ea4a69b67f0e994e8ecc8de4b7d8c50/validate_pyproject_schema_store-2026.7.8-py3-none-any.whl", hash = "sha256:8eb021f774b91926a2a194ddcd1cdaaa1b716b8031434fb099bf9b0bf03f8a89", size = 198162, upload-time = "2026-07-08T10:32:03.432Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.apple.com/simple" }
+sdist = { url = "https://pypi.apple.com/packages/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://pypi.apple.com/packages/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
+
+[[package]]
+name = "yamllint"
+version = "1.38.0"
+source = { registry = "https://pypi.apple.com/simple" }
+dependencies = [
+ { name = "pathspec" },
+ { name = "pyyaml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
+sdist = { url = "https://pypi.apple.com/packages/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446, upload-time = "2026-01-13T07:47:53.276Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
+ { url = "https://pypi.apple.com/packages/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940, upload-time = "2026-01-13T07:47:51.343Z" },
]