diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4878780 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.venv/ +.git/ +.pytest_cache/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +.mypy_cache/ +.ruff_cache/ +*.png +*.svg +*.mp4 +*.apng +*.mov +misc/ +scripts/ +docs/ +examples/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f8ff2b5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.mp4 filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8694bca..8a5945d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} @@ -36,3 +36,21 @@ jobs: - name: Test run: uv run pytest --cov=src --cov-report=xml + + test-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python 3.10 + run: uv python install 3.10 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Test + run: uv run pytest --cov=src --cov-report=xml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..34ea10a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,21 @@ +name: Deploy docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Deploy + run: uvx --with mkdocs-material mkdocs gh-deploy --force diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a5960d..380d1e5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v7 - name: Build run: uv build diff --git a/.gitignore b/.gitignore index 0463dff..4bbe952 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ Thumbs.db NOTES.md TASKS.md tests/example_dirplot.png +tests/animation/ +demo/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1be6df2..1340861 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,3 +15,12 @@ repos: - id: ruff args: [--fix] - id: ruff-format + + - repo: local + hooks: + - id: mypy + name: mypy + entry: uv run mypy src + language: system + pass_filenames: false + types: [python] diff --git a/CHANGELOG.md b/CHANGELOG.md index adb6108..eba9fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,702 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.4] - 2026-05-15 + +### Fixed + +- **`--inline` fills terminal width in iTerm2** — the iTerm2 inline image protocol now + receives an explicit `width=` parameter so the image always fills the full column + count, regardless of pixel-to-cell rounding differences (scrollbar width, DPI). Ghostty + and Kitty are unaffected. + +### Added + +- **`dirplot diff` command** — compares two directory trees A and B as a treemap. Files are + sized by B. Borders show diff status: green = added, red = removed, blue = changed (content + differs). Unchanged files show no border. Supports `--context/--no-context` (default: on, + i.e. unchanged files are shown). A and B accept any source supported by `dirplot map`: + local directories, `github://owner/repo[@ref]`, archives (`.zip`, `.tar.gz`, …), `s3://`, + `ssh://`, `docker://`, and `pod://`. All visual and remote-access options are available: + `--output`, `--format`, `--show/--no-show`, `--inline`, `--font-size`, `--colormap`, + `--exclude`, `--depth`, `--size`, `--cushion/--no-cushion`, `--dark/--light`, + `--log-scale`, `--header/--no-header`, `--quiet`, `--ssh-key`, `--ssh-password-file`, + `--aws-profile`, `--no-sign`, `--github-token-file`, `--k8s-namespace`, + `--k8s-container`, `--password-file`, and `--no-input`. + +- **`dirplot diff` enhancements** — single-argument shorthand: `dirplot diff .` diffs the + working tree against HEAD (git) or tip (hg). Supports `@` syntax for local git + repos (e.g. `dirplot diff .@HEAD~5 .@HEAD`). When a source is a local git or hg repo, + only tracked files are scanned (untracked files ignored). Change detection uses blob hash + comparison — edits that don't change file size are caught, and Git LFS files are handled + transparently (pointer size vs disk size no longer causes false positives). + +- **`--include` flag** (replaces `--subtree`, which remains as a hidden alias) — available on + `map`, `diff`, and `metrics`. Keeps only the named subtrees after scanning; supports nested + paths (`--include src/dirplot/fonts`). Allowlist complement to `--exclude`. + +- **Glob patterns in `--exclude`** — the `--exclude` flag now accepts glob patterns on all + commands and all backends (local, git, hg, SSH, S3, GitHub, Docker, Kubernetes, archives). + Plain names (`.git`, `node_modules`) still work as before and match any path component. + New: single-component globs (`*.egg-info`), relative paths (`src/vendor`), and `**` globs + (`**/__pycache__`). Matching is consistent across all backends — previously each backend + used a different comparison strategy (absolute paths, basenames, full URIs). + +- **ISO 8601 timestamps in event log** — `dirplot watch --event-log` now writes timestamps + as timezone-aware ISO 8601 strings (e.g. `2026-05-12T14:21:52.341+00:00`) instead of raw + Unix epoch floats. `dirplot replay` still accepts both formats for backwards compatibility. + +- **`--inline` on `dirplot git` and `dirplot hg`** — displays the single-frame output + directly in the terminal (iTerm2, Kitty, Ghostty protocols). Only available in + single-frame mode (no `--range` or `--period`). + +- **`@ref` on HTTPS GitHub URLs** — `https://github.com/owner/repo@v1.0` is now a valid + `repo` argument for `dirplot git`, equivalent to `github://owner/repo@v1.0`. + +### Changed + +- **`dirplot git` and `dirplot hg` interface redesigned** — the animation model is now + explicit: `--range` or `--period` triggers animation mode (APNG / MP4, one frame per + commit); neither flag produces a single static PNG of the last commit (HEAD / tip). + +- **`--animate` removed** (`git`, `hg`) — use `--range` or `--period` to produce an + animation. Without either, a single static frame is rendered. + +- **`--max-commits` renamed to `--first`** (`git`, `hg`) — keeps the first N commits + after the range/period filter is applied. The old name is no longer accepted. + +- **`--last N` added** (`git`, `hg`) — keeps the *last* N commits after the range/period + filter is applied. Counterpart to `--first`. + +- **`--last PERIOD` renamed to `--period`** (`git`, `hg`) — the time-period filter is now + `--period 30d`, `--period 24h`, etc. The old `--last` name for this flag is removed. + +- **`--period` without `--range` triggers animation** — commits within the period relative + to now are fetched and animated. GitHub URLs use `--shallow-since` for an efficient + date-bounded shallow clone. + +- **`--period` with `--range` filters relative to range end** — when both are given, the + period cutoff is anchored to the timestamp of the last commit in the range rather than + to now (e.g. "last 3 days of activity on this branch"). + +- **`--first` / `--last` slice post-fetch** — the count cap is applied in Python after all + commits are fetched, not via `git log -n`. This ensures `--first` always gives the oldest + N commits and `--last` always gives the newest N commits. + +- **`--first` no longer controls clone depth when `--range` is given** — previously + `--first N` would pass `--depth N` to `git clone`, making tags outside the shallow history + unreachable. `--depth` is now only used when `--range` is absent. + +- **Output extension `.png` for animations** — APNG output uses `.png` (not `.apng`). + +- **`--output -` implies `--no-show`** — piping to stdout no longer opens a viewer window; + pass `--show` explicitly to override. + +- **`dirplot watch` simplified** — animation output removed from `watch`; use `dirplot replay` + on a `--event-log` file to produce APNG/MP4. New `--snapshot FILE` option writes the current + treemap PNG on each filesystem change (for external tools or wallpaper updaters). + +- **matplotlib replaced by cmap** — matplotlib is no longer a dependency. Colormap lookups + now use the `cmap` package instead, which is significantly smaller. All colormap names + previously accepted by matplotlib continue to work. + +## [0.4.3] - 2026-05-08 + +### Changed + +- **Secret flags removed** — `--github-token`, `--ssh-password`, and `--password` + flags have been removed from all commands. Use the `$GITHUB_TOKEN` / `$SSH_PASSWORD` + environment variables or the `--github-token-file`, `--ssh-password-file`, and + `--password-file` options instead. This prevents secrets from appearing in shell + history and process listings. +- **`SSH_KEY` and `SSH_PASSWORD` environment variables removed** — SSH credentials + are now resolved via `--ssh-key` / `--ssh-password-file` flags, `~/.ssh/config` + `IdentityFile`, the ssh-agent, or an interactive prompt. The non-standard env vars + are no longer read. +- **`--logscale` renamed to `--log-scale`** — the flag now follows the standard + CLI convention of hyphen-separated words. +- **`--top -n` short form removed** (`dirplot metrics`) — `-n` is conventionally + reserved for `--dry-run`; use `--top N` directly. +- **`--range -r`, `--max-commits -n`, `--workers -w` short forms removed** (`git`, + `hg`) — these single-letter aliases conflicted with conventions or were too obscure + to warrant a shorthand. +- **`--colormap -c`, `--subtree -s`, `--breadcrumbs -b/-B`, `--k8s-namespace -N` + short forms removed** — single-letter flags reserved for commonly-used options per + CLIG guidelines. +- **Status messages now always go to stderr** — info lines from `dirplot map` and + the "Watching … (Ctrl-C to stop)" line from `dirplot watch` previously went to + stdout in some cases; they now consistently go to stderr. +- **`$COLUMNS` / `$LINES` honoured for terminal size** — when `ioctl` and + `os.get_terminal_size()` are both unavailable (CI, SSH sessions, scripts), the + standard `$COLUMNS` and `$LINES` environment variables are now checked before + falling back to the hardcoded `160×45` default. +- **`dirplot map` with no arguments shows help** — running `dirplot map` without + paths or piped input in an interactive terminal now prints the command help instead + of an error. + +### Added + +- **`dirplot metrics` command** — scans any source supported by `dirplot map` + (local, SSH, S3, GitHub, Docker, Kubernetes, archives, stdin) and prints a + structured text summary: + - File and directory counts (with empty-directory count) + - Total size (human-readable) + - Maximum tree depth and scan time + - Top N file extensions with file count *and* total bytes + - Largest N files and directories, each with its percentage share of total size +- **`--sort-by count|size`** on `dirplot metrics` — controls extension ordering; + `count` (default) sorts by number of files, `size` sorts by total bytes. +- **`--top N`** on `dirplot metrics` — caps the number of entries shown + in each list (extensions, largest files, largest dirs). Default: 10. +- **`--json` / `--no-json`** on `dirplot metrics` — outputs all metrics as a + structured JSON object, suitable for piping into `jq` or scripts. +- **`--metrics` / `--no-metrics`** on `dirplot map` — prints the full metrics + block after scanning, before rendering. Lets you get treemap and metrics in + a single pass without running two commands. +- **`tree_metrics(root_node, t_scan, top_n, sort_by) → str`** in + `dirplot.scanner` — public API returning the human-readable metrics string. +- **`tree_metrics_dict(root_node, t_scan, top_n, sort_by) → dict`** in + `dirplot.scanner` — public API returning a structured dict with all metrics; + the source of truth for both the text and JSON outputs. + +### Changed + +- **`--log`/`--logscale` merged into a single `--logscale` parameter** — the + boolean `--log/--no-log` flag has been removed from all commands (`map`, `watch`, + `git`, `hg`, `replay`). Pass `--logscale ` (any value > 1) to enable + log-scale layout compression; omit it or pass `0` to disable. The default is `0` + (disabled). The ratio controls the max/min layout-size ratio of leaf files after + transformation. + +### Fixed + +- **`dirplot overview` command** — Resolves app name/description/version from + `importlib.metadata` so the overview output shows richer context without requiring + manual wiring. + +- **macOS Keychain error during git clone** — `git clone` is now invoked with + `-c credential.helper=` to suppress the system credential helper. This prevents + the `-25308` Keychain error in non-interactive environments (CI, sandboxed runs). + Private repos are unaffected because the GitHub token is embedded directly in the + clone URL, so no credential helper is needed regardless. + +## [0.4.2] - 2026-04-15 + +### Fixed + +- **Cushion shading applied to directory tiles** — the batch cushion pass + incorrectly treated directory rectangles as file tiles, applying full-strength + shading to the entire area of each directory. Directories are now shaded at half + strength (scale 0.5) to give broad structural context, while individual file leaf + tiles continue to receive full-strength shading. The effect is two-level: directory + gradients provide coarse orientation, per-file gradients add local detail — matching + the hierarchical intent of van Wijk (1999). Both the PNG and SVG renderers are updated. + +### Added + +- **`dirplot overview` command** — prints a human-readable summary of all + commands, their arguments, options, and global options. Appears at position + #2 in the help listing. + +- **`dirplot hg` command** — replay Mercurial changeset history as an animated + treemap, identical in interface to `dirplot git`. Supports `--animate`, + `--max-commits`, `--last`, `--total-duration`, `--frame-duration`, `--fade-out`, + `--dark`/`--light`, `--workers`, `--crf`, `--codec`, and all other animation flags. + The `@rev` suffix on the repo path passes a revset directly to `hg log`. + Requires `hg` on `PATH`. Only local repositories are supported — there is no + `hg://` URI scheme because there is no Mercurial equivalent of GitHub. + ```bash + dirplot hg /path/to/repo --output history.png --animate + dirplot hg /path/to/repo@tip --output history.png + dirplot hg /path/to/repo --animate --last 30d --output history.mp4 + ``` + +## [0.4.1] - 2026-04-03 + +### Added + +- **`--last PERIOD`** for `dirplot git` — filter commits by a relative time period instead + of (or in addition to) `--max-commits`. Accepts a number followed by a unit: + `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `mo` (months = 30 days). + For GitHub URLs, uses `git clone --shallow-since` for an efficient date-bounded shallow + clone. `--last` and `--max-commits` may be combined (date filter + count cap both apply). + ```bash + dirplot git . -o history.mp4 --animate --last 30d + dirplot git . -o history.mp4 --animate --last 24h + dirplot git github://owner/repo -o history.mp4 --animate --last 2w --max-commits 10 + ``` + +- **`dirplot demo` command** — new subcommand that runs a curated set of example commands + and saves outputs to a folder. Useful for first-time walkthroughs or verifying that + everything works in a given environment. Accepts `--output` (default: `demo/`), + `--github-url` (default: `https://github.com/deeplook/dirplot`), and + `--interactive` / `-i` to step through and confirm each command individually. Output + uses rich formatting with colour, section rules, and status indicators. + ```bash + dirplot demo # run all examples, save to ./demo/ + dirplot demo --output ~/dp-demo --interactive + ``` + +- **`--fade-out` for animated output** — appends a fade-out sequence at the end of + animations produced by `dirplot git --animate`, `dirplot watch --animate`, and + `dirplot replay`. Four flags control the effect: + - `--fade-out` / `--no-fade-out` — enable/disable (default: off) + - `--fade-out-duration SECS` — total fade length in seconds (default: 1.0) + - `--fade-out-frames N` — number of blend steps; defaults to 4 per second of duration + so longer fades are automatically finer-grained + - `--fade-out-color COLOR` — target colour: `auto` (black in dark mode, white in light + mode), `transparent` (PNG/APNG only; fades to fully transparent), any CSS colour + name, or hex code (e.g. `"#1a1a2e"`) + ```bash + dirplot git . -o history.png --animate --fade-out + dirplot git . -o history.mp4 --animate --fade-out --fade-out-duration 2.0 + dirplot git . -o history.png --animate --fade-out --fade-out-color transparent + ``` + +- **`--dark` / `--light` mode** for all treemap commands — controls background and border + colours. Dark mode (default) uses a near-black canvas with white directory labels; light + mode uses a white canvas with black labels. Available on `map`, `git`, `watch`, and + `replay`. + ```bash + dirplot map . --light + dirplot git . -o history.mp4 --animate --light + ``` + +- **Metadata in MP4/MOV output** — `dirplot git`, `dirplot watch`, and `dirplot replay` + now embed the same dirplot metadata (date, software version, OS, Python version, + executed command) into MP4/MOV files that was previously only written to PNG and SVG. + `dirplot read-meta` reads it back via `ffprobe`. + +- **Automatic `gh` CLI credential fallback** — if `--github-token` and `GITHUB_TOKEN` + are both absent, dirplot silently runs `gh auth token`. Users authenticated with the + [GitHub CLI](https://cli.github.com/) (`gh auth login`) can access private repositories + with no extra configuration. Token resolution order: `--github-token` → + `$GITHUB_TOKEN` → `gh auth token`. + +### Changed + +- `--fade-out-frames` defaults dynamically to `round(fade_out_duration × 4)` rather than + a fixed 4, so a 2-second fade automatically uses 8 frames and a 0.5-second fade uses 2. + +### Fixed + +- **`--total-duration` overshooting the target length** — when many commits fell within + a burst (closely-spaced timestamps), their proportional frame durations would each be + raised to the 200 ms floor, inflating the total well beyond the requested duration + (e.g. 34 s instead of 30 s). The floor is still applied for readability, but the + non-floored frames are now scaled down to compensate so the sum always matches + `--total-duration` exactly. + +### Docs + +- Added `## dirplot read-meta` section to `docs/CLI.md` (previously undocumented). +- Documented external tool requirements: `git` (required by `dirplot git`), `ffmpeg` + (required for MP4 output), `ffprobe` (required by `read-meta` on MP4 files) — in both + `README.md` and `docs/CLI.md`. + +## [0.4.0] - 2026-03-28 + +### Added + +- **MP4 video output** — all three animation commands (`watch --animate`, `git --animate`, + `replay`) now write MP4 video when the output path ends in `.mp4` or `.mov`. Quality is + controlled via `--crf` (Constant Rate Factor: 0 = lossless, 51 = worst, default 23) and + `--codec` (`libx264` H.264 or `libx265` H.265). MP4 files are typically 10–100× smaller + than equivalent APNGs. Requires `ffmpeg` on PATH. + ```bash + dirplot git . -o history.mp4 --animate + dirplot git . -o history.mp4 --animate --crf 18 --codec libx265 + dirplot replay events.jsonl -o replay.mp4 --total-duration 30 + dirplot watch . -o treemap.mp4 --animate + ``` +- **`@ref` suffix for `dirplot git`**: local repository paths now accept an optional + `@ref` suffix to target a specific branch, tag, or commit SHA without needing + `--range` (e.g. `dirplot git .@my-branch -o out.apng --animate`). `--range` takes + precedence when both are provided. +- **`dirplot git` subcommand** — replays a git repository's commit history as an + animated treemap. Each commit becomes one frame; changed tiles receive the same + colour-coded highlight borders as `watch --animate` (green = created, blue = modified, + red = deleted). The commit SHA and local timestamp are shown in the root tile header, + and a progress bar at the top of each frame advances as the animation plays. + ```bash + # Animate all commits, write APNG + dirplot git . --output history.apng --animate --exclude .git + + # Last 50 commits on main, 30-second animation with time-proportional frame durations + dirplot git . --output history.apng --animate \ + --range main~50..main --total-duration 30 + + # Live-updating static PNG (last frame wins; useful with an auto-refreshing viewer) + dirplot git /path/to/repo --output treemap.png --max-commits 100 + ``` +- **`--range`** (`-r`): git revision range passed directly to `git log` + (e.g. `main~50..main`, `v1.0..HEAD`). Defaults to the full history of the current branch. +- **`--max-commits`** (`-n`): cap the number of commits processed. +- **`--frame-duration`**: fixed frame display time in ms when `--total-duration` is not set + (default: 1000 ms). +- **`--total-duration`**: target total animation length in seconds. Frame durations are + scaled proportionally to the real elapsed time between commits, so quiet periods in + development history map to longer pauses and burst activity to rapid flips. A 200 ms + floor prevents very fast commits from being invisible; durations are capped at 65 535 ms + (APNG uint16 limit). A summary line reports the actual range: + `Proportional timing: 200–7553 ms/frame (total ~30.1s)`. +- **`--workers`** (`-w`): number of parallel render workers in animate mode (default: all + CPU cores). Rendering is memory-bandwidth bound, so 4–8 workers is typically optimal; + use this flag to tune for your hardware. +- **Time-proportional progress bar**: a 2 px bar at the top of each frame advances in + proportion to animation time consumed, not frame count — so a burst of closely-spaced + commits produces only a small movement while a long quiet period advances it visibly. + With fixed `--frame-duration` the bar is linear as before. + +- **Debounced watch** (`--debounce SECONDS`, default `0.5`): the `watch` subcommand now + collects rapid file-system event bursts and regenerates the treemap once per quiet + period instead of on every raw event. A `git checkout` touching 100 files triggers + exactly one render after the activity settles. Pass `--debounce 0` to restore the + old immediate-fire behaviour. + ```bash + dirplot watch . --output treemap.png # 500 ms debounce (default) + dirplot watch . --output treemap.png --debounce 1.0 # 1 s quiet window + dirplot watch . --output treemap.png --debounce 0 # immediate, as before + ``` +- **Event log** (`--event-log FILE`): on Ctrl-C exit, all raw file-system events + recorded during the session are written as newline-delimited JSON (JSONL) to the + given file. Each line has `timestamp`, `type`, `path`, and `dest_path` fields. + The log is written only if there are events to record. + ```bash + dirplot watch src --output treemap.png --event-log events.jsonl + # Ctrl-C, then: + cat events.jsonl | python3 -m json.tool + ``` +- **File-change highlights** (`--animate`): each APNG frame now draws colour-coded + borders around tiles that changed since the previous frame — green for created, + blue for modified, red for deleted, orange for moved. Deleted files are highlighted + retroactively on the *previous* frame (since the tile no longer exists in the current + one), so the animation clearly shows both the disappearance and the appearance of files. + Moved files appear as a deletion at the old path and a creation at the new path. +- **Graceful finalization**: Ctrl-C now flushes any pending debounced render before + stopping the observer, so the output file always reflects the final state of the + watched tree. A second Ctrl-C during APNG writing is ignored so the file can finish + being written. +- **Tree comment stripping**: trailing `# comments` in `tree` output are now ignored + by the path-list parser, so annotated tree listings (e.g. `├── config.json # app config`) + are parsed correctly. Filenames containing `#` without a leading space are preserved. +- **`scripts/apng_frames.py`**: utility script to list frame durations, dimensions, and + offsets in an APNG file. +- **`scripts/watch_events.py`**: utility script to watch directories and log filesystem + events to a CSV file (or stdout) in real time using watchdog. +- **`--depth` for `watch`**: the `watch` subcommand now accepts `--depth N` to limit + recursion depth, matching the behaviour of `dirplot map`. + ```bash + dirplot watch . --output treemap.png --depth 3 + ``` +- **`dirplot replay` subcommand** — replays a JSONL filesystem event log (as produced + by `dirplot watch --event-log`) as an animated treemap APNG. Events are grouped into + time buckets (one frame per bucket, default 60 s), with colour-coded highlight borders + matching `watch --animate`. Only files referenced in the event log appear in the + treemap; the common ancestor of all paths is used as the tree root. Frame durations + can be uniform (`--frame-duration`, default 500 ms) or proportional to the real time + gaps between buckets (`--total-duration`). Frames are rendered in parallel. + ```bash + # Replay an event log with 60-second buckets, 30-second total animation + dirplot replay events.jsonl --output replay.apng --total-duration 30 + + # Smaller buckets for fine-grained activity, fixed frame duration + dirplot replay events.jsonl --output replay.apng --bucket 10 --frame-duration 200 + ``` + +- **`dirplot git` accepts GitHub URLs** — pass a `github://owner/repo[@branch]` or + `https://github.com/owner/repo` URL directly to `dirplot git`. dirplot clones the + repository into a temporary directory (shallow when `--max-commits` is set, full + otherwise), runs the full history pipeline locally, and removes the clone on exit. + No permanent local copy is created. + ```bash + # Animate the last 50 commits of a GitHub repo — no local clone needed + dirplot git github://owner/repo --output history.png --animate --max-commits 50 + + # Specific branch + dirplot git github://owner/repo@main --output history.png --animate --max-commits 50 + ``` +- **Total commit count shown** — `dirplot git` now reports the total number of commits + available alongside the number being animated, so you can gauge how much history + exists before committing to a longer run: + ``` + Replaying 20 of 147 commit(s) (increase --max-commits to process more) ... + ``` + For GitHub URLs the count is fetched with a single cheap API request (one commit + object + `Link` header). For local repos `git rev-list --count HEAD` is used. +- **`--github-token`** (`$GITHUB_TOKEN`): added to `dirplot git` for private GitHub + repos or to raise the API rate limit when fetching the total commit count. + +### Changed + +- **`libarchive-c` is now an optional dependency.** Install it with + `pip install 'dirplot[libarchive]'` (plus the system library: + `brew install libarchive` / `apt install libarchive-dev`) to enable + `.iso`, `.cpio`, `.rpm`, `.cab`, `.lha`, `.xar`, `.pkg`, `.dmg`, `.a`, `.ar`, + and `.tar.zst` / `.tzst` support. The base install works without it; a clear + error is shown if you try to open one of these formats without the extra. + +- **`--animate` writes the APNG once on exit** instead of reading and rewriting the + entire file on every render. Frames are accumulated as raw PNG bytes in memory and + flushed as a single multi-frame APNG when the watcher stops (Ctrl-C). This removes + an O(N²) disk-I/O pattern where frame K required reading a K-frame APNG just to + append one more frame. Status output during a session now reads `Captured frame N`; + the final `Wrote N-frame APNG → …` line confirms the file was written on exit. + +### Fixed + +- **Initial scan progress**: the `watch` subcommand now prints `Scanning …` + before the first render and starts the filesystem observer only after the initial + treemap has been generated, avoiding spurious events during the first scan. +- **`--animate` race condition**: the debounce timer thread was marked as daemon, + causing an in-progress render to be killed when the main thread exited after + `observer.join()`. The timer is no longer a daemon thread; `flush()` joins any + in-flight render before stopping. +- **`--animate` Pillow APNG regression**: passing `pnginfo` alongside `save_all=True` + caused Pillow to silently write a static PNG instead of an APNG. The `pnginfo` + argument is now omitted from multi-frame saves (cross-process timing metadata is + no longer needed since frames are held in memory for the lifetime of the process). +- **APNG frame duration overflow**: restoring the inter-session frame duration from + stored metadata could produce a value exceeding 65 535 ms — the maximum expressible + by APNG's uint16 `delay_num` field when `delay_den = 1000` — causing Pillow to raise + `cannot write duration`. Durations are now capped at 65 535 ms (≈ 65 s). + +- **Path-list input from `tree` / `find`** (`--paths-from FILE` or stdin pipe): the `map` + subcommand now accepts a list of paths produced by `tree` or `find` — either piped via + stdin or read from a file with `--paths-from`. Format is auto-detected: `tree` output + (detected by `├──` / `└──` box-drawing characters) or `find` output (one path per line). + Handles `tree -s` / `tree -h` (size columns), `tree -f` (full embedded paths), and the + default indented name format. Ancestor/descendant duplicates are collapsed automatically + so only the minimal set of roots is passed to the scanner. + ```bash + # Implicit stdin — no flag needed + tree src/ | dirplot map + tree -s src/ | dirplot map # with file sizes in tree output + find . -name "*.py" | dirplot map + + # Explicit file + tree src/ > paths.txt && dirplot map --paths-from paths.txt + + # Explicit stdin + tree src/ | dirplot map --paths-from - + ``` + Positional path arguments and path-list input are mutually exclusive — combining them + exits with a clear error. Only local paths are supported (remote backends such as + `docker://`, `s3://`, `ssh://` remain positional-arg only). + +- **`dirplot watch` accepts multiple directories**: the `watch` subcommand now takes + one or more positional path arguments and schedules a filesystem observer for each, + regenerating the treemap from all roots on every change. + ```bash + dirplot watch src tests --output treemap.png + ``` +- **`dirplot map` accepts multiple file paths as roots**: previously, multi-root mode + required every argument to be a directory. Individual files can now be passed as roots; + each is treated as a leaf node and displayed under the common parent directory. + ```bash + dirplot map src/main.py src/util.py --no-show + ``` +- **stdout output** (`--output -`): passing `-` as the output path writes the PNG or SVG + bytes to stdout, enabling piping to other tools. Header and progress lines are + automatically redirected to stderr to keep the binary stream clean. + ```bash + dirplot map . --output - | convert - -resize 50% small.png + dirplot map . --output - --format svg > treemap.svg + ``` + +## [0.3.3] - 2026-03-14 + +### Added + +- **Breadcrumbs mode** (`--breadcrumbs/--no-breadcrumbs`, `-b/-B`, on by default): directories + that form a single-child chain (one subdirectory, no files) are collapsed into a single tile + whose header shows the full path separated by ` / ` (e.g. `src / dirplot / fonts`). When the + label is too wide, middle segments are replaced with `…` (`src / … / fonts`). The root tile + is never collapsed. Disable with `-B` or `--no-breadcrumbs`. +- **Tree depth in root label**: the root tile header now includes `depth: N` alongside the + file, directory, and size summary (e.g. `myproject — 124 files, 18 dirs, 4.0 MB (…), depth: 6`). + The depth reflects the original tree structure and is invariant to whether breadcrumbs mode + is active. + +## [0.3.2] - 2026-03-13 + +### Added + +- **`dirplot watch`** subcommand — watches a directory and regenerates the treemap + on every file-system change using watchdog (FSEvents on macOS, inotify on Linux, + kqueue on BSD). Requires `watchdog`, now a core dependency. + ```bash + dirplot watch . --output treemap.png + dirplot watch . --output treemap.png --animate # APNG, one frame per change + ``` +- **Vertical file labels**: file tiles that are at least twice as tall as wide now + display their label rotated 90° CCW, letting the text span the full tile height + instead of being squeezed into the narrow width. +- **Scan and render timing** shown in header output: + `Found 1,414 files … [2.3s]` and `Saved dirplot to out.png [0.4s]`. +- **Multiple local roots**: `dirplot map src tests` accepts two or more local + directory paths, finds their common parent, and shows only those subtrees. +- **`--subtree` / `-s`** option (repeatable) — allowlist complement to `--exclude`: + keep only the named direct children of the root after scanning. Supports nested + paths such as `--subtree src/dirplot/fonts`. + +### Fixed + +- `--exclude` on pod and Docker backends now prunes entire subtrees — previously only + the exact path was matched, so all children leaked through. +- Clearer error for distroless pods: exit code 126 from `kubectl exec` now surfaces as + an actionable message explaining that the container has no shell or `find` utility. +- Adaptive file-label font size is now computed with a single `textbbox` measurement + (one call per tile) instead of stepping down one pixel at a time — eliminates an + O(font_size × n_tiles) bottleneck that caused near-blocking on large trees such as + `.venv` directories. + +### Changed + +- `-s` short alias reassigned from `--font-size` to `--subtree`. `--font-size` still + works as before; it just no longer has a single-letter alias. + +## [0.3.1] - 2026-03-11 + +### Added + +- `github://` URI now accepts an optional subpath after the repository name, letting + you scan a subdirectory directly: + - `github://owner/repo/sub/path` — subpath on the default branch + - `github://owner/repo@ref/sub/path` — subpath on a specific branch, tag, or commit SHA + - `https://github.com/owner/repo/tree/branch/sub/path` — full GitHub URL form + - Tags and commit SHAs are supported wherever a branch ref was previously accepted + (e.g. `github://torvalds/linux@v6.12`), as the GitHub trees API accepts any git ref. +- `--legend N` replaces the old boolean `--legend/--no-legend` flag. It now shows a + **file-count legend** — a sorted list of the top N extensions by number of files, + with a coloured swatch and the file count for each entry: + - Pass `--legend` alone to use the default of 20 entries. + - Pass `--legend 10` for a custom limit. + - Omit the flag entirely to show no legend. + - The number of rows is also capped automatically so the box never overflows the + image, based on available vertical space and the current `--font-size`. + - Extensions with the same count are sorted alphabetically as a tiebreaker. + - When the total number of extensions exceeds the limit, a `(+N more)` line is + appended at the bottom of the box. + +- The root tile header now includes a summary of the scanned tree after an em-dash + separator: `myproject — 124 files, 18 dirs, 4.0 MB (4,231,680 bytes)`. + Applies to both PNG and SVG output. The label is truncated with `…` when the tile + is too narrow to fit the full string. + +- Greatly expanded archive format support via the new `libarchive-c` core dependency + (wraps the system libarchive C library): + - **New formats**: `.iso`, `.cpio`, `.xar`, `.pkg`, `.dmg`, `.img`, `.rpm`, `.cab`, + `.lha`, `.lzh`, `.a`, `.ar`, `.tar.zst`, `.tzst` + - **New ZIP aliases**: `.nupkg` (NuGet), `.vsix` (VS Code extension), `.ipa` (iOS app), + `.aab` (Android App Bundle) + - `.tar.zst` / `.tzst` routed through libarchive for consistent behaviour across all + supported Python versions (stdlib `tarfile` only gained zstd support in 3.12). + - `libarchive-c>=5.0` added as a core dependency alongside `py7zr` and `rarfile`. + Requires the system libarchive library: + `brew install libarchive` / `apt install libarchive-dev`. + - See [ARCHIVES.md](docs/ARCHIVES.md) for the full format table, platform notes, and + intentionally unsupported formats (`.deb`, UDIF `.dmg`). +- Encrypted archive handling: + - `--password` CLI option passes a passphrase upfront. + - If an archive turns out to be encrypted and no password was given, dirplot prompts + interactively (`Password:` hidden-input prompt) and retries — no need to re-run with a flag. + - A wrong password exits cleanly with `Error: incorrect password.` + - `PasswordRequired` exception exported from `dirplot.archives` for programmatic use. + - **Encryption behaviour by format** (since dirplot reads metadata only, never extracts): + - ZIP and 7z: central directory / file list is unencrypted by default → readable without + a password even for encrypted archives. + - RAR with header encryption (`-hp`): listing is hidden without password; + wrong password raises `PasswordRequired`. + +### Fixed + +- `--version` moved back to the top-level `dirplot` command (was accidentally scoped + to `dirplot map` after the CLI was restructured into subcommands). + +## [0.3.0] - 2026-03-10 + +### Added + +- Kubernetes pod scanning via `pod://pod-name/path` syntax — uses `kubectl exec` and + `find` to build the tree without copying files out of the pod. Works on any running + pod that has a POSIX shell and `find` (GNU or BusyBox). No extra dependency; only + `kubectl` is required. + - Namespace can be specified inline (`pod://pod-name@namespace:/path`) or via + `--k8s-namespace`. + - Container can be selected for multi-container pods via `--k8s-container`. + - `-xdev` is intentionally omitted so mounted volumes (emptyDir, PVC, etc.) within + the scanned path are traversed — the common case in k8s where images declare + `VOLUME` entries that are always mounted on a separate filesystem. + - Automatically falls back to a portable `sh` + `stat` loop on BusyBox/Alpine pods. +- Docker container scanning via `docker://container:/path` syntax — uses `docker exec` + and `find` to build the tree without copying files out of the container. Works on any + running container that has a POSIX shell and `find` (GNU or BusyBox). No extra + dependency; only the `docker` CLI is required. + - Automatically detects BusyBox `find` (Alpine-based images) and falls back to a + portable `sh` + `stat` loop when GNU `-printf` is unavailable. + - Virtual filesystems (`/proc`, `/sys`, `/dev`) are skipped via `-xdev`. + - Supports `--exclude`, `--depth`, `--log`, and all other standard options. + - `Dockerfile` and `.dockerignore` added so the project itself can be used as a + scan target. +- SVG output format via `--format svg` or by saving to a `.svg`-suffixed path with `--output`. + The output is a fully self-contained, interactive SVG file: + - **CSS hover highlight** — file tiles brighten and gain a soft glow; directory headers + brighten on mouse-over (`.tile` / `.dir-tile` classes, no JavaScript needed). + - **Floating tooltip panel** — a JavaScript-driven semi-transparent panel tracks the cursor + and shows the file or directory name, human-readable size, and file-type / item count. + No external scripts or stylesheets — the panel logic is embedded in the SVG itself. + - **Van Wijk cushion shading** — approximated via a single diagonal `linearGradient` + overlay (`gradientUnits="objectBoundingBox"`), defined once and shared across all tiles. + Matches the ×1.20 highlight / ×0.80 shadow range of the PNG renderer. + Disabled with `--no-cushion`. +- `--format png|svg` CLI option; format is also auto-detected from the `--output` file + extension. +- `create_treemap_svg()` added to the public Python API (`from dirplot import create_treemap_svg`). +- `drawsvg>=2.4` added as a core dependency. +- Rename the treemap command to `map` (dirplot map ). +- Add `termsize` subcommand and restructure CLI as multi-command app. +- Add `--depht` parameter to limit the scanning of large file trees. +- Support for SSH remote directory scanning (`pip install dirplot[ssh]`). +- Support for AWS S3 buckets in the cloud (`pip install dirplot[s3]`). +- Support for local archive files, .zip, tgz, .tar.xz, .rar, .7z, etc. +- Include example archives for 17 different extentions for testing. +- Comprehensive documentation. +- `github://owner/repo[@branch]` URI scheme for GitHub repository scanning. The old + `github:owner/repo` shorthand has been removed. +- File tiles now have a 1-px dark outline (60/255 below fill colour per channel) so + adjacent same-coloured tiles — e.g. a directory full of extension-less files — are + always visually distinct rather than blending into a single flat block. + +### Changed + +- `docs/REMOTE-ACCESS.md` renamed to `docs/EXAMPLES.md`; Docker and Kubernetes pod + sections added; images with captions added for all remote backends. + +### Fixed + +- SVG tooltips now show the original byte count when `--log` is active, not the + log-transformed layout value. `Node.original_size` is populated by `apply_log_sizes` + for both file and directory nodes and is used by the SVG renderer for `data-size`. +- GitHub error messages are now clear and actionable. + +## [0.2.0] - 2026-03-09 + +### Added + +- Support for Windows, incl. full test suite + +### Fixed + +- Improved README, Makefile + +## [0.1.2] - 2026-03-06 + +### Fixed + +- Partly incorrect `uvx install dirplot` command +- Wrong version number in `uv.lock` + ## [0.1.1] - 2026-03-06 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5946fc6..2bdf8b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,29 +3,66 @@ ## Development Setup ```bash -git clone https://github.com/deeplook/dirplot +git clone -c credential.helper= https://github.com/deeplook/dirplot cd dirplot -uv sync --all-extras -uv run pre-commit install +uv sync --all-extras # install all dependencies including dev tools and extras +uv run pre-commit install # install git hooks for formatting/linting +``` + +## Running Tests + +```bash +make test # run the full test suite +make coverage # run with coverage report (target: 90% line coverage) +``` + +To run a specific test file or test: + +```bash +uv run pytest tests/test_cli.py +uv run pytest tests/test_drawing.py::test_cushion_shading -v ``` ## Code Style -This project uses [ruff](https://docs.astral.sh/ruff/) for linting and formatting, and [mypy](https://mypy.readthedocs.io/) for type checking. +This project uses [ruff](https://docs.astral.sh/ruff/) for linting and formatting, and [mypy](https://mypy.readthedocs.io/) for type checking. Pre-commit hooks run these automatically on each commit. ```bash make format # auto-format and fix lint issues make lint # check only (no changes) ``` -## Testing +## Code Organisation -```bash -make test # run the test suite -make coverage # run with coverage report ``` +src/dirplot/ + app.py — Typer app entry point; imports all subcommands + main.py — CLI entry point called by the `dirplot` script + commands/ + treemap.py — `dirplot map` command + diff.py — `dirplot diff` command + vcs.py — `dirplot git` and `dirplot hg` commands + watch.py — `dirplot watch` command + replay.py — `dirplot replay` command + misc.py — `dirplot demo`, `dirplot termsize`, `dirplot read-meta` + render_png.py — PNG treemap renderer (Pillow) + render_svg.py — SVG treemap renderer + layout.py — squarified treemap layout algorithm + scanner.py — local filesystem scanner and metrics + display.py — inline terminal display (iTerm2 / Kitty protocols) + terminal.py — terminal size detection + github.py — GitHub Git Trees API backend + ssh.py — SSH backend (paramiko) + s3.py — AWS S3 backend (boto3) + docker.py — Docker backend (docker exec) + k8s.py — Kubernetes backend (kubectl exec) + archive.py — archive reading (zip, tar, 7z, rar, libarchive) + node.py — Node dataclass (shared tree representation) +``` + +All remote backends return a `Node` tree using the same dataclass, so `create_treemap` and `create_treemap_svg` work identically regardless of source. -Target: 90 % line coverage. +To add a new command: create `src/dirplot/commands/mycommand.py` with a Typer app, then import and add it in `app.py`. ## Submitting Changes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7fb13d9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12.13-slim + +WORKDIR /app + +# Install build tools needed by some optional deps +RUN pip install --no-cache-dir uv==0.11.3 + +# Copy project metadata first for layer caching +COPY pyproject.toml README.md ./ + +# Copy source +COPY src/ src/ + +# Install dirplot with all extras (no dev tools in the runtime image) +RUN uv pip install --system --no-cache ".[ssh,s3]" + +# Copy the rest (tests, docs, etc.) so the container is a useful scan target +COPY . . + +# Run as non-root +RUN useradd -m app && chown -R app /app +USER app + +# Keep the container running so docker:// scanning works +CMD ["python", "-c", "import time; time.sleep(1e9)"] diff --git a/Makefile b/Makefile index f615612..6c3fb95 100644 --- a/Makefile +++ b/Makefile @@ -1,38 +1,45 @@ -.PHONY: install lint format test coverage clean install-tool check-all publish-test publish +.PHONY: install lint format test coverage clean install-tool check-all publish-test publish serve-docs help -install: - uv sync --all-extras +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " %-14s %s\n", $$1, $$2}' -lint: - uv run ruff check src tests - uv run --all-extras mypy src +install: ## Install all dependencies (including extras) + uv sync --all-extras -format: +format: ## Auto-format and fix lint issues uv run ruff format src tests uv run ruff check --fix src tests -test: +lint: ## Run ruff and mypy + uv run ruff check src tests + uv run --all-extras mypy src + +test: ## Run the test suite uv run --all-extras pytest -coverage: +coverage: ## Run tests with HTML + terminal coverage report uv run --all-extras pytest --cov=src --cov-report=html --cov-report=term -clean: +check-all: install format lint test clean ## Run format, lint, test, and clean + @echo "All checks passed!" + +install-tool: ## Install dirplot as a uv tool (reinstall) + uv tool install --reinstall . + +clean: ## Remove build artifacts and caches rm -rf dist build *.egg-info rm -rf .pytest_cache .mypy_cache .ruff_cache rm -rf htmlcov .coverage coverage.xml find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name .DS_Store -exec rm {} + -install-tool: - uv tool install --reinstall . - -publish-test: +publish-test: ## Build and publish to TestPyPI uv build uv publish --index testpypi --token $(TEST_PYPI_TOKEN) -publish: +publish: ## Build and publish to PyPI uv build uv publish --token $(PYPI_TOKEN) -check-all: install format lint test clean - @echo "All checks passed!" +serve-docs: ## Serve the MkDocs documentation locally (http://localhost:8000) + uv run --with mkdocs-material mkdocs serve diff --git a/README.md b/README.md index 9f22b17..eba6882 100644 --- a/README.md +++ b/README.md @@ -7,100 +7,134 @@ [![License](https://img.shields.io/pypi/l/dirplot.svg)](https://pypi.org/project/dirplot/) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=flat&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/deeplook) -**dirplot** creates static nested treemap images for directory trees. It can display them in the system image viewer (default, works everywhere) or inline inside the terminal using the [iTerm2 inline image protocol](https://iterm2.com/documentation-images.html) or the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) — detected automatically at runtime. - -Each rectangle represents a file; its **area** is proportional to the file size and its **colour** is determined by the file extension. Directories are shown as labelled, nested containers, mirroring the actual hierarchy. - -![dirplot output](https://raw.githubusercontent.com/deeplook/dirplot/main/docs/dirplot.png) - -## Installation +**dirplot** creates nested treemap images for directory trees. It can display them in the system image viewer or inline in the terminal (iTerm2 and Kitty protocols, auto-detected). It also animates git history, watches live filesystems, and scans remote sources. ```bash -# As a standalone tool (recommended) -uv tool install dirplot - -# Into the current environment pip install dirplot +dirplot map . # treemap of current directory, opens in system viewer +dirplot map . --inline # display inline in terminal (iTerm2 / Kitty / Ghostty) ``` -### From GitHub - -```bash -# As a standalone tool -uv tool install git+https://github.com/deeplook/dirplot - -# Into the current environment -pip install git+https://github.com/deeplook/dirplot -``` - -## Usage +![dirplot output](https://raw.githubusercontent.com/deeplook/dirplot/main/docs/dirplot.png) -```bash -# Use it before installing it -uvx run dirplot --help +## Where to start -# Show dirplot for the current directory (opens image in system viewer) -dirplot . +| I want to… | Go to | +|---|---| +| Scan a local directory or archive | [Quick start](#quick-start) | +| Scan a GitHub repo, S3 bucket, SSH host, or container | [Remote access & examples](docs/EXAMPLES.md) | +| Scan Google Drive | [Google Drive](docs/EXAMPLES.md#google-drive) | +| Animate git history or watch live filesystems | [Git History Animation](docs/EXAMPLES.md#git-history-animation) | +| Use dirplot from Python | [Python API](docs/API.md) | +| Run in Docker | [Running via Docker](docs/CLI.md#running-dirplot-via-docker) | +| Fix an error | [Troubleshooting](docs/CLI.md#troubleshooting) | -# Save to a file without displaying -dirplot . --output dirplot.png --no-show +## How to run dirplot -# Display inline (protocol auto-detected: iTerm2, Kitty, Ghostty) -dirplot . --inline +| Method | Command | Notes | +|---|---|---| +| Installed CLI | `dirplot map .` | After `pip install` / `uv tool install` | +| No install (uv) | `uvx dirplot map .` | Runs the latest release ephemerally | +| Python API | `from dirplot import build_tree, create_treemap` | See [API.md](docs/API.md) | +| Docker | `docker run --rm dirplot dirplot map … --output -` | See [Docker](docs/CLI.md#running-dirplot-via-docker) | + +## Features + +- Squarified treemap layout; file area proportional to size; per-extension colours (GitHub Linguist palette for known types, configurable Matplotlib colormap for the rest). +- PNG, animated PNG (APNG), MP4, and MOV output for single frames and animations; interactive SVG for static maps; renders at terminal pixel size or a custom `WIDTHxHEIGHT`. +- **Inline terminal display** — renders directly into iTerm2, Kitty, Ghostty, WezTerm, and Warp without opening a separate window; protocol auto-detected. +- **Animate git history** (`dirplot git`), **Mercurial history** (`dirplot hg`), and **replay filesystem event logs** (`dirplot replay`) — output APNG, MP4, or MOV. **Watch live filesystems** (`dirplot watch`) with optional snapshot and event logging. +- **Scan metrics** (`dirplot metrics`) — file/dir counts, total size, depth, top extensions by count or size, largest files and directories with percentage of total; JSON output supported. +- **Compare two trees** (`dirplot diff`) — treemap diff of any two sources (local dirs, GitHub repos, archives, S3, SSH, Docker, K8s, or two commits/tags); `dirplot diff .` shows uncommitted changes; files sized by B; colour-coded borders show added (green), removed (red), and changed (blue) files. Git/hg repos scan only tracked files; change detection uses blob hashes (LFS-aware). +- Scan **SSH hosts**, **AWS S3**, **GitHub repos** (public and private), **Docker containers**, **Kubernetes pods**, and **Google Drive** — no extra deps beyond the respective CLI. +- Read **archives** directly (zip, tar, 7z, rar, jar, whl, …) without unpacking. +- Works on macOS, Linux, and Windows (WSL2 fully supported). -# Exclude directories -dirplot . --exclude .venv --exclude .git +## Installation -# Use a different colormap and larger directory labels -dirplot . --colormap Set2 --font-size 18 +```bash +# Recommended: isolated tool install via uv (fastest) +uv tool install dirplot -# Render at a fixed resolution instead of terminal size -dirplot . --size 1920x1080 --output dirplot.png --no-show +# Alternative: pipx (install pipx first if needed: brew install pipx on macOS) +pipx install dirplot -# Don't apply cushion shading — makes tiles look flat -dirplot . --no-cushion +# Into the current environment +pip install dirplot ``` -### Options +**Optional extras** — install only what you need: -| Flag | Short | Default | Description | -|---|---|---|---| -| `--output` | `-o` | — | Save PNG to this path | -| `--show/--no-show` | | `--show` | Display the image after rendering | -| `--inline` | | off | Display in terminal (protocol auto-detected) | -| `--legend/--no-legend` | | `--no-legend` | Show file-extension colour legend | -| `--font-size` | `-s` | `12` | Directory label font size in pixels | -| `--colormap` | `-c` | `tab20` | Matplotlib colormap for unknown extensions | -| `--exclude` | `-e` | — | Path to exclude (repeatable) | -| `--size` | | terminal size | Output dimensions as `WIDTHxHEIGHT` (e.g. `1920x1080`) | -| `--header/--no-header` | | `--header` | Print info lines before rendering | -| `--cushion/--no-cushion` | | `--cushion` | Apply van Wijk cushion shading for a raised 3-D look | +| Extra | Enables | Install | +|---|---|---| +| `ssh` | Scan remote servers via SSH (adds [paramiko](https://www.paramiko.org/)) | `pip install "dirplot[ssh]"` | +| `s3` | Scan AWS S3 buckets (adds [boto3](https://boto3.amazonaws.com/)) | `pip install "dirplot[s3]"` | +| `libarchive` | Additional archive formats: `.tar.zst`, `.iso`, `.dmg`, `.rpm`, `.cab`, … (requires system [libarchive](https://libarchive.org/)) | `pip install "dirplot[libarchive]"` | -## Inline Display +**Other runtime requirements:** -The `--inline` flag renders the image directly in the terminal. The protocol is auto-detected at runtime: terminals that support the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) use APC chunks (`ESC_G…`); all others fall back to the [iTerm2 inline image protocol](https://iterm2.com/documentation-images.html) (`ESC]1337;File=…`). +- `dirplot watch` — [watchdog](https://github.com/gorakhargosh/watchdog) is installed automatically. +- `dirplot git` — requires `git` on `PATH`. +- `dirplot hg` — requires `hg` (Mercurial) on `PATH`. +- MP4 output (`dirplot git`, `dirplot hg`, `dirplot replay`) — requires [ffmpeg](https://ffmpeg.org/) on `PATH`. +- `dirplot read-meta` on `.mp4` files — requires `ffprobe` (bundled with ffmpeg). -| Terminal | Platform | Protocol | -|---|---|---| -| [iTerm2](https://iterm2.com/) | macOS | iTerm2 | -| [WezTerm](https://wezfurlong.org/wezterm/) | macOS, Linux, Windows | iTerm2 | -| [Warp](https://www.warp.dev/) | macOS, Linux | iTerm2 | -| [Hyper](https://hyper.is/) | macOS, Linux, Windows | iTerm2 | -| [Kitty](https://sw.kovidgoyal.net/kitty/) | macOS, Linux | Kitty | -| [Ghostty](https://ghostty.org/) | macOS, Linux | Kitty | +## Quick start -The default mode (`--show`, no `--inline`) opens the PNG in the system viewer (`open` on macOS, `xdg-open` on Linux) and works in any terminal. +```bash +dirplot map . # current directory +dirplot map . --inline # display in terminal (iTerm2/Kitty) +dirplot map . --output treemap.png --no-show # save to file +dirplot map . --log-scale 4 --inline # log scale (4× ratio), inline +dirplot map github://pallets/flask # GitHub repo +dirplot map gdrive:// # Google Drive root (requires gog) +dirplot map docker://my-container:/app # Docker container +dirplot map project.zip # archive file +tree src/ | dirplot map # pipe tree output + +dirplot git . -o snapshot.png # static snapshot of HEAD +dirplot git .@v1.0 --inline # inline snapshot at tag +dirplot git . -o history.mp4 --range main # full git history +dirplot git . -o history.mp4 --period 30d # last 30 days +dirplot git github://owner/repo -o h.mp4 --period 7d # GitHub, last week + +dirplot hg /path/to/repo -o history.png --range 0:tip # full hg history +dirplot hg /path/to/repo@tip -o history.png # static, tip only + +dirplot watch . --snapshot treemap.png # live watch, snapshot on each change +dirplot watch . --event-log events.jsonl # record events for replay +dirplot replay events.jsonl -o timelapse.mp4 --total-duration 30 # render recording as MP4 + +dirplot demo # run examples, save to ./demo/ + +dirplot metrics . # scan metrics: counts, size, top extensions +dirplot metrics . --sort-by size # sort extensions by total bytes +dirplot metrics . --top 5 --json # top-5 entries as JSON +dirplot map . --metrics --no-show # treemap + metrics in one pass + +dirplot diff . # uncommitted changes (git/hg) +dirplot diff . --no-context # only show changed files +dirplot diff .@HEAD~5 .@HEAD # last 5 commits +dirplot diff old/ new/ # compare two directories +dirplot diff old/ new/ --output diff.png --no-show # save to file +dirplot diff github://owner/repo@v1 github://owner/repo@v2 # compare two GitHub tags +dirplot diff archive_v1.tar.gz archive_v2.zip # compare two archives +``` -> **Note:** `--inline` does not work in AI coding assistants such as Claude Code, Cursor, or GitHub Copilot Chat. These tools intercept terminal output as plain text and do not implement any graphics protocol, so the escape sequences are either stripped or displayed as garbage. Use the default `--show` mode (system viewer) or `--output` to save the PNG to a file instead. +**Docker** — build once, then pipe output to the host: -## How It Works +```bash +docker build -t dirplot . +docker run --rm dirplot dirplot map github://steipete/birdclaw --output - | imgcat +``` -1. Scans the directory tree, collecting each file's path, extension, and size in bytes. -2. Computes a squarified dirplot layout recursively — directories allocate space for their children. -3. Renders to a PNG via Pillow (PIL) at the exact pixel dimensions of the current terminal window (detected via `TIOCGWINSZ`), or at a custom size when `--size` is given. -4. Displays via the system image viewer (`open` / `xdg-open`) or inline via an auto-detected terminal graphics protocol (iTerm2 or Kitty). +## Documentation -Extension colours come from the [GitHub Linguist](https://github.com/github/linguist) language colour table (~500 known extensions). Unknown extensions fall back to an MD5-stable colour derived from the chosen `--colormap`. File label text is automatically black or white depending on the background luminance. +- [CLI reference](docs/CLI.md) — all commands, flags, and usage examples +- [Remote access & examples](docs/EXAMPLES.md) — SSH, S3, GitHub, Docker, Kubernetes, git history animation +- [Archive formats](docs/ARCHIVES.md) — supported formats and dependencies +- [Python API](docs/API.md) — programmatic usage +- [Troubleshooting](docs/CLI.md#troubleshooting) — common issues and fixes ## Development @@ -112,10 +146,6 @@ make test See [CONTRIBUTING.md](CONTRIBUTING.md) for full details. -## Platform Support - -This tool has been developed and tested on macOS. Linux should work, and Windows support is untested. Feedback and bug reports from Linux and Windows users are very welcome — please open an issue on GitHub. - ## License -[MIT](LICENSE) +MIT — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 90e3577..d2bb719 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | |---------|--------------------| -| 0.1.x | :white_check_mark: | +| 0.4.x | :white_check_mark: | ## Reporting a Vulnerability diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d6df341 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,133 @@ +# Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +The public API centres on `build_tree`, `create_treemap`, and `create_treemap_svg`: + +```python +from pathlib import Path +from dirplot import build_tree, apply_log_sizes, create_treemap, create_treemap_svg + +root = build_tree(Path("/path/to/project")) + +# PNG — returns a BytesIO containing PNG bytes +buf = create_treemap(root, width_px=1920, height_px=1080, colormap="tab20", cushion=True) +Path("treemap.png").write_bytes(buf.read()) + +# SVG — returns a BytesIO containing UTF-8 SVG bytes +# Includes CSS hover highlight, a JS floating tooltip, and cushion gradient shading. +buf = create_treemap_svg(root, width_px=1920, height_px=1080, cushion=True) +Path("treemap.svg").write_bytes(buf.read()) + +# Log scale — use when one large file dominates the layout and squashes everything +# else into tiny slivers. Compresses size differences before rendering; original +# byte counts are preserved in node.original_size for display. +apply_log_sizes(root) +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +To open a PNG in the system image viewer or display it inline in the terminal: + +```python +from dirplot.display import display_window, display_inline + +buf.seek(0) +display_window(buf) # system viewer (works everywhere) + +buf.seek(0) +display_inline(buf) # inline in terminal (iTerm2 / Kitty / WezTerm) +``` + +In a Jupyter notebook, PNG output renders automatically via PIL: + +```python +from PIL import Image +buf = create_treemap(root, width_px=1280, height_px=720) +buf.seek(0) # rewind before opening +Image.open(buf) # Jupyter renders PIL images automatically via _repr_png_() +``` + +## Metrics + +`tree_metrics` and `tree_metrics_dict` compute statistics from a scanned `Node` tree — the same data shown by `dirplot metrics`: + +```python +from pathlib import Path +from dirplot import build_tree +from dirplot.scanner import tree_metrics, tree_metrics_dict + +root = build_tree(Path("/path/to/project")) + +# Human-readable string (same as CLI output) +print(tree_metrics(root, t_scan=0.0)) + +# Sort extensions by total bytes instead of file count +print(tree_metrics(root, t_scan=0.0, sort_by="size")) + +# Limit to top 5 entries per list +print(tree_metrics(root, t_scan=0.0, top_n=5)) + +# Structured dict — suitable for JSON serialisation or downstream processing +import json +data = tree_metrics_dict(root, t_scan=0.0) +print(json.dumps(data, indent=2)) +``` + +`tree_metrics_dict` returns: + +```python +{ + "files": int, # total file count + "dirs": int, # total directory count + "empty_dirs": int, # directories with no children + "total_size_bytes": int, + "depth": int, # maximum nesting depth (root = 0, first-level children = 1, …) + "scan_time_s": float, + "top_extensions": [ + {"ext": str, "count": int, "size_bytes": int}, + … + ], + "largest_files": [ + {"path": str, "size_bytes": int, "pct": float}, # pct = % of total size + … + ], + "largest_dirs": [ + {"path": str, "size_bytes": int, "pct": float}, + … + ], +} +``` + +## Remote backends + +Each remote backend exposes a `build_tree_*` function that returns the same `Node` type accepted by `create_treemap`. See [EXAMPLES.md](EXAMPLES.md) for full per-backend documentation and authentication details. + +```python +# GitHub +from dirplot.github import build_tree_github +root, branch = build_tree_github("pallets", "flask", token="ghp_…", depth=4) + +# SSH +from dirplot.ssh import connect, build_tree_ssh +client = connect("prod.example.com", "alice", ssh_key="~/.ssh/prod_key") +sftp = client.open_sftp() +try: + root = build_tree_ssh(sftp, "/var/www", depth=5) +finally: + sftp.close() + client.close() + +# S3 +from dirplot.s3 import make_s3_client, build_tree_s3 +s3 = make_s3_client(profile="prod") # authenticated +s3 = make_s3_client(no_sign=True) # public bucket +root = build_tree_s3(s3, "my-bucket", "path/to/prefix/", depth=5) + +# Docker +from dirplot.docker import build_tree_docker +root = build_tree_docker("my-container", "/app", depth=5) + +# Kubernetes +from dirplot.k8s import build_tree_pod +root = build_tree_pod("my-pod", "/app", namespace="staging", container="main", depth=5) +``` diff --git a/docs/ARCHIVES.md b/docs/ARCHIVES.md new file mode 100644 index 0000000..b10dd5f --- /dev/null +++ b/docs/ARCHIVES.md @@ -0,0 +1,179 @@ +# Archive file support + +dirplot can read local archive files as treemap inputs without unpacking them. +The archive is treated as a virtual directory: its members form the node tree +and sizes come from the uncompressed sizes stored in the archive metadata. + +```bash +dirplot map project.zip +dirplot map release.tar.gz --depth 2 +dirplot map app.jar +dirplot map backup.7z --exclude node_modules +dirplot map secret.zip --password-file ~/pwd.txt # password-protected +``` + +## Supported formats + +### Standard library (no extra install) + +| Extension(s) | Format | +|---|---| +| `.zip` | ZIP | +| `.jar`, `.war`, `.ear` | ZIP (Java archives) | +| `.whl` | ZIP (Python wheel) | +| `.apk` | ZIP (Android package) | +| `.epub` | ZIP (eBook) | +| `.xpi` | ZIP (browser extension) | +| `.nupkg` | ZIP (NuGet package) | +| `.vsix` | ZIP (VS Code extension) | +| `.ipa` | ZIP (iOS app package) | +| `.aab` | ZIP (Android App Bundle) | +| `.tar`, `.tar.gz`, `.tgz` | TAR | +| `.tar.bz2`, `.tbz2` | TAR | +| `.tar.xz`, `.txz` | TAR | + +ZIP and all its synonyms (`.jar`, `.whl`, `.nupkg`, etc.) use the same stdlib `zipfile` module — the format is identical, only the extension differs. + +### Bundled dependencies (included with dirplot) + +| Extension(s) | Format | Library | +|---|---|---| +| `.7z` | 7-Zip | `py7zr` | +| `.rar` | RAR | `rarfile` | + +### Requires system libarchive (see [below](#system-libarchive-requirement)) + +| Extension(s) | Format | +|---|---| +| `.tar.zst`, `.tzst` | TAR + Zstandard | +| `.iso` | ISO 9660 disc image | +| `.cpio` | CPIO archive | +| `.xar` | XAR / macOS package | +| `.pkg` | macOS installer package (XAR) | +| `.dmg` | macOS disk image (plain HFS+/FAT only — see [notes](#intentionally-unsupported-formats)) | +| `.img` | Raw/FAT disk image | +| `.rpm` | RPM package (Red Hat / Fedora / SUSE) | +| `.cab` | Microsoft Cabinet (Windows installers/drivers) | +| `.lha`, `.lzh` | LHA/LZH (legacy, common in Japanese software) | +| `.a`, `.ar` | Unix static library / generic `ar` archive | + +`.tar.zst` / `.tzst` are routed through libarchive rather than stdlib `tarfile`, which only gained zstd support in Python 3.12. This ensures consistent behaviour across all supported Python versions (3.10+). + +Symlinks inside TAR archives are silently skipped. Dotfiles (members whose filename starts with `.`) are skipped in all formats, consistent with the behaviour of local and remote directory scans. + +## Python dependencies + +`py7zr` and `rarfile` are bundled with dirplot — no extra install is needed: + +```bash +pip install dirplot # py7zr and rarfile included +``` + +## System libarchive requirement + +`libarchive-c` is also bundled as a Python binding, but it wraps the **system libarchive C library**, which must be installed separately. Without it, the formats in the libarchive table above are unavailable (dirplot will report a clear error). + +```bash +# macOS +brew install libarchive + +# Debian / Ubuntu +sudo apt install libarchive-dev + +# Fedora / RHEL +sudo dnf install libarchive-devel +``` + +On macOS the Homebrew-installed libarchive includes support for reading Apple +disk images (`.dmg`), ISO 9660 (`.iso`), XAR/PKG (`.xar`, `.pkg`), CPIO +(`.cpio`), and raw disk images (`.img`). Support for individual formats may +vary across Linux distributions depending on how their libarchive package was +compiled. + +## RAR: rar CLI not required at runtime + +`rarfile` reads RAR metadata in pure Python. The `rar` CLI only needs to be +present if **extraction** is attempted — dirplot only reads member metadata +(names and uncompressed sizes) and never extracts content, so the CLI is +**not required at runtime**. + +The CLI *is* required to regenerate the RAR test fixture: + +```bash +brew install rar # macOS +python scripts/make_fixtures.py +``` + +### macOS Gatekeeper note + +After installing `rar` via Homebrew on macOS you may see the process killed +with `SIGKILL` (exit status -9): + +``` +subprocess.CalledProcessError: Command '['/opt/homebrew/bin/rar', ...]' +died with . +``` + +This is macOS Gatekeeper quarantining the binary because WinRAR is not +notarized by Apple. Remove the quarantine flag once: + +```bash +xattr -d com.apple.quarantine /opt/homebrew/bin/rar +``` + +After that the binary runs normally. + +## Test fixtures + +`tests/fixtures/` contains one pre-built archive per format. They are +generated by: + +```bash +python scripts/make_fixtures.py +``` + +The script creates a small sample tree and archives it in every supported format. The RAR fixture is skipped automatically if the `rar` CLI is not found. + +The pytest `sample_archives` session fixture in `tests/conftest.py` regenerates +the same files into a temporary directory at test-session start, so running the +script is not required for CI or for running the test suite locally. + +## Intentionally unsupported formats + +**`.deb` (Debian/Ubuntu packages)** — a `.deb` file is an `ar` archive whose +members are `debian-binary`, `control.tar.*`, and `data.tar.*`. libarchive +sees only those three `ar` members, not the actual file tree inside +`data.tar.*`. Showing three opaque tarball blobs is not useful, so `.deb` is +not registered as a supported input. To inspect the contents, extract the +`data.tar.*` member first: + +```bash +ar x package.deb data.tar.gz +dirplot map data.tar.gz +``` + +**macOS UDIF disk images (`.dmg`)** — most modern macOS disk images use the +proprietary UDIF format, which is not supported by the open-source libarchive. +dirplot will report a clear error if the format is unrecognised. Plain +HFS+/FAT images without UDIF wrapping may still be readable. + +## Behaviour notes + +- **Root name**: the archive filename without its suffix is used as the root + node name. For compound suffixes like `.tar.gz` both suffixes are stripped + (`release.tar.gz` → `release`). +- **Zero-size members**: reported as 1 byte so they remain visible in the + treemap. +- **Archives inside directories**: when scanning a local directory, SSH host, + S3 bucket, or GitHub repository, any archive files found as members of that + scan are treated as regular files — they are not recursively introspected. +- **Password-protected archives**: supported for zip, 7z, rar, and libarchive + formats via `--password-file ` (pass a file containing the password to + avoid exposing it in shell history). If no password is supplied and the + archive is encrypted, dirplot will prompt interactively unless `--no-input` + is set, in which case it exits with an error. +- **`--exclude` matching**: for local directory scans, excludes are resolved + to absolute paths. For archives, excludes are matched against the member's + filename component and its full path string inside the archive (e.g. + `--exclude META-INF` skips any member named `META-INF` or whose path starts + with `META-INF/`). diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..2b2ebe6 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,684 @@ +# CLI Reference + +- [dirplot map](#dirplot-map-treemap-for-any-directory-tree) +- [dirplot metrics](#dirplot-metrics-directory-metrics) +- [dirplot diff](#dirplot-diff-compare-two-directory-trees) +- [dirplot watch](#dirplot-watch-live-watch-mode) +- [dirplot replay](#dirplot-replay-event-log-replay) +- [dirplot git](#dirplot-git-git-history-animation) +- [dirplot read-meta](#dirplot-read-meta-read-embedded-metadata) +- [dirplot demo](#dirplot-demo-run-example-commands) +- [Inline terminal display](#inline-terminal-display) +- [Running via Docker](#running-dirplot-via-docker) +- [Troubleshooting](#troubleshooting) + +--- + +## `dirplot map` — treemap for any directory tree + +```bash +# Use without installing +uvx dirplot --help + +# Current directory in system viewer +dirplot map . + +# Display inline in terminal (iTerm2 / Kitty / WezTerm, auto-detected) +dirplot map . --inline + +# Save to file without displaying +dirplot map . --output treemap.png --no-show + +# Exclude paths — plain names, globs, or relative paths +dirplot map . --exclude .venv --exclude .git +dirplot map . --exclude "*.egg-info" --exclude "**/__pycache__" +dirplot map . --exclude src/vendor + +# Focus on named subtrees (keeps only these paths; repeatable) +dirplot map . --include src --include tests +dirplot map . --include src/dirplot/fonts + +# Multiple roots shown under their common parent +dirplot map src tests +dirplot map src/main.py src/util.py + +# Pipe tree or find output (format auto-detected) +tree src/ | dirplot map +tree -s src/ | dirplot map +find . -name "*.py" | dirplot map +find . -type d | dirplot map + +# Read a saved path list from a file +tree src/ > paths.txt && dirplot map --paths-from paths.txt + +# Custom size, colormap, font +dirplot map . --size 1920x1080 --output treemap.png --no-show +dirplot map . --colormap Set2 --font-size 18 + +# Log scale — use when one large file dominates and squashes everything else +dirplot map . --log-scale 4 + +# Interactive SVG output (hover highlight + floating tooltip) +dirplot map . --output treemap.svg --no-show + +# Pipe PNG bytes to stdout +dirplot map . --output - | convert - -resize 50% small.png +dirplot map . --output - --format svg > treemap.svg + +# Archive files — no unpacking needed +dirplot map project.zip +dirplot map release.tar.gz --depth 2 + +# Remote sources +dirplot map ssh://alice@prod.example.com/var/www +dirplot map s3://noaa-ghcn-pds --no-sign +dirplot map github://pallets/flask +dirplot map github://torvalds/linux@v6.12/Documentation +dirplot map gdrive:// # Google Drive root (requires gog) +dirplot map gdrive://FOLDER_ID # specific Drive folder +dirplot map docker://my-container:/app +dirplot map pod://my-pod:/app +``` + +See [EXAMPLES.md](EXAMPLES.md) for detailed examples of each remote backend and git history animation. + +### Options + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--paths-from` | | — | File with path list (`tree`/`find` output); `-` for stdin | +| `--output` | `-o` | — | Save to this path (PNG or SVG); `-` for stdout | +| `--format` | `-f` | auto | Output format: `png` or `svg` | +| `--show/--no-show` | | `--show` | Display the image after rendering (`--output -` implies `--no-show`) | +| `--inline` | | off | Display in terminal (auto-detected protocol; PNG only) — see [Inline terminal display](#inline-terminal-display) | +| `--legend [N]` | | off | File-count legend; `N` = max entries (default: 20) | +| `--font-size` | | `12` | Directory label font size in pixels | +| `--colormap` | | `tab20` | Matplotlib colormap for unknown extensions | +| `--exclude` | `-e` | — | Pattern to exclude (repeatable): plain name, glob (`*.egg-info`), `**` glob, or relative path | +| `--include` | | — | Keep only these subtrees (repeatable, supports nested paths); the inverse of `--exclude` | +| `--depth` | | unlimited | Maximum recursion depth | +| `--size` | | terminal size | Output dimensions as `WIDTHxHEIGHT` (e.g. `1920x1080`) | +| `--header/--no-header` | | `--header` | Print info lines before rendering | +| `--cushion/--no-cushion` | | `--cushion` | Van Wijk cushion shading for a raised 3-D look | +| `--log-scale` | | `0` (off) | Log-scale compression ratio; any value > 1 enables it (e.g. `4` = largest tile is at most 4× the smallest) | +| `--breadcrumbs/--no-breadcrumbs` | | `--breadcrumbs` | Collapse single-child chains into `foo / bar / baz` labels | +| `--metrics/--no-metrics` | | off | Print detailed metrics after scanning (same output as `dirplot metrics`) | +| `--password-file` | | — | File containing archive password; prompted interactively if not supplied | +| `--github-token-file` | | `$GITHUB_TOKEN` | File containing GitHub personal access token | +| `--ssh-key` | | `~/.ssh/id_rsa` | SSH private key path | +| `--ssh-password-file` | | — | File containing SSH password | +| `--aws-profile` | | `$AWS_PROFILE` | Named AWS profile | +| `--no-sign` | | off | Anonymous access for public S3 buckets | + +--- + +## `dirplot metrics` — directory metrics + +Scans a directory tree and prints a structured text summary: file/directory counts, total size, tree depth, scan time, top file extensions (by count or size), and the largest files and directories with their share of total size. All remote sources supported by `dirplot map` are accepted. + +```bash +# Basic metrics for the current directory +dirplot metrics . + +# Remote sources work identically to `dirplot map` +dirplot metrics github://pallets/flask +dirplot metrics s3://my-bucket --no-sign +dirplot metrics project.zip + +# Sort top extensions by total bytes instead of file count +dirplot metrics . --sort-by size + +# Show only top 5 entries in each list +dirplot metrics . --top 5 + +# JSON output — pipe into jq, scripts, or monitoring tools +dirplot metrics . --json +dirplot metrics . --json | jq '.largest_files[0]' + +# Combine with map to get treemap + metrics in one pass +dirplot map . --metrics --no-show +``` + +### Output fields + +``` + Files: 1,011 + Dirs: 70 (0 empty) + Total size: 4.5 MB + Depth: 7 ← maximum nesting level in the tree + Scan time: 1.28s + Top extensions (10) [by count]: + .py 962 2.3 MB + .json 19 48.2 KB + … + Largest files: + 671.6 KB 14.9% uv.lock + 191.8 KB 4.3% CHANGELOG.md + … + Largest dirs: + 2.3 MB 51.1% src + … +``` + +### Options + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--top` | | `10` | Number of entries to show in each list | +| `--sort-by` | | `count` | Sort top extensions by `count` (files) or `size` (bytes) | +| `--json` / `--no-json` | | off | Output all metrics as JSON | +| `--exclude` | `-e` | — | Pattern to exclude (repeatable): plain name, glob (`*.egg-info`), `**` glob, or relative path | +| `--include` | | — | Keep only these subtrees (repeatable); the inverse of `--exclude` | +| `--depth` | | unlimited | Maximum recursion depth | +| `--paths-from` | | — | File with path list (`tree`/`find` output); `-` for stdin | +| `--password-file` | | — | File containing archive password; prompted interactively if needed | +| `--github-token-file` | | `$GITHUB_TOKEN` | File containing GitHub personal access token | +| `--ssh-key` | | `~/.ssh/id_rsa` | SSH private key path | +| `--ssh-password-file` | | — | File containing SSH password | +| `--aws-profile` | | `$AWS_PROFILE` | Named AWS profile | +| `--no-sign` | | off | Anonymous access for public S3 buckets | +| `--k8s-namespace` | | — | Kubernetes namespace | +| `--k8s-container` | | — | Container name for multi-container pods | + +--- + +## `dirplot diff` — compare two directory trees + +Compares two directory trees A and B as a treemap. Tiles are sized by B (the new tree). Colour-coded borders indicate the diff status of each file: **green** = added (present in B, absent in A), **red** = removed (present in A, absent in B), **blue** = changed (present in both, but content differs). Unchanged files have no border. By default, unchanged files are included as context (`--context`); pass `--no-context` to show only changed, added, and removed files. + +A and B can be **any source supported by `dirplot map`** — local directories, GitHub repos, archives, S3 paths, SSH hosts, Docker containers, or Kubernetes pods. + +**When a source is a local git or hg repository**, only tracked files are scanned (equivalent to `git diff` / `hg diff` semantics — untracked files are ignored). Change detection uses blob hash comparison, not file size, so edits that don't change file size are caught correctly. Git LFS files are handled transparently. + +**Single-argument shorthand** — pass only one argument to diff the working tree against HEAD (git) or tip (hg): + +```bash +dirplot diff . # uncommitted changes in current repo +dirplot diff /path/to/repo # uncommitted changes in that repo +``` + +**`@ref` syntax** — append `@` to any local path or GitHub URL to pin it to a specific commit, tag, or branch: + +```bash +dirplot diff .@HEAD~5 .@HEAD # last 5 commits +dirplot diff .@abc1234 .@def5678 # two specific SHAs +dirplot diff .@v1.0 .@v2.0 # two tags +dirplot diff github://owner/repo@v1.0 github://owner/repo@v2.0 # GitHub tags +``` + +```bash +# Basic comparison — open in system viewer +dirplot diff old/ new/ + +# Uncommitted changes, only show changed files +dirplot diff . --no-context + +# Compare an S3 prefix against a local directory +dirplot diff s3://my-bucket/v1 ./v2 + +# Save to file +dirplot diff old/ new/ --output diff.png --no-show + +# Light mode, SVG output +dirplot diff old/ new/ --light --output diff.svg --no-show +``` + +### Options + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--output` | `-o` | — | Save to this path (PNG or SVG); `-` for stdout | +| `--format` | `-f` | auto | Output format: `png` or `svg` | +| `--show/--no-show` | | `--show` | Display the image after rendering (`--output -` implies `--no-show`) | +| `--inline` | | off | Display in terminal (auto-detected protocol; PNG only) | +| `--context/--no-context` | | `--context` | Include unchanged files in the treemap | +| `--font-size` | | `12` | Directory label font size in pixels | +| `--colormap` | | `tab20` | Colormap for unknown extensions | +| `--exclude` | `-e` | — | Pattern to exclude (repeatable): plain name, glob (`*.egg-info`), `**` glob, or relative path | +| `--include` | | — | Keep only these subtrees (repeatable); the inverse of `--exclude` | +| `--depth` | | unlimited | Maximum recursion depth | +| `--size` | | terminal size | Output dimensions as `WIDTHxHEIGHT` (e.g. `1920x1080`) | +| `--cushion/--no-cushion` | | `--cushion` | Van Wijk cushion shading for a raised 3-D look | +| `--dark/--light` | | `--dark` | Canvas and label colour scheme | +| `--log-scale` | | `0` (off) | Log-scale compression ratio; any value > 1 enables it | +| `--header/--no-header` | | `--header` | Print info lines before rendering | +| `--quiet` | | off | Suppress all status output | +| `--ssh-key` | | `~/.ssh/id_rsa` | SSH private key file | +| `--ssh-password-file` | | — | File containing SSH password | +| `--aws-profile` | | `$AWS_PROFILE` | Named AWS profile for S3 access | +| `--no-sign` | | off | Anonymous access for public S3 buckets | +| `--github-token-file` | | `$GITHUB_TOKEN` | File containing GitHub personal access token | +| `--k8s-namespace` | | — | Kubernetes namespace | +| `--k8s-container` | | — | Container name for multi-container pods | +| `--password-file` | | — | File containing archive password | +| `--no-input` | | off | Fail instead of prompting for passwords | + +--- + +## `dirplot watch` — live watch mode + +Monitors directories and regenerates the treemap on every filesystem change. Use `--snapshot` to write the current PNG on each change (useful for external tools or wallpaper updaters). To produce an animated APNG or MP4, record events with `--event-log` and replay with `dirplot replay`. + +```bash +# Watch a directory (display only, no file output) +dirplot watch . + +# Watch multiple directories +dirplot watch src tests + +# Write a snapshot PNG on each change +dirplot watch . --snapshot treemap.png + +# Adjust debounce (default 0.5 s) +dirplot watch . --snapshot treemap.png --debounce 1.0 +dirplot watch . --snapshot treemap.png --debounce 0 # immediate + +# Log all events to a JSONL file (replay later with dirplot replay) +dirplot watch src --event-log events.jsonl +dirplot watch src --snapshot treemap.png --event-log events.jsonl +``` + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--snapshot` | — | Write the current treemap as a PNG to this file on each change | +| `--debounce` | `0.5` | Seconds of quiet before regenerating; `0` disables | +| `--event-log` | — | Write raw events as JSONL on Ctrl-C exit | +| `--log-scale` | `0` (off) | Log-scale compression ratio; any value > 1 enables it | +| `--size` | terminal size | Output dimensions as `WIDTHxHEIGHT` | +| `--depth` | — | Maximum recursion depth | +| `--exclude` / `-e` | — | Pattern to exclude (repeatable): plain name, glob (`*.egg-info`), `**` glob, or relative path | +| `--colormap` | `tab20` | Matplotlib colormap | +| `--font-size` | `12` | Directory label font size in pixels | +| `--cushion/--no-cushion` | `--cushion` | Van Wijk cushion shading | + +--- + +## `dirplot replay` — event log replay + +Replays a JSONL event log produced by `dirplot watch --event-log` as an animated treemap. Events are grouped into time buckets (one frame per bucket). + +> **Requires** `ffmpeg` on `PATH` for MP4 output. + +```bash +# Replay as APNG (60-second buckets, 30-second total) +dirplot replay events.jsonl --output replay.apng --total-duration 30 + +# Replay as MP4 +dirplot replay events.jsonl --output replay.mp4 --total-duration 30 +dirplot replay events.jsonl --output replay.mp4 --crf 18 # higher quality +dirplot replay events.jsonl --output replay.mp4 --codec libx265 # smaller file + +# Fine-grained buckets with fixed frame duration +dirplot replay events.jsonl --output replay.apng --bucket 10 --frame-duration 200 + +# Fade out at the end +dirplot replay events.jsonl --output replay.mp4 --total-duration 30 --fade-out +dirplot replay events.jsonl --output replay.png --total-duration 30 --fade-out --fade-out-color white +``` + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--output` / `-o` | required | Output `.png`, `.apng`, or `.mp4` | +| `--bucket` | `60.0` | Time bucket size in seconds; one frame per bucket | +| `--frame-duration` | `500` | Frame display time in ms (when `--total-duration` is not set) | +| `--total-duration` | — | Target total animation length in seconds; frames scale proportionally to real time gaps | +| `--fade-out` / `--no-fade-out` | off | Append a fade-out sequence at the end | +| `--fade-out-duration` | `1.0` | Duration of the fade-out in seconds | +| `--fade-out-frames` | 4 × duration | Number of fade frames; defaults to 4 per second | +| `--fade-out-color` | `auto` | Fade target: `auto` (black/white per mode), `transparent` (PNG/APNG only), CSS name, or hex | +| `--crf` | `23` | MP4 quality: 0 = lossless, 51 = worst. Ignored for APNG | +| `--codec` | `libx264` | MP4 codec: `libx264` (H.264) or `libx265` (H.265) | +| `--workers` | all CPU cores | Parallel render workers | +| `--log-scale` | `0` (off) | Log-scale compression ratio; any value > 1 enables it | +| `--size` | terminal size | Output dimensions as `WIDTHxHEIGHT` | +| `--depth` | — | Maximum directory depth | +| `--exclude` / `-e` | — | Pattern to exclude (repeatable): plain name, glob (`*.egg-info`), `**` glob, or relative path | +| `--colormap` | `tab20` | Matplotlib colormap | +| `--font-size` | `12` | Directory label font size in pixels | +| `--cushion/--no-cushion` | `--cushion` | Van Wijk cushion shading | + +--- + +## `dirplot git` — git history treemap + +Renders a single commit or an animated history of a git repository as a treemap. Without `--range` or `--period`, a single PNG of the last commit (HEAD or the given ref) is produced. With `--range` or `--period`, an animated APNG or MP4 is produced — one frame per commit. + +> **Requires** `git` on `PATH`. `ffmpeg` is also required for MP4 output. + +The `repo` argument accepts: + +| Form | Example | +|---|---| +| Local path | `.`, `/path/to/repo` | +| Local path with ref | `.@my-branch`, `.@v1.0`, `.@abc1234` | +| `github://` URL | `github://owner/repo`, `github://owner/repo@branch` | +| HTTPS GitHub URL | `https://github.com/owner/repo`, `https://github.com/owner/repo@v1.0` | +| HTTPS GitHub tree URL | `https://github.com/owner/repo/tree/branch` | + +For GitHub URLs, dirplot clones into a temporary directory (shallow when possible) and removes it on exit. + +**Single frame** (no `--range` or `--period`): + +```bash +# Snapshot of HEAD +dirplot git . --output snapshot.png + +# Specific local branch or tag +dirplot git .@my-branch --output branch.png +dirplot git .@v1.0 --output v1.png --inline + +# GitHub repo at a specific tag — display inline +dirplot git https://github.com/owner/repo@v1.0 --inline +dirplot git github://owner/repo@v1.0 --inline +``` + +**Animation** (`--range` or `--period` triggers multi-frame output): + +A bare branch or tag name (`--range main`) animates **all** commits on that branch. +The `A..B` syntax animates only commits reachable from B but not from A (standard git range). + +```bash +# All commits on main → animated PNG +dirplot git . --range main --output history.png + +# All commits on main, time-proportional frame durations +dirplot git . --range main --total-duration 30 --output history.png + +# Only the last 50 commits on main +dirplot git . --range main --last 50 --output history.png + +# Specific revision range → animated PNG +dirplot git . --range main~50..main --output history.png + +# Tagged release range +dirplot git . --range v1.0..v2.0 --output release.mp4 + +# First 10 commits of a range +dirplot git github://owner/repo --range v1.0..v2.0 --first 10 --output history.png + +# Last 10 commits of a range +dirplot git github://owner/repo --range v1.0..v2.0 --last 10 --output history.png + +# All commits in the last 30 days +dirplot git . --period 30d --output history.mp4 + +# Commits in a branch that fall within the last 3 days of that branch's history +dirplot git github://owner/repo --range main --period 3d --output history.png + +# Fade out to black at the end +dirplot git . --period 7d --total-duration 20 \ + --fade-out --fade-out-duration 2.0 --output history.mp4 +``` + +See [EXAMPLES.md — Git History Animation](EXAMPLES.md#git-history-animation) for more examples including video output. + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--output` / `-o` | — | Output file: `.png` (static or animated APNG) or `.mp4` / `.mov`. Required unless `--inline` is given | +| `--inline` | off | Render and display the image directly in the terminal (single-frame mode only; not compatible with `--range` or `--period`) | +| `--range` | — | Git revision range. A bare branch/tag name (e.g. `main`) animates all commits on it; `A..B` animates commits in B but not A. Triggers animation mode | +| `--period` | — | Relative time filter: `30d`, `24h`, `2w`, `1mo`, `30m`. Triggers animation mode. Without `--range`, filters from now; with `--range`, filters relative to the range end | +| `--first` / `--last` | — | After applying `--range` / `--period`, keep only the first or last N commits | +| `--frame-duration` | `1000` | Frame display time in ms (when `--total-duration` is not set) | +| `--total-duration` | — | Target total animation length in seconds; frames scale proportionally to real time gaps between commits | +| `--fade-out` / `--no-fade-out` | off | Append a fade-out sequence at the end (animation mode only) | +| `--fade-out-duration` | `1.0` | Duration of the fade-out in seconds | +| `--fade-out-frames` | 4 × duration | Number of fade frames; defaults to 4 per second | +| `--fade-out-color` | `auto` | Fade target: `auto` (black/white per mode), `transparent` (PNG/APNG only), CSS name, or hex | +| `--crf` | `23` | MP4 quality: 0 = lossless, 51 = worst. Ignored for APNG | +| `--codec` | `libx264` | MP4 codec: `libx264` (H.264) or `libx265` (~40% smaller at same quality) | +| `--workers` | all CPU cores | Parallel render workers; 4–8 is typically optimal | +| `--log-scale` | `0` (off) | Log-scale compression ratio; any value > 1 enables it | +| `--size` | terminal size | Output dimensions as `WIDTHxHEIGHT` | +| `--depth` | — | Maximum directory depth | +| `--exclude` / `-e` | — | Pattern to exclude (repeatable): plain name, glob (`*.egg-info`), `**` glob, or relative path | +| `--colormap` | `tab20` | Matplotlib colormap | +| `--font-size` | `12` | Directory label font size in pixels | +| `--cushion/--no-cushion` | `--cushion` | Van Wijk cushion shading | +| `--github-token-file` | `$GITHUB_TOKEN` | File containing GitHub personal access token | + +--- + +## `dirplot hg` — Mercurial history treemap + +Renders a single changeset or an animated history of a Mercurial repository as a treemap. Without `--range` or `--period`, a single PNG of the tip (or the given rev) is produced. With `--range` or `--period`, an animated APNG or MP4 is produced — one frame per changeset. + +> **Requires** `hg` on `PATH`. `ffmpeg` is also required for MP4 output. + +The `repo` argument accepts a local path (`.`, `/path/to/repo`) optionally followed by `@rev` to pin to a specific revision or tag (e.g. `.@tip`, `.@1.0`, `.@abc1234`). + +**Single frame** (no `--range` or `--period`): + +```bash +# Snapshot of tip +dirplot hg . --output snapshot.png + +# Specific revision or tag +dirplot hg .@tip --output tip.png +dirplot hg .@1.0 --inline +``` + +**Animation** (`--range` triggers multi-frame output): + +```bash +# Full history as animated PNG +dirplot hg . --range 0:tip --output history.png + +# Revision range +dirplot hg . --range 10:tip --output history.mp4 + +# First 20 changesets of a range +dirplot hg . --range 0:tip --first 20 --output history.png +``` + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--output` / `-o` | — | Output file: `.png` (static or animated APNG) or `.mp4` / `.mov`. Required unless `--inline` is given | +| `--inline` | off | Render and display the image directly in the terminal (single-frame mode only; not compatible with `--range`) | +| `--range` | — | Mercurial revision range (e.g. `0:tip`, `10:tip`). Triggers animation mode | +| `--period` | — | Relative time filter: `30d`, `24h`, `2w`, `1mo`, `30m`. Triggers animation mode | +| `--first` / `--last` | — | After applying `--range` / `--period`, keep only the first or last N changesets | +| `--frame-duration` | `1000` | Frame display time in ms (when `--total-duration` is not set) | +| `--total-duration` | — | Target total animation length in seconds | +| `--fade-out` / `--no-fade-out` | off | Append a fade-out sequence at the end (animation mode only) | +| `--fade-out-duration` | `1.0` | Duration of the fade-out in seconds | +| `--fade-out-frames` | 4 × duration | Number of fade frames; defaults to 4 per second | +| `--fade-out-color` | `auto` | Fade target: `auto` (black/white per mode), `transparent` (PNG/APNG only), CSS name, or hex | +| `--crf` | `23` | MP4 quality: 0 = lossless, 51 = worst. Ignored for APNG | +| `--codec` | `libx264` | MP4 codec: `libx264` (H.264) or `libx265` (~40% smaller at same quality) | +| `--workers` | all CPU cores | Parallel render workers | +| `--log-scale` | `0` (off) | Log-scale compression ratio; any value > 1 enables it | +| `--size` | terminal size | Output dimensions as `WIDTHxHEIGHT` | +| `--depth` | — | Maximum directory depth | +| `--exclude` / `-e` | — | Pattern to exclude (repeatable) | +| `--colormap` | `tab20` | Matplotlib colormap | +| `--font-size` | `12` | Directory label font size in pixels | +| `--cushion/--no-cushion` | `--cushion` | Van Wijk cushion shading | + +--- + +## `dirplot read-meta` — read embedded metadata + +Reads dirplot metadata (date, software version, OS, Python version, executed command) embedded in a PNG, SVG, or MP4/MOV output file. + +> **Requires** `ffprobe` on `PATH` (bundled with [ffmpeg](https://ffmpeg.org/)) to read metadata from `.mp4` / `.mov` files. + +```bash +dirplot read-meta treemap.png +dirplot read-meta treemap.svg +dirplot read-meta history.mp4 +dirplot read-meta a.png b.png c.svg # multiple files +``` + +--- + +## `dirplot demo` — run example commands + +Runs a curated set of example commands covering each subcommand and saves outputs to a folder. Useful for a first-time walkthrough or to verify that everything works in your environment. + +```bash +# Run all examples with defaults (saves to ./demo/) +dirplot demo + +# Custom output folder and repo +dirplot demo --output ~/dirplot-demo --github-url https://github.com/pallets/flask + +# Step through commands one by one +dirplot demo --interactive +``` + +Examples produced: + +| Output file | Command | +|---|---| +| *(stdout)* | `dirplot termsize` | +| `map-local.png` | `dirplot map .` (dark mode, PNG) | +| `map-github.png` | `dirplot map github://owner/repo` (dark mode, PNG) | +| `map-local.svg` | `dirplot map .` (light mode, SVG) | +| `git-static.png` | `dirplot git github://owner/repo --first 1` (static PNG of latest commit) | +| `git.mp4` | `dirplot git github://owner/repo --range main --first 10 --total-duration 20` | +| `git-animated.png` | `dirplot git github://owner/repo --range main --first 10 --total-duration 20 --fade-out` | +| *(stdout)* | `dirplot read-meta map-local.png` | + +`dirplot watch` and `dirplot replay` are listed but skipped with an explanatory note — both require interactive or pre-recorded input. + +### Options + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--output` | `-o` | `demo` | Folder for generated output files | +| `--github-url` | | `https://github.com/deeplook/dirplot` | GitHub repository URL for remote examples | +| `--interactive` | `-i` | off | Ask for confirmation before each command is run | + +--- + +## Inline terminal display + +The `--inline` flag renders the image directly in the terminal. The protocol is auto-detected at runtime. + +| Terminal | Platform | Protocol | +|---|---|---| +| [iTerm2](https://iterm2.com/) | macOS | iTerm2 | +| [WezTerm](https://wezfurlong.org/wezterm/) | macOS, Linux, Windows | Kitty & iTerm2 | +| [Warp](https://www.warp.dev/) | macOS, Linux | iTerm2 | +| [Hyper](https://hyper.is/) | macOS, Linux, Windows | iTerm2 | +| [Kitty](https://sw.kovidgoyal.net/kitty/) | macOS, Linux | Kitty | +| [Ghostty](https://ghostty.org/) | macOS, Linux | Kitty | + +The default `--show` mode opens the image in the system viewer (`open` on macOS, `xdg-open` on Linux) and works in any terminal. + +> **Windows:** Common shells and terminal emulators (PowerShell, cmd, Windows Terminal) do not support inline image protocols. [WezTerm](https://wezfurlong.org/wezterm/) is currently the only mainstream Windows terminal with support (Kitty protocol). WSL2 is treated as Linux and has full support. + +> **AI coding assistants:** `--inline` does not work in Claude Code, Cursor, or GitHub Copilot Chat — these tools intercept terminal output as plain text. Use `--show` or `--output` instead. + +> **Tip:** In supported terminals, the rendered image can often be dragged directly out of the terminal window into another application. + +--- + +## Running dirplot via Docker + +Build the image once from the repo root: + +```bash +docker build -t dirplot . +``` + +Then run any `dirplot` command inside the container. Since the container has no display, use `--output -` to stream the PNG to stdout and display it on the host. + +**Save to a local file:** + +```bash +docker run --rm -v "$PWD":/out dirplot dirplot map github://steipete/birdclaw \ + --output /out/birdclaw.png --no-show +open birdclaw.png +``` + +**Display inline (iTerm2 with `imgcat`):** + +```bash +docker run --rm dirplot dirplot map github://steipete/birdclaw \ + --output - | imgcat +``` + +**Display inline (any iTerm2-compatible terminal, no extra tools):** + +```bash +docker run --rm dirplot dirplot map github://steipete/birdclaw \ + --output - | python3 -c " +import sys, base64 +data = sys.stdin.buffer.read() +sys.stdout.buffer.write( + b'\033]1337;File=inline=1;size=' + str(len(data)).encode() + + b':' + base64.b64encode(data) + b'\a\n' +) +" +``` + +> **Note:** `--inline` does not work when running inside a container — dirplot cannot probe your host terminal from within Docker. Use `--output -` and display the bytes on the host side instead, as shown above. +> +> **Terminal size:** the container has no tty, so dirplot cannot detect your terminal dimensions. The default 1280×720 fallback is used unless you pass `--size WIDTHxHEIGHT` explicitly or set `-e COLUMNS=$(tput cols) -e LINES=$(tput lines)`. + +--- + +## Troubleshooting + +### Image is the wrong size or too small in `--inline` mode + +dirplot reads the terminal pixel size via `TIOCGWINSZ`. This can fail or return wrong values when: + +- **stdout is a pipe** (e.g. `uv run`, `nohup`, CI): pass `--size WIDTHxHEIGHT` explicitly, or set `COLUMNS` and `LINES` env vars. +- **Inside Docker**: same as above — the container has no tty. +- **`--inline` in Docker**: not supported; use `--output - | imgcat` instead (see [Running via Docker](#running-dirplot-via-docker)). + +### `--inline` shows nothing or garbled output + +- Confirm your terminal is in the supported list above. +- In tmux/screen, the inline protocol may be blocked. Try running dirplot in a bare terminal session. +- AI coding tool terminals (Claude Code, Cursor, Copilot Chat) do not support inline images — use `--show` or `--output`. + +### GitHub rate limit errors + +Without a token, GitHub allows 60 unauthenticated API requests per IP per hour. Authenticate via: + +```bash +gh auth login # Option 1: gh CLI (picked up automatically) +export GITHUB_TOKEN=ghp_… # Option 2: env var +dirplot map github://… --github-token-file ~/.github-token # Option 3: token file +``` + +See [EXAMPLES.md — GitHub Repositories](EXAMPLES.md#github-repositories) for full authentication details. + +### Large remote trees are slow or truncated + +- Use `--depth N` to limit recursion (start with `--depth 3`). +- GitHub's Git Trees API truncates responses above ~100k entries; dirplot warns and renders what it received. +- For SSH scans, slow hosts may time out on very large trees — use `--depth` to reduce the `find` traversal. +- If one large file squashes everything else into tiny tiles, add `--log-scale 4`. + +### Archive errors + +- **`libarchive-c` import error**: the Python binding is installed but the system C library is missing. Install it: + ```bash + brew install libarchive # macOS + sudo apt install libarchive-dev # Debian/Ubuntu + ``` +- **Password-protected archive**: pass `--password-file ` or let dirplot prompt interactively. Use `--no-input` to fail instead of prompting. +- **`.deb` / UDIF `.dmg` not supported**: see [ARCHIVES.md — Intentionally unsupported formats](ARCHIVES.md#intentionally-unsupported-formats). + +### MP4 output fails + +Ensure `ffmpeg` is installed and on `PATH`: + +```bash +ffmpeg -version # should print version info +brew install ffmpeg # macOS +sudo apt install ffmpeg # Debian/Ubuntu +``` diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..4d36ca9 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,689 @@ +# Examples + +- [Metrics](#metrics) +- [Diff](#diff) +- [Remote Access](#remote-access) + - [SSH](#remote-servers-via-ssh) + - [AWS S3](#aws-s3) + - [GitHub Repositories](#github-repositories) + - [Google Drive](#google-drive) + - [Docker Containers](#docker-containers) + - [Kubernetes Pods](#kubernetes-pods) +- [Git History Animation](#git-history-animation) + +--- + +## Metrics + +`dirplot metrics` scans a directory tree and prints a structured text summary — file/dir counts, total size, depth, scan time, top extensions (by count or size), and the largest files and directories with their percentage of total size. It accepts the same sources as `dirplot map`. + +```bash +# Local directory +dirplot metrics . +dirplot metrics /path/to/project + +# Sort top extensions by total bytes instead of file count +dirplot metrics . --sort-by size + +# Limit each list to 5 entries +dirplot metrics . --top 5 + +# JSON output — pipe into jq or scripts +dirplot metrics . --json +dirplot metrics . --json | jq '.largest_files' +dirplot metrics . --json | jq '.top_extensions[] | select(.ext == ".py")' + +# Remote sources — identical to map +dirplot metrics github://pallets/flask +dirplot metrics github://torvalds/linux --depth 3 --sort-by size +dirplot metrics s3://my-bucket --no-sign +dirplot metrics ssh://alice@prod.example.com/var/www +dirplot metrics docker://my-container:/app +dirplot metrics project.zip + +# Exclude directories +dirplot metrics . -e .venv -e .git + +# Get treemap and metrics in a single pass +dirplot map . --metrics --no-show +``` + +--- + +## Diff + +`dirplot diff` compares two trees and renders a treemap with colour-coded diff borders. A and B accept **any source supported by `dirplot map`** — local directories, GitHub repos, archives, S3 paths, SSH hosts, Docker containers, or Kubernetes pods. + +When a source is a **local git or hg repository**, only tracked files are scanned (untracked files are invisible, matching `git diff` / `hg diff` semantics). Change detection uses blob hash comparison, so edits that don't change file size are caught correctly. Git LFS files are handled transparently. + +**`@ref` syntax** — append `@` to any local path or GitHub URL to pin it to a specific commit, tag, or branch. Works with commit SHAs, tag names, and branch names: + +```bash +dirplot diff .@HEAD~5 .@HEAD # last 5 commits +dirplot diff .@abc1234 .@def5678 # two commit SHAs +dirplot diff .@v1.0 .@v2.0 # two tags in the current repo +``` + +```bash +# Uncommitted changes in the current repo (single-argument shorthand) +dirplot diff . +dirplot diff /path/to/repo + +# Uncommitted changes — only show changed files +dirplot diff . --no-context + +# Compare two commits in the current repo +dirplot diff .@HEAD~5 .@HEAD + +# Local directories (non-git) +dirplot diff old/ new/ + +# Save to file without opening a viewer +dirplot diff old/ new/ --output diff.png --no-show + +# Show only changed/added/removed files (hide unchanged context) +dirplot diff old/ new/ --no-context + +# Two GitHub tags +dirplot diff github://owner/repo@v1.0 github://owner/repo@v2.0 + +# Two GitHub commits +dirplot diff github://owner/repo@abc1234 github://owner/repo@def5678 + +# Two GitHub tags — private repo +dirplot diff github://my-org/private@v1 github://my-org/private@v2 \ + --github-token-file ~/.github-token + +# Two archives +dirplot diff release-1.0.tar.gz release-2.0.tar.gz + +# S3 prefix vs local directory +dirplot diff s3://my-bucket/v1 ./v2 --aws-profile prod + +# Two SSH paths +dirplot diff ssh://alice@host/srv/v1 ssh://alice@host/srv/v2 + +# Docker containers (baseline vs new image) +dirplot diff docker://app-v1:/app docker://app-v2:/app +``` + +--- + +## Remote Access + +*dirplot* can scan directory trees on remote sources (remote servers via SSH, AWS S3 buckets, GitHub repositories, Docker containers, and Kubernetes pods) without copying files locally. Remote backends are optional dependencies — install only what you need. + +> **Warning:** Remote trees can contain hundreds of thousands of files. Use `--depth N` to limit how far down the tree dirplot recurses until you have a feel for the size of the target. Start with `--depth 3`. + +> **Tip:** If one large file (a binary, dataset, or build artifact) dominates the layout and squashes everything else into tiny slivers, add `--log-scale 4` to use log-scaled file sizes instead — this makes small files much more visible. +> The value controls the max/min layout-size ratio after compression: `--log-scale 4` means the largest file's tile is at most 4× the smallest. Values in the range **2–10** are most useful. + +--- + +## Remote Servers via SSH + +Scan hosts reachable over SSH using [paramiko](https://www.paramiko.org/). + +```bash +pip install "dirplot[ssh]" +``` + +### Usage + +```bash +# ssh://user@host/path format +dirplot map ssh://alice@prod.example.com/var/www + +# SCP-style user@host:/path format +dirplot map alice@prod.example.com:/var/www + +# Exclude paths, cap depth, save to file +dirplot map ssh://alice@prod.example.com/var --exclude /var/cache --depth 4 --output remote.png --no-show +``` + +### Authentication + +Credentials are resolved in this order: + +1. `--ssh-key PATH` — explicit private key file +2. `IdentityFile` from `~/.ssh/config` for the target host +3. ssh-agent (picked up automatically) +4. `--ssh-password-file FILE` — file containing the SSH password +5. Interactive password prompt as a last resort + +### SSH config + +`~/.ssh/config` is read automatically. Host aliases, custom ports, and `IdentityFile` directives all work as expected: + +``` +Host prod + HostName prod.example.com + User alice + IdentityFile ~/.ssh/prod_key + Port 2222 +``` + +```bash +dirplot map ssh://prod/var/www # resolves using the config block above +``` + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--ssh-key` | `~/.ssh/id_rsa` | Path to SSH private key | +| `--ssh-password-file` | — | File containing SSH password | +| `--depth` | unlimited | Maximum recursion depth | + +### Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +```python +from dirplot.ssh import connect, build_tree_ssh +from dirplot.render_png import create_treemap + +client = connect("prod.example.com", "alice", ssh_key="~/.ssh/prod_key") +sftp = client.open_sftp() +try: + root = build_tree_ssh(sftp, "/var/www", depth=5) +finally: + sftp.close() + client.close() + +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +--- + +## AWS S3 + +Scan S3 buckets using [boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html). File sizes come from S3 object metadata — no data is downloaded. + +```bash +pip install "dirplot[s3]" +``` + +### Usage + +```bash +# Scan a bucket prefix +dirplot map s3://my-bucket/path/to/prefix + +# Scan an entire bucket +dirplot map s3://my-bucket + +# Public bucket (no AWS credentials needed) +dirplot map s3://noaa-ghcn-pds --no-sign + +# Use a named AWS profile, cap depth, save to file +dirplot map s3://my-bucket/data --aws-profile prod --depth 3 --output s3.png --no-show +``` + +### Authentication + +boto3's standard credential chain is used automatically — no extra configuration needed if your environment is already set up for AWS. Credentials are resolved in this order: + +1. `--aws-profile` (or `AWS_PROFILE` env var) — named profile from `~/.aws/config` +2. `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` environment variables +3. `~/.aws/credentials` file +4. IAM instance role (on EC2 / ECS / Lambda) +5. `--no-sign` — skip signing entirely for anonymous access to public buckets + +`--aws-profile` takes precedence over `AWS_PROFILE` and all lower-priority methods in the chain. + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--aws-profile` | `AWS_PROFILE` env var | Named AWS profile | +| `--no-sign` | off | Anonymous access for public buckets | +| `--depth` | unlimited | Maximum recursion depth | +| `--exclude` | — | Full `s3://bucket/key` URI to skip (repeatable) | + +### Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +```python +from dirplot.s3 import make_s3_client, build_tree_s3 +from dirplot.render_png import create_treemap + +# Authenticated access +s3 = make_s3_client(profile="prod") + +# Anonymous access to a public bucket +s3 = make_s3_client(no_sign=True) + +root = build_tree_s3(s3, "my-bucket", "path/to/prefix/", depth=5) +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +### Public buckets to explore + +These buckets are publicly accessible with `--no-sign`. Use `--depth 2` or `--depth 3` on large buckets to avoid long scan times. + +| Bucket | Contents | Quick start | +|---|---|---| +| `s3://noaa-ghcn-pds` | NOAA Global Historical Climatology Network | `dirplot map s3://noaa-ghcn-pds --no-sign --depth 2` | +| `s3://noaa-goes16` | NOAA GOES-16 weather satellite imagery | `dirplot map s3://noaa-goes16 --no-sign --depth 3` | +| `s3://sentinel-s2-l1c` | Copernicus Sentinel-2 satellite data (eu-central-1) | `dirplot map s3://sentinel-s2-l1c --no-sign --depth 2` | +| `s3://1000genomes` | 1000 Genomes Project | `dirplot map s3://1000genomes --no-sign --depth 3` | + +
+ NOAA GHCN S3 bucket treemap +
dirplot map s3://noaa-ghcn-pds --no-sign --depth 2
+
+ +--- + +## GitHub Repositories + +Scan any GitHub repository using the [Git trees API](https://docs.github.com/en/rest/git/trees). File sizes come from blob metadata — no file content is downloaded. No extra dependency is required; dirplot uses `urllib` from the Python standard library. + +### Usage + +```bash +# github:// scheme +dirplot map github://owner/repo + +# Specific branch, tag, or commit SHA +dirplot map github://owner/repo@dev + +# Full GitHub URL (also accepted) +dirplot map https://github.com/owner/repo/tree/main + +# Save to file +dirplot map github://FastAPI/FastAPI --output fastapi.png --no-show +``` + +
+ FastAPI repository treemap +
dirplot map github://FastAPI/FastAPI
+
+ + + +
+ CPython repository treemap +
dirplot map github://python/cpython
+
+ +
+ PyPy repository treemap +
dirplot map github://pypy/pypy
+
+ +### Authentication + +A token is **not required for public repositories** under normal use. Each scan makes 1–2 API calls, and GitHub allows 60 unauthenticated requests per hour per IP. A token is needed when: + +- Scanning **private repositories** +- Running in CI/CD where many processes share the same IP +- Scanning repeatedly and hitting the unauthenticated rate limit + +**Option 1 — gh CLI (easiest):** authenticate once and dirplot picks up your credentials automatically: + +```bash +gh auth login +dirplot map github://my-org/private-repo +``` + +**Option 2 — environment variable:** + +```bash +export GITHUB_TOKEN=ghp_… +dirplot map github://my-org/private-repo +``` + +**Option 3 — token file:** + +```bash +dirplot map github://my-org/private-repo --github-token-file ~/.github-token +``` + +Token resolution order: `--github-token-file` → `$GITHUB_TOKEN` → `gh auth token`. + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--github-token-file` | `$GITHUB_TOKEN` | File containing personal access token | +| `--depth` | unlimited | Maximum recursion depth | +| `--exclude` | — | Repo-relative path to skip (repeatable) | + +### Notes + +- Dotfiles and dot-directories (`.github`, `.env`, etc.) are skipped, consistent with local scanning behaviour. +- If the repository tree exceeds GitHub's API limit (~100k entries), the response will be truncated. dirplot prints a warning and renders what was returned. Use `--depth` to avoid this. +- The `--depth` flag here applies to the in-memory tree built from the API response, not to the number of API calls (the full flat tree is always fetched in one request). + +### Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +```python +from dirplot.github import build_tree_github +from dirplot.render_png import create_treemap +import os + +root, branch = build_tree_github( + "pallets", "flask", + token=os.environ.get("GITHUB_TOKEN"), + depth=4, +) +print(f"Branch: {branch}, size: {root.size:,} bytes") +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +
+ Flask repository treemap +
dirplot map github://pallets/flask --legend
+
+ +--- + +## Google Drive + +Scan a Google Drive using the [gog CLI](https://gogcli.sh/) — a unified Google Workspace CLI that handles OAuth2 authentication. No extra Python dependency is needed; dirplot shells out to `gog` the same way the Docker backend uses `docker exec`. + +### Setup + +```bash +# Install gog +brew install gogcli # macOS + +# Authenticate once (opens browser for OAuth2) +gog auth +``` + +### Usage + +```bash +# Scan your entire Drive (My Drive + shared drives) +dirplot map gdrive:// + +# Scan with depth limit (recommended for large drives) +dirplot map gdrive:// --depth 3 + +# Scan a specific folder by its Drive folder ID +dirplot map gdrive://1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms + +# Save to file +dirplot map gdrive:// --depth 4 --output drive.png --no-show + +# Display inline in terminal +dirplot map gdrive:// --depth 3 --log-scale 4 --inline +``` + +To find a folder ID: open the folder in Google Drive in your browser — the ID is the long string at the end of the URL (`https://drive.google.com/drive/folders/`). + +### Notes + +- **Google-native formats** (Docs, Sheets, Slides, Forms, …) have no byte size in the Drive API. dirplot shows them as 1 byte so they remain visible as tiles rather than disappearing. +- **Authentication** is handled entirely by `gog`. Run `gog auth` once; tokens are cached and refreshed automatically. +- **Large drives** can contain tens of thousands of files. Use `--depth N` to limit the scan until you have a feel for the size. +- **Dotfiles** and dot-directories are skipped, consistent with local scanning behaviour. + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--depth` | unlimited | Maximum recursion depth | +| `--exclude` | — | Path pattern to skip (repeatable) | +| `--log-scale` | 0 (off) | Useful when a few large files dominate the layout | + +### Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +```python +from dirplot.gdrive import build_tree_gdrive +from dirplot.render_png import create_treemap + +# Scan from Drive root +root = build_tree_gdrive(depth=3) + +# Scan a specific folder +root = build_tree_gdrive("1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms", depth=5) + +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +--- + +## Docker Containers + +Scan a running Docker container's filesystem using `docker exec`. No extra dependency is required beyond the `docker` CLI being in `PATH`. + +### Usage + +```bash +# docker://container/path — slash separator +dirplot map docker://my-container/app + +# docker://container:/path — colon separator (both forms accepted) +dirplot map docker://my-container:/app + +# Cap depth, save to file +dirplot map docker://my-container:/usr --depth 3 --output container.png --no-show + +# Real example +docker run -d --name pg-demo -e POSTGRES_PASSWORD=x postgres:17-alpine +dirplot map docker://pg-demo:/usr --inline +docker rm -f pg-demo +``` + +
+ Postgres container /usr treemap +
dirplot map docker://pg-demo:/usr --log-scale 4
+
+ +### Requirements + +- `docker` CLI in `PATH` +- The container must be running (`docker ps` should list it) +- The container image must have a `find` binary (true for all common Linux base images) + +### Notes + +- Symlinks are skipped. +- Dotfiles and dot-directories are skipped, consistent with local scanning behaviour. +- `find` is first attempted with GNU find's `-printf` for efficiency; if that fails (BusyBox/Alpine images), a POSIX `sh` + `stat` fallback is used automatically. + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--depth` | unlimited | Maximum recursion depth | +| `--exclude` | — | Absolute path inside the container to skip (repeatable) | + +### Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +```python +from dirplot.docker import build_tree_docker +from dirplot.render_png import create_treemap + +root = build_tree_docker("my-container", "/app", depth=5) +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +--- + +## Kubernetes Pods + +Scan a running Kubernetes pod's filesystem using `kubectl exec`. No extra dependency is required beyond `kubectl` being in `PATH` and configured to reach a cluster. + +### Usage + +```bash +# Default namespace, slash separator +dirplot map pod://my-pod/app + +# Default namespace, colon separator +dirplot map pod://my-pod:/app + +# Explicit namespace via URL +dirplot map pod://my-pod@staging:/app + +# Explicit namespace via flag (overrides @namespace in URL) +dirplot map pod://my-pod:/app --k8s-namespace staging + +# Multi-container pod — pick a specific container +dirplot map pod://my-pod:/app --k8s-container sidecar + +# Cap depth, save to file +dirplot map pod://my-pod:/usr --depth 3 --output pod.png --no-show + +# Real example (minikube) +minikube start + +kubectl run pg-demo --image=postgres:17-alpine --restart=Never \ + --env POSTGRES_PASSWORD=x +kubectl wait --for=condition=Ready pod/pg-demo --timeout=90s + +dirplot map pod://pg-demo/var/lib/postgresql --inline + +kubectl delete pod pg-demo --grace-period=0 +``` + +
+ Postgres pod /var treemap +
dirplot map pod://pg-demo/var/
+
+ +### Requirements + +- `kubectl` CLI in `PATH`, configured for a reachable cluster +- The pod must be in `Running` state +- The pod image must have a `find` binary (true for all common Linux base images) + +### Notes + +- Symlinks are skipped. +- Dotfiles and dot-directories are skipped, consistent with local scanning behaviour. +- Unlike Docker scanning, `-xdev` is intentionally omitted so that mounted volumes (emptyDir, PVC, etc.) within the scanned path are traversed — this is the common case in k8s where images declare `VOLUME` entries that k8s always mounts separately. +- `find` is first attempted with GNU find's `-printf`; if that fails (BusyBox/Alpine images), a POSIX `sh` + `stat` fallback is used automatically. + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--k8s-namespace` | current context default | Kubernetes namespace | +| `--k8s-container` | pod default | Container name for multi-container pods | +| `--depth` | unlimited | Maximum recursion depth | +| `--exclude` | — | Absolute path inside the pod to skip (repeatable) | + +### Python API + +> **Note:** The programmatic Python API is still evolving and may change between releases without notice. Pin a specific version if you depend on it. The CLI interface is stable. + +```python +from dirplot.k8s import build_tree_pod +from dirplot.render_png import create_treemap + +root = build_tree_pod( + "my-pod", + "/app", + namespace="staging", + container="main", + depth=5, +) +buf = create_treemap(root, width_px=1920, height_px=1080) +``` + +--- + +## Git History Animation + +Render a single commit snapshot or replay a repository's commit history as an animated treemap. Each commit becomes one frame; changed tiles receive colour-coded highlight borders (green = created, blue = modified, red = deleted). Works with local repositories, `github://` URLs, and full HTTPS GitHub URLs — remote repos are cloned into a temporary directory and removed on exit. + +> **Requires** `git` on `PATH`. `ffmpeg` is required for MP4 output. + +### Single frame (no `--range` or `--period`) + +Without `--range` or `--period`, a single PNG of the last commit (HEAD, or the ref specified with `@ref`) is produced. + +```bash +# Snapshot of HEAD in current repo +dirplot git . --output snapshot.png + +# Specific branch or tag — display inline in terminal +dirplot git .@my-branch --inline +dirplot git .@v1.0 --output v1.png + +# GitHub repo at a specific tag +dirplot git github://owner/repo@v1.0 --inline +dirplot git https://github.com/owner/repo@v1.0 --output snapshot.png +``` + +### Animation (with `--range` or `--period`) + +Adding `--range` or `--period` triggers animation mode — an APNG (`.png`) or MP4 (`.mp4`) with one frame per commit. + +A bare branch or tag name (`--range main`) animates **all** commits on that branch. +The `A..B` syntax animates only commits reachable from B but not from A (standard git range). + +```bash +# All commits on main → animated PNG +dirplot git . --range main --output history.png + +# All commits on main — MP4 with time-proportional frame durations +dirplot git . --range main --total-duration 30 --output history.mp4 + +# Only the last 50 commits on main +dirplot git . --range main --last 50 --total-duration 30 --output history.png + +# Tagged release range, MP4 output, log scale +dirplot git github://openclaw/openclaw --range 871e8882..8445c9a5 \ + --log-scale 4 --size 1920x1080 --output openclaw.mp4 + +# First 10 commits of a tagged range +dirplot git github://owner/repo --range v1.0..v2.0 --first 10 --output history.png + +# Last 10 commits of a tagged range +dirplot git github://owner/repo --range v1.0..v2.0 --last 10 --output history.png + +# All commits in the last 30 days +dirplot git . --period 30d --output history.mp4 + +# Commits on main that fall within the last 3 days of main's history +dirplot git github://owner/repo --range main --period 3d --output history.png + +# Fade out to black at the end +dirplot git . --period 7d --total-duration 20 \ + --fade-out --fade-out-duration 2.0 --output history.mp4 +``` + +
+ +
dirplot git https://github.com/steipete/birdclaw --size 1000x600 --range main -o steipete-birdclaw.mp4
+
+ +### From live filesystem events + +To animate real-time filesystem activity (e.g. a build or test run), use `dirplot watch` + `dirplot replay`: + +```bash +# 1. Record events while you work +dirplot watch . --event-log events.jsonl + +# 2. Replay as a video (Ctrl-C watch first) +dirplot replay events.jsonl --output timelapse.mp4 --total-duration 30 +``` + +### Key flags + +| Flag | Description | +|---|---| +| `--range` | Git revision range (e.g. `HEAD`, `main~50..main`, `v1.0..HEAD`). Triggers animation | +| `--period` | Relative time filter: `30d`, `24h`, `2w`, `1mo`, `30m`. Triggers animation | +| `--first N` | Keep only the first N commits after applying range/period | +| `--last N` | Keep only the last N commits after applying range/period | +| `--total-duration` | Target total animation length in seconds (time-proportional frame durations) | +| `--frame-duration` | Fixed frame duration in ms when `--total-duration` is not set (default: 1000) | +| `--inline` | Display single-frame output directly in the terminal (not compatible with animation) | + +See [CLI.md — `dirplot git`](CLI.md#dirplot-git--git-history-treemap) for the full options reference. diff --git a/docs/dirplot.png b/docs/dirplot.png index 7d655ec..12d5205 100644 Binary files a/docs/dirplot.png and b/docs/dirplot.png differ diff --git a/docs/docker.png b/docs/docker.png new file mode 100644 index 0000000..d05139c Binary files /dev/null and b/docs/docker.png differ diff --git a/docs/fastapi.png b/docs/fastapi.png new file mode 100644 index 0000000..bd15fb4 Binary files /dev/null and b/docs/fastapi.png differ diff --git a/docs/flask.png b/docs/flask.png new file mode 100644 index 0000000..6f814c2 Binary files /dev/null and b/docs/flask.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0a1fe78 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,151 @@ +# dirplot + +[![CI](https://github.com/deeplook/dirplot/actions/workflows/ci.yml/badge.svg)](https://github.com/deeplook/dirplot/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/dirplot.svg)](https://pypi.org/project/dirplot/) +[![Python](https://img.shields.io/pypi/pyversions/dirplot.svg)](https://pypi.org/project/dirplot/) +[![Downloads](https://img.shields.io/pypi/dm/dirplot.svg)](https://pepy.tech/project/dirplot) +[![License](https://img.shields.io/pypi/l/dirplot.svg)](https://pypi.org/project/dirplot/) +[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=flat&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/deeplook) + +**dirplot** creates nested treemap images for directory trees. It can display them in the system image viewer or inline in the terminal (iTerm2 and Kitty protocols, auto-detected). It also animates git history, watches live filesystems, and scans remote sources. + +```bash +pip install dirplot +dirplot map . # treemap of current directory, opens in system viewer +dirplot map . --inline # display inline in terminal (iTerm2 / Kitty / Ghostty) +``` + +![dirplot output](https://raw.githubusercontent.com/deeplook/dirplot/main/docs/dirplot.png) + +## Where to start + +| I want to… | Go to | +|---|---| +| Scan a local directory or archive | [Quick start](#quick-start) | +| Scan a GitHub repo, S3 bucket, SSH host, or container | [Remote access & examples](EXAMPLES.md) | +| Scan Google Drive | [Google Drive](EXAMPLES.md#google-drive) | +| Animate git history or watch live filesystems | [Git History Animation](EXAMPLES.md#git-history-animation) | +| Use dirplot from Python | [Python API](API.md) | +| Run in Docker | [Running via Docker](CLI.md#running-dirplot-via-docker) | +| Fix an error | [Troubleshooting](CLI.md#troubleshooting) | + +## How to run dirplot + +| Method | Command | Notes | +|---|---|---| +| Installed CLI | `dirplot map .` | After `pip install` / `uv tool install` | +| No install (uv) | `uvx dirplot map .` | Runs the latest release ephemerally | +| Python API | `from dirplot import build_tree, create_treemap` | See [API.md](API.md) | +| Docker | `docker run --rm dirplot dirplot map … --output -` | See [Docker](CLI.md#running-dirplot-via-docker) | + +## Features + +- Squarified treemap layout; file area proportional to size; per-extension colours (GitHub Linguist palette for known types, configurable Matplotlib colormap for the rest). +- PNG, animated PNG (APNG), MP4, and MOV output for single frames and animations; interactive SVG for static maps; renders at terminal pixel size or a custom `WIDTHxHEIGHT`. +- **Inline terminal display** — renders directly into iTerm2, Kitty, Ghostty, WezTerm, and Warp without opening a separate window; protocol auto-detected. +- **Animate git history** (`dirplot git`), **Mercurial history** (`dirplot hg`), and **replay filesystem event logs** (`dirplot replay`) — output APNG, MP4, or MOV. **Watch live filesystems** (`dirplot watch`) with optional snapshot and event logging. +- **Scan metrics** (`dirplot metrics`) — file/dir counts, total size, depth, top extensions by count or size, largest files and directories with percentage of total; JSON output supported. +- **Compare two trees** (`dirplot diff`) — treemap diff of any two sources (local dirs, GitHub repos, archives, S3, SSH, Docker, K8s, or two commits/tags); `dirplot diff .` shows uncommitted changes; files sized by B; colour-coded borders show added (green), removed (red), and changed (blue) files. Git/hg repos scan only tracked files; change detection uses blob hashes (LFS-aware). +- Scan **SSH hosts**, **AWS S3**, **GitHub repos** (public and private), **Docker containers**, **Kubernetes pods**, and **Google Drive** — no extra deps beyond the respective CLI. +- Read **archives** directly (zip, tar, 7z, rar, jar, whl, …) without unpacking. +- Works on macOS, Linux, and Windows (WSL2 fully supported). + +## Installation + +```bash +# Recommended: isolated tool install via uv (fastest) +uv tool install dirplot + +# Alternative: pipx (install pipx first if needed: brew install pipx on macOS) +pipx install dirplot + +# Into the current environment +pip install dirplot +``` + +**Optional extras** — install only what you need: + +| Extra | Enables | Install | +|---|---|---| +| `ssh` | Scan remote servers via SSH (adds [paramiko](https://www.paramiko.org/)) | `pip install "dirplot[ssh]"` | +| `s3` | Scan AWS S3 buckets (adds [boto3](https://boto3.amazonaws.com/)) | `pip install "dirplot[s3]"` | +| `libarchive` | Additional archive formats: `.tar.zst`, `.iso`, `.dmg`, `.rpm`, `.cab`, … (requires system [libarchive](https://libarchive.org/)) | `pip install "dirplot[libarchive]"` | + +**Other runtime requirements:** + +- `dirplot watch` — [watchdog](https://github.com/gorakhargosh/watchdog) is installed automatically. +- `dirplot git` — requires `git` on `PATH`. +- `dirplot hg` — requires `hg` (Mercurial) on `PATH`. +- MP4 output (`dirplot git`, `dirplot hg`, `dirplot replay`) — requires [ffmpeg](https://ffmpeg.org/) on `PATH`. +- `dirplot read-meta` on `.mp4` files — requires `ffprobe` (bundled with ffmpeg). + +## Quick start + +```bash +dirplot map . # current directory +dirplot map . --inline # display in terminal (iTerm2/Kitty) +dirplot map . --output treemap.png --no-show # save to file +dirplot map . --log-scale 4 --inline # log scale (4× ratio), inline +dirplot map github://pallets/flask # GitHub repo +dirplot map gdrive:// # Google Drive root (requires gog) +dirplot map docker://my-container:/app # Docker container +dirplot map project.zip # archive file +tree src/ | dirplot map # pipe tree output + +dirplot git . -o snapshot.png # static snapshot of HEAD +dirplot git .@v1.0 --inline # inline snapshot at tag +dirplot git . -o history.mp4 --range main # full git history +dirplot git . -o history.mp4 --period 30d # last 30 days +dirplot git github://owner/repo -o h.mp4 --period 7d # GitHub, last week + +dirplot hg /path/to/repo -o history.png --range 0:tip # full hg history +dirplot hg /path/to/repo@tip -o history.png # static, tip only + +dirplot watch . --snapshot treemap.png # live watch, snapshot on each change +dirplot watch . --event-log events.jsonl # record events for replay +dirplot replay events.jsonl -o timelapse.mp4 --total-duration 30 # render recording as MP4 + +dirplot demo # run examples, save to ./demo/ + +dirplot metrics . # scan metrics: counts, size, top extensions +dirplot metrics . --sort-by size # sort extensions by total bytes +dirplot metrics . --top 5 --json # top-5 entries as JSON +dirplot map . --metrics --no-show # treemap + metrics in one pass + +dirplot diff . # uncommitted changes (git/hg) +dirplot diff . --no-context # only show changed files +dirplot diff .@HEAD~5 .@HEAD # last 5 commits +dirplot diff old/ new/ # compare two directories +dirplot diff old/ new/ --output diff.png --no-show # save to file +dirplot diff github://owner/repo@v1 github://owner/repo@v2 # compare two GitHub tags +dirplot diff archive_v1.tar.gz archive_v2.zip # compare two archives +``` + +**Docker** — build once, then pipe output to the host: + +```bash +docker build -t dirplot . +docker run --rm dirplot dirplot map github://steipete/birdclaw --output - | imgcat +``` + +## Documentation + +- [CLI reference](CLI.md) — all commands, flags, and usage examples +- [Remote access & examples](EXAMPLES.md) — SSH, S3, GitHub, Docker, Kubernetes, git history animation +- [Archive formats](ARCHIVES.md) — supported formats and dependencies +- [Python API](API.md) — programmatic usage +- [Troubleshooting](CLI.md#troubleshooting) — common issues and fixes + +## Development + +```bash +git clone https://github.com/deeplook/dirplot +cd dirplot +make test +``` + +See [CONTRIBUTING.md](https://github.com/deeplook/dirplot/blob/main/CONTRIBUTING.md) for full details. + +## License + +MIT — see [LICENSE](https://github.com/deeplook/dirplot/blob/main/LICENSE). diff --git a/docs/k8s.png b/docs/k8s.png new file mode 100644 index 0000000..9bd5d79 Binary files /dev/null and b/docs/k8s.png differ diff --git a/docs/pypy.png b/docs/pypy.png new file mode 100644 index 0000000..c4e69e6 Binary files /dev/null and b/docs/pypy.png differ diff --git a/docs/python.png b/docs/python.png new file mode 100644 index 0000000..0e50936 Binary files /dev/null and b/docs/python.png differ diff --git a/docs/s3.png b/docs/s3.png new file mode 100644 index 0000000..fb87e5d Binary files /dev/null and b/docs/s3.png differ diff --git a/docs/steipete-birdclaw.mp4 b/docs/steipete-birdclaw.mp4 new file mode 100644 index 0000000..c3fe711 --- /dev/null +++ b/docs/steipete-birdclaw.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03d0d142521d8b402b71347be4f7ed3260274ea62b6728bb24168d98d6abf5fd +size 3773345 diff --git a/events.jsonl b/events.jsonl new file mode 100644 index 0000000..0a161c1 --- /dev/null +++ b/events.jsonl @@ -0,0 +1,26 @@ +{"timestamp": 1778532086, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/__init__.py"} +{"timestamp": 1778532146, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/scanner.py"} +{"timestamp": 1778532206, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/colors.py"} +{"timestamp": 1778532266, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/render_png.py"} +{"timestamp": 1778532326, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/svg_render.py"} +{"timestamp": 1778532386, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/display.py"} +{"timestamp": 1778532446, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/terminal.py"} +{"timestamp": 1778532506, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/archives.py"} +{"timestamp": 1778532566, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/git_scanner.py"} +{"timestamp": 1778532626, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/github.py"} +{"timestamp": 1778532686, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/ssh.py"} +{"timestamp": 1778532746, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/docker.py"} +{"timestamp": 1778532806, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/watch.py"} +{"timestamp": 1778532866, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/replay_scanner.py"} +{"timestamp": 1778532926, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/commands/treemap.py"} +{"timestamp": 1778532986, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/commands/replay.py"} +{"timestamp": 1778533046, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/commands/vcs.py"} +{"timestamp": 1778533106, "type": "modified", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/colors.py"} +{"timestamp": 1778533166, "type": "modified", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/render_png.py"} +{"timestamp": 1778533226, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/helpers/scan.py"} +{"timestamp": 1778533286, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/helpers/animation.py"} +{"timestamp": 1778533346, "type": "modified", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/commands/treemap.py"} +{"timestamp": 1778533406, "type": "modified", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/commands/vcs.py"} +{"timestamp": 1778533586, "type": "deleted", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/hg_scanner.py"} +{"timestamp": 1778533646, "type": "created", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/hg_scanner.py"} +{"timestamp": 1778533826, "type": "modified", "path": "/Users/dinugherman/dev/dirplot/src/dirplot/git_scanner.py"} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d73aa5e --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,48 @@ +site_name: dirplot +site_description: Nested treemap visualizations for directory trees and archives +site_url: https://deeplook.github.io/dirplot +repo_url: https://github.com/deeplook/dirplot +repo_name: deeplook/dirplot + +theme: + name: material + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - search.highlight + - content.code.copy + +nav: + - Home: index.md + - CLI Reference: CLI.md + - Examples: EXAMPLES.md + - Archive Formats: ARCHIVES.md + - Python API: API.md + +plugins: + - search + +markdown_extensions: + - tables + - fenced_code + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - pymdownx.snippets + - toc: + permalink: true diff --git a/pyproject.toml b/pyproject.toml index 847d8a9..62a105e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "dirplot" -version = "0.1.1" -description = "Static treemap bitmaps for directory trees, displayed as inline terminal images" +version = "0.4.4" +description = "Nested treemap visualizations for directory trees and archives" readme = "README.md" license = {text = "MIT"} requires-python = ">=3.10" @@ -22,23 +22,31 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Visualization", + "Topic :: System :: Archiving", "Topic :: System :: Filesystems", "Topic :: Terminals", "Topic :: Utilities", "Typing :: Typed", ] dependencies = [ - "matplotlib>=3.7", + "drawsvg>=2.4.1", + "cmap>=0.4", "pillow>=9.0", + "py7zr>=0.20", + "rarfile>=4.0", "squarify>=0.4", "typer>=0.9", + "watchdog>=6.0.0", ] [project.scripts] dirplot = "dirplot.__main__:main" [project.optional-dependencies] +ssh = ["paramiko>=3.0"] +s3 = ["boto3>=1.26"] dev = [ "pytest>=8.0", "pytest-cov>=5.0", @@ -46,6 +54,9 @@ dev = [ "ruff>=0.4", "pre-commit>=3.0", ] +libarchive = [ + "libarchive-c>=5.0", +] [project.urls] Repository = "https://github.com/deeplook/dirplot" @@ -56,6 +67,24 @@ url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true +[tool.hatch.build.targets.sdist] +exclude = [ + "movies/", + "output/", + "misc/", + "scripts/", + "tests/animation/", + "*.cast", + "*.jsonl", + "*.png", + "*.mp4", + "*.mov", + "*.svg", + "PLAN.md", + "TASKS.md", + "DEBUG_DOCKER.md", +] + [tool.hatch.build.targets.wheel] packages = ["src/dirplot"] @@ -66,6 +95,7 @@ packages = ["src/dirplot"] line-length = 100 target-version = "py310" src = ["src", "tests"] +exclude = ["tests/animation"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] @@ -83,7 +113,7 @@ python_version = "3.10" strict = true [[tool.mypy.overrides]] -module = ["matplotlib.*", "squarify", "PIL.*"] +module = ["squarify", "PIL.*", "paramiko.*", "boto3.*", "botocore.*", "py7zr.*", "rarfile", "drawsvg.*", "libarchive.*"] ignore_missing_imports = true # --------------------------------------------------------------------------- @@ -94,6 +124,10 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] addopts = "-v --tb=short" +markers = [ + "docker: requires a running Docker daemon (deselect with -m 'not docker')", + "k8s: requires a running Kubernetes cluster via kubectl (deselect with -m 'not k8s')", +] [tool.coverage.run] source = ["src"] diff --git a/scripts/apng_frames.py b/scripts/apng_frames.py new file mode 100755 index 0000000..d26367a --- /dev/null +++ b/scripts/apng_frames.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""List frame durations in an APNG file.""" + +import struct +import sys +from pathlib import Path + + +def read_apng_frames(path: Path) -> list[dict]: + """Parse APNG chunks and return per-frame info.""" + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("Not a PNG file") + + frames = [] + pos = 8 + while pos < len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + chunk_type = data[pos + 4 : pos + 8] + + if chunk_type == b"acTL": + num_frames = struct.unpack(">I", data[pos + 8 : pos + 12])[0] + num_plays = struct.unpack(">I", data[pos + 12 : pos + 16])[0] + print(f"Animation: {num_frames} frames, loops={num_plays} (0=infinite)") + print() + + elif chunk_type == b"fcTL": + payload = data[pos + 8 : pos + 8 + length] + seq = struct.unpack(">I", payload[0:4])[0] + w = struct.unpack(">I", payload[4:8])[0] + h = struct.unpack(">I", payload[8:12])[0] + x_off = struct.unpack(">I", payload[12:16])[0] + y_off = struct.unpack(">I", payload[16:20])[0] + delay_num = struct.unpack(">H", payload[20:22])[0] + delay_den = struct.unpack(">H", payload[22:24])[0] + if delay_den == 0: + delay_den = 100 + duration_ms = delay_num / delay_den * 1000 + frames.append( + { + "seq": seq, + "size": f"{w}x{h}", + "offset": f"+{x_off}+{y_off}", + "duration_ms": duration_ms, + } + ) + + pos += 12 + length # 4 len + 4 type + data + 4 crc + + return frames + + +def main() -> None: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + path = Path(sys.argv[1]) + frames = read_apng_frames(path) + + if not frames: + print("Not an APNG (no animation chunks found).") + return + + total = 0.0 + for i, f in enumerate(frames): + print(f" Frame {i + 1}: {f['duration_ms']:8.1f}ms {f['size']} {f['offset']}") + total += f["duration_ms"] + + print() + print(f" Total: {total:.1f}ms ({total / 1000:.2f}s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/make_docs_images.sh b/scripts/make_docs_images.sh new file mode 100644 index 0000000..538cc02 --- /dev/null +++ b/scripts/make_docs_images.sh @@ -0,0 +1,16 @@ +uv run dirplot map --size 800x400 --no-show --output docs/fastapi.png github://FastAPI/FastAPI +uv run dirplot map --size 800x400 --no-show --output docs/flask.png github://pallets/flask --legend +uv run dirplot map --size 800x400 --no-show --output docs/python.png github://python/cpython +uv run dirplot map --size 800x400 --no-show --output docs/pypy.png github://pypy/pypy + +uv run dirplot map --size 800x400 --no-show --no-sign --depth 2 --output docs/s3.png s3://noaa-ghcn-pds + +docker run -d --name pg-demo -e POSTGRES_PASSWORD=x postgres:17-alpine +uv run dirplot map --size 800x400 --no-show --log-scale 4 --output docs/docker.png docker://pg-demo:/usr +docker rm -f pg-demo + +kubectl run pg-demo --image=postgres:17-alpine --restart=Never \ + --env POSTGRES_PASSWORD=x +kubectl wait --for=condition=Ready pod/pg-demo --timeout=90s +uv run dirplot map --size 800x400 --no-show --output docs/k8s.png pod://pg-demo/var/ +kubectl delete pod pg-demo --grace-period=0 diff --git a/scripts/make_fixtures.py b/scripts/make_fixtures.py new file mode 100644 index 0000000..2a2d63e --- /dev/null +++ b/scripts/make_fixtures.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +"""Generate archive fixtures for the test suite. + +Creates tests/fixtures/ with sample. in every format supported by +dirplot.archives, built from the same tree as the ``sample_tree`` pytest +fixture: + + README.md (50 B) + docs/ + guide.md (80 B) + src/ + app.py (100 B) + util.py (200 B) + .hidden (10 B) ← present in archive; skipped by scanner + +Run once locally to refresh the on-disk fixtures: + + python scripts/make_fixtures.py + +The pytest ``sample_archives`` session fixture in tests/conftest.py +generates the same files in a temp directory automatically, so running +this script is not required for CI. +""" + +from __future__ import annotations + +import io +import shutil +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +FIXTURES_DIR = REPO_ROOT / "tests" / "fixtures" + +# Mirrors the sample_tree fixture in tests/conftest.py plus a dotfile. +SAMPLE_FILES: list[tuple[str, bytes]] = [ + ("README.md", b"x" * 50), + ("docs/guide.md", b"x" * 80), + ("src/app.py", b"x" * 100), + ("src/util.py", b"x" * 200), + (".hidden", b"x" * 10), +] + + +def _zip_bytes() -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in SAMPLE_FILES: + zf.writestr(name, data) + return buf.getvalue() + + +def _tar_bytes(mode: str) -> bytes: + buf = io.BytesIO() + with tarfile.open(mode=mode, fileobj=buf) as tf: + for name, data in SAMPLE_FILES: + info = tarfile.TarInfo(name=name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _write_7z(dest: Path) -> None: + import py7zr + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + with py7zr.SevenZipFile(dest, "w") as sz: + for name, _ in SAMPLE_FILES: + sz.write(tmp_path / name, name) + + +def _write_rar(dest: Path) -> bool: + rar = shutil.which("rar") + if not rar: + return False + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + subprocess.run( + [rar, "a", "-r", str(dest), "."], cwd=tmp_path, check=True, capture_output=True + ) + return True + + +def _write_bsdtar(dest: Path, fmt: str) -> bool: + """Write an archive using bsdtar (libarchive CLI). Returns False if bsdtar is absent.""" + bsdtar = shutil.which("bsdtar") + if not bsdtar: + return False + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + if fmt == "iso9660": + subprocess.run( + [bsdtar, "-cf", str(dest), "--format", fmt, "-C", str(tmp_path), "."], + check=True, + capture_output=True, + ) + elif fmt == "zstd": + # tar + zstd: --zstd is a compression filter, not a --format value + names = [name for name, _ in SAMPLE_FILES] + subprocess.run( + [bsdtar, "-cf", str(dest), "--zstd"] + names, + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + else: + names = [name for name, _ in SAMPLE_FILES] + subprocess.run( + [bsdtar, "-cf", str(dest), "--format", fmt] + names, + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + return True + + +def _write_rpm(dest: Path) -> bool: + """Write a minimal RPM to *dest* using rpmbuild. Returns False if unavailable. + + rpmbuild is available on Linux (rpm-build package) but not on macOS by default. + libarchive can read RPM but not write it, so we use the rpmbuild CLI here. + """ + rpmbuild = shutil.which("rpmbuild") + if not rpmbuild: + return False + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + # Build tree under BUILDROOT as required by rpmbuild + buildroot = tmp_path / "BUILDROOT" / "sample-1.0-1.noarch" + for name, data in SAMPLE_FILES: + f = buildroot / name.lstrip("/") + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + spec = tmp_path / "sample.spec" + spec.write_text( + "%define _topdir {tmp}\n" + "%define _rpmdir {tmp}/RPMS\n" + "%define _builddir {tmp}/BUILD\n" + "%define _srcrpmdir {tmp}/SRPMS\n" + "%define _build_name_fmt %%{{NAME}}-%%{{VERSION}}-%%{{RELEASE}}.%%{{ARCH}}.rpm\n" + "Name: sample\n" + "Version: 1.0\n" + "Release: 1\n" + "Summary: dirplot test fixture\n" + "License: MIT\n" + "BuildArch: noarch\n" + "%description\ndirplot test fixture\n" + "%files\n" + + "".join(f"/{name}\n" for name, _ in SAMPLE_FILES if not name.startswith(".")) + + "%changelog\n", + ).format(tmp=tmp_path) + subprocess.run( + [rpmbuild, "-bb", "--buildroot", str(buildroot), str(spec)], + check=True, + capture_output=True, + ) + rpms = list((tmp_path / "RPMS").rglob("*.rpm")) + if not rpms: + return False + dest.write_bytes(rpms[0].read_bytes()) + return True + + +def _write_ar(dest: Path) -> None: + """Write a Unix ar archive using the `ar` CLI (always available via binutils/Xcode CLT).""" + ar = shutil.which("ar") + assert ar, "`ar` not found — install Xcode Command Line Tools or binutils" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + members = [] + for name, data in SAMPLE_FILES: + # ar archives are flat; use basename only (slashes not supported) + flat_name = name.replace("/", "_") + f = tmp_path / flat_name + f.write_bytes(data) + members.append(flat_name) + subprocess.run( + [ar, "rcs", str(dest)] + members, + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + + +def _write_cab(dest: Path) -> bool: + """Write a Cabinet archive using gcab (Linux) or makecab (Windows). Returns False if absent.""" + gcab = shutil.which("gcab") or shutil.which("makecab") + if not gcab: + return False + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + flat_files = [] + for name, _ in SAMPLE_FILES: + flat_files.append(str(tmp_path / name)) + subprocess.run( + [gcab, "-c", str(dest)] + flat_files, + check=True, + capture_output=True, + ) + return True + + +def _write_lha(dest: Path) -> bool: + """Write an LHA archive using the `lha` CLI. Returns False if absent. + + Note: lhasa (common on Linux) is extract-only. The `lha` package + (brew install lha on macOS, lhamt on some Linux distros) can create archives. + """ + lha = shutil.which("lha") + if not lha: + return False + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + subprocess.run( + [lha, "a", str(dest)] + [name for name, _ in SAMPLE_FILES], + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + return True + + +def main() -> None: + FIXTURES_DIR.mkdir(parents=True, exist_ok=True) + + zip_bytes = _zip_bytes() + + tasks: list[tuple[str, bytes | None]] = [ + # ZIP and synonyms (same bytes, different extensions) + ("sample.zip", zip_bytes), + ("sample.jar", zip_bytes), + ("sample.war", zip_bytes), + ("sample.ear", zip_bytes), + ("sample.whl", zip_bytes), + ("sample.apk", zip_bytes), + ("sample.epub", zip_bytes), + ("sample.xpi", zip_bytes), + ("sample.nupkg", zip_bytes), + ("sample.vsix", zip_bytes), + ("sample.ipa", zip_bytes), + ("sample.aab", zip_bytes), + # tar variants + ("sample.tar", _tar_bytes("w:")), + ("sample.tar.gz", _tar_bytes("w:gz")), + ("sample.tgz", _tar_bytes("w:gz")), + ("sample.tar.bz2", _tar_bytes("w:bz2")), + ("sample.tbz2", _tar_bytes("w:bz2")), + ("sample.tar.xz", _tar_bytes("w:xz")), + ("sample.txz", _tar_bytes("w:xz")), + ] + + for filename, data in tasks: + dest = FIXTURES_DIR / filename + assert data is not None + dest.write_bytes(data) + print(f" {filename:30s} {dest.stat().st_size:>8,} B") + + # 7z + dest_7z = FIXTURES_DIR / "sample.7z" + _write_7z(dest_7z) + print(f" {'sample.7z':30s} {dest_7z.stat().st_size:>8,} B") + + # RAR + dest_rar = FIXTURES_DIR / "sample.rar" + if _write_rar(dest_rar): + print(f" {'sample.rar':30s} {dest_rar.stat().st_size:>8,} B") + else: + print(f" {'sample.rar':30s} skipped (rar CLI not found)", file=sys.stderr) + + # libarchive-based formats (require bsdtar) + for filename, fmt in [ + ("sample.cpio", "cpio"), + ("sample.xar", "xar"), + ("sample.iso", "iso9660"), + ("sample.tar.zst", "zstd"), + ("sample.tzst", "zstd"), + ]: + dest_la = FIXTURES_DIR / filename + if _write_bsdtar(dest_la, fmt): + print(f" {filename:30s} {dest_la.stat().st_size:>8,} B") + else: + print(f" {filename:30s} skipped (bsdtar CLI not found)", file=sys.stderr) + + # RPM (requires rpmbuild, typically available on Linux) + dest_rpm = FIXTURES_DIR / "sample.rpm" + if _write_rpm(dest_rpm): + print(f" {'sample.rpm':30s} {dest_rpm.stat().st_size:>8,} B") + else: + print(f" {'sample.rpm':30s} skipped (rpmbuild not found)", file=sys.stderr) + + # .a — Unix static library (ar archive); `ar` is always available (binutils / Xcode CLT) + dest_a = FIXTURES_DIR / "sample.a" + _write_ar(dest_a) + print(f" {'sample.a':30s} {dest_a.stat().st_size:>8,} B") + + # .cab — Microsoft Cabinet (gcab on Linux, typically absent on macOS) + dest_cab = FIXTURES_DIR / "sample.cab" + if _write_cab(dest_cab): + print(f" {'sample.cab':30s} {dest_cab.stat().st_size:>8,} B") + else: + print(f" {'sample.cab':30s} skipped (gcab not found)", file=sys.stderr) + + # .lha / .lzh — LHA (lha CLI; brew install lha on macOS, lhasa on Linux is extract-only) + for filename in ("sample.lha", "sample.lzh"): + dest_lha = FIXTURES_DIR / filename + if _write_lha(dest_lha): + print(f" {filename:30s} {dest_lha.stat().st_size:>8,} B") + else: + print(f" {filename:30s} skipped (lha not found)", file=sys.stderr) + + print(f"\nFixtures written to {FIXTURES_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/watch_events.py b/scripts/watch_events.py new file mode 100755 index 0000000..8af4d6e --- /dev/null +++ b/scripts/watch_events.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Watch directories and log filesystem events to a CSV file. + +Usage: watch_events.py [-o events.csv] DIR [DIR ...] + +Streams events until Ctrl-C. Each row: timestamp, event_type, src_path, dest_path. +""" + +import csv +import signal +import sys +import time +from pathlib import Path + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +FIELDNAMES = ["timestamp", "event_type", "src_path", "dest_path"] + + +class CSVEventHandler(FileSystemEventHandler): + def __init__(self, writer: csv.DictWriter, stream) -> None: + super().__init__() + self._writer = writer + self._stream = stream + + def _write(self, verb: str, event: FileSystemEvent) -> None: + src = event.src_path + src_s = src.decode() if isinstance(src, bytes) else src + dest = getattr(event, "dest_path", None) + dest_s = (dest.decode() if isinstance(dest, bytes) else dest) or "" + self._writer.writerow( + { + "timestamp": f"{time.time():.6f}", + "event_type": verb, + "src_path": src_s, + "dest_path": dest_s, + } + ) + self._stream.flush() + + def on_created(self, event: FileSystemEvent) -> None: + self._write("created", event) + + def on_deleted(self, event: FileSystemEvent) -> None: + self._write("deleted", event) + + def on_modified(self, event: FileSystemEvent) -> None: + self._write("modified", event) + + def on_moved(self, event: FileSystemEvent) -> None: + self._write("moved", event) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="Watch directories and log events to CSV.") + parser.add_argument("dirs", nargs="+", type=Path, help="Directories to watch") + parser.add_argument( + "-o", "--output", type=Path, default=None, help="Output CSV file (default: stdout)" + ) + args = parser.parse_args() + + for d in args.dirs: + if not d.is_dir(): + print(f"Error: {d} is not a directory", file=sys.stderr) + sys.exit(1) + + stream = ( + open(args.output, "w", newline="", encoding="utf-8") # noqa: SIM115 + if args.output + else sys.stdout + ) + + writer = csv.DictWriter(stream, fieldnames=FIELDNAMES, quoting=csv.QUOTE_ALL) + writer.writeheader() + stream.flush() + + handler = CSVEventHandler(writer, stream) + observer = Observer() + for d in args.dirs: + observer.schedule(handler, str(d), recursive=True) + + observer.start() + roots = ", ".join(str(d) for d in args.dirs) + print(f"Watching {roots} (Ctrl-C to stop)", file=sys.stderr) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + signal.signal(signal.SIGINT, signal.SIG_IGN) + observer.stop() + observer.join() + if args.output: + stream.close() + print(f"Wrote {args.output}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/dirplot/__init__.py b/src/dirplot/__init__.py index 67432da..72fd049 100644 --- a/src/dirplot/__init__.py +++ b/src/dirplot/__init__.py @@ -1,3 +1,17 @@ from importlib.metadata import version +from dirplot.display import display_inline, display_window +from dirplot.render_png import create_treemap +from dirplot.scanner import apply_log_sizes, build_tree +from dirplot.svg_render import create_treemap_svg + __version__ = version("dirplot") + +__all__ = [ + "build_tree", + "apply_log_sizes", + "create_treemap", + "create_treemap_svg", + "display_inline", + "display_window", +] diff --git a/src/dirplot/__main__.py b/src/dirplot/__main__.py index 4fbb4ce..6818695 100644 --- a/src/dirplot/__main__.py +++ b/src/dirplot/__main__.py @@ -1,9 +1,50 @@ """Entry point for ``python -m dirplot``.""" +import os +import sys + +# Handle --no-color and TERM=dumb before any Rich/Typer imports so the env var +# takes effect at console creation time (Rich reads NO_COLOR in Console.__init__). +if "--no-color" in sys.argv: + os.environ["NO_COLOR"] = "1" + sys.argv = [a for a in sys.argv if a != "--no-color"] +if os.environ.get("TERM") == "dumb": + os.environ["NO_COLOR"] = "1" +if os.environ.get("FORCE_COLOR"): + os.environ.pop("NO_COLOR", None) + from dirplot.main import app +def _inject_legend_default(argv: list[str]) -> list[str]: + """If ``--legend`` appears without a following integer, insert ``20``. + + This lets users write ``--legend`` as a bare flag (meaning "use the + default of 20 entries") while also allowing ``--legend 10`` for a + custom limit. + """ + result: list[str] = [] + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "--legend": + result.append(arg) + next_arg = argv[i + 1] if i + 1 < len(argv) else "" + try: + int(next_arg) + result.append(next_arg) + i += 2 + except ValueError: + result.append("20") + i += 1 + else: + result.append(arg) + i += 1 + return result + + def main() -> None: + sys.argv[1:] = _inject_legend_default(sys.argv[1:]) app() diff --git a/src/dirplot/_overview.py b/src/dirplot/_overview.py new file mode 100644 index 0000000..3981068 --- /dev/null +++ b/src/dirplot/_overview.py @@ -0,0 +1,230 @@ +"""_overview.py: Reusable overview command for Typer applications. + +This module provides a plug-and-play `overview` command that prints a +human-readable summary of any Typer application, including: +- Application name and description +- Global options (from @app.callback) +- All commands with their arguments and options +- Nested sub-commands (from add_typer) at any depth + +Usage: + from dirplot._overview import add_overview_command + + add_overview_command(app) # call after defining all commands + +Note: + Call add_overview_command() AFTER registering all commands and sub-apps + to ensure they appear in the overview output. +""" + +from importlib import metadata +from typing import Any + +import click +import typer + + +def add_overview_command( + app: typer.Typer, + name: str = "overview", + help_text: str = ( + "Display an overview of all commands, global options, and" + " command-specific options/arguments." + ), +) -> None: + """ + Register an overview command on the given Typer application. + + Args: + app: The Typer application to add the overview command to. + name: The name of the overview command (default: "overview"). + help_text: Help text for the overview command. + """ + + @app.command(name=name, help=help_text) + def overview_command() -> None: + _print_overview(app) + + +def _print_overview(app: typer.Typer) -> None: + """Print the full application overview.""" + typer.echo("Application Overview") + typer.echo("=" * 80) + + # Build the Click command/group from the Typer app. + click_group = typer.main.get_command(app) + + app_name, app_description, app_version = _resolve_app_metadata(app, click_group) + commands = getattr(click_group, "commands", {}) or {} + global_params = getattr(click_group, "params", []) or [] + global_option_count = sum(isinstance(param, click.Option) for param in global_params) + + typer.echo("\nApplication") + typer.echo(f" Name : {app_name}") + typer.echo(f" Description : {app_description}") + if app_version: + typer.echo(f" Version : {app_version}") + typer.echo(f" Commands : {len(commands)} top-level") + typer.echo(f" Global opts : {global_option_count}") + + # Global options (from callback / group params) + typer.echo("\nGlobal Options") + if global_params: + for param in global_params: + _print_param(param, is_global=True) + else: + typer.echo(" (none)") + + # Commands (with recursive handling of nested groups) + typer.echo("\nCommands") + _print_commands(click_group, indent=1) + + +def _print_commands( + group: click.Command, + indent: int = 1, + max_depth: int = 10, + seen: set[int] | None = None, +) -> None: + """Recursively print commands, handling nested command groups.""" + if seen is None: + seen = set() + + # Cycle detection + group_id = id(group) + if group_id in seen: + typer.echo(" " * indent + "(circular reference detected)") + return + seen.add(group_id) + + # Depth limit + if indent > max_depth: + typer.echo(" " * indent + "(max depth reached)") + return + + commands = getattr(group, "commands", {}) or {} + if not commands: + typer.echo(" " * indent + "(no commands registered)") + return + + base_indent = " " * indent + + for cmd_name in sorted(commands.keys()): + cmd = commands[cmd_name] + cmd_help = getattr(cmd, "help", None) or "(no description)" + + # Check if this command is itself a group (nested sub-application) + is_group = isinstance(cmd, click.Group) + group_marker = " [group]" if is_group else "" + + typer.echo(f"\n{base_indent}{cmd_name}{group_marker}") + typer.echo(f"{base_indent} Help : {cmd_help}") + + # Print group-level options if this is a nested group + if is_group: + group_params = getattr(cmd, "params", []) or [] + group_opts = [p for p in group_params if isinstance(p, click.Option)] + if group_opts: + typer.echo(f"{base_indent} Group Options:") + for opt in group_opts: + _print_param(opt, indent=indent + 2) + + # Recursively print sub-commands + typer.echo(f"{base_indent} Sub-commands:") + _print_commands(cmd, indent=indent + 2, max_depth=max_depth, seen=seen) + else: + # Regular command: print its parameters + params = getattr(cmd, "params", []) or [] + if not params: + typer.echo(f"{base_indent} Parameters : (none)") + continue + + args = [p for p in params if isinstance(p, click.Argument)] + opts = [p for p in params if isinstance(p, click.Option)] + + if args: + typer.echo(f"{base_indent} Arguments:") + for arg in args: + _print_param(arg, indent=indent + 2) + + if opts: + typer.echo(f"{base_indent} Options:") + for opt in opts: + _print_param(opt, indent=indent + 2) + + +def _print_param(param: Any, is_global: bool = False, indent: int = 3) -> None: + """Helper to format and display a Click Parameter object.""" + base_indent = " " * indent + prefix = "Global " if is_global else "" + if isinstance(param, click.Option): + names = ", ".join(param.opts) if getattr(param, "opts", None) else (param.name or "?") + help_text = getattr(param, "help", None) + else: + # click.Argument has no .opts and no .help + names = param.name or "?" + help_text = None + try: + type_name = param.type.name if hasattr(param.type, "name") else str(param.type) + except (AttributeError, TypeError): + type_name = "unknown" + default_str = ( + f" (default: {param.default!r})" if param.default is not None and not param.required else "" + ) + required_str = " [required]" if param.required else "" + + line = f"{base_indent}{names:<18} : {type_name}{default_str}{required_str}" + if help_text: + line += f" — {help_text}" + + typer.echo(prefix + line) + + +def _resolve_app_metadata( + app: typer.Typer, click_group: click.Command +) -> tuple[str, str, str | None]: + """Return a best-effort (name, description, version) tuple for the app.""" + app_name = _clean_text(getattr(click_group, "name", None)) + app_description = _clean_text(getattr(click_group, "help", None)) + app_version: str | None = None + + package_name = _infer_package_name(app) + if package_name: + app_name = app_name or package_name + dist_names = metadata.packages_distributions().get(package_name, [package_name]) + for dist_name in dist_names: + try: + meta = metadata.metadata(dist_name) + app_name = app_name or _clean_text(meta.get("Name")) # type: ignore[attr-defined] + app_description = app_description or _clean_text(meta.get("Summary")) # type: ignore[attr-defined] + app_version = metadata.version(dist_name) + break + except metadata.PackageNotFoundError: + continue + + return app_name or "(unnamed)", app_description or "(no description)", app_version + + +def _infer_package_name(app: typer.Typer) -> str | None: + """Infer the top-level package name from the app callback or commands.""" + callback_info = getattr(app, "registered_callback", None) + callback = getattr(callback_info, "callback", None) + module_name = getattr(callback, "__module__", None) + if isinstance(module_name, str) and module_name and module_name != "__main__": + return module_name.split(".", 1)[0] + + for command_info in getattr(app, "registered_commands", []): + command_callback = getattr(command_info, "callback", None) + module_name = getattr(command_callback, "__module__", None) + if isinstance(module_name, str) and module_name and module_name != "__main__": + return module_name.split(".", 1)[0] + + return None + + +def _clean_text(value: Any) -> str | None: + """Normalise help text for compact single-line overview output.""" + if not isinstance(value, str): + return None + cleaned = " ".join(value.split()) + return cleaned or None diff --git a/src/dirplot/app.py b/src/dirplot/app.py new file mode 100644 index 0000000..efc0477 --- /dev/null +++ b/src/dirplot/app.py @@ -0,0 +1,45 @@ +"""Typer application instance and top-level callback.""" + +import typer + +from dirplot import __version__ + +app = typer.Typer( + context_settings={"help_option_names": ["-h", "--help"]}, + rich_markup_mode="rich", + epilog="Docs & issues: https://github.com/deeplook/dirplot", +) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(__version__) + raise typer.Exit() + + +@app.callback(invoke_without_command=True) +def _app_callback( + ctx: typer.Context, + version: bool = typer.Option( + False, + "--version", + "-V", + callback=_version_callback, + is_eager=True, + help="Show version and exit", + ), + no_color: bool = typer.Option( + False, + "--no-color", + help="Disable ANSI color output (equivalent to setting NO_COLOR=1).", + is_eager=True, + hidden=False, + ), +) -> None: + if no_color: + import os + + os.environ["NO_COLOR"] = "1" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + raise typer.Exit() diff --git a/src/dirplot/archives.py b/src/dirplot/archives.py new file mode 100644 index 0000000..7823af3 --- /dev/null +++ b/src/dirplot/archives.py @@ -0,0 +1,370 @@ +"""Archive file scanning (zip, tar, 7z, rar, libarchive) as virtual directory trees.""" + +from __future__ import annotations + +import tarfile +import zipfile +from collections import defaultdict +from pathlib import Path, PurePosixPath + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + +ARCHIVE_SUFFIXES = frozenset( + { + ".zip", + ".tar", + ".tgz", + ".tbz2", + ".txz", + ".tzst", + ".7z", + ".rar", + # ZIP-based formats + ".jar", + ".war", + ".ear", + ".whl", + ".apk", + ".epub", + ".xpi", + ".nupkg", + ".vsix", + ".ipa", + ".aab", + # libarchive-handled formats + ".dmg", + ".pkg", + ".img", + ".iso", + ".xar", + ".cpio", + ".rpm", + ".cab", + ".lha", + ".lzh", + ".a", + ".ar", + } +) +COMPOUND_SUFFIXES = frozenset({".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst"}) + + +def is_archive_path(s: str) -> bool: + """Return True if *s* ends with a known archive extension.""" + name = Path(s).name.lower() + return any(name.endswith(suf) for suf in COMPOUND_SUFFIXES | ARCHIVE_SUFFIXES) + + +def _archive_type(path: Path) -> str: + name = path.name.lower() + if any( + name.endswith(s) + for s in (".tar.gz", ".tar.bz2", ".tar.xz", ".tgz", ".tbz2", ".txz", ".tar") + ): + return "tar" + if any( + name.endswith(s) + for s in ( + ".zip", + ".jar", + ".war", + ".ear", + ".whl", + ".apk", + ".epub", + ".xpi", + ".nupkg", + ".vsix", + ".ipa", + ".aab", + ) + ): + return "zip" + if name.endswith(".7z"): + return "7z" + if name.endswith(".rar"): + return "rar" + if any( + name.endswith(s) + for s in ( + ".dmg", + ".pkg", + ".img", + ".iso", + ".xar", + ".cpio", + ".rpm", + ".cab", + ".lha", + ".lzh", + ".a", + ".ar", + ".tar.zst", + ".tzst", + ) + ): + return "libarchive" + raise ValueError(f"Unsupported archive: {path.name}") + + +class PasswordRequired(Exception): + """Raised when an archive is encrypted and no password has been supplied.""" + + +def _root_name(path: Path) -> str: + """Strip archive suffix(es) to get the display name.""" + name = path.name.lower() + for suf in COMPOUND_SUFFIXES: + if name.endswith(suf): + return Path(path.stem).stem + return path.stem + + +def _read_zip(path: Path, password: str | None = None) -> list[tuple[str, int, bool]]: + """Return (member_path, size, is_dir) tuples for a zip archive. + + ZIP central-directory metadata (names, sizes) is stored unencrypted even in + password-protected archives, so a password is rarely needed here. It is + accepted for completeness and forwarded to ZipFile.setpassword(). + """ + entries: list[tuple[str, int, bool]] = [] + try: + with zipfile.ZipFile(path) as zf: + if password is not None: + zf.setpassword(password.encode()) + for info in zf.infolist(): + is_dir = info.filename.endswith("/") + member_path = info.filename.rstrip("/") + size = info.file_size + entries.append((member_path, size, is_dir)) + except RuntimeError as exc: + if "encrypted" in str(exc).lower() or "password" in str(exc).lower(): + raise PasswordRequired(path.name) from exc + raise + return entries + + +def _read_tar(path: Path) -> list[tuple[str, int, bool]]: + """Return (member_path, size, is_dir) tuples for a tar archive, skipping symlinks.""" + entries: list[tuple[str, int, bool]] = [] + with tarfile.open(path) as tf: + for member in tf.getmembers(): + if member.issym() or member.islnk(): + continue + member_path = member.name + while member_path.startswith("./"): + member_path = member_path[2:] + member_path = member_path.lstrip("/") + if not member_path: + continue + is_dir = member.isdir() + size = member.size + entries.append((member_path, size, is_dir)) + return entries + + +def _read_rar(path: Path, password: str | None = None) -> list[tuple[str, int, bool]]: + """Return (member_path, size, is_dir) tuples for a RAR archive.""" + import shutil + + import rarfile + + if not shutil.which(rarfile.UNRAR_TOOL): + raise RuntimeError( + "unrar not found on PATH. Install it to read RAR archives:\n" + " macOS: brew install rar\n" + " Linux: apt install unrar / dnf install unrar\n" + " Windows: https://www.rarlab.com/download.htm" + ) + + entries: list[tuple[str, int, bool]] = [] + try: + with rarfile.RarFile(path) as rf: + if password is not None: + rf.setpassword(password) + for info in rf.infolist(): + is_dir = info.is_dir() + member_path = info.filename.rstrip("/") + size = info.file_size + entries.append((member_path, size, is_dir)) + except (rarfile.PasswordRequired, rarfile.RarWrongPassword) as exc: + raise PasswordRequired(path.name) from exc + return entries + + +def _read_libarchive(path: Path, password: str | None = None) -> list[tuple[str, int, bool]]: + """Return (member_path, size, is_dir) tuples using the libarchive-c package. + + Handles formats not covered by stdlib or bundled libraries: .iso, .cpio, + .xar, .pkg, .dmg, .img, .rpm, .cab, .lha/.lzh, .a/.ar, .tar.zst/.tzst, + and any other format that the installed system libarchive supports. + + Raises: + ImportError: if ``libarchive-c`` is not installed. + PasswordRequired: if the archive is encrypted and no password was given. + OSError: if the archive cannot be opened (unsupported format, corrupted). + """ + try: + import libarchive + except ImportError as exc: + raise ImportError( + f"libarchive-c is required to read {path.suffix} archives. " + "Install it with: pip install 'dirplot[libarchive]'\n" + "(The system libarchive library must also be present: " + "brew install libarchive or apt install libarchive-dev)" + ) from exc + + entries: list[tuple[str, int, bool]] = [] + try: + with libarchive.file_reader(str(path), passphrase=password) as archive: + for entry in archive: + if entry.issym or entry.islnk: + continue + member_path = entry.pathname.rstrip("/") + # Skip root-directory placeholders ('.', '', '/') + if not member_path or member_path == ".": + continue + # Strip leading './' + while member_path.startswith("./"): + member_path = member_path[2:] + if not member_path: + continue + is_dir = entry.isdir + size = entry.size or 0 + entries.append((member_path, size, is_dir)) + except Exception as exc: + msg = str(exc).lower() + if "passphrase" in msg or "password" in msg or "encrypted" in msg: + raise PasswordRequired(path.name) from exc + raise OSError( + f"Cannot open {path.name}: {exc}\n" + "The file format may not be supported by the installed libarchive, " + "or the archive may be encrypted/corrupted." + ) from exc + return entries + + +def _read_7z(path: Path, password: str | None = None) -> list[tuple[str, int, bool]]: + """Return (member_path, size, is_dir) tuples for a 7z archive.""" + import py7zr + + entries: list[tuple[str, int, bool]] = [] + try: + with py7zr.SevenZipFile(path, mode="r", password=password) as sz: + for info in sz.list(): + member_path = info.filename.rstrip("/") + is_dir = info.is_directory + size = info.uncompressed or 0 + entries.append((member_path, size, is_dir)) + except py7zr.exceptions.PasswordRequired as exc: + raise PasswordRequired(path.name) from exc + return entries + + +def _entries_to_tree( + entries: list[tuple[str, int, bool]], + root_name: str, + exclude: frozenset[str], + depth: int | None, +) -> Node: + """Build a Node tree from a flat list of (path, size, is_dir) tuples.""" + # Collect all entries and synthesize any missing intermediate directories + all_entries: dict[str, tuple[int, bool]] = {} + for member_path, size, is_dir in entries: + if not member_path: + continue + all_entries[member_path] = (size, is_dir) + for ancestor in PurePosixPath(member_path).parents: + anc_str = str(ancestor) + if anc_str not in (".", "") and anc_str not in all_entries: + all_entries[anc_str] = (0, True) + + by_parent: dict[str, list[tuple[str, int, bool, str]]] = defaultdict(list) + for member_path, (size, is_dir) in all_entries.items(): + p = PurePosixPath(member_path) + parent = str(p.parent) + if parent == ".": + parent = "" + name = p.name + by_parent[parent].append((member_path, size, is_dir, name)) + + def recurse(prefix: str, name: str, current_depth: int | None) -> Node: + children: list[Node] = [] + for member_path, size, is_dir, child_name in sorted( + by_parent.get(prefix, []), key=lambda t: t[3] + ): + if child_name.startswith("."): + continue + if matches_exclude(member_path, exclude): + continue + if is_dir: + if current_depth is not None and current_depth <= 1: + child: Node = Node( + name=child_name, + path=Path(member_path), + size=1, + is_dir=True, + extension="", + ) + else: + child = recurse( + member_path, + child_name, + None if current_depth is None else current_depth - 1, + ) + else: + ext = PurePosixPath(child_name).suffix.lower() or NO_EXT + child = Node( + name=child_name, + path=Path(member_path), + size=max(1, size), + is_dir=False, + extension=ext, + ) + children.append(child) + + total = sum(c.size for c in children) or 1 + return Node( + name=name, + path=Path(prefix) if prefix else Path(root_name), + size=total, + is_dir=True, + extension="", + children=children, + ) + + return recurse("", root_name, depth) + + +def build_tree_archive( + path: Path, + *, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, + password: str | None = None, +) -> Node: + """Read an archive file and return a Node tree of its contents. + + Args: + path: Path to the archive file. + exclude: Set of member names or paths to skip. + depth: Maximum recursion depth. ``None`` means unlimited. + password: Passphrase for encrypted archives. When ``None`` and the + archive is encrypted, ``PasswordRequired`` is raised so the caller + can prompt the user and retry. + """ + kind = _archive_type(path) + if kind == "zip": + entries = _read_zip(path, password) + elif kind == "tar": + entries = _read_tar(path) + elif kind == "7z": + entries = _read_7z(path, password) + elif kind == "libarchive": + entries = _read_libarchive(path, password) + else: + entries = _read_rar(path, password) + + root_name = _root_name(path) + return _entries_to_tree(entries, root_name, exclude, depth) diff --git a/src/dirplot/colors.py b/src/dirplot/colors.py index b32d8e9..cab680d 100644 --- a/src/dirplot/colors.py +++ b/src/dirplot/colors.py @@ -2,11 +2,7 @@ from typing import Any -import matplotlib - -matplotlib.use("Agg") - -import matplotlib.pyplot as plt # noqa: E402 +import cmap as _cmap_lib # Matplotlib RGBA colour as returned by a colormap call. RGBAColor = tuple[float, float, float, float] @@ -720,12 +716,13 @@ def assign_colors(extensions: list[str], colormap: str = "tab20") -> dict[str, R import hashlib use_linguist = colormap == "tab20" - cmap: Any = plt.get_cmap(colormap) - n: int = int(cmap.N) if hasattr(cmap, "N") else 256 + cm: Any = _cmap_lib.Colormap(colormap) + n: int = 256 result: dict[str, RGBAColor] = {} for ext in set(extensions): if use_linguist and ext.lower() in _LINGUIST: result[ext] = _hex_to_rgba(_LINGUIST[ext.lower()]) else: - result[ext] = tuple(cmap((int(hashlib.md5(ext.encode()).hexdigest(), 16) % n) / n)) + rgba = cm((int(hashlib.md5(ext.encode()).hexdigest(), 16) % n) / n) + result[ext] = (float(rgba[0]), float(rgba[1]), float(rgba[2]), float(rgba[3])) return result diff --git a/src/dirplot/commands/__init__.py b/src/dirplot/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dirplot/commands/diff.py b/src/dirplot/commands/diff.py new file mode 100644 index 0000000..e6decbe --- /dev/null +++ b/src/dirplot/commands/diff.py @@ -0,0 +1,456 @@ +"""The ``diff`` command: treemap of two directory trees with diff highlights.""" + +from __future__ import annotations + +import os +import subprocess +import time +import webbrowser +from pathlib import Path + +import typer + +from dirplot.app import app +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE +from dirplot.display import display_inline, display_window +from dirplot.scanner import prune_to_subtrees +from dirplot.terminal import default_canvas_size, get_terminal_size + +# Border colours for diff status — applied to file tile borders only. +# Fill colours remain the standard Linguist/colormap palette. +DIFF_COLORS: dict[str, str] = { + "removed": "deleted", # red — in A but not in B + "added": "created", # green — in B but not in A + "changed": "modified", # blue — in both, but size differs +} + +_DIFF_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot diff src/ build/ [dim]# compare two local directories[/dim]\n\n" + " dirplot diff v1/ v2/ --output diff.png [dim]# save to file[/dim]\n\n" + " dirplot diff v1/ v2/ --no-show --output diff.svg [dim]# SVG output[/dim]\n\n" + " dirplot diff v1/ v2/ --size 1920x1080 [dim]# fixed resolution[/dim]\n\n" + " dirplot diff v1/ v2/ --depth 3 [dim]# limit directory depth[/dim]\n\n" + " dirplot diff github://owner/repo@v1 github://owner/repo@v2" + " [dim]# compare two GitHub tags[/dim]\n\n" + " dirplot diff archive_v1.tar.gz archive_v2.zip [dim]# compare two archives[/dim]\n\n" + "\n[bold]Highlight colours (borders only)[/bold]\n\n" + " [green]green[/green] — added (in B, not in A)\n" + " [red]red[/red] — removed (in A, not in B)\n" + " [blue]blue[/blue] — changed (in both, but size differs in B)\n" +) + + +@app.command(name="diff", epilog=_DIFF_EPILOG) +def diff_cmd( + tree_a: str = typer.Argument( + ..., + metavar="A", + help="Source tree (baseline), or a local git/hg repo for uncommitted changes", + ), + tree_b: str | None = typer.Argument( + None, metavar="B", help="Target tree (comparison). Omit to diff A against its working tree." + ), + output: Path | None = typer.Option(None, "--output", "-o", help="Save image to file"), + fmt: str | None = typer.Option( + None, + "--format", + help="Output format: png or svg (inferred from --output extension if omitted)", + metavar="FORMAT", + ), + show: bool = typer.Option(True, "--show/--no-show", help="Display the image after rendering"), + inline: bool = typer.Option( + False, + "--inline", + help="Show in terminal (auto-detects iTerm2/Kitty protocol) instead of a separate window", + ), + font_size: int = typer.Option( + DEFAULT_FONT_SIZE, "--font-size", help="Directory label font size in pixels" + ), + colormap: str = typer.Option( + DEFAULT_COLORMAP, + "--colormap", + help="Colormap for file-extension fill colours (default: tab20 uses Linguist palette)", + ), + exclude: list[str] = typer.Option([], "--exclude", "-e", help="Paths to exclude (repeatable)"), + include: list[str] = typer.Option( + [], + "--include", + help="Show only this subtree (repeatable; supports nested paths). Allowlist complement to --exclude.", # noqa: E501 + ), + depth: int | None = typer.Option(None, "--depth", help="Maximum directory depth"), + size: str | None = typer.Option( + None, "--size", help="Output dimensions as WIDTHxHEIGHT", metavar="WIDTHxHEIGHT" + ), + cushion: bool = typer.Option(True, "--cushion/--no-cushion", help="Van Wijk cushion shading"), + dark: bool = typer.Option(True, "--dark/--light", help="Dark background (default) or light"), + log_scale: float = typer.Option( + 0.0, + "--log-scale", + help="Log-scale compression ratio (> 1 to enable)", + show_default=True, + ), + context: bool = typer.Option( + True, + "--context/--no-context", + help="Include unchanged files for context. --no-context shows only diff files.", + ), + header: bool = typer.Option(True, "--header/--no-header", help="Print info lines to stderr"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output"), + ssh_key: str | None = typer.Option( + None, "--ssh-key", help="SSH private key file (default: ~/.ssh/id_rsa)" + ), + aws_profile: str | None = typer.Option( + None, "--aws-profile", envvar="AWS_PROFILE", help="AWS profile name for S3 access" + ), + no_sign: bool = typer.Option( + False, "--no-sign", help="Skip AWS signing for anonymous access to public S3 buckets" + ), + k8s_namespace: str | None = typer.Option( + None, "--k8s-namespace", help="Kubernetes namespace (overrides @namespace in pod URL)" + ), + k8s_container: str | None = typer.Option( + None, "--k8s-container", help="Container name for multi-container pods" + ), + password_file: Path | None = typer.Option( + None, + "--password-file", + help="File containing the archive password (avoids exposing it in shell history).", + metavar="FILE", + ), + ssh_password_file: Path | None = typer.Option( + None, + "--ssh-password-file", + help="File containing the SSH password (avoids exposing it in shell history).", + metavar="FILE", + ), + github_token_file: Path | None = typer.Option( + None, + "--github-token-file", + help="File containing a GitHub personal access token (avoids exposing it in shell history).", # noqa: E501 + metavar="FILE", + ), + no_input: bool = typer.Option( + False, + "--no-input", + help="Disable all interactive prompts; fail instead of prompting for passwords.", + ), +) -> None: + """Compare two directory trees A and B as a treemap with diff highlights. + + Example: dirplot diff old/ new/ --inline + + A and B can be local directories, GitHub repos (github://owner/repo[@ref]), + archives (.zip/.tar.gz), S3 paths (s3://bucket/prefix), SSH paths, Docker + containers, or Kubernetes pods — any source supported by the map command. + + Files are sized by their size in B (the target tree). Borders indicate + diff status: [green]green[/green] = added, [red]red[/red] = removed, + [blue]blue[/blue] = changed (size differs). + Unchanged files show no border. Use --no-context to hide them entirely. + """ + import sys + + import cmap as _cmap_lib + + from dirplot.git_scanner import ( + build_node_tree, + build_tree_git_worktree, + git_file_hashes, + git_worktree_hashes, + is_git_ref_path, + parse_git_ref_path, + ) + from dirplot.helpers.scan import scan_tree + from dirplot.hg_scanner import ( + build_tree_hg_worktree, + hg_worktree_hashes, + is_hg_repo, + ) + from dirplot.render_png import create_treemap + from dirplot.scanner import apply_log_sizes + from dirplot.svg_render import create_treemap_svg + + def _info(msg: str) -> None: + if not quiet and header: + typer.echo(msg, err=True) + + # Single-argument shorthand: `dirplot diff .` → `dirplot diff .@HEAD .` + resolved_b: str + if tree_b is None: + p = Path(tree_a) + is_git = ( + p.is_dir() + and subprocess.run( + ["git", "-C", str(p), "rev-parse", "--git-dir"], capture_output=True + ).returncode + == 0 + ) + is_hg = p.is_dir() and (p / ".hg").is_dir() + if is_git: + resolved_b = tree_a + tree_a = f"{tree_a}@HEAD" + elif is_hg: + resolved_b = tree_a + tree_a = f"{tree_a}@tip" + else: + typer.echo("Error: B is required when A is not a local git or hg repository.", err=True) + raise typer.Exit(1) + else: + resolved_b = tree_b + + # Validate colormap + _valid_cmaps = set(_cmap_lib.Catalog().short_keys()) + if colormap not in _valid_cmaps: + valid = ", ".join(sorted(_valid_cmaps)) + typer.echo(f"Unknown colormap '{colormap}'. Valid options:\n{valid}", err=True) + raise typer.Exit(1) + + # Resolve credentials/tokens from files + github_token: str | None = os.environ.get("GITHUB_TOKEN") + ssh_password: str | None = None + password: str | None = None + + if password_file is not None: + if not password_file.exists(): + typer.echo(f"Error: --password-file not found: {password_file}", err=True) + raise typer.Exit(1) + password = password_file.read_text().strip() + + if ssh_password_file is not None: + if not ssh_password_file.exists(): + typer.echo(f"Error: --ssh-password-file not found: {ssh_password_file}", err=True) + raise typer.Exit(1) + ssh_password = ssh_password_file.read_text().strip() + + if github_token_file is not None: + if not github_token_file.exists(): + typer.echo(f"Error: --github-token-file not found: {github_token_file}", err=True) + raise typer.Exit(1) + github_token = github_token_file.read_text().strip() + + def _is_local_git_repo(s: str) -> bool: + import subprocess as _sp + + p = Path(s) + if not p.is_dir(): + return False + return ( + _sp.run( + ["git", "-C", str(p), "rev-parse", "--git-dir"], + capture_output=True, + ).returncode + == 0 + ) + + def _scan(label: str, src: str) -> tuple[object, str | None]: + p = Path(src) + if not is_git_ref_path(src) and _is_local_git_repo(src): + _info(f"Scanning {label}: {src} (tracked files only) ...") + excluded_set = frozenset(exclude) + node = build_tree_git_worktree(p.resolve(), excluded_set, depth) + return node, None + if not is_git_ref_path(src) and p.is_dir() and is_hg_repo(p.resolve()): + _info(f"Scanning {label}: {src} (tracked files only) ...") + excluded_set = frozenset(exclude) + node = build_tree_hg_worktree(p.resolve(), excluded_set, depth) + return node, None + return scan_tree( + roots=[src], + paths_from=None, + exclude=exclude, + depth=depth, + ssh_key=ssh_key, + ssh_password=ssh_password, + aws_profile=aws_profile, + no_sign=no_sign, + github_token=github_token, + k8s_namespace=k8s_namespace, + k8s_container=k8s_container, + password=password, + no_input=no_input, + log=_info, + )[::2] # (node, title) — drop t_scan + + # Scan both trees + t0 = time.monotonic() + _info(f"Scanning A: {tree_a} ...") + node_a, title_a = _scan("A", tree_a) + _info(f"Scanning B: {resolved_b} ...") + node_b, title_b = _scan("B", resolved_b) + t_scan = time.monotonic() - t0 + _info(f"Scanned in {t_scan:.1f}s") + + # Build flat file maps {rel_path: size} + def _flatten(node: object, prefix: str = "") -> dict[str, int]: + from dirplot.scanner import Node as ScanNode + + n: ScanNode = node # type: ignore[assignment] + result: dict[str, int] = {} + if not n.is_dir: + result[prefix] = n.size + else: + for child in n.children: + child_prefix = f"{prefix}/{child.name}" if prefix else child.name + result.update(_flatten(child, child_prefix)) + return result + + files_a = _flatten(node_a) + files_b = _flatten(node_b) + + # Use a stable virtual root path for highlight key generation. + # build_node_tree keys highlights as (virtual_root / rel).as_posix(). + virtual_root_b = Path(resolved_b) + + # For git ref sources, compare blob hashes for accurate change detection. + # Size comparison alone misses edits that don't change the file size. + hashes_a: dict[str, str] = {} + hashes_b: dict[str, str] = {} + if is_git_ref_path(tree_a): + repo_a, ref_a = parse_git_ref_path(tree_a) + hashes_a = git_file_hashes(repo_a.resolve(), ref_a) + elif _is_local_git_repo(tree_a): + hashes_a = git_worktree_hashes(Path(tree_a).resolve()) + elif is_hg_repo(Path(tree_a).resolve()): + hashes_a = hg_worktree_hashes(Path(tree_a).resolve()) + if is_git_ref_path(resolved_b): + repo_b, ref_b = parse_git_ref_path(resolved_b) + hashes_b = git_file_hashes(repo_b.resolve(), ref_b) + elif _is_local_git_repo(resolved_b): + hashes_b = git_worktree_hashes(Path(resolved_b).resolve()) + elif is_hg_repo(Path(resolved_b).resolve()): + hashes_b = hg_worktree_hashes(Path(resolved_b).resolve()) + + # Compute diff highlights keyed to match rect_map keys produced by build_node_tree. + highlights: dict[str, str] = {} + all_keys = set(files_a) | set(files_b) + for rel in all_keys: + key = (virtual_root_b / rel).as_posix() + if rel in files_a and rel not in files_b: + highlights[key] = DIFF_COLORS["removed"] + elif rel not in files_a and rel in files_b: + highlights[key] = DIFF_COLORS["added"] + elif rel in files_a and rel in files_b: + if hashes_a and hashes_b: + if hashes_a.get(rel) != hashes_b.get(rel): + highlights[key] = DIFF_COLORS["changed"] + elif files_a[rel] != files_b[rel]: + highlights[key] = DIFF_COLORS["changed"] + + n_removed = sum(1 for v in highlights.values() if v == DIFF_COLORS["removed"]) + n_added = sum(1 for v in highlights.values() if v == DIFF_COLORS["added"]) + n_changed = sum(1 for v in highlights.values() if v == DIFF_COLORS["changed"]) + _info(f"Diff: {n_added} added, {n_removed} removed, {n_changed} changed") + + # Build combined node tree sized by B. + # With --context: include all files (unchanged for context + diff files). + # With --no-context: include only files that changed, were added, or were removed. + # Use hash comparison when available so LFS files (pointer size != disk size) + # are not falsely counted as changed. + def _is_changed(rel: str) -> bool: + if rel not in files_b: + return True # removed + if rel not in files_a: + return True # added + if hashes_a and hashes_b: + return hashes_a.get(rel) != hashes_b.get(rel) + return files_a[rel] != files_b[rel] + + changed_keys = {rel for rel in set(files_a) | set(files_b) if _is_changed(rel)} + if context: + combined_files = dict(files_b) + for rel in files_a: + if rel not in combined_files: + combined_files[rel] = files_a[rel] + else: + combined_files = { + rel: (files_b[rel] if rel in files_b else files_a[rel]) for rel in changed_keys + } + + root_node = build_node_tree(virtual_root_b, combined_files, depth) + + if include: + root_node = prune_to_subtrees(root_node, set(include)) + + if log_scale > 1: + apply_log_sizes(root_node, log_scale) + + # Resolve output size + to_stdout = output is not None and str(output) == "-" + if to_stdout: + show = False + inline_cols: int | None = None + if size is not None: + try: + w_str, h_str = size.lower().split("x", 1) + width_px, height_px = int(w_str), int(h_str) + except ValueError: + typer.echo(f"Invalid --size '{size}'. Expected WIDTHxHEIGHT.", err=True) + raise typer.Exit(1) from None + _info(f"Output size: {width_px}x{height_px}px") + else: + width_px, height_px = default_canvas_size() + if inline: + inline_cols, *_ = get_terminal_size() + _info(f"Terminal size: {width_px}x{height_px}px") + + # Resolve format + if fmt is not None: + if fmt not in ("png", "svg"): + typer.echo(f"Unknown format '{fmt}'. Valid options: png, svg", err=True) + raise typer.Exit(1) + use_svg = fmt == "svg" + elif output is not None and output.suffix.lower() == ".svg": + use_svg = True + else: + use_svg = False + + label_a = title_a or Path(tree_a).name + label_b = title_b or Path(resolved_b).name + title_suffix = f"{label_a} → {label_b}" + + t_render_start = time.monotonic() + if use_svg: + buf = create_treemap_svg( + root_node, width_px, height_px, font_size, colormap, None, cushion, depth, dark + ) + else: + buf = create_treemap( + root_node, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + depth, + highlights=highlights, + title_suffix=title_suffix, + dark=dark, + logscale=log_scale, + ) + t_render = time.monotonic() - t_render_start + + if output is not None: + if to_stdout: + sys.stdout.buffer.write(buf.read()) + buf.seek(0) + else: + output.write_bytes(buf.read()) + _info(f"Saved diff to {output} [{t_render:.1f}s]") + buf.seek(0) + + if show and not to_stdout: + if use_svg: + if output is not None: + webbrowser.open(output.resolve().as_uri()) + else: + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp: + tmp.write(buf.read()) + webbrowser.open(Path(tmp.name).resolve().as_uri()) + elif inline: + display_inline(buf, cols=inline_cols) + else: + display_window(buf, title=f"dirplot diff: {title_suffix}") diff --git a/src/dirplot/commands/metrics.py b/src/dirplot/commands/metrics.py new file mode 100644 index 0000000..d5ff318 --- /dev/null +++ b/src/dirplot/commands/metrics.py @@ -0,0 +1,157 @@ +"""The ``metrics`` command: print detailed tree metrics.""" + +import os +from pathlib import Path + +import typer + +from dirplot.app import app +from dirplot.helpers.scan import scan_tree +from dirplot.scanner import prune_to_subtrees, tree_metrics, tree_metrics_dict + +_METRICS_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot metrics . [dim]# current directory[/dim]\n\n" + " dirplot metrics . --sort-by size [dim]# sort extensions by total bytes[/dim]\n\n" + " dirplot metrics . --top 5 [dim]# limit each list to 5 entries[/dim]\n\n" + " dirplot metrics . --json [dim]# machine-readable JSON output[/dim]\n\n" + " dirplot metrics . --json | jq '.largest_files[0]' [dim]# pipe into jq[/dim]\n\n" + " dirplot metrics . -e .venv -e .git [dim]# exclude paths[/dim]\n\n" + " dirplot metrics github://pallets/flask [dim]# GitHub repo[/dim]\n\n" + " dirplot metrics s3://my-bucket --no-sign [dim]# public S3 bucket[/dim]\n\n" + " dirplot map . --metrics --no-show [dim]# treemap + metrics in one pass[/dim]" +) + + +@app.command(name="metrics", epilog=_METRICS_EPILOG) +def metrics_command( + roots: list[str] = typer.Argument( + default=None, + help="Root(s) to scan. Supports the same sources as the map command.", + ), + paths_from: Path | None = typer.Option( + None, + "--paths-from", + help="File containing a path list in tree or find output format. Use - for stdin.", + metavar="FILE", + ), + exclude: list[str] = typer.Option([], "--exclude", "-e", help="Paths to exclude (repeatable)"), + include: list[str] = typer.Option( + [], + "--include", + help="Show only this subtree (repeatable; supports nested paths). Allowlist complement to --exclude.", # noqa: E501 + ), + depth: int | None = typer.Option( + None, "--depth", help="Maximum recursion depth (local and remote)" + ), + ssh_key: str | None = typer.Option( + None, "--ssh-key", help="SSH private key file (default: ~/.ssh/id_rsa)" + ), + aws_profile: str | None = typer.Option( + None, "--aws-profile", envvar="AWS_PROFILE", help="AWS profile name for S3 access" + ), + no_sign: bool = typer.Option( + False, "--no-sign", help="Skip AWS signing for anonymous access to public S3 buckets" + ), + k8s_namespace: str | None = typer.Option( + None, "--k8s-namespace", help="Kubernetes namespace (overrides @namespace in pod URL)" + ), + k8s_container: str | None = typer.Option( + None, "--k8s-container", help="Container name for multi-container pods" + ), + password_file: Path | None = typer.Option( + None, + "--password-file", + help="File containing the archive password (avoids exposing the password in shell history).", # noqa: E501 + metavar="FILE", + ), + ssh_password_file: Path | None = typer.Option( + None, + "--ssh-password-file", + help="File containing the SSH password (avoids exposing the password in shell history).", + metavar="FILE", + ), + github_token_file: Path | None = typer.Option( + None, + "--github-token-file", + help="File containing a GitHub personal access token (avoids exposing the token in shell history).", # noqa: E501 + metavar="FILE", + ), + top_n: int = typer.Option( + 10, "--top", help="Number of top extensions / largest files / largest dirs to show." + ), + sort_by: str = typer.Option( + "count", + "--sort-by", + help="Sort top extensions by: count (default) or size.", + metavar="FIELD", + ), + as_json: bool = typer.Option(False, "--json", help="Output metrics as JSON."), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."), + no_input: bool = typer.Option( + False, + "--no-input", + help="Disable all interactive prompts; fail instead of prompting for passwords.", + ), +) -> None: + """Print detailed metrics for a scanned directory tree. + + Example: dirplot metrics . --top 20 + """ + roots = roots or [] + + github_token: str | None = os.environ.get("GITHUB_TOKEN") + ssh_password: str | None = None + password: str | None = None + + if password_file is not None: + if not password_file.exists(): + typer.echo(f"Error: --password-file not found: {password_file}", err=True) + raise typer.Exit(1) + password = password_file.read_text().strip() + + if ssh_password_file is not None: + if not ssh_password_file.exists(): + typer.echo(f"Error: --ssh-password-file not found: {ssh_password_file}", err=True) + raise typer.Exit(1) + ssh_password = ssh_password_file.read_text().strip() + + if github_token_file is not None: + if not github_token_file.exists(): + typer.echo(f"Error: --github-token-file not found: {github_token_file}", err=True) + raise typer.Exit(1) + github_token = github_token_file.read_text().strip() + + def _metrics_log(msg: str) -> None: + if not quiet: + typer.echo(msg, err=True) + + root_node, t_scan, _ = scan_tree( + roots=roots, + paths_from=paths_from, + exclude=exclude, + depth=depth, + ssh_key=ssh_key, + ssh_password=ssh_password, + aws_profile=aws_profile, + no_sign=no_sign, + github_token=github_token, + k8s_namespace=k8s_namespace, + k8s_container=k8s_container, + password=password, + no_input=no_input, + log=_metrics_log, + ) + if include: + root_node = prune_to_subtrees(root_node, set(include)) + if sort_by not in ("count", "size"): + typer.echo(f"Invalid --sort-by value '{sort_by}'. Choose: count, size", err=True) + raise typer.Exit(1) + if as_json: + import json + + typer.echo( + json.dumps(tree_metrics_dict(root_node, t_scan, top_n=top_n, sort_by=sort_by), indent=2) + ) + else: + typer.echo(tree_metrics(root_node, t_scan, top_n=top_n, sort_by=sort_by)) diff --git a/src/dirplot/commands/misc.py b/src/dirplot/commands/misc.py new file mode 100644 index 0000000..cfe18da --- /dev/null +++ b/src/dirplot/commands/misc.py @@ -0,0 +1,316 @@ +"""Small standalone commands: termsize, read-meta, demo.""" + +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import typer + +from dirplot.app import app +from dirplot.terminal import get_terminal_size + +_TERMSIZE_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot termsize [dim]# print cols × rows and pixel dimensions[/dim]\n\n" + " dirplot termsize [dim]# run before dirplot map to check the default canvas size[/dim]" +) + +_READ_META_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot read-meta treemap.png [dim]# read metadata from a PNG[/dim]\n\n" + " dirplot read-meta treemap.svg [dim]# read metadata from an SVG[/dim]\n\n" + " dirplot read-meta history.mp4 [dim]# read metadata from an MP4 (requires ffprobe)[/dim]\n\n" + " dirplot read-meta a.png b.png c.svg [dim]# multiple files[/dim]\n\n" + " dirplot read-meta *.png [dim]# glob expansion[/dim]" +) + +_DEMO_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot demo [dim]# run all examples, save to ./demo/[/dim]\n\n" + " dirplot demo --output ~/dirplot-demo [dim]# custom output folder[/dim]\n\n" + " dirplot demo --github-url https://github.com/pallets/flask" + " [dim]# use a different GitHub repo for remote examples[/dim]\n\n" + " dirplot demo --interactive [dim]# step through each command with confirmation[/dim]" +) + + +@app.command(name="termsize", epilog=_TERMSIZE_EPILOG) +def termsize() -> None: + """Show the current terminal size in characters and pixels.""" + cols, rows, width_px, height_px = get_terminal_size() + typer.echo(f"Characters : {cols} cols × {rows} rows") + typer.echo(f"Pixels : {width_px} × {height_px}") + + +@app.command(name="read-meta", epilog=_READ_META_EPILOG) +def read_meta( + files: list[Path] = typer.Argument( + ..., help="PNG, SVG, or MP4/MOV file(s) to read dirplot metadata from" + ), +) -> None: + """Read dirplot metadata embedded in one or more PNG, SVG, or MP4/MOV files.""" + any_error = False + + for file in files: + if len(files) > 1: + typer.echo(f"==> {file} <==") + + if not file.exists(): + typer.echo(f"Error: file not found: {file}", err=True) + any_error = True + continue + + suffix = file.suffix.lower() + + if suffix == ".png": + from PIL import Image + + img = Image.open(file) + info = img.info + meta_keys = {"Date", "Software", "URL", "Python", "OS", "Command"} + found = {k: v for k, v in info.items() if k in meta_keys} + if not found: + typer.echo("No dirplot metadata found in PNG.", err=True) + any_error = True + continue + for k, v in found.items(): + typer.echo(f"{k}: {v}") + + elif suffix == ".svg": + content = file.read_text(encoding="utf-8") + try: + root = ET.fromstring(content) + except ET.ParseError as exc: + typer.echo(f"Error parsing SVG: {exc}", err=True) + any_error = True + continue + svg_meta: dict[str, str] = {} + for desc in root.iter("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description"): + for child in desc: + local = child.tag.split("}")[-1] if "}" in child.tag else child.tag + ns_uri = child.tag.split("}")[0].lstrip("{") if "}" in child.tag else "" + if ns_uri == "https://github.com/deeplook/dirplot#" and child.text: + svg_meta[local] = child.text + if not svg_meta: + typer.echo("No dirplot metadata found in SVG.", err=True) + any_error = True + continue + for k, v in svg_meta.items(): + typer.echo(f"{k}: {v}") + + elif suffix in {".mp4", ".mov"}: + import json + import shutil + import subprocess + + if not shutil.which("ffprobe"): + typer.echo("Error: ffprobe not found on PATH (install ffmpeg).", err=True) + any_error = True + continue + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(file)], + capture_output=True, + ) + if result.returncode != 0: + typer.echo( + f"Error reading MP4 metadata: {result.stderr.decode(errors='replace')}", + err=True, + ) + any_error = True + continue + tags = json.loads(result.stdout).get("format", {}).get("tags", {}) + meta_keys = {"Date", "Software", "URL", "Python", "OS", "Command"} + found = {k: v for k, v in tags.items() if k in meta_keys} + if not found: + typer.echo("No dirplot metadata found in MP4.", err=True) + any_error = True + continue + for k, v in found.items(): + typer.echo(f"{k}: {v}") + + else: + typer.echo( + f"Unsupported file type: {suffix!r}. Expected .png, .svg, or .mp4/.mov", err=True + ) + any_error = True + + if any_error: + raise typer.Exit(1) + + +@app.command(name="demo", epilog=_DEMO_EPILOG) +def demo_cmd( + output: Path = typer.Option( + Path("demo"), "--output", "-o", help="Folder for generated output files" + ), + github_url: str = typer.Option( + "https://github.com/deeplook/dirplot", + "--github-url", + help="GitHub repository URL used for remote examples", + ), + interactive: bool = typer.Option( + False, "--interactive", "-i", help="Ask for confirmation before each command is run" + ), +) -> None: + """Run a set of example commands to illustrate dirplot features.""" + import subprocess + + output.mkdir(parents=True, exist_ok=True) + + # Convert https://github.com/owner/repo → github://owner/repo + m = re.match(r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", github_url) + gh_path = f"github://{m.group(1)}" if m else github_url + + base_cmd = [sys.executable, "-m", "dirplot"] + + examples: list[tuple[str, list[str]]] = [ + ( + "termsize — show current terminal dimensions", + ["termsize"], + ), + ( + "map local directory (dark mode, PNG)", + [ + "map", + ".", + "--no-show", + "--output", + str(output / "map-local.png"), + "--size", + "800x600", + ], + ), + ( + "map github repo (dark mode, PNG)", + [ + "map", + gh_path, + "--no-show", + "--output", + str(output / "map-github.png"), + "--size", + "800x600", + ], + ), + ( + "map local directory (light mode, SVG)", + [ + "map", + ".", + "--no-show", + "--output", + str(output / "map-local.svg"), + "--size", + "800x600", + "--light", + ], + ), + ( + "git — last 5 commits of github repo (static PNG)", + [ + "git", + gh_path, + "--output", + str(output / "git-static.png"), + "--size", + "800x600", + "--max-commits", + "5", + ], + ), + ( + "git — last 10 commits of github repo (animated MP4)", + [ + "git", + gh_path, + "--output", + str(output / "git.mp4"), + "--size", + "800x600", + "--max-commits", + "10", + "--animate", + "--total-duration", + "20", + ], + ), + ( + "git — last 10 commits of github repo (animated PNG with fade-out)", + [ + "git", + gh_path, + "--output", + str(output / "git-animated.png"), + "--size", + "800x600", + "--max-commits", + "10", + "--animate", + "--total-duration", + "20", + "--fade-out", + ], + ), + ( + "read-meta — metadata embedded in a generated PNG", + ["read-meta", str(output / "map-local.png")], + ), + ] + + skipped = [ + ("watch", "interactive; watches a directory for changes indefinitely"), + ("replay", "interactive; requires a JSONL event log produced by `watch --event-log`"), + ] + + from rich.console import Console + from rich.panel import Panel + + console = Console(highlight=False) + + console.print() + console.print( + Panel( + f"[bold cyan]dirplot demo[/bold cyan]\n[dim]Outputs →[/dim] {output.resolve()}/", + border_style="cyan", + padding=(0, 2), + ) + ) + + n_total = len(examples) + for i, (label, args) in enumerate(examples, 1): + cmd_display = "dirplot " + " ".join(args) + console.print() + console.rule(f"[bold]{i}/{n_total}[/bold] {label}", style="cyan") + console.print(f" [dim]$[/dim] [bold cyan]{cmd_display}[/bold cyan]") + console.print() + + if interactive: + from rich.prompt import Confirm + + if not Confirm.ask(" Run this command?", default=True, console=console): + console.print(" [yellow]⏭ Skipped[/yellow]") + continue + + result = subprocess.run(base_cmd + args) + + console.print() + if result.returncode == 0: + console.print(" [bold green]✓ Done[/bold green]") + else: + console.print(f" [bold red]✗ Exited with code {result.returncode}[/bold red]") + + console.print() + console.rule(style="dim") + for cmd_name, reason in skipped: + console.print(f" [dim]⏭ [bold]{cmd_name}[/bold]: {reason}[/dim]") + + console.print() + console.print( + Panel( + f"[bold green]✓ Demo complete[/bold green]\n" + f"[dim]Outputs saved to:[/dim] {output.resolve()}/", + border_style="green", + padding=(0, 2), + ) + ) diff --git a/src/dirplot/commands/replay.py b/src/dirplot/commands/replay.py new file mode 100644 index 0000000..bea3869 --- /dev/null +++ b/src/dirplot/commands/replay.py @@ -0,0 +1,327 @@ +"""The ``replay`` command: replay a JSONL filesystem event log as an animated treemap.""" + +import io +import os +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import typer + +from dirplot.app import app +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE +from dirplot.filters import matches_exclude +from dirplot.helpers.animation import ( + proportional_durations, + resolve_fade_color, + worker_ignore_sigint, +) +from dirplot.terminal import default_canvas_size + +_REPLAY_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot replay events.jsonl -o replay.apng" + " [dim]# 60-second buckets, 500 ms/frame, APNG[/dim]\n\n" + " dirplot replay events.jsonl -o replay.mp4 --total-duration 30" + " [dim]# MP4, proportional timing, 30 s animation[/dim]\n\n" + " dirplot replay events.jsonl -o replay.mp4 --crf 18" + " [dim]# MP4, higher quality[/dim]\n\n" + " dirplot replay events.jsonl -o replay.mp4 --codec libx265 --crf 28" + " [dim]# H.265, smaller file[/dim]\n\n" + " dirplot replay events.jsonl -o replay.apng --bucket 10 --frame-duration 200" + " [dim]# finer-grained 10-second buckets[/dim]\n\n" + " dirplot replay events.jsonl -o replay.mp4 --total-duration 30 --fade-out" + " [dim]# fade to black at the end[/dim]\n\n" + " dirplot replay events.jsonl -o replay.apng --total-duration 30" + " --fade-out --fade-out-color white [dim]# fade to white (light mode)[/dim]" +) + + +@app.command(name="replay", epilog=_REPLAY_EPILOG) +def replay_cmd( + event_log: Path = typer.Argument(..., help="JSONL event log produced by fswatched.py"), + output: Path = typer.Option(..., "--output", "-o", help="Output file (.png or .apng)"), + bucket: float = typer.Option( + 60.0, + "--bucket", + help="Time bucket size in seconds: one frame per bucket", + show_default=True, + ), + frame_duration: int = typer.Option( + 500, "--frame-duration", help="Frame display duration in ms (default: 500)" + ), + total_duration: float | None = typer.Option( + None, + "--total-duration", + help=( + "Target total animation length in seconds. Frames are shown proportionally" + " to the real time gaps between buckets. Overrides --frame-duration." + ), + ), + exclude: list[str] = typer.Option([], "--exclude", "-e", help="Paths to exclude (repeatable)"), + font_size: int = typer.Option( + DEFAULT_FONT_SIZE, "--font-size", help="Directory label font size in pixels" + ), + colormap: str = typer.Option(DEFAULT_COLORMAP, "--colormap", help="Matplotlib colormap"), + size: str | None = typer.Option( + None, "--size", help="Output size as WIDTHxHEIGHT", metavar="WIDTHxHEIGHT" + ), + cushion: bool = typer.Option(True, "--cushion/--no-cushion", help="Apply cushion shading"), + dark: bool = typer.Option(True, "--dark/--light", help="Dark background (default) or light"), + logscale: float = typer.Option( + 0.0, + "--log-scale", + help="Log-scale compression ratio (max/min ratio). 0 disables; must be > 1 to enable.", + show_default=True, + ), + depth: int | None = typer.Option(None, "--depth", help="Maximum directory depth"), + workers: int | None = typer.Option( + None, + "--workers", + help="Parallel render workers (default: all CPU cores)", + ), + crf: int = typer.Option( + 23, + "--crf", + help="MP4 quality: Constant Rate Factor (0=lossless, 51=worst; default 23). " + "Ignored for APNG output.", + show_default=True, + ), + codec: str = typer.Option( + "libx264", + "--codec", + help="MP4 video codec: libx264 (H.264, default) or libx265 (H.265, smaller files). " + "Ignored for APNG output.", + ), + fade_out: bool = typer.Option( + False, + "--fade-out/--no-fade-out", + help="Append a fade-out sequence at the end of the animation", + ), + fade_out_duration: float = typer.Option( + 1.0, + "--fade-out-duration", + help="Total duration of the fade-out in seconds", + show_default=True, + ), + fade_out_frames: int | None = typer.Option( + None, + "--fade-out-frames", + help="Number of equidistant frames in the fade-out (default: 4 per second of duration)", + ), + fade_out_color: str = typer.Option( + "auto", + "--fade-out-color", + help=( + "Target colour for the fade-out: 'auto' (black in dark mode, white in light mode), " + "'transparent' (APNG only), a CSS colour name, or a hex code" + ), + metavar="COLOR", + ), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."), +) -> None: + """Replay a JSONL filesystem event log as an animated treemap.""" + from dirplot.replay_scanner import ( + _render_replay_frame_worker, + apply_events, + bucket_events, + parse_events, + ) + + if not event_log.exists(): + typer.echo(f"Error: event log not found: {event_log}", err=True) + raise typer.Exit(1) + + if output.suffix.lower() not in {".png", ".apng", ".mp4", ".mov"}: + typer.echo("Error: --output must be a .png, .apng, .mp4, or .mov file.", err=True) + raise typer.Exit(1) + + if size is not None: + try: + w_str, h_str = size.lower().split("x", 1) + width_px, height_px = int(w_str), int(h_str) + except ValueError: + typer.echo(f"Invalid --size '{size}'. Expected WIDTHxHEIGHT.", err=True) + raise typer.Exit(1) from None + else: + width_px, height_px = default_canvas_size() + + if not quiet: + typer.echo(f"Reading events from {event_log} ...", err=True) + events = parse_events(event_log) + if not events: + typer.echo("Error: no events found in event log.", err=True) + raise typer.Exit(1) + + # Derive common root from all paths in the event log + all_paths = [e[2] for e in events] + [e[3] for e in events if e[3]] + common_root = Path(os.path.commonpath(all_paths)) + if not common_root.is_dir(): + common_root = common_root.parent + if not quiet: + typer.echo(f"Common root: {common_root}", err=True) + + excluded = frozenset(exclude) + + # Build initial files dict by statting only paths that appear in the event log + files: dict[str, int] = {} + for _ts, _type, path_str, dest_str in events: + for p_str in (path_str, dest_str) if dest_str else (path_str,): + p = Path(p_str) + if not p_str.startswith(str(common_root)): + continue + if ( + matches_exclude(str(p.relative_to(common_root)).replace(os.sep, "/"), excluded) + or not p.is_file() + ): + continue + try: + rel = str(p.relative_to(common_root)).replace(os.sep, "/") + files[rel] = max(1, p.stat().st_size) + except (OSError, ValueError): + pass + if not quiet: + typer.echo(f" {len(files)} unique files from event log", err=True) + + buckets = bucket_events(events, bucket) + if not quiet: + typer.echo( + f"Grouped {len(events)} events into {len(buckets)} frame(s) ({bucket:.0f}s buckets) ...", # noqa: E501 + err=True, + ) + + # Pre-compute per-frame durations + if total_duration is not None: + if total_duration <= 0: + typer.echo("Error: --total-duration must be positive.", err=True) + raise typer.Exit(1) + timestamps = [ts for ts, _ in buckets] + gaps: list[float] = [ + max(1.0, float(timestamps[j + 1] - timestamps[j])) for j in range(len(timestamps) - 1) + ] + gaps.append(gaps[-1] if gaps else 1.0) + total_ms = total_duration * 1000 + frame_durations = proportional_durations(gaps, total_ms) + min_d, max_d = min(frame_durations), max(frame_durations) + if not quiet: + typer.echo( + f" Proportional timing: {min_d}–{max_d} ms/frame" + f" (total ~{sum(frame_durations) / 1000:.1f}s)", + err=True, + ) + else: + frame_durations = [frame_duration] * len(buckets) + + # Phase 1: sequential pass — apply events bucket by bucket, collect snapshots + Snapshot = tuple[int, float, dict[str, int], dict[str, str], dict[str, str]] + snapshots: list[Snapshot] = [] + + for i, (ts, bucket_evs) in enumerate(buckets): + highlights = apply_events(files, common_root, bucket_evs, excluded) + deletions = {p: v for p, v in highlights.items() if v == "deleted"} + cur_hl = {p: v for p, v in highlights.items() if v != "deleted"} + snapshots.append((i, ts, dict(files), cur_hl, deletions)) + + # Phase 2: parallel render + total = len(snapshots) + n_workers = min(workers if workers is not None else (os.cpu_count() or 1), total) + if not quiet: + typer.echo(f"Rendering {total} frame(s) using {n_workers} worker(s) ...", err=True) + + total_anim_ms = sum(frame_durations) + cumulative_ms = 0.0 + frame_progress: dict[int, float] = {} + for orig_i, *_ in snapshots: + cumulative_ms += frame_durations[orig_i] + frame_progress[orig_i] = cumulative_ms / total_anim_ms + + render_args = [ + ( + str(common_root), + files_copy, + cur_hl, + ts, + orig_i, + frame_progress[orig_i], + depth, + logscale, + width_px, + height_px, + font_size, + colormap, + cushion, + dark, + ) + for orig_i, ts, files_copy, cur_hl, _del in snapshots + ] + + raw: dict[int, tuple[bytes, dict[str, tuple[int, int, int, int]]]] = {} + + try: + with ProcessPoolExecutor(max_workers=n_workers, initializer=worker_ignore_sigint) as pool: + futures = { + pool.submit(_render_replay_frame_worker, args): args[4] for args in render_args + } + for done, future in enumerate(as_completed(futures), 1): + orig_i, png_bytes, rect_map = future.result() + raw[orig_i] = (png_bytes, rect_map) + if not quiet: + typer.echo(f" Rendered {done}/{total}", err=True) + except KeyboardInterrupt: + typer.echo("\nInterrupted.", err=True) + raise typer.Exit(1) from None + + # Phase 3: assemble ordered frames, patch deletions onto prior frame + frame_bytes: list[bytes] = [] + final_durations: list[int] = [] + + for j, (orig_i, _ts, _files, _hl, deletions) in enumerate(snapshots): + if deletions and j > 0: + prev_bytes, prev_rect = raw[snapshots[j - 1][0]] + from PIL import Image, ImageDraw + + from dirplot.render_png import _draw_highlights + + prev_img = Image.open(io.BytesIO(prev_bytes)).convert("RGB") + _draw_highlights(ImageDraw.Draw(prev_img), prev_rect, deletions) + buf = io.BytesIO() + prev_img.save(buf, format="PNG") + frame_bytes[-1] = buf.getvalue() + frame_bytes.append(raw[orig_i][0]) + final_durations.append(frame_durations[orig_i]) + + if fade_out and frame_bytes: + from dirplot.render_png import _frames_as_rgba, make_fade_out_frames + + fade_color = resolve_fade_color(fade_out_color, dark) + fade_transparent = len(fade_color) == 4 and fade_color[3] == 0 + if fade_transparent and output.suffix.lower() in {".mp4", ".mov"}: + fade_color = (0, 0, 0) if dark else (255, 255, 255) + fade_transparent = False + if fade_transparent: + frame_bytes = _frames_as_rgba(frame_bytes) + n_fo = fade_out_frames + if n_fo is None: + n_fo = max(1, round(fade_out_duration * 4)) + extra, extra_durs = make_fade_out_frames( + frame_bytes[-1], + n_frames=n_fo, + duration_ms=int(fade_out_duration * 1000), + target_color=fade_color, + ) + frame_bytes.extend(extra) + final_durations.extend(extra_durs) + + if output.suffix.lower() in {".mp4", ".mov"}: + from dirplot.render_png import build_metadata, write_mp4 + + write_mp4( + output, frame_bytes, final_durations, crf=crf, codec=codec, metadata=build_metadata() + ) + else: + from dirplot.render_png import write_apng + + write_apng(output, frame_bytes, final_durations) + if not quiet: + typer.echo( + f"Wrote {len(frame_bytes)}-frame {output.suffix.upper()[1:]} → {output}", err=True + ) diff --git a/src/dirplot/commands/treemap.py b/src/dirplot/commands/treemap.py new file mode 100644 index 0000000..bfc17bc --- /dev/null +++ b/src/dirplot/commands/treemap.py @@ -0,0 +1,371 @@ +"""The ``map`` command: render a directory tree as a treemap.""" + +import sys +import time +import webbrowser +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path + +import cmap as _cmap_lib +import typer +from rich.console import Console as _Console + +from dirplot.app import app +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE +from dirplot.display import display_inline, display_window +from dirplot.helpers.scan import scan_tree +from dirplot.render_png import create_treemap +from dirplot.scanner import ( + apply_breadcrumbs, + apply_log_sizes, + collect_extensions, + max_depth, + prune_to_subtrees, + tree_metrics, +) +from dirplot.svg_render import create_treemap_svg +from dirplot.terminal import default_canvas_size, get_terminal_size + + +@contextmanager +def _no_op_ctx() -> Generator[None, None, None]: + yield + + +_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot map . [dim]# open in system viewer[/dim]\n\n" + " dirplot map . --no-show --output treemap.png [dim]# save to file[/dim]\n\n" + " dirplot map github://owner/repo [dim]# map a GitHub repo[/dim]\n\n" + " dirplot map . --inline [dim]# render inline (iTerm2 / Kitty / Ghostty)[/dim]\n\n" + " dirplot map . --legend 20 [dim]# show file-count legend (top 20)[/dim]\n\n" + " dirplot map . --exclude .venv --exclude .git [dim]# skip paths[/dim]\n\n" + " dirplot map . --colormap Set2 --font-size 14 [dim]# custom colours and label size[/dim]\n\n" + " dirplot map . --size 1920x1080 --no-show --output out.png [dim]# fixed resolution[/dim]\n\n" + " dirplot map . --no-header --inline [dim]# suppress info lines before the plot[/dim]\n\n" + " dirplot map . --no-cushion [dim]# makes tiles look flat[/dim]\n\n" + " dirplot map archive.zip [dim]# map a zip archive without unpacking[/dim]\n\n" + " dirplot map release.tar.gz --depth 2 [dim]# limit depth into a tarball[/dim]\n\n" + " dirplot map app.jar --exclude META-INF [dim]# skip a member directory[/dim]\n\n" + " dirplot map src tests [dim]# map two subtrees under their common parent[/dim]\n\n" + " dirplot map . --include src --include tests [dim]# same result, explicit root[/dim]" +) + + +@app.command(name="map", epilog=_EPILOG) +def main( + ctx: typer.Context, + roots: list[str] = typer.Argument( + default=None, + help="Root(s) to map: one or more local directories (multiple → shows only those " + "subtrees under their common parent), archive file, ssh://…, s3://…, " + r"github://owner/repo\[@branch], https://github.com/owner/repo\[/tree/branch], " + r"docker://container:/path, or pod://pod-name\[@namespace]/path. " + "Omit to read paths from --paths-from or stdin (tree/find output).", + ), + paths_from: Path | None = typer.Option( + None, + "--paths-from", + help="File containing a path list in tree or find output format. Use - for stdin.", + metavar="FILE", + ), + output: Path | None = typer.Option( + None, + "--output", + "-o", + help="Output path (optional). Use .svg extension for SVG output. Use - for stdout.", + ), + fmt: str | None = typer.Option( + None, + "--format", + help="Output format: png or svg. Defaults to svg if --output ends in .svg, else png.", + metavar="FORMAT", + ), + show: bool = typer.Option(True, "--show/--no-show", help="Display the image after rendering"), + inline: bool = typer.Option( + False, + "--inline", + help="Show in terminal (auto-detects iTerm2/Kitty protocol) instead of a separate window", + ), + legend: int | None = typer.Option( + None, + "--legend", + help="Show file-count legend; value sets max entries shown (default: 20)", + metavar="N", + ), + font_size: int = typer.Option( + DEFAULT_FONT_SIZE, "--font-size", help="Directory label font size in pixels (default: 12)" + ), + colormap: str = typer.Option( + DEFAULT_COLORMAP, + "--colormap", + help=( + "Matplotlib colormap for file-extension colours (default: tab20). " + "The default uses the GitHub Linguist palette for known extensions; " + "any other colormap overrides Linguist and applies to all extensions. " + "Qualitative maps (tab10, tab20, Set1-3, Paired, Accent, Dark2, Pastel1-2) " + "give distinct hues. " + "Sequential maps (viridis, plasma, inferno, Blues, Greens, …) " + "blend across a gradient. " + "Diverging maps (coolwarm, RdBu, Spectral, …) " + "have two contrasting hues. " + "Run with an invalid name to see all options." + ), + ), + exclude: list[str] = typer.Option([], "--exclude", "-e", help="Paths to exclude (repeatable)"), + include: list[str] = typer.Option( + [], + "--include", + "--subtree", # hidden backwards-compat alias + help=( + "Show only this subtree (repeatable; supports nested paths like src/fonts). " + "Allowlist complement to --exclude." + ), + ), + ssh_key: str | None = typer.Option( + None, "--ssh-key", help="SSH private key file (default: ~/.ssh/id_rsa)" + ), + depth: int | None = typer.Option( + None, "--depth", help="Maximum recursion depth (local and remote)" + ), + aws_profile: str | None = typer.Option( + None, "--aws-profile", envvar="AWS_PROFILE", help="AWS profile name for S3 access" + ), + no_sign: bool = typer.Option( + False, "--no-sign", help="Skip AWS signing for anonymous access to public S3 buckets" + ), + k8s_namespace: str | None = typer.Option( + None, "--k8s-namespace", help="Kubernetes namespace (overrides @namespace in pod URL)" + ), + k8s_container: str | None = typer.Option( + None, "--k8s-container", help="Container name for multi-container pods" + ), + size: str | None = typer.Option( + None, + "--size", + help="Output size as WIDTHxHEIGHT in pixels (e.g. 1920x1080). Defaults to terminal size.", + metavar="WIDTHxHEIGHT", + ), + header: bool = typer.Option( + True, "--header/--no-header", help="Print info lines before rendering (default: on)" + ), + cushion: bool = typer.Option( + True, + "--cushion/--no-cushion", + help="Apply van Wijk cushion shading: gives each tile a raised 3-D look.", + ), + dark: bool = typer.Option(True, "--dark/--light", help="Dark background (default) or light"), + logscale: float = typer.Option( + 0.0, + "--log-scale", + help="Log-scale compression ratio (max/min ratio). 0 disables; must be > 1 to enable.", + show_default=True, + ), + password_file: Path | None = typer.Option( + None, + "--password-file", + help="File containing the archive password (avoids exposing the password in shell history).", # noqa: E501 + metavar="FILE", + ), + ssh_password_file: Path | None = typer.Option( + None, + "--ssh-password-file", + help="File containing the SSH password (avoids exposing the password in shell history).", + metavar="FILE", + ), + github_token_file: Path | None = typer.Option( + None, + "--github-token-file", + help="File containing a GitHub personal access token (avoids exposing the token in shell history).", # noqa: E501 + metavar="FILE", + ), + breadcrumbs: bool = typer.Option( + True, + "--breadcrumbs/--no-breadcrumbs", + help=( + "Collapse single-subdirectory chains into breadcrumb labels" + " (e.g. foo / bar / baz). Default: on." + ), + ), + show_metrics: bool = typer.Option( + False, + "--metrics/--no-metrics", + help="Print detailed metrics after scanning (same output as the metrics command).", + ), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."), + no_input: bool = typer.Option( + False, + "--no-input", + help="Disable all interactive prompts; fail instead of prompting for passwords.", + ), +) -> None: + """Create a nested treemap bitmap for a directory tree. + + Example: dirplot map . --inline + """ + import os + + roots = roots or [] + + # Show help when called with no arguments and no piped input. + if not roots and paths_from is None and sys.stdin.isatty(): + typer.echo(ctx.get_help()) + raise typer.Exit() + + to_stdout = output is not None and str(output) == "-" + if to_stdout: + show = False + + github_token: str | None = os.environ.get("GITHUB_TOKEN") + ssh_password: str | None = None + password: str | None = None + + if password_file is not None: + if not password_file.exists(): + typer.echo(f"Error: --password-file not found: {password_file}", err=True) + raise typer.Exit(1) + password = password_file.read_text().strip() + + if ssh_password_file is not None: + if not ssh_password_file.exists(): + typer.echo(f"Error: --ssh-password-file not found: {ssh_password_file}", err=True) + raise typer.Exit(1) + ssh_password = ssh_password_file.read_text().strip() + + if github_token_file is not None: + if not github_token_file.exists(): + typer.echo(f"Error: --github-token-file not found: {github_token_file}", err=True) + raise typer.Exit(1) + github_token = github_token_file.read_text().strip() + + def _info(msg: str) -> None: + if not quiet: + typer.echo(msg, err=True) + + _valid_cmaps = set(_cmap_lib.Catalog().short_keys()) + if colormap not in _valid_cmaps: + valid = ", ".join(sorted(_valid_cmaps)) + typer.echo(f"Unknown colormap '{colormap}'. Valid options:\n{valid}", err=True) + raise typer.Exit(1) + + root_node, t_scan, _display_title = scan_tree( + roots=roots, + paths_from=paths_from, + exclude=exclude, + depth=depth, + ssh_key=ssh_key, + ssh_password=ssh_password, + aws_profile=aws_profile, + no_sign=no_sign, + github_token=github_token, + k8s_namespace=k8s_namespace, + k8s_container=k8s_container, + password=password, + no_input=no_input, + log=_info if header else None, + ) + + if include: + root_node = prune_to_subtrees(root_node, set(include)) + + tree_depth = max_depth(root_node) + + if breadcrumbs: + root_node = apply_breadcrumbs(root_node) + + if logscale > 1: + apply_log_sizes(root_node, logscale) + total_files = len(collect_extensions(root_node)) + if header: + _f = "file" if total_files == 1 else "files" + _info(f"Found {total_files:,} {_f}, total size: {root_node.size:,} bytes [{t_scan:.1f}s]") + + if show_metrics: + typer.echo(tree_metrics(root_node, t_scan), err=to_stdout) + + inline_cols: int | None = None + if size is not None: + try: + w_str, h_str = size.lower().split("x", 1) + width_px, height_px = int(w_str), int(h_str) + except ValueError: + typer.echo( + f"Invalid --size value '{size}'. Expected format: WIDTHxHEIGHT (e.g. 1920x1080)", + err=True, + ) + raise typer.Exit(1) from None + if header: + _info(f"Output size: {width_px}x{height_px}px") + else: + width_px, height_px = default_canvas_size() + if inline: + inline_cols, *_ = get_terminal_size() + if header: + _info(f"Terminal size: {width_px}x{height_px}px") + + # Resolve output format: explicit --format > inferred from --output extension > png + if fmt is not None: + if fmt not in ("png", "svg"): + typer.echo(f"Unknown format '{fmt}'. Valid options: png, svg", err=True) + raise typer.Exit(1) + use_svg = fmt == "svg" + elif output is not None and output.suffix.lower() == ".svg": + use_svg = True + else: + use_svg = False + + _stderr_console = _Console(stderr=True) + t_render_start = time.monotonic() + with _stderr_console.status("Rendering…", spinner="dots") if not quiet else _no_op_ctx(): + if use_svg: + buf = create_treemap_svg( + root_node, + width_px, + height_px, + font_size, + colormap, + legend, + cushion, + tree_depth, + dark, + ) + else: + buf = create_treemap( + root_node, + width_px, + height_px, + font_size, + colormap, + legend, + cushion, + tree_depth, + dark=dark, + logscale=logscale, + ) + t_render = time.monotonic() - t_render_start + + if output is not None: + if to_stdout: + sys.stdout.buffer.write(buf.read()) + buf.seek(0) + else: + output.write_bytes(buf.read()) + if header: + _info(f"Saved dirplot to {output} [{t_render:.1f}s]") + buf.seek(0) + + if show and not to_stdout: + if use_svg: + if output is not None: + webbrowser.open(output.resolve().as_uri()) + else: + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as tmp: + tmp.write(buf.read()) + webbrowser.open(Path(tmp.name).resolve().as_uri()) + elif inline: + display_inline(buf, cols=inline_cols) + else: + display_window(buf, title=_display_title) diff --git a/src/dirplot/commands/vcs.py b/src/dirplot/commands/vcs.py new file mode 100644 index 0000000..c8a35c7 --- /dev/null +++ b/src/dirplot/commands/vcs.py @@ -0,0 +1,1050 @@ +"""The ``git`` and ``hg`` commands: replay VCS history as an animated treemap.""" + +import io +import os +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import typer + +from dirplot.app import app +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE +from dirplot.display import display_inline +from dirplot.helpers.animation import ( + proportional_durations, + resolve_fade_color, + worker_ignore_sigint, +) +from dirplot.helpers.time import parse_last_period +from dirplot.scanner import apply_log_sizes +from dirplot.terminal import default_canvas_size, get_terminal_size + +_GIT_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot git . --inline" + " [dim]# snapshot of HEAD, inline in terminal[/dim]\n\n" + " dirplot git . -o snapshot.png" + " [dim]# snapshot of HEAD, saved to file[/dim]\n\n" + " dirplot git .@my-branch -o snapshot.png" + " [dim]# snapshot of tip of a branch[/dim]\n\n" + " dirplot git . -o history.png --range v1.0..HEAD" + " [dim]# animate a revision range (APNG)[/dim]\n\n" + " dirplot git . -o history.mp4 --range v1.0..HEAD" + " [dim]# animate a revision range (MP4)[/dim]\n\n" + " dirplot git . -o history.mp4 --period 30d" + " [dim]# animate last 30 days of commits[/dim]\n\n" + " dirplot git . -o history.mp4 --period 30d --last 20" + " [dim]# last 20 commits within that period[/dim]\n\n" + " dirplot git github://owner/repo -o history.mp4 --period 90d --first 50" + " [dim]# GitHub repo, first 50 commits in period[/dim]" +) + +_HG_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot hg . --inline" + " [dim]# snapshot of tip, inline in terminal[/dim]\n\n" + " dirplot hg . -o snapshot.png" + " [dim]# snapshot of tip, saved to file[/dim]\n\n" + " dirplot hg .@my-branch -o snapshot.png" + " [dim]# snapshot of tip of a branch[/dim]\n\n" + " dirplot hg . -o history.png --range 0:tip" + " [dim]# animate full history (APNG)[/dim]\n\n" + " dirplot hg . -o history.mp4 --period 30d" + " [dim]# animate last 30 days of changesets[/dim]\n\n" + " dirplot hg . -o history.mp4 --period 30d --last 20 --total-duration 30" + " [dim]# last 20 changesets, proportional timing[/dim]" +) + + +def run_vcs_animation( + repo: Path, + snapshots: list[tuple[int, str, int, dict[str, int], dict[str, str], dict[str, str]]], + commit_durations: list[int], + output: Path, + *, + width_px: int, + height_px: int, + font_size: int, + colormap: str, + depth: int | None, + logscale: float, + cushion: bool, + dark: bool, + workers: int | None, + crf: int, + codec: str, + fade_out: bool, + fade_out_duration: float, + fade_out_frames: int | None, + fade_out_color: str, + quiet: bool = False, +) -> None: + """Render *snapshots* to an animated APNG or MP4 at *output*. + + Phases 2–4 of the VCS animation pipeline: parallel frame rendering, + deletion-highlight patching, and output writing. Shared by ``git_cmd`` + and ``hg_cmd``; the VCS-specific Phase 1 (collecting snapshots) is + handled by each command. + """ + from dirplot.git_scanner import _render_frame_worker + from dirplot.render_png import _draw_highlights + + # ── Phase 2: parallel render ────────────────────────────────────────── + total = len(snapshots) + n_workers = min(workers if workers is not None else (os.cpu_count() or 1), total) + if not quiet: + typer.echo(f"Rendering {total} frame(s) using {n_workers} worker(s) ...", err=True) + + # Per-frame progress: fraction of total animation time elapsed after each frame plays. + total_anim_ms = sum(commit_durations) + cumulative_ms = 0.0 + frame_progress: dict[int, float] = {} + for orig_i, *_ in snapshots: + cumulative_ms += commit_durations[orig_i] + frame_progress[orig_i] = cumulative_ms / total_anim_ms + + render_args = [ + ( + str(repo), + files_copy, + cur_hl, + sha, + ts, + orig_i, + frame_progress[orig_i], + depth, + logscale, + width_px, + height_px, + font_size, + colormap, + cushion, + dark, + ) + for orig_i, sha, ts, files_copy, cur_hl, _del in snapshots + ] + + # raw[orig_i] = (png_bytes, rect_map) as returned by the worker + raw: dict[int, tuple[bytes, dict[str, tuple[int, int, int, int]]]] = {} + + try: + with ProcessPoolExecutor(max_workers=n_workers, initializer=worker_ignore_sigint) as pool: + futures = {pool.submit(_render_frame_worker, args): args[5] for args in render_args} + for done, future in enumerate(as_completed(futures), 1): + orig_i, png_bytes, rect_map = future.result() + raw[orig_i] = (png_bytes, rect_map) + if not quiet: + typer.echo(f" Rendered {done}/{total}", err=True) + except KeyboardInterrupt: + typer.echo("\nInterrupted.", err=True) + raise typer.Exit(1) from None + + # ── Phase 3: assemble ordered frames, patch deletions ───────────────── + frame_bytes: list[bytes] = [] + frame_durations: list[int] = [] + + for j, (orig_i, _sha, _ts, _files, _hl, deletions) in enumerate(snapshots): + if deletions and j > 0: + # Draw deletion highlights onto the previous frame. + prev_bytes, prev_rect = raw[snapshots[j - 1][0]] + from PIL import Image, ImageDraw + + prev_img = Image.open(io.BytesIO(prev_bytes)).convert("RGB") + _draw_highlights(ImageDraw.Draw(prev_img), prev_rect, deletions) + buf = io.BytesIO() + prev_img.save(buf, format="PNG") + frame_bytes[-1] = buf.getvalue() + frame_bytes.append(raw[orig_i][0]) + frame_durations.append(commit_durations[orig_i]) + + # ── Phase 4: write output ───────────────────────────────────────────── + if fade_out and frame_bytes: + from dirplot.render_png import _frames_as_rgba, make_fade_out_frames + + fade_color = resolve_fade_color(fade_out_color, dark) + fade_transparent = len(fade_color) == 4 and fade_color[3] == 0 + if fade_transparent and output.suffix.lower() in {".mp4", ".mov"}: + fade_color = (0, 0, 0) if dark else (255, 255, 255) + fade_transparent = False + if fade_transparent: + frame_bytes = _frames_as_rgba(frame_bytes) + extra, extra_durs = make_fade_out_frames( + frame_bytes[-1], + n_frames=fade_out_frames + if fade_out_frames is not None + else max(1, round(fade_out_duration * 4)), + duration_ms=int(fade_out_duration * 1000), + target_color=fade_color, + ) + frame_bytes.extend(extra) + frame_durations.extend(extra_durs) + + if output.suffix.lower() in {".mp4", ".mov"}: + from dirplot.render_png import build_metadata, write_mp4 + + write_mp4( + output, + frame_bytes, + frame_durations, + crf=crf, + codec=codec, + metadata=build_metadata(), + ) + else: + from dirplot.render_png import write_apng + + write_apng(output, frame_bytes, frame_durations) + fmt = output.suffix.upper()[1:] + if not quiet: + typer.echo(f"Wrote {len(frame_bytes)}-frame {fmt} → {output}", err=True) + + +@app.command(name="git", epilog=_GIT_EPILOG) +def git_cmd( + repo_arg: str = typer.Argument( + ".", + help=( + "Git repository path (optionally suffixed with @ref, e.g. .@my-branch)," + " github://owner/repo[@branch], or https://github.com/owner/repo[@ref]" + ), + ), + output: Path | None = typer.Option( + None, "--output", "-o", help="Output file (.png or .mp4/.mov for animations)" + ), + revision_range: str | None = typer.Option( + None, + "--range", + help=( + "Git revision range to animate (e.g. v1.0..HEAD). " + "Triggers animation mode. " + "When using a GitHub URL with --first, " + "ensure the clone depth covers the range's base commit." + ), + ), + period: str | None = typer.Option( + None, + "--period", + help="Time window to animate (e.g. 30d, 24h, 2w, 1mo). Triggers animation mode.", + metavar="PERIOD", + ), + first: int | None = typer.Option( + None, "--first", help="Take only the first N commits of the range (animation mode only)" + ), + last: int | None = typer.Option( + None, "--last", help="Take only the last N commits of the range (animation mode only)" + ), + exclude: list[str] = typer.Option( + [], "--exclude", "-e", help="Top-level paths to exclude (repeatable)" + ), + font_size: int = typer.Option( + DEFAULT_FONT_SIZE, "--font-size", help="Directory label font size in pixels" + ), + colormap: str = typer.Option(DEFAULT_COLORMAP, "--colormap", help="Matplotlib colormap"), + size: str | None = typer.Option( + None, "--size", help="Output size as WIDTHxHEIGHT", metavar="WIDTHxHEIGHT" + ), + cushion: bool = typer.Option( + True, "--cushion/--no-cushion", help="Apply van Wijk cushion shading" + ), + dark: bool = typer.Option(True, "--dark/--light", help="Dark background (default) or light"), + inline: bool = typer.Option( + False, + "--inline", + help="Display snapshot inline in the terminal (iTerm2/Kitty/Ghostty). Single-frame mode only.", # noqa: E501 + ), + logscale: float = typer.Option( + 0.0, + "--log-scale", + help="Log-scale compression ratio (max/min ratio). 0 disables; must be > 1 to enable.", + show_default=True, + ), + depth: int | None = typer.Option(None, "--depth", help="Maximum directory depth"), + frame_duration: int = typer.Option( + 1000, + "--frame-duration", + help="Frame display duration in ms when not using --total-duration", + ), + total_duration: float | None = typer.Option( + None, + "--total-duration", + help=( + "Target total animation length in seconds. Frames are shown proportionally" + " to the real time gaps between commits. Overrides --frame-duration." + ), + ), + workers: int | None = typer.Option( + None, + "--workers", + help="Parallel render workers (default: all CPU cores). " + "Rendering is memory-bandwidth bound, so the optimal value depends on your hardware; " + "try --workers 4-8 if the default is slower than expected.", + ), + crf: int = typer.Option( + 23, + "--crf", + help="MP4 quality: Constant Rate Factor (0=lossless, 51=worst; default 23).", + show_default=True, + ), + codec: str = typer.Option( + "libx264", + "--codec", + help="MP4 video codec: libx264 (H.264, default) or libx265 (H.265, smaller files).", + ), + github_token_file: Path | None = typer.Option( + None, + "--github-token-file", + help="File containing a GitHub personal access token (avoids exposing the token in shell history).", # noqa: E501 + metavar="FILE", + ), + fade_out: bool = typer.Option( + False, + "--fade-out/--no-fade-out", + help="Append a fade-out sequence at the end of the animation", + ), + fade_out_duration: float = typer.Option( + 1.0, + "--fade-out-duration", + help="Total duration of the fade-out in seconds", + show_default=True, + ), + fade_out_frames: int | None = typer.Option( + None, + "--fade-out-frames", + help="Number of equidistant frames in the fade-out (default: 4 per second of duration)", + ), + fade_out_color: str = typer.Option( + "auto", + "--fade-out-color", + help=( + "Target colour for the fade-out: 'auto' (black in dark mode, white in light mode), " + "'transparent' (PNG/APNG only), a CSS colour name, or a hex code" + ), + metavar="COLOR", + ), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."), +) -> None: + """Render a git repo as a treemap snapshot or animate a commit range. + + Without --range or --period: renders a single snapshot of the last commit + (HEAD, or the tip of the branch/tag given via @ref). Use --inline to display + it directly in the terminal, or --output to save it as a PNG. + + With --range or --period: produces an animation (PNG/APNG or MP4) of the + matching commits. --first N and --last N slice the commit list. + """ + import shutil + import subprocess + import tempfile + from datetime import datetime + + if not shutil.which("git"): + typer.echo( + "Error: git not found on PATH. Install it to use dirplot git:\n" + " macOS: brew install git (or install Xcode Command Line Tools)\n" + " Linux: apt install git / dnf install git\n" + " Windows: https://git-scm.com/download/win", + err=True, + ) + raise typer.Exit(1) + + from dirplot.git_scanner import build_node_tree, git_apply_diff, git_initial_files, git_log + from dirplot.github import ( + _gh_cli_token, + count_commits_github, + is_github_path, + parse_github_path, + ) + + is_animation = revision_range is not None or period is not None + + if inline and is_animation: + typer.echo( + "Error: --inline is only available in single-frame mode (no --range or --period).", + err=True, + ) + raise typer.Exit(1) + if not inline and output is None: + typer.echo("Error: --output is required unless --inline is given.", err=True) + raise typer.Exit(1) + if (first is not None or last is not None) and not is_animation: + typer.echo("Error: --first and --last require --range or --period.", err=True) + raise typer.Exit(1) + if first is not None and last is not None: + typer.echo("Error: --first and --last are mutually exclusive.", err=True) + raise typer.Exit(1) + + if output is not None: + if is_animation and output.suffix.lower() not in {".png", ".mp4", ".mov"}: + typer.echo( + "Error: animation --output must be a .png (APNG), .mp4, or .mov file.", err=True + ) + raise typer.Exit(1) + if not is_animation and output.suffix.lower() != ".png": + typer.echo("Error: snapshot --output must be a .png file.", err=True) + raise typer.Exit(1) + + period_dt: datetime | None = None + if period is not None: + try: + period_dt = parse_last_period(period) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + + github_token: str | None = os.environ.get("GITHUB_TOKEN") + _tmpdir: tempfile.TemporaryDirectory[str] | None = None + _gh_owner: str | None = None + _gh_repo_name: str | None = None + _gh_ref: str | None = None + _gh_token: str | None = None + _at_ref: str | None = None + repo: Path + if github_token_file is not None: + if not github_token_file.exists(): + typer.echo(f"Error: --github-token-file not found: {github_token_file}", err=True) + raise typer.Exit(1) + github_token = github_token_file.read_text().strip() + + if is_github_path(repo_arg): + gh_owner, gh_repo_name, gh_ref, _ = parse_github_path(repo_arg) + _gh_owner, _gh_repo_name, _gh_ref = gh_owner, gh_repo_name, gh_ref + _at_ref = gh_ref + token = github_token or _gh_cli_token() + _gh_token = token + if token: + clone_url = f"https://x-access-token:{token}@github.com/{gh_owner}/{gh_repo_name}.git" + else: + clone_url = f"https://github.com/{gh_owner}/{gh_repo_name}.git" + _tmpdir = tempfile.TemporaryDirectory(prefix="dirplot-git-") + # Clone into a subdirectory named after the repo so that repo.name + # reflects the actual repository name (not the temp dir basename). + _clone_dir = Path(_tmpdir.name) / gh_repo_name + # Always clone with blobs so git ls-tree --long and git cat-file resolve + # sizes locally (fast). Without blobs, git fetches each size lazily over + # the network, which is slower than just cloning the objects upfront. + clone_cmd = ["git", "-c", "credential.helper=", "clone", "--quiet"] + if period_dt is not None and revision_range is None: + # --period without --range: use as shallow-since cutoff. + # With --range the clone must fetch enough history to resolve both + # range endpoints, so we skip shallow cloning entirely. + period_iso = period_dt.strftime("%Y-%m-%dT%H:%M:%SZ") + clone_cmd += [f"--shallow-since={period_iso}"] + elif first is not None and revision_range is None: + clone_cmd += ["--depth", str(first)] + if gh_ref: + clone_cmd += ["--branch", gh_ref] + clone_cmd += [clone_url, str(_clone_dir)] + if not quiet: + typer.echo(f"Cloning github:{gh_owner}/{gh_repo_name} ...", err=True) + try: + subprocess.run(clone_cmd, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + typer.echo(f"Error cloning repository: {exc.stderr.strip()}", err=True) + raise typer.Exit(1) from exc + repo = _clone_dir + else: + if "@" in repo_arg: + repo_path_str, _, _at_ref = repo_arg.partition("@") + else: + repo_path_str = repo_arg + repo = Path(repo_path_str).resolve() + if not (repo / ".git").exists(): + typer.echo(f"Error: not a git repository: {repo}", err=True) + raise typer.Exit(1) + + if size is not None: + try: + w_str, h_str = size.lower().split("x", 1) + width_px, height_px = int(w_str), int(h_str) + except ValueError: + typer.echo(f"Invalid --size '{size}'. Expected WIDTHxHEIGHT.", err=True) + raise typer.Exit(1) from None + else: + width_px, height_px = default_canvas_size() + + inline_cols: int | None = None + if inline and size is None: + inline_cols, *_ = get_terminal_size() + + if not quiet: + typer.echo(f"Reading git log from {repo} ...", err=True) + + _shallow_hint = "" + if _tmpdir is not None and revision_range and period_dt is not None: + _shallow_hint = ( + "\nHint: --period controls the shallow clone cutoff date. " + "If --range references a commit beyond that window, " + "use a wider --period." + ) + + # In single-frame mode, fetch only the last commit at the ref (or HEAD). + _log_range = revision_range if is_animation else _at_ref + # When --range + --period: fetch all range commits first, then filter by + # period relative to the range end. Without --range, pass period_dt to + # git_log so it uses --after (anchored to now). + _period_for_log = period_dt if revision_range is None else None + try: + commits = git_log(repo, _log_range, None, _period_for_log) + except subprocess.CalledProcessError as exc: + typer.echo(f"Error reading git log: {exc.stderr.strip()}{_shallow_hint}", err=True) + raise typer.Exit(1) from exc + + if not commits: + typer.echo(f"No commits found.{_shallow_hint}", err=True) + raise typer.Exit(1) + + # When --range + --period: filter to commits within period of the range end. + if is_animation and revision_range is not None and period_dt is not None: + from datetime import datetime, timezone + + period_seconds = (datetime.now(tz=timezone.utc) - period_dt).total_seconds() + cutoff_ts = commits[-1][1] - period_seconds + commits = [c for c in commits if c[1] >= cutoff_ts] + if not commits: + typer.echo("No commits found within --period of the range end.", err=True) + raise typer.Exit(1) + + # Apply --first / --last slicing after fetching (git log --reverse -n N gives + # newest commits, not oldest, so we must slice in Python instead). + if is_animation: + if first is not None: + commits = commits[:first] + elif last is not None: + commits = commits[-last:] + else: + commits = commits[-1:] + + # Show total commits on HEAD so the user knows how much history is available. + total_in_repo: int | None = None + if _gh_owner is not None: + total_in_repo = count_commits_github(_gh_owner, _gh_repo_name, _gh_ref, _gh_token) # type: ignore[arg-type] + else: + try: + _r = subprocess.run( + ["git", "-C", str(repo), "rev-list", "--count", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + total_in_repo = int(_r.stdout.strip()) + except (subprocess.CalledProcessError, ValueError): + pass + + if not quiet: + if is_animation: + if total_in_repo is not None and total_in_repo > len(commits): + _filters = [] + if period_dt is not None: + _filters.append(f"--period {period}") + if first is not None: + _filters.append(f"--first {first}") + if last is not None: + _filters.append(f"--last {last}") + _filter_str = " and ".join(_filters) if _filters else "filters" + typer.echo( + f"Animating {len(commits)} of {total_in_repo} commit(s) " + f"(filtered by {_filter_str}) ...", + err=True, + ) + else: + typer.echo(f"Animating {len(commits)} commit(s) ...", err=True) + else: + sha, ts, subject = commits[-1] + typer.echo( + f"Rendering snapshot: {sha[:8]} {subject[:72]}", + err=True, + ) + + excluded = frozenset(exclude) + files: dict[str, int] = {} + prev_sha: str | None = None + + if is_animation: + # Pre-compute per-commit frame durations. + if total_duration is not None: + if total_duration <= 0: + typer.echo("Error: --total-duration must be positive.", err=True) + raise typer.Exit(1) + timestamps = [ts for _, ts, _ in commits] + gaps: list[float] = [ + max(1.0, float(timestamps[j + 1] - timestamps[j])) + for j in range(len(timestamps) - 1) + ] + gaps.append(gaps[-1] if gaps else 1.0) + total_ms = total_duration * 1000 + commit_durations = proportional_durations(gaps, total_ms) + min_d, max_d = min(commit_durations), max(commit_durations) + if not quiet: + typer.echo( + f" Proportional timing: {min_d}–{max_d} ms/frame" + f" (total ~{sum(commit_durations) / 1000:.1f}s)", + err=True, + ) + else: + commit_durations = [frame_duration] * len(commits) + + # ── Phase 1: fast sequential git pass ──────────────────────────────── + Snapshot = tuple[int, str, int, dict[str, int], dict[str, str], dict[str, str]] + snapshots: list[Snapshot] = [] + + for i, (sha, ts, subject) in enumerate(commits): + if not quiet: + typer.echo(f" [{i + 1}/{len(commits)}] {sha[:8]} {subject[:72]}", err=True) + try: + if prev_sha is None: + files = git_initial_files(repo, sha, excluded) + all_hl: dict[str, str] = {} + else: + all_hl = git_apply_diff(repo, files, prev_sha, sha, excluded) + except subprocess.CalledProcessError as exc: + typer.echo(f" Warning: skipping {sha[:8]}: {exc.stderr.strip()}", err=True) + prev_sha = sha + continue + prev_sha = sha + deletions = {p: v for p, v in all_hl.items() if v == "deleted"} + cur_hl = {p: v for p, v in all_hl.items() if v != "deleted"} + snapshots.append((i, sha, ts, dict(files), cur_hl, deletions)) + + if not snapshots: + typer.echo("No frames captured.", err=True) + raise typer.Exit(1) + + assert output is not None + run_vcs_animation( + repo=repo, + snapshots=snapshots, + commit_durations=commit_durations, + output=output, + width_px=width_px, + height_px=height_px, + font_size=font_size, + colormap=colormap, + depth=depth, + logscale=logscale, + cushion=cushion, + dark=dark, + workers=workers, + crf=crf, + codec=codec, + fade_out=fade_out, + fade_out_duration=fade_out_duration, + fade_out_frames=fade_out_frames, + fade_out_color=fade_out_color, + quiet=quiet, + ) + + else: + # ── Single frame: render the last commit ────────────────────────────── + sha, ts, subject = commits[-1] + try: + files = git_initial_files(repo, sha, excluded) + except subprocess.CalledProcessError as exc: + typer.echo(f"Error reading commit {sha[:8]}: {exc.stderr.strip()}", err=True) + raise typer.Exit(1) from exc + + node = build_node_tree(repo, files, depth) + if logscale > 1: + apply_log_sizes(node, logscale) + + from dirplot.render_png import create_treemap + + png_buf = create_treemap( + node, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + title_suffix=f"sha:{sha[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", + dark=dark, + logscale=logscale, + ) + if inline: + display_inline(png_buf, cols=inline_cols) + else: + assert output is not None + output.write_bytes(png_buf.read()) + if not quiet: + typer.echo(f"Wrote {output}", err=True) + + +@app.command(name="hg", epilog=_HG_EPILOG) +def hg_cmd( + repo_arg: str = typer.Argument( + ".", + help="Mercurial repository path (optionally suffixed with @rev, e.g. .@tip)", + ), + output: Path | None = typer.Option( + None, "--output", "-o", help="Output file (.png or .mp4/.mov for animations)" + ), + revision_range: str | None = typer.Option( + None, + "--range", + help="Mercurial revision range to animate (e.g. 0:tip). Triggers animation mode.", + ), + period: str | None = typer.Option( + None, + "--period", + help="Time window to animate (e.g. 30d, 24h, 2w, 1mo). Triggers animation mode.", + metavar="PERIOD", + ), + first: int | None = typer.Option( + None, "--first", help="Take only the first N changesets of the range (animation mode only)" + ), + last: int | None = typer.Option( + None, "--last", help="Take only the last N changesets of the range (animation mode only)" + ), + exclude: list[str] = typer.Option( + [], "--exclude", "-e", help="Top-level paths to exclude (repeatable)" + ), + font_size: int = typer.Option( + DEFAULT_FONT_SIZE, "--font-size", help="Directory label font size in pixels" + ), + colormap: str = typer.Option(DEFAULT_COLORMAP, "--colormap", help="Matplotlib colormap"), + size: str | None = typer.Option( + None, "--size", help="Output size as WIDTHxHEIGHT", metavar="WIDTHxHEIGHT" + ), + cushion: bool = typer.Option( + True, "--cushion/--no-cushion", help="Apply van Wijk cushion shading" + ), + dark: bool = typer.Option(True, "--dark/--light", help="Dark background (default) or light"), + inline: bool = typer.Option( + False, + "--inline", + help="Display snapshot inline in the terminal (iTerm2/Kitty/Ghostty). Single-frame mode only.", # noqa: E501 + ), + logscale: float = typer.Option( + 0.0, + "--log-scale", + help="Log-scale compression ratio (max/min ratio). 0 disables; must be > 1 to enable.", + show_default=True, + ), + depth: int | None = typer.Option(None, "--depth", help="Maximum directory depth"), + frame_duration: int = typer.Option( + 1000, + "--frame-duration", + help="Frame display duration in ms when not using --total-duration", + ), + total_duration: float | None = typer.Option( + None, + "--total-duration", + help=( + "Target total animation length in seconds. Frames are shown proportionally" + " to the real time gaps between changesets. Overrides --frame-duration." + ), + ), + workers: int | None = typer.Option( + None, + "--workers", + help="Parallel render workers (default: all CPU cores).", + ), + crf: int = typer.Option( + 23, + "--crf", + help="MP4 quality: Constant Rate Factor (0=lossless, 51=worst; default 23).", + show_default=True, + ), + codec: str = typer.Option( + "libx264", + "--codec", + help="MP4 video codec: libx264 (H.264, default) or libx265 (H.265, smaller files).", + ), + fade_out: bool = typer.Option( + False, + "--fade-out/--no-fade-out", + help="Append a fade-out sequence at the end of the animation", + ), + fade_out_duration: float = typer.Option( + 1.0, + "--fade-out-duration", + help="Total duration of the fade-out in seconds", + show_default=True, + ), + fade_out_frames: int | None = typer.Option( + None, + "--fade-out-frames", + help="Number of equidistant frames in the fade-out (default: 4 per second of duration)", + ), + fade_out_color: str = typer.Option( + "auto", + "--fade-out-color", + help=( + "Target colour for the fade-out: 'auto' (black in dark mode, white in light mode), " + "'transparent' (PNG/APNG only), a CSS colour name, or a hex code" + ), + metavar="COLOR", + ), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."), +) -> None: + """Render a Mercurial repo as a treemap snapshot or animate a changeset range. + + Without --range or --period: renders a single snapshot of the tip (or the + tip of the branch/rev given via @ref). Use --inline to display it directly + in the terminal, or --output to save it as a PNG. + + With --range or --period: produces an animation (PNG/APNG or MP4) of the + matching changesets. --first N and --last N slice the changeset list. + """ + import shutil + import subprocess + from datetime import datetime + + if not shutil.which("hg"): + typer.echo( + "Error: hg not found on PATH. Install Mercurial to use dirplot hg:\n" + " macOS: brew install mercurial\n" + " Linux: apt install mercurial / dnf install mercurial\n" + " Windows: https://www.mercurial-scm.org/downloads", + err=True, + ) + raise typer.Exit(1) + + from dirplot.git_scanner import build_node_tree + from dirplot.hg_scanner import hg_apply_diff, hg_initial_files, hg_log + + is_animation = revision_range is not None or period is not None + + if inline and is_animation: + typer.echo( + "Error: --inline is only available in single-frame mode (no --range or --period).", + err=True, + ) + raise typer.Exit(1) + if not inline and output is None: + typer.echo("Error: --output is required unless --inline is given.", err=True) + raise typer.Exit(1) + if (first is not None or last is not None) and not is_animation: + typer.echo("Error: --first and --last require --range or --period.", err=True) + raise typer.Exit(1) + if first is not None and last is not None: + typer.echo("Error: --first and --last are mutually exclusive.", err=True) + raise typer.Exit(1) + + if output is not None: + if is_animation and output.suffix.lower() not in {".png", ".mp4", ".mov"}: + typer.echo( + "Error: animation --output must be a .png (APNG), .mp4, or .mov file.", err=True + ) + raise typer.Exit(1) + if not is_animation and output.suffix.lower() != ".png": + typer.echo("Error: snapshot --output must be a .png file.", err=True) + raise typer.Exit(1) + + period_dt: datetime | None = None + if period is not None: + try: + period_dt = parse_last_period(period) + except ValueError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + + _at_ref: str | None = None + if "@" in repo_arg: + repo_path_str, _, _at_ref = repo_arg.partition("@") + else: + repo_path_str = repo_arg + repo = Path(repo_path_str).resolve() + + if not (repo / ".hg").exists(): + typer.echo(f"Error: not a Mercurial repository: {repo}", err=True) + raise typer.Exit(1) + + if size is not None: + try: + w_str, h_str = size.lower().split("x", 1) + width_px, height_px = int(w_str), int(h_str) + except ValueError: + typer.echo(f"Invalid --size '{size}'. Expected WIDTHxHEIGHT.", err=True) + raise typer.Exit(1) from None + else: + width_px, height_px = default_canvas_size() + + inline_cols: int | None = None + if inline and size is None: + inline_cols, *_ = get_terminal_size() + + if not quiet: + typer.echo(f"Reading hg log from {repo} ...", err=True) + + _log_range = revision_range if is_animation else _at_ref + _period_for_log = period_dt if revision_range is None else None + try: + commits = hg_log(repo, _log_range, None, _period_for_log) + except subprocess.CalledProcessError as exc: + typer.echo(f"Error reading hg log: {exc.stderr.strip()}", err=True) + raise typer.Exit(1) from exc + + if not commits: + typer.echo("No changesets found.", err=True) + raise typer.Exit(1) + + # When --range + --period: filter to changesets within period of the range end. + if is_animation and revision_range is not None and period_dt is not None: + from datetime import datetime, timezone + + period_seconds = (datetime.now(tz=timezone.utc) - period_dt).total_seconds() + cutoff_ts = commits[-1][1] - period_seconds + commits = [c for c in commits if c[1] >= cutoff_ts] + if not commits: + typer.echo("No changesets found within --period of the range end.", err=True) + raise typer.Exit(1) + + # Apply --first / --last slicing after fetching (hg log --limit N gives + # newest changesets, not oldest, so we must slice in Python instead). + if is_animation: + if first is not None: + commits = commits[:first] + elif last is not None: + commits = commits[-last:] + else: + commits = commits[-1:] + + try: + _r = subprocess.run( + ["hg", "log", "-R", str(repo), "--template", "x\n"], + capture_output=True, + text=True, + check=True, + ) + total_in_repo: int | None = _r.stdout.count("x") + except (subprocess.CalledProcessError, ValueError): + total_in_repo = None + + if not quiet: + if is_animation: + if total_in_repo is not None and total_in_repo > len(commits): + _filters = [] + if period_dt is not None: + _filters.append(f"--period {period}") + if first is not None: + _filters.append(f"--first {first}") + if last is not None: + _filters.append(f"--last {last}") + _filter_str = " and ".join(_filters) if _filters else "filters" + typer.echo( + f"Animating {len(commits)} of {total_in_repo} changeset(s) " + f"(filtered by {_filter_str}) ...", + err=True, + ) + else: + typer.echo(f"Animating {len(commits)} changeset(s) ...", err=True) + else: + node_id, ts, subject = commits[-1] + typer.echo( + f"Rendering snapshot: {node_id[:8]} {subject[:72]}", + err=True, + ) + + excluded = frozenset(exclude) + files: dict[str, int] = {} + prev_node: str | None = None + + if is_animation: + # Pre-compute per-changeset frame durations. + if total_duration is not None: + if total_duration <= 0: + typer.echo("Error: --total-duration must be positive.", err=True) + raise typer.Exit(1) + timestamps = [ts for _, ts, _ in commits] + gaps: list[float] = [ + max(1.0, float(timestamps[j + 1] - timestamps[j])) + for j in range(len(timestamps) - 1) + ] + gaps.append(gaps[-1] if gaps else 1.0) + total_ms = total_duration * 1000 + commit_durations = proportional_durations(gaps, total_ms) + min_d, max_d = min(commit_durations), max(commit_durations) + if not quiet: + typer.echo( + f" Proportional timing: {min_d}–{max_d} ms/frame" + f" (total ~{sum(commit_durations) / 1000:.1f}s)", + err=True, + ) + else: + commit_durations = [frame_duration] * len(commits) + + # ── Phase 1: fast sequential hg pass ───────────────────────────────── + Snapshot = tuple[int, str, int, dict[str, int], dict[str, str], dict[str, str]] + snapshots: list[Snapshot] = [] + + for i, (node_id, ts, subject) in enumerate(commits): + if not quiet: + typer.echo(f" [{i + 1}/{len(commits)}] {node_id[:8]} {subject[:72]}", err=True) + try: + if prev_node is None: + files = hg_initial_files(repo, node_id, excluded) + all_hl: dict[str, str] = {} + else: + all_hl = hg_apply_diff(repo, files, prev_node, node_id, excluded) + except subprocess.CalledProcessError as exc: + typer.echo(f" Warning: skipping {node_id[:8]}: {exc.stderr.strip()}", err=True) + prev_node = node_id + continue + prev_node = node_id + deletions = {p: v for p, v in all_hl.items() if v == "deleted"} + cur_hl = {p: v for p, v in all_hl.items() if v != "deleted"} + snapshots.append((i, node_id, ts, dict(files), cur_hl, deletions)) + + if not snapshots: + typer.echo("No frames captured.", err=True) + raise typer.Exit(1) + + assert output is not None + run_vcs_animation( + repo=repo, + snapshots=snapshots, + commit_durations=commit_durations, + output=output, + width_px=width_px, + height_px=height_px, + font_size=font_size, + colormap=colormap, + depth=depth, + logscale=logscale, + cushion=cushion, + dark=dark, + workers=workers, + crf=crf, + codec=codec, + fade_out=fade_out, + fade_out_duration=fade_out_duration, + fade_out_frames=fade_out_frames, + fade_out_color=fade_out_color, + quiet=quiet, + ) + + else: + # ── Single frame: render the last changeset ─────────────────────────── + node_id, ts, subject = commits[-1] + try: + files = hg_initial_files(repo, node_id, excluded) + except subprocess.CalledProcessError as exc: + typer.echo(f"Error reading changeset {node_id[:8]}: {exc.stderr.strip()}", err=True) + raise typer.Exit(1) from exc + + node_tree = build_node_tree(repo, files, depth) + if logscale > 1: + apply_log_sizes(node_tree, logscale) + + from dirplot.render_png import create_treemap + + png_buf = create_treemap( + node_tree, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + title_suffix=f"rev:{node_id[:8]} {datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')}", # noqa: E501 + dark=dark, + logscale=logscale, + ) + if inline: + display_inline(png_buf, cols=inline_cols) + else: + assert output is not None + output.write_bytes(png_buf.read()) + if not quiet: + typer.echo(f"Wrote {output}", err=True) diff --git a/src/dirplot/commands/watch.py b/src/dirplot/commands/watch.py new file mode 100644 index 0000000..f3381d9 --- /dev/null +++ b/src/dirplot/commands/watch.py @@ -0,0 +1,142 @@ +"""The ``watch`` command: regenerate treemap on filesystem changes.""" + +import signal +import time +from pathlib import Path + +import typer + +from dirplot.app import app +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE +from dirplot.terminal import default_canvas_size + +_WATCH_EPILOG = ( + "[bold]Examples[/bold]\n\n" + " dirplot watch . [dim]# watch current directory[/dim]\n\n" + " dirplot watch src tests [dim]# watch multiple directories[/dim]\n\n" + " dirplot watch . --snapshot treemap.png [dim]# write PNG on each change[/dim]\n\n" + " dirplot watch . --snapshot treemap.png --debounce 1.0 [dim]# 1-second debounce[/dim]\n\n" + " dirplot watch . --snapshot treemap.png --debounce 0 [dim]# immediate regeneration[/dim]\n\n" + " dirplot watch src --event-log events.jsonl [dim]# record events for replay[/dim]\n\n" + " dirplot watch src --snapshot treemap.png --event-log events.jsonl" + " [dim]# snapshot + log[/dim]" +) + + +@app.command(name="watch", epilog=_WATCH_EPILOG) +def watch_cmd( + paths: list[Path] = typer.Argument(..., help="Directories to watch"), + exclude: list[str] = typer.Option([], "--exclude", "-e", help="Paths to exclude (repeatable)"), + font_size: int = typer.Option( + DEFAULT_FONT_SIZE, "--font-size", help="Directory label font size in pixels" + ), + colormap: str = typer.Option(DEFAULT_COLORMAP, "--colormap", help="Matplotlib colormap"), + size: str | None = typer.Option( + None, "--size", help="Output size as WIDTHxHEIGHT", metavar="WIDTHxHEIGHT" + ), + cushion: bool = typer.Option( + True, "--cushion/--no-cushion", help="Apply van Wijk cushion shading" + ), + dark: bool = typer.Option(True, "--dark/--light", help="Dark background (default) or light"), + logscale: float = typer.Option( + 0.0, + "--log-scale", + help="Log-scale compression ratio (max/min ratio). 0 disables; must be > 1 to enable.", + show_default=True, + ), + depth: int | None = typer.Option( + None, + "--depth", + help="Maximum recursion depth (same as for map)", + ), + debounce: float = typer.Option( + 0.5, + "--debounce", + help="Seconds of quiet after last event before regenerating (0 to disable)", + show_default=True, + ), + event_log: Path | None = typer.Option( + None, + "--event-log", + help="Write all raw events as JSONL to this file on exit", + metavar="FILE", + ), + snapshot: Path | None = typer.Option( + None, + "--snapshot", + help="Write the current treemap as a PNG to this file on each filesystem change.", + metavar="FILE", + ), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."), +) -> None: + """Watch one or more directories and regenerate the treemap on every file change. + + Example: dirplot watch . --snapshot out.png + """ + from dirplot.watch import TreemapEventHandler + + try: + from watchdog.observers import Observer + except ImportError: + typer.echo("Error: watchdog is required. Run: pip install watchdog", err=True) + raise typer.Exit(1) from None + + for path in paths: + if not path.exists() or not path.is_dir(): + typer.echo(f"Error: not a directory: {path}", err=True) + raise typer.Exit(1) + + if size is not None: + try: + w_str, h_str = size.lower().split("x", 1) + width_px, height_px = int(w_str), int(h_str) + except ValueError: + typer.echo(f"Invalid --size '{size}'. Expected WIDTHxHEIGHT.", err=True) + raise typer.Exit(1) from None + else: + width_px, height_px = default_canvas_size() + + excluded = frozenset(exclude) + roots = [path.resolve() for path in paths] + + handler = TreemapEventHandler( + roots, + output=snapshot, + exclude=excluded, + width_px=width_px, + height_px=height_px, + font_size=font_size, + colormap=colormap, + cushion=cushion, + logscale=logscale, + debounce=debounce, + event_log=event_log, + depth=depth, + dark=dark, + ) + + observer = Observer() + try: + # Generate an initial treemap immediately + roots_str = ", ".join(str(r) for r in roots) + if not quiet: + typer.echo(f"Scanning {roots_str} ...", err=True) + handler._regenerate() + + for root in roots: + observer.schedule(handler, str(root), recursive=True) + observer.start() + if not quiet: + snapshot_info = f" → {snapshot}" if snapshot else "" + typer.echo(f"Watching {roots_str}{snapshot_info} (Ctrl-C to stop)", err=True) + + while True: + time.sleep(1) + except KeyboardInterrupt: + pass + finally: + signal.signal(signal.SIGINT, signal.SIG_IGN) + handler.flush() + if observer.is_alive(): + observer.stop() + observer.join() diff --git a/src/dirplot/defaults.py b/src/dirplot/defaults.py new file mode 100644 index 0000000..f35c6d3 --- /dev/null +++ b/src/dirplot/defaults.py @@ -0,0 +1,5 @@ +"""Shared default values for CLI options and rendering.""" + +DEFAULT_COLORMAP: str = "tab20" +DEFAULT_FONT_SIZE: int = 12 +DEFAULT_LEGEND_MAX_ROWS: int = 20 diff --git a/src/dirplot/display.py b/src/dirplot/display.py index fd1570a..eeeaa0b 100644 --- a/src/dirplot/display.py +++ b/src/dirplot/display.py @@ -3,13 +3,21 @@ import base64 import io import os -import select import sys -import termios import time -import tty from typing import BinaryIO, TextIO +_IS_WINDOWS = sys.platform == "win32" + +try: + import select as _select + import termios + import tty + + _HAS_TERMIOS = True +except ImportError: + _HAS_TERMIOS = False + def _read_fd_response(fd: int, timeout: float = 0.3) -> bytes: """Read from *fd* until quiet or *timeout* seconds.""" @@ -19,7 +27,7 @@ def _read_fd_response(fd: int, timeout: float = 0.3) -> bytes: remaining = deadline - time.monotonic() if remaining <= 0: break - ready, _, _ = select.select([fd], [], [], min(remaining, 0.05)) + ready, _, _ = _select.select([fd], [], [], min(remaining, 0.05)) if ready: chunk = os.read(fd, 256) if chunk: @@ -33,32 +41,33 @@ def _read_fd_response(fd: int, timeout: float = 0.3) -> bytes: def _detect_inline_protocol() -> str: """Return ``"iterm2"``, ``"kitty"``, or ``""`` based on terminal probing.""" - try: - fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) - except OSError: - fd = -1 - - if fd >= 0: - old = termios.tcgetattr(fd) + if _HAS_TERMIOS and not _IS_WINDOWS: try: - tty.setraw(fd) - - # Probe iTerm2 capabilities - os.write(fd, b"\x1b]1337;Capabilities\x1b\\") - resp = _read_fd_response(fd, 0.3) - if b"Capabilities=" in resp and b"F" in resp: - return "iterm2" - - # Probe Kitty APC graphics protocol - os.write(fd, b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c") - resp = _read_fd_response(fd, 0.3) - if b"\x1b_G" in resp: - return "kitty" - except Exception: # noqa: BLE001 - pass - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old) - os.close(fd) + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + except OSError: + fd = -1 + + if fd >= 0: + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + + # Probe iTerm2 capabilities + os.write(fd, b"\x1b]1337;Capabilities\x1b\\") + resp = _read_fd_response(fd, 0.3) + if b"Capabilities=" in resp and b"F" in resp: + return "iterm2" + + # Probe Kitty APC graphics protocol + os.write(fd, b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c") + resp = _read_fd_response(fd, 0.3) + if b"\x1b_G" in resp: + return "kitty" + except Exception: # noqa: BLE001 + pass + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + os.close(fd) # Env-var heuristic fallback term_program = os.environ.get("TERM_PROGRAM", "").lower() @@ -73,10 +82,16 @@ def _detect_inline_protocol() -> str: def _open_tty_write_binary() -> tuple[BinaryIO, bool]: """Return (file_obj, owned) for the best available binary output channel. - Tries /dev/tty first using low-level os.open() (avoids O_CREAT/O_TRUNC + On Unix, tries /dev/tty first using low-level os.open() (avoids O_CREAT/O_TRUNC that Python's built-in open() adds, which can fail on some macOS setups). - Falls back to sys.stdout.buffer if /dev/tty is unavailable. + On Windows, tries CONOUT$ instead. + Falls back to sys.stdout.buffer if neither is available. """ + if _IS_WINDOWS: + try: + return open("CONOUT$", "wb", buffering=0), True # noqa: SIM115 + except OSError: + return sys.stdout.buffer, False try: fd = os.open("/dev/tty", os.O_WRONLY | os.O_NOCTTY) return os.fdopen(fd, "wb", buffering=0, closefd=True), True @@ -86,6 +101,11 @@ def _open_tty_write_binary() -> tuple[BinaryIO, bool]: def _open_tty_write_text() -> tuple[TextIO, bool]: """Return (file_obj, owned) for the best available text output channel.""" + if _IS_WINDOWS: + try: + return open("CONOUT$", "w"), True # noqa: SIM115 + except OSError: + return sys.stdout, False try: fd = os.open("/dev/tty", os.O_WRONLY | os.O_NOCTTY) return os.fdopen(fd, "w", closefd=True), True @@ -93,11 +113,12 @@ def _open_tty_write_text() -> tuple[TextIO, bool]: return sys.stdout, False -def _display_iterm2(buf: io.BytesIO) -> None: +def _display_iterm2(buf: io.BytesIO, cols: int | None = None) -> None: """Display the PNG inline using the iTerm2 escape sequence protocol.""" data = buf.read() b64 = base64.b64encode(data).decode() - payload = f"\x1b]1337;File=inline=1;size={len(data)};preserveAspectRatio=1:{b64}\a" + width_param = f";width={cols}" if cols is not None else "" + payload = f"\x1b]1337;File=inline=1;size={len(data)}{width_param};preserveAspectRatio=1:{b64}\a" f, owned = _open_tty_write_text() try: f.write(payload) @@ -127,17 +148,41 @@ def display_kitty(buf: io.BytesIO) -> None: out.close() -def display_inline(buf: io.BytesIO) -> None: - """Display the PNG inline, auto-detecting the terminal graphics protocol.""" +def display_inline(buf: io.BytesIO, cols: int | None = None) -> None: + """Display the PNG inline, auto-detecting the terminal graphics protocol. + + *cols* — when provided, hints the number of character columns the image + should fill (iTerm2 protocol only). Pass the terminal column count when + the image was generated to fill the full terminal width so that pixel→cell + rounding differences (scrollbar, DPI) do not leave empty columns. + """ protocol = _detect_inline_protocol() if protocol == "kitty": display_kitty(buf) else: - _display_iterm2(buf) + _display_iterm2(buf, cols=cols) + +def display_window(buf: io.BytesIO, title: str | None = None) -> None: + """Open the PNG in the system default image viewer. + + When *title* is given it is used as a prefix for the temporary file name + (sanitised so it is safe on all platforms), making the file easier to + identify in the OS image viewer's title bar. + """ + import re + import tempfile + import webbrowser + from pathlib import Path -def display_window(buf: io.BytesIO) -> None: - """Open the PNG in the system default image viewer.""" from PIL import Image - Image.open(buf).show() + if title: + safe = re.sub(r"[^\w.\-]", "_", title) + prefix = f"dirplot-{safe}-" + img = Image.open(buf) + with tempfile.NamedTemporaryFile(prefix=prefix, suffix=".png", delete=False) as tmp: + img.save(tmp, format="PNG") + webbrowser.open(Path(tmp.name).resolve().as_uri()) + else: + Image.open(buf).show() diff --git a/src/dirplot/docker.py b/src/dirplot/docker.py new file mode 100644 index 0000000..bd94cc2 --- /dev/null +++ b/src/dirplot/docker.py @@ -0,0 +1,252 @@ +"""Docker container directory scanning via the docker CLI.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path, PurePosixPath + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + + +def _docker_cmd() -> str: + """Return the docker executable name, raising if not found.""" + import shutil + + if shutil.which("docker") is None: + raise FileNotFoundError( + "docker CLI not found in PATH. Install Docker from https://docs.docker.com/get-docker/" + ) + return "docker" + + +def is_docker_path(s: str) -> bool: + """Return True if *s* looks like a Docker container path.""" + return s.startswith("docker://") + + +def parse_docker_path(s: str) -> tuple[str, str]: + """Parse a Docker path string into *(container, remote_path)*. + + Accepted formats:: + + docker://container/path + docker://container:/path + """ + rest = s[len("docker://") :] + + # Support both docker://container:/path and docker://container/path + if ":" in rest: + container, remote_path = rest.split(":", 1) + else: + parts = rest.split("/", 1) + container = parts[0] + remote_path = "/" + parts[1] if len(parts) > 1 else "/" + + if not remote_path.startswith("/"): + remote_path = "/" + remote_path + + return container, remote_path + + +def _check_container(docker: str, container_name: str) -> None: + """Raise FileNotFoundError if the container does not exist or is not running.""" + result = subprocess.run( + [docker, "inspect", "--type", "container", container_name], + capture_output=True, + ) + if result.returncode != 0: + raise FileNotFoundError(f"Docker container not found or not running: {container_name!r}") + + +def _run_find( + docker: str, container_name: str, remote_path: str, depth: int | None +) -> subprocess.CompletedProcess[str]: + """Run find inside the container, trying GNU find then BusyBox fallback. + + GNU find (Debian/Ubuntu/RHEL images) supports ``-printf`` for efficient + single-pass output. BusyBox find (Alpine images) does not; we fall back to + a POSIX sh + stat loop that works on any image with a shell. + """ + max_depth_args = ["-maxdepth", str(depth)] if depth is not None else [] + + # GNU find: single pass, output is "rel_path\tsize\ttype" + result = subprocess.run( + [ + docker, + "exec", + container_name, + "find", + remote_path, + "-xdev", + *max_depth_args, + "-not", + "-type", + "l", + "-printf", + r"%P\t%s\t%y\n", + ], + capture_output=True, + text=True, + ) + if result.returncode == 0 or "unrecognized" not in result.stderr: + return result + + # BusyBox fallback: sh + stat loop. remote_path is passed as $1 to avoid + # quoting issues inside the script string. + # Use ${p%/}/ so that p="/" → prefix="/", p="/app" → prefix="/app/", + # avoiding the double-slash bug when stripping the root path. + max_depth_sh = f"-maxdepth {depth}" if depth is not None else "" + script = ( + 'p="$1"; pfx="${p%/}/"\n' + f'find "$p" -xdev {max_depth_sh} -not -type l | while IFS= read -r f; do\n' + ' r="${f#${pfx}}"\n' + ' case "$r" in "") continue;; "$f") continue;; /*) continue;; esac\n' + ' s=$(stat -c%s "$f" 2>/dev/null) || s=1\n' + ' [ -d "$f" ] && t=d || t=f\n' + ' printf "%s\\t%s\\t%s\\n" "$r" "$s" "$t"\n' + "done" + ) + return subprocess.run( + [docker, "exec", container_name, "sh", "-c", script, "_", remote_path], + capture_output=True, + text=True, + ) + + +def build_tree_docker( + container_name: str, + remote_path: str, + exclude: frozenset[str] = frozenset(), + *, + depth: int | None = None, + _progress: list[int] | None = None, +) -> Node: + """Build a :class:`~dirplot.scanner.Node` tree from a Docker container path. + + Uses ``docker exec`` to run ``find`` inside the container, so the container + must be running and have a POSIX ``find`` binary available (standard on all + common Linux base images). + + Args: + container_name: Container name or ID. + remote_path: Absolute path inside the container. + exclude: Set of absolute paths inside the container to skip. + depth: Maximum recursion depth. ``None`` means unlimited. + _progress: Internal one-element counter for progress reporting. + """ + docker = _docker_cmd() + _check_container(docker, container_name) + + result = _run_find(docker, container_name, remote_path, depth) + if result.returncode != 0: + err = result.stderr.strip() + raise OSError(f"find failed in container {container_name!r} at {remote_path!r}: {err}") + + # Parse find output into (rel_path, size, is_dir) entries + entries: list[tuple[str, int, bool]] = [] + for line in result.stdout.splitlines(): + parts = line.split("\t", 2) + if len(parts) != 3: + continue + rel_path, size_str, ftype = parts + if not rel_path: + continue # the root entry itself (empty relative path) + name = PurePosixPath(rel_path).name + if name.startswith("."): + continue # skip hidden files + if matches_exclude(rel_path, exclude): + continue + try: + size = int(size_str) + except ValueError: + size = 1 + entries.append((rel_path, max(size, 1), ftype == "d")) + + if _progress is not None: + _progress[0] += 1 + if _progress[0] % 100 == 0: + print( + f"\r scanned {_progress[0]} entries…", + end="", + file=sys.stderr, + flush=True, + ) + + return _entries_to_tree(remote_path, entries) + + +def _entries_to_tree(root_path: str, entries: list[tuple[str, int, bool]]) -> Node: + """Convert a flat list of *(rel_path, size, is_dir)* entries into a Node tree.""" + dir_nodes: dict[str, Node] = {} + root_name = PurePosixPath(root_path).name or root_path + root_node = Node(name=root_name, path=Path(root_path), size=0, is_dir=True, children=[]) + dir_nodes[""] = root_node # empty string = root + + # Sort so parent dirs appear before their children + entries_sorted = sorted(entries, key=lambda e: e[0]) + + for rel_path, size, is_dir in entries_sorted: + pure = PurePosixPath(rel_path) + name = pure.name + parent_rel = str(pure.parent) if str(pure.parent) != "." else "" + abs_path = root_path.rstrip("/") + "/" + rel_path + + # Ensure intermediate directories exist in the tree + _ensure_dir(dir_nodes, root_path, parent_rel) + + parent_node = dir_nodes.get(parent_rel, root_node) + + if is_dir: + node = Node( + name=name, + path=Path(abs_path), + size=0, + is_dir=True, + children=[], + ) + dir_nodes[rel_path] = node + else: + ext = pure.suffix.lower() or NO_EXT + node = Node( + name=name, + path=Path(abs_path), + size=size, + is_dir=False, + extension=ext, + ) + + parent_node.children.append(node) + + _compute_sizes(root_node) + return root_node + + +def _ensure_dir(dir_nodes: dict[str, Node], root_path: str, rel_path: str) -> None: + """Create missing intermediate directory nodes.""" + if rel_path in dir_nodes or rel_path == "": + return + pure = PurePosixPath(rel_path) + parent_rel = str(pure.parent) if str(pure.parent) != "." else "" + _ensure_dir(dir_nodes, root_path, parent_rel) + + abs_path = root_path.rstrip("/") + "/" + rel_path + node = Node( + name=pure.name, + path=Path(abs_path), + size=0, + is_dir=True, + children=[], + ) + dir_nodes[rel_path] = node + dir_nodes[parent_rel].children.append(node) + + +def _compute_sizes(node: Node) -> int: + """Recursively set directory sizes to the sum of their children's sizes.""" + if not node.is_dir: + return node.size + total = sum(_compute_sizes(c) for c in node.children) + node.size = max(total, 1) + return node.size diff --git a/src/dirplot/filters.py b/src/dirplot/filters.py new file mode 100644 index 0000000..1942a80 --- /dev/null +++ b/src/dirplot/filters.py @@ -0,0 +1,47 @@ +"""Path-pattern filtering utilities for --exclude.""" + +from __future__ import annotations + +import fnmatch +from pathlib import PurePosixPath + + +def matches_exclude(rel_path: str, patterns: frozenset[str]) -> bool: + """Return True if *rel_path* matches any pattern in *patterns*. + + Pattern semantics: + - No ``/``: matched as a glob against every path component. + ``".git"`` and ``"*.egg-info"`` both skip matching dirs anywhere in the tree. + - Contains ``/`` but no ``**``: fnmatch against the full relative path. + ``"src/vendor"`` matches exactly that subtree. + - Contains ``**``: full glob matching with ``**`` spanning multiple components. + ``"**/__pycache__"`` skips ``__pycache__`` at any depth. + """ + if not patterns: + return False + parts = tuple(PurePosixPath(rel_path).parts) + for pattern in patterns: + if "/" not in pattern: + if any(fnmatch.fnmatch(part, pattern) for part in parts): + return True + elif "**" not in pattern: + if fnmatch.fnmatch(rel_path, pattern): + return True + else: + pat_parts = tuple(PurePosixPath(pattern).parts) + if _glob_match(parts, pat_parts): + return True + return False + + +def _glob_match(path_parts: tuple[str, ...], pat_parts: tuple[str, ...]) -> bool: + """Recursive glob matcher supporting ``**``.""" + if not pat_parts: + return not path_parts + if pat_parts[0] == "**": + return any(_glob_match(path_parts[i:], pat_parts[1:]) for i in range(len(path_parts) + 1)) + if not path_parts: + return False + if fnmatch.fnmatch(path_parts[0], pat_parts[0]): + return _glob_match(path_parts[1:], pat_parts[1:]) + return False diff --git a/src/dirplot/gdrive.py b/src/dirplot/gdrive.py new file mode 100644 index 0000000..5f3c11d --- /dev/null +++ b/src/dirplot/gdrive.py @@ -0,0 +1,184 @@ +"""Google Drive scanning via the gog CLI (https://gogcli.sh/).""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path, PurePosixPath + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + +_GDRIVE_FOLDER_MIME = "application/vnd.google-apps.folder" + +# Google-native formats have no byte size (stored as 0). Show them as 1 byte +# so they remain visible in the treemap rather than disappearing. +_GDRIVE_NATIVE_MIME_PREFIX = "application/vnd.google-apps." + + +def _gog_cmd() -> str: + """Return the gog executable path, raising FileNotFoundError if absent.""" + if shutil.which("gog") is None: + raise FileNotFoundError( + "gog CLI not found in PATH. " + "Install from https://gogcli.sh/ and authenticate with `gog auth`." + ) + return "gog" + + +def is_gdrive_path(s: str) -> bool: + """Return True if *s* looks like a Google Drive path.""" + return s.startswith("gdrive://") + + +def parse_gdrive_path(s: str) -> str | None: + """Parse a Google Drive URL into an optional folder ID. + + Returns the folder ID if one was specified, or ``None`` for the Drive root. + + Accepted formats:: + + gdrive:// → Drive root (My Drive + shared drives) + gdrive://1BxiMVs0XRA5nFMdKvBdBZjg → specific folder ID + """ + rest = s[len("gdrive://") :].strip("/") + return rest if rest else None + + +def build_tree_gdrive( + folder_id: str | None = None, + *, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, + _progress: list[int] | None = None, +) -> Node: + """Build a :class:`~dirplot.scanner.Node` tree from Google Drive. + + Shells out to ``gog drive tree --json`` and parses the flat item list into + a Node hierarchy. Authentication is handled entirely by gog — run + ``gog auth`` once before use. + + Args: + folder_id: Drive folder ID to start from. ``None`` scans from the + Drive root (My Drive + all shared drives). + exclude: Set of path patterns to skip. + depth: Maximum recursion depth. ``None`` means unlimited. + _progress: Internal one-element counter for progress reporting. + """ + gog = _gog_cmd() + + cmd = [gog, "drive", "tree", "--json"] + if folder_id: + cmd += ["--parent", folder_id] + # gog uses --depth 0 for unlimited; dirplot uses None + cmd += ["--depth", str(depth) if depth is not None else "0"] + cmd += ["--max", "0"] # unlimited items + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + err = result.stderr.strip() or result.stdout.strip() + raise OSError(f"gog drive tree failed: {err}") + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise OSError(f"gog drive tree returned invalid JSON: {exc}") from exc + + items: list[dict[str, object]] = data.get("items", []) + if data.get("truncated"): + print( + " Warning: Google Drive results truncated. " + "Use --depth to limit recursion or --max in gog directly.", + file=sys.stderr, + ) + + entries: list[tuple[str, int, bool]] = [] + for item in items: + path_str = str(item.get("path") or "") + if not path_str: + continue + name = PurePosixPath(path_str).name + if name.startswith("."): + continue + if matches_exclude(path_str, exclude): + continue + + mime = str(item.get("mimeType") or "") + is_dir = mime == _GDRIVE_FOLDER_MIME + + raw_size = item.get("size") + size = int(raw_size) if isinstance(raw_size, int | float) else 0 + if not is_dir and size == 0: + # Google-native formats (Docs, Sheets, Slides, …) report no byte + # size. Use 1 so they remain visible in the treemap. + size = 1 + + entries.append((path_str, size, is_dir)) + + if _progress is not None: + _progress[0] += 1 + if _progress[0] % 100 == 0: + print( + f"\r scanned {_progress[0]} entries…", + end="", + file=sys.stderr, + flush=True, + ) + + root_label = folder_id or "My Drive" + return _entries_to_tree(root_label, entries) + + +def _entries_to_tree(root_name: str, entries: list[tuple[str, int, bool]]) -> Node: + """Convert a flat list of *(rel_path, size, is_dir)* entries into a Node tree.""" + dir_nodes: dict[str, Node] = {} + root_node = Node(name=root_name, path=Path(root_name), size=0, is_dir=True, children=[]) + dir_nodes[""] = root_node + + for rel_path, size, is_dir in sorted(entries, key=lambda e: e[0]): + pure = PurePosixPath(rel_path) + name = pure.name + parent_rel = str(pure.parent) if str(pure.parent) != "." else "" + + _ensure_dir(dir_nodes, parent_rel) + parent_node = dir_nodes.get(parent_rel, root_node) + + if is_dir: + node = Node(name=name, path=Path(rel_path), size=0, is_dir=True, children=[]) + dir_nodes[rel_path] = node + else: + node = Node( + name=name, + path=Path(rel_path), + size=size, + is_dir=False, + extension=pure.suffix.lower() or NO_EXT, + ) + + parent_node.children.append(node) + + _compute_sizes(root_node) + return root_node + + +def _ensure_dir(dir_nodes: dict[str, Node], rel_path: str) -> None: + """Create missing intermediate directory nodes.""" + if rel_path in dir_nodes or rel_path == "": + return + pure = PurePosixPath(rel_path) + parent_rel = str(pure.parent) if str(pure.parent) != "." else "" + _ensure_dir(dir_nodes, parent_rel) + node = Node(name=pure.name, path=Path(rel_path), size=0, is_dir=True, children=[]) + dir_nodes[rel_path] = node + dir_nodes[parent_rel].children.append(node) + + +def _compute_sizes(node: Node) -> int: + """Recursively set directory sizes to the sum of their children's sizes.""" + if not node.is_dir: + return node.size + total = sum(_compute_sizes(c) for c in node.children) + node.size = max(total, 1) + return node.size diff --git a/src/dirplot/git_scanner.py b/src/dirplot/git_scanner.py new file mode 100644 index 0000000..644ea87 --- /dev/null +++ b/src/dirplot/git_scanner.py @@ -0,0 +1,459 @@ +"""Build a Node tree from a git commit and compute per-commit change highlights.""" + +import contextlib +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + + +def git_log( + repo: Path, + revision_range: str | None = None, + max_count: int | None = None, + last: datetime | None = None, +) -> list[tuple[str, int, str]]: + """Return commits as (sha, unix_timestamp, subject), oldest-first.""" + cmd = ["git", "-C", str(repo), "log", "--format=%H %at %s", "--reverse"] + if max_count is not None: + cmd += [f"-{max_count}"] + if last is not None: + cmd += [f"--after={last.strftime('%Y-%m-%dT%H:%M:%SZ')}"] + if revision_range: + cmd.append(revision_range) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + commits: list[tuple[str, int, str]] = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + sha, _, rest = line.partition(" ") + ts_str, _, subject = rest.partition(" ") + try: + ts = int(ts_str) + except ValueError: + ts = 0 + commits.append((sha, ts, subject)) + return commits + + +def git_initial_files( + repo: Path, + commit: str, + exclude: frozenset[str] = frozenset(), +) -> dict[str, int]: + """Return ``{relative_filepath: size}`` for all tracked blobs at *commit*. + + This is the O(files) baseline scan used only for the first commit. + Subsequent commits should use :func:`git_apply_diff` to update the dict + incrementally in O(changed files). + """ + result = subprocess.run( + ["git", "-C", str(repo), "ls-tree", "-r", "--long", commit], + capture_output=True, + text=True, + check=True, + ) + files: dict[str, int] = {} + for line in result.stdout.splitlines(): + if not line.strip(): + continue + meta, sep, filepath = line.partition("\t") + if not sep or not filepath: + continue + parts = meta.split() + if len(parts) < 4 or parts[1] != "blob": + continue + if matches_exclude(filepath, exclude): + continue + try: + size = max(1, int(parts[3])) + except ValueError: + size = 1 + files[filepath] = size + return files + + +def _blob_sizes(repo: Path, hashes: list[str]) -> dict[str, int]: + """Return ``{blob_hash: size}`` for *hashes* via ``git cat-file --batch-check``. + + Runs a single subprocess regardless of how many hashes are requested. + """ + if not hashes: + return {} + result = subprocess.run( + ["git", "-C", str(repo), "cat-file", "--batch-check"], + input="\n".join(hashes), + capture_output=True, + text=True, + check=True, + ) + out: dict[str, int] = {} + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[1] == "blob": + with contextlib.suppress(ValueError): + out[parts[0]] = max(1, int(parts[2])) + return out + + +def git_apply_diff( + repo: Path, + files: dict[str, int], + prev_commit: str, + curr_commit: str, + exclude: frozenset[str] = frozenset(), +) -> dict[str, str]: + """Update *files* in-place with the changes from *prev_commit* to *curr_commit*. + + Uses ``git diff-tree`` (O(changed files)) instead of re-scanning the full + tree with ``git ls-tree`` (O(all files)). Blob sizes for added/modified + files are fetched in a single ``git cat-file --batch-check`` call. + + Returns a highlights dict ``{abs_path: event_type}`` suitable for passing + to :func:`~dirplot.render_png.create_treemap`. + """ + result = subprocess.run( + ["git", "-C", str(repo), "diff-tree", "-r", "--no-commit-id", prev_commit, curr_commit], + capture_output=True, + text=True, + check=True, + ) + + to_add: dict[str, str] = {} # relative path → new blob hash + to_delete: list[str] = [] + highlights: dict[str, str] = {} + + for line in result.stdout.splitlines(): + if not line.strip() or not line.startswith(":"): + continue + meta, sep, paths = line.partition("\t") + if not sep: + continue + # meta: ": " + meta_parts = meta.lstrip(":").split() + if len(meta_parts) < 5: + continue + new_hash = meta_parts[3] + status = meta_parts[4] + path_list = paths.split("\t") + + if status == "A" and path_list: + fp = path_list[0] + if not matches_exclude(fp, exclude): + to_add[fp] = new_hash + highlights[(repo / fp).as_posix()] = "created" + elif status == "M" and path_list: + fp = path_list[0] + if not matches_exclude(fp, exclude): + to_add[fp] = new_hash + highlights[(repo / fp).as_posix()] = "modified" + elif status == "D" and path_list: + fp = path_list[0] + if not matches_exclude(fp, exclude): + to_delete.append(fp) + highlights[(repo / fp).as_posix()] = "deleted" + elif status.startswith("R") and len(path_list) >= 2: + old_fp, new_fp = path_list[0], path_list[1] + if not matches_exclude(old_fp, exclude): + to_delete.append(old_fp) + highlights[(repo / old_fp).as_posix()] = "deleted" + if not matches_exclude(new_fp, exclude): + to_add[new_fp] = new_hash + highlights[(repo / new_fp).as_posix()] = "created" + elif status.startswith("C") and len(path_list) >= 2: + new_fp = path_list[1] + if not matches_exclude(new_fp, exclude): + to_add[new_fp] = new_hash + highlights[(repo / new_fp).as_posix()] = "created" + + # Fetch all new blob sizes in one batch call. + if to_add: + sizes = _blob_sizes(repo, list(to_add.values())) + for fp, blob_hash in to_add.items(): + files[fp] = sizes.get(blob_hash, 1) + + for fp in to_delete: + files.pop(fp, None) + + return highlights + + +def build_node_tree( + repo: Path, + files: dict[str, int], + depth: int | None = None, +) -> Node: + """Convert a ``{relative_filepath: size}`` dict into a Node tree rooted at *repo*.""" + tree: dict[str, Any] = {} + + for filepath, size in files.items(): + parts = filepath.split("/") + + if depth is not None and len(parts) > depth: + parts = parts[:depth] + + d = tree + for part in parts[:-1]: + entry = d.get(part) + if entry is None: + d[part] = {} + d = d[part] + elif isinstance(entry, dict): + d = entry + else: + break + else: + leaf = parts[-1] + existing = d.get(leaf) + if isinstance(existing, dict): + pass + elif isinstance(existing, int): + d[leaf] = existing + size + else: + d[leaf] = size + + def _to_node(name: str, path: Path, data: dict[str, Any]) -> Node: + children: list[Node] = [] + for child_name in sorted(data): + child_val = data[child_name] + child_path = path / child_name + if isinstance(child_val, dict): + child = _to_node(child_name, child_path, child_val) + else: + dot_idx = child_name.rfind(".") + ext = child_name[dot_idx:].lower() if dot_idx > 0 else NO_EXT + child = Node( + name=child_name, + path=child_path, + size=child_val, + is_dir=False, + extension=ext, + ) + children.append(child) + total = sum(c.size for c in children) + return Node(name=name, path=path, size=max(1, total), is_dir=True, children=children) + + return _to_node(repo.name, repo, tree) + + +RectMap = dict[str, tuple[int, int, int, int]] + + +def _render_frame_worker(args: tuple[Any, ...]) -> tuple[int, bytes, RectMap]: + """Top-level picklable worker for parallel frame rendering. + + Accepts a single tuple so it works with both ``ProcessPoolExecutor.map`` + and ``submit``. Defined at module level so it can be pickled by ``spawn`` + worker processes. + + Args: + args: ``(repo_str, files, current_highlights, sha, ts, orig_i, + total_commits, depth, logscale, width_px, height_px, + font_size, colormap, cushion, dark)`` + + Returns: + ``(orig_i, png_bytes, rect_map)`` + """ + ( + repo_str, + files, + current_highlights, + sha, + ts, + orig_i, + progress, + depth, + logscale, + width_px, + height_px, + font_size, + colormap, + cushion, + dark, + ) = args + + from datetime import datetime + from pathlib import Path + + from dirplot.render_png import create_treemap + from dirplot.scanner import apply_log_sizes + + repo = Path(repo_str) + node = build_node_tree(repo, files, depth) + if logscale > 1: + apply_log_sizes(node, logscale) + + rect_map: dict[str, tuple[int, int, int, int]] = {} + dt_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + buf = create_treemap( + node, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + highlights=current_highlights or None, + rect_map_out=rect_map, + title_suffix=f"sha:{sha[:8]} {dt_str}", + progress=progress, + dark=dark, + logscale=logscale, + ) + return (orig_i, buf.read(), rect_map) + + +def build_tree_from_git( + repo: Path, + commit: str, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, +) -> Node: + """Convenience wrapper: full scan of *commit* → Node tree.""" + files = git_initial_files(repo, commit, exclude) + return build_node_tree(repo, files, depth) + + +def build_tree_git_worktree( + repo: Path, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, +) -> Node: + """Build a Node tree from the working tree, restricted to git-tracked files. + + Uses ``git ls-files`` to enumerate tracked paths, then reads actual on-disk + sizes. Untracked files are ignored — this matches ``git diff HEAD`` semantics. + """ + result = subprocess.run( + ["git", "-C", str(repo), "ls-files"], + capture_output=True, + text=True, + check=True, + ) + files: dict[str, int] = {} + for filepath in result.stdout.splitlines(): + if not filepath: + continue + if matches_exclude(filepath, exclude): + continue + abs_path = repo / filepath + try: + size = max(1, abs_path.stat().st_size) + except OSError: + size = 1 + files[filepath] = size + return build_node_tree(repo, files, depth) + + +def git_worktree_hashes(repo: Path) -> dict[str, str]: + """Return ``{relative_filepath: sha1}`` for tracked files in the working tree. + + Computes the blob SHA of each file's current on-disk content using + ``git hash-object``, so it matches the format returned by :func:`git_file_hashes`. + """ + ls = subprocess.run( + ["git", "-C", str(repo), "ls-files"], + capture_output=True, + text=True, + check=True, + ) + paths = [p for p in ls.stdout.splitlines() if p] + if not paths: + return {} + # git hash-object reads stdin paths separated by NUL + abs_paths = [str(repo / p) for p in paths] + ho = subprocess.run( + ["git", "hash-object", "--stdin-paths"], + input="\n".join(abs_paths) + "\n", + capture_output=True, + text=True, + check=True, + ) + hashes = ho.stdout.splitlines() + return dict(zip(paths, hashes, strict=False)) + + +def git_file_hashes(repo: Path, ref: str) -> dict[str, str]: + """Return ``{relative_filepath: blob_sha}`` for all tracked blobs at *ref*. + + Used for accurate change detection between two git refs — size alone is + insufficient because a file's content can change while its size stays the same. + """ + result = subprocess.run( + ["git", "-C", str(repo), "ls-tree", "-r", "--long", ref], + capture_output=True, + text=True, + check=True, + ) + hashes: dict[str, str] = {} + for line in result.stdout.splitlines(): + if not line.strip(): + continue + meta, sep, filepath = line.partition("\t") + if not sep or not filepath: + continue + parts = meta.split() + if len(parts) < 4 or parts[1] != "blob": + continue + hashes[filepath] = parts[2] # blob SHA + return hashes + + +def is_git_ref_path(s: str) -> bool: + """Return True if *s* looks like ``@``. + + The path component must exist on disk and be a git repository. + The ref component must be non-empty and must not look like a URL + scheme (``://``) so that ``github://owner/repo@branch`` is not + mistakenly matched. + """ + if "://" in s: + return False + at = s.rfind("@") + if at <= 0: + return False + path_part = s[:at] + ref_part = s[at + 1 :] + if not ref_part: + return False + repo_path = Path(path_part) + if not repo_path.exists(): + return False + result = subprocess.run( + ["git", "-C", str(repo_path), "rev-parse", "--git-dir"], + capture_output=True, + ) + return result.returncode == 0 + + +def parse_git_ref_path(s: str) -> tuple[Path, str]: + """Split ``@`` into ``(Path(local-path), ref)``.""" + at = s.rfind("@") + return Path(s[:at]), s[at + 1 :] + + +def build_tree_git_ref( + s: str, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, +) -> tuple[Node, str]: + """Scan a local git repo at a specific ref. + + *s* must be in ``@`` format. + Returns ``(root_node, display_title)`` where *display_title* is + ``repo-name@ref``. + """ + repo, ref = parse_git_ref_path(s) + # Resolve the ref to a full SHA so the title is unambiguous. + result = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--short", ref], + capture_output=True, + text=True, + ) + short_sha = result.stdout.strip() if result.returncode == 0 else ref + display_title = f"{repo.resolve().name}@{short_sha}" + node = build_tree_from_git(repo.resolve(), ref, exclude=exclude, depth=depth) + return node, display_title diff --git a/src/dirplot/github.py b/src/dirplot/github.py new file mode 100644 index 0000000..c2fef9f --- /dev/null +++ b/src/dirplot/github.py @@ -0,0 +1,283 @@ +"""GitHub repository tree scanning via the Git trees API (no extra dependencies).""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from collections import defaultdict +from pathlib import Path, PurePosixPath +from typing import Any + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + + +def is_github_path(s: str) -> bool: + """Return True if *s* looks like a GitHub repository reference.""" + return s.startswith("github://") or "github.com/" in s + + +def parse_github_path(s: str) -> tuple[str, str, str | None, str]: + """Parse a GitHub reference into *(owner, repo, ref_or_None, subpath)*. + + The ref may be a branch name, tag, or commit SHA — all are passed directly + to the GitHub trees API which accepts any git ref. + + Accepted formats:: + + github://owner/repo + github://owner/repo@ref + github://owner/repo/sub/path + github://owner/repo@ref/sub/path + https://github.com/owner/repo + https://github.com/owner/repo@ref + https://github.com/owner/repo/tree/ref + https://github.com/owner/repo/tree/ref/sub/path + """ + if s.startswith("github://"): + rest = s[len("github://") :] + parts = rest.split("/") + owner = parts[0] + repo_seg = parts[1] if len(parts) > 1 else "" + ref: str | None + if "@" in repo_seg: + repo, ref = repo_seg.split("@", 1) + else: + repo, ref = repo_seg, None + subpath = "/".join(parts[2:]) + return owner, repo, ref, subpath + + # URL form: https://github.com/owner/repo[@ref][/tree/ref[/subpath]] + parts = s.split("github.com/", 1)[1].strip("/").split("/") + owner = parts[0] + repo_seg = parts[1] if len(parts) > 1 else "" + if "@" in repo_seg: + repo, ref = repo_seg.split("@", 1) + subpath = "" + else: + repo = repo_seg + ref = None + if len(parts) > 3 and parts[2] == "tree": + ref = parts[3] + subpath = "/".join(parts[4:]) + else: + subpath = "" + return owner, repo, ref, subpath + + +def _gh_cli_token() -> str | None: + """Return the token from the gh CLI if installed and authenticated, else None.""" + import subprocess + + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() or None + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + +def _api_get(url: str, token: str | None) -> Any: + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github+json") + req.add_header("X-GitHub-Api-Version", "2022-11-28") + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as exc: + if exc.code == 401: + raise PermissionError( + "GitHub authentication failed — token invalid or expired. " + "Set GITHUB_TOKEN, use --github-token-file, or run 'gh auth login'." + ) from exc + if exc.code == 403: + # Could be rate-limit (unauthenticated: 60 req/h) or repo permissions. + body = exc.read().decode(errors="replace") + if "rate limit" in body.lower(): + raise PermissionError( + "GitHub API rate limit exceeded (60 req/h without a token). " + "Set GITHUB_TOKEN, use --github-token-file, or run 'gh auth login' " + "to raise the limit to 5,000 req/h." + ) from exc + raise PermissionError( + "GitHub access denied — the repository may be private. " + "Set GITHUB_TOKEN, use --github-token-file, or run 'gh auth login'." + ) from exc + if exc.code == 404: + raise FileNotFoundError( + f"GitHub repository or branch not found: {url}\n" + "Check the owner/repo spelling and branch name. " + "Private repositories require GITHUB_TOKEN, --github-token-file, " + "or 'gh auth login'." + ) from exc + raise + + +def count_commits_github(owner: str, repo: str, ref: str | None, token: str | None) -> int | None: + """Return the total commit count on *ref* using a single cheap API call. + + Fetches one commit and reads the ``page`` number from the ``Link: rel="last"`` + response header. Returns ``None`` if the count cannot be determined. + """ + url = f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1" + if ref: + url += f"&sha={ref}" + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github+json") + req.add_header("X-GitHub-Api-Version", "2022-11-28") + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req) as resp: + link = resp.getheader("Link") or "" + for part in link.split(","): + if 'rel="last"' in part: + url_part = part.split(";")[0].strip().strip("<>") + for param in url_part.split("?", 1)[-1].split("&"): + if param.startswith("page="): + return int(param[5:]) + # No "last" link — all commits fit on one page (≤1 here since per_page=1) + data = json.loads(resp.read()) + return len(data) + except Exception: + return None + + +def _default_branch(owner: str, repo: str, token: str | None) -> str: + data = _api_get(f"https://api.github.com/repos/{owner}/{repo}", token) + return str(data["default_branch"]) + + +def build_tree_github( + owner: str, + repo: str, + ref: str | None = None, + *, + token: str | None = None, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, + subpath: str = "", +) -> tuple[Node, str]: + """Fetch a GitHub repository tree and return *(root_node, resolved_ref)*. + + Uses ``GET /repos/{owner}/{repo}/git/trees/{ref}?recursive=1`` — a single + API call that returns the complete file tree with sizes for all blobs. + + Args: + owner: GitHub username or organisation. + repo: Repository name. + ref: Branch, tag, or commit SHA. Defaults to the repo's default branch. + token: GitHub personal access token. Falls back to ``GITHUB_TOKEN`` env var. + Public repos work without a token but are rate-limited (60 req/h). + exclude: Set of paths (relative to repo root) to skip. + depth: Maximum recursion depth. ``None`` means unlimited. + subpath: Optional subdirectory within the repo to use as the tree root. + """ + token = token or os.environ.get("GITHUB_TOKEN") or _gh_cli_token() + resolved = ref or _default_branch(owner, repo, token) + + data = _api_get( + f"https://api.github.com/repos/{owner}/{repo}/git/trees/{resolved}?recursive=1", + token, + ) + + if data.get("truncated"): + print( + "Warning: GitHub truncated the tree (repository too large). " + "Results are incomplete — use --depth to limit the scan.", + file=sys.stderr, + ) + + node = _items_to_tree(data["tree"], repo, exclude, depth, subpath) + return node, resolved + + +def _items_to_tree( + items: list[dict[str, Any]], + repo: str, + exclude: frozenset[str], + depth: int | None, + subpath: str = "", +) -> Node: + """Build a Node tree from the flat list returned by the GitHub trees API.""" + prefix = subpath.strip("/") + if prefix: + root_name = PurePosixPath(prefix).name + filtered: list[dict[str, Any]] = [] + for item in items: + p = item["path"] + if p == prefix: + continue # the directory itself — not a child + if p.startswith(prefix + "/"): + item = dict(item, path=p[len(prefix) + 1 :]) + filtered.append(item) + if not filtered: + raise FileNotFoundError(f"Subpath '{prefix}' not found in repository '{repo}'.") + items = filtered + else: + root_name = repo + + by_parent: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in items: + p = PurePosixPath(item["path"]) + parent = str(p.parent) + if parent == ".": + parent = "" + item["_name"] = p.name + by_parent[parent].append(item) + + def recurse(rel_prefix: str, name: str, current_depth: int | None) -> Node: + children: list[Node] = [] + for item in sorted(by_parent.get(rel_prefix, []), key=lambda i: i["_name"]): + if item["_name"].startswith("."): + continue + if matches_exclude(item["path"], exclude): + continue + if item["type"] == "tree": + if current_depth is not None and current_depth <= 1: + child: Node = Node( + name=item["_name"], + path=Path(item["path"]), + size=1, + is_dir=True, + extension="", + ) + else: + child = recurse( + item["path"], + item["_name"], + None if current_depth is None else current_depth - 1, + ) + else: + ext = PurePosixPath(item["_name"]).suffix.lower() or NO_EXT + child = Node( + name=item["_name"], + path=Path(item["path"]), + size=max(1, item.get("size") or 1), + is_dir=False, + extension=ext, + ) + children.append(child) + + total = sum(c.size for c in children) or 1 + return Node( + name=name, + path=Path(rel_prefix) if rel_prefix else Path(root_name), + size=total, + is_dir=True, + extension="", + children=children, + ) + + return recurse("", root_name, depth) diff --git a/src/dirplot/helpers/__init__.py b/src/dirplot/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dirplot/helpers/animation.py b/src/dirplot/helpers/animation.py new file mode 100644 index 0000000..4543925 --- /dev/null +++ b/src/dirplot/helpers/animation.py @@ -0,0 +1,76 @@ +"""Shared animation utilities: frame timing, fade colours, worker init.""" + +import signal + +import typer + + +def worker_ignore_sigint() -> None: + """Initializer for ProcessPoolExecutor workers: ignore SIGINT so Ctrl-C is + handled only by the main process and workers exit cleanly on shutdown.""" + signal.signal(signal.SIGINT, signal.SIG_IGN) + + +def proportional_durations(gaps: list[float], total_ms: float, floor_ms: int = 200) -> list[int]: + """Convert time *gaps* into integer frame durations that sum to *total_ms*. + + Each frame gets a duration proportional to its gap. Frames whose + proportional share would fall below *floor_ms* are raised to that floor; + the remaining frames are scaled down so the total still equals *total_ms*. + A final rounding correction is applied to the longest frame so the integer + sum matches exactly. + """ + total_gap = sum(gaps) + proportional = [g / total_gap * total_ms for g in gaps] + + # Apply floor + raw = [max(float(floor_ms), p) for p in proportional] + + # If flooring inflated the total, scale down the non-floored frames to compensate. + raw_sum = sum(raw) + if raw_sum > total_ms: + floored_budget = floor_ms * sum(1 for p in proportional if p < floor_ms) + non_floored_sum = sum(v for p, v in zip(proportional, raw, strict=False) if p >= floor_ms) + available = total_ms - floored_budget + if available > 0 and non_floored_sum > 0: + scale = available / non_floored_sum + raw = [ + v if p < floor_ms else v * scale for p, v in zip(proportional, raw, strict=False) + ] + # else: every frame is at the floor — can't compress further, accept slight overage + + durations = [max(floor_ms, min(65535, round(d))) for d in raw] + + # Absorb integer-rounding residual into the longest frame. + residual = round(total_ms) - sum(durations) + if residual and durations: + idx = max(range(len(durations)), key=lambda i: durations[i]) + durations[idx] = max(floor_ms, durations[idx] + residual) + + return durations + + +def resolve_fade_color( + color_str: str, dark: bool +) -> tuple[int, int, int] | tuple[int, int, int, int]: + """Resolve a --fade-out-color string to an RGB or RGBA tuple. + + ``"auto"`` returns black (dark mode) or white (light mode). + ``"transparent"`` returns ``(0, 0, 0, 0)``. + Any other string is parsed by PIL's ``ImageColor.getrgb()``. + """ + if color_str == "auto": + return (0, 0, 0) if dark else (255, 255, 255) + if color_str.lower() == "transparent": + return (0, 0, 0, 0) + from PIL import ImageColor + + try: + return ImageColor.getrgb(color_str) + except (ValueError, AttributeError): + typer.echo( + f"Error: invalid --fade-out-color {color_str!r}. " + "Use a colour name, hex code, or 'transparent'.", + err=True, + ) + raise typer.Exit(1) from None diff --git a/src/dirplot/helpers/scan.py b/src/dirplot/helpers/scan.py new file mode 100644 index 0000000..2a74424 --- /dev/null +++ b/src/dirplot/helpers/scan.py @@ -0,0 +1,307 @@ +"""Unified tree-scanning helper shared by the map and metrics commands.""" + +import os +import sys +import time +from collections.abc import Callable +from pathlib import Path + +import typer + +from dirplot.archives import PasswordRequired, build_tree_archive, is_archive_path +from dirplot.docker import build_tree_docker, is_docker_path, parse_docker_path +from dirplot.gdrive import build_tree_gdrive, is_gdrive_path, parse_gdrive_path +from dirplot.git_scanner import build_tree_git_ref, is_git_ref_path +from dirplot.github import build_tree_github, is_github_path, parse_github_path +from dirplot.k8s import build_tree_pod, is_pod_path, parse_pod_path +from dirplot.pathlist import parse_pathlist +from dirplot.s3 import build_tree_s3, is_s3_path, make_s3_client, parse_s3_path +from dirplot.scanner import NO_EXT, Node, build_tree, build_tree_multi +from dirplot.ssh import build_tree_ssh, connect, is_ssh_path, parse_ssh_path + + +def scan_tree( + roots: list[str], + paths_from: Path | None, + exclude: list[str], + depth: int | None, + ssh_key: str | None, + ssh_password: str | None, + aws_profile: str | None, + no_sign: bool, + github_token: str | None, + k8s_namespace: str | None, + k8s_container: str | None, + password: str | None, + no_input: bool = False, + log: Callable[[str], None] | None = None, +) -> tuple[Node, float, str | None]: + """Scan a root path and return (root_node, t_scan_seconds, display_title). + + *log* is called with each "Scanning ..." message when provided. + """ + + def _emit(msg: str) -> None: + if log is not None: + log(msg) + + use_stdin = paths_from is not None or (not roots and not sys.stdin.isatty()) + if use_stdin and roots: + typer.echo( + "Error: cannot combine positional paths with --paths-from / piped stdin.", + err=True, + ) + raise typer.Exit(1) + + root = roots[0] if len(roots) == 1 else "" + display_title: str | None = None + t_scan_start = time.monotonic() + + if use_stdin: + if paths_from is None or str(paths_from) == "-": + raw = sys.stdin.read() + else: + if not paths_from.exists(): + typer.echo(f"Error: --paths-from path does not exist: {paths_from}", err=True) + raise typer.Exit(1) + raw = paths_from.read_text() + parsed = parse_pathlist(raw.splitlines()) + if not parsed: + typer.echo("Error: no paths found in path-list input.", err=True) + raise typer.Exit(1) + for p in parsed: + if not p.exists(): + typer.echo(f"Path does not exist: {p}", err=True) + raise typer.Exit(1) + excluded = frozenset(exclude) + root_paths = [p.resolve() for p in parsed] + common_str = os.path.commonpath([str(p) for p in root_paths]) + _emit(f"Scanning {len(root_paths)} paths under {common_str} ...") + root_node = build_tree_multi(root_paths, excluded, depth) + elif not roots: + typer.echo("Error: at least one path is required.", err=True) + raise typer.Exit(1) + elif len(roots) > 1: + for r in roots: + if any( + f(r) + for f in ( + is_docker_path, + is_pod_path, + is_github_path, + is_gdrive_path, + is_s3_path, + is_ssh_path, + is_archive_path, + is_git_ref_path, + ) + ): + typer.echo( + f"Multiple roots are only supported for local paths, got: {r}", + err=True, + ) + raise typer.Exit(1) + root_paths = [] + for r in roots: + rp = Path(r) + if not rp.exists(): + typer.echo(f"Path does not exist: {r}", err=True) + raise typer.Exit(1) + if not rp.is_dir() and not rp.is_file(): + typer.echo(f"Not a file or directory: {r}", err=True) + raise typer.Exit(1) + root_paths.append(rp.resolve()) + excluded = frozenset(exclude) + common_str = os.path.commonpath([str(p) for p in root_paths]) + _emit(f"Scanning {len(roots)} paths under {common_str} ...") + root_node = build_tree_multi(root_paths, excluded, depth) + elif is_gdrive_path(root): + gdrive_folder_id = parse_gdrive_path(root) + label = f"gdrive://{gdrive_folder_id}" if gdrive_folder_id else "gdrive://" + _emit(f"Scanning {label} ...") + progress = [0] + try: + root_node = build_tree_gdrive( + gdrive_folder_id, + exclude=frozenset(exclude), + depth=depth, + _progress=progress, + ) + except (FileNotFoundError, OSError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + if progress[0] >= 100: + print("", file=sys.stderr) + elif is_docker_path(root): + docker_container, docker_path = parse_docker_path(root) + _emit(f"Scanning docker://{docker_container}:{docker_path} ...") + progress = [0] + try: + root_node = build_tree_docker( + docker_container, + docker_path, + exclude=frozenset(exclude), + depth=depth, + _progress=progress, + ) + except (FileNotFoundError, OSError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + if progress[0] >= 100: + print("", file=sys.stderr) + elif is_pod_path(root): + pod_name, pod_ns, pod_path = parse_pod_path(root) + namespace = k8s_namespace or pod_ns + ns_label = f"@{namespace}" if namespace else "" + _emit(f"Scanning pod://{pod_name}{ns_label}:{pod_path} ...") + progress = [0] + try: + root_node = build_tree_pod( + pod_name, + pod_path, + namespace=namespace, + container=k8s_container, + exclude=frozenset(exclude), + depth=depth, + _progress=progress, + ) + except (FileNotFoundError, OSError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + if progress[0] >= 100: + print("", file=sys.stderr) + elif is_github_path(root): + gh_owner, gh_repo, gh_ref, gh_subpath = parse_github_path(root) + display_title = f"{gh_owner}-{gh_repo}" + try: + root_node, resolved_ref = build_tree_github( + gh_owner, + gh_repo, + gh_ref, + token=github_token, + exclude=frozenset(exclude), + depth=depth, + subpath=gh_subpath, + ) + except (PermissionError, FileNotFoundError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + subpath_label = f"/{gh_subpath}" if gh_subpath else "" + _emit(f"Scanning github:{gh_owner}/{gh_repo}@{resolved_ref}{subpath_label} ...") + elif is_s3_path(root): + bucket, prefix = parse_s3_path(root) + _emit(f"Scanning {root} ...") + try: + s3 = make_s3_client(profile=aws_profile, no_sign=no_sign) + except ImportError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + progress = [0] + root_node = build_tree_s3( + s3, + bucket, + prefix, + exclude=frozenset(exclude), + depth=depth, + _progress=progress, + ) + if progress[0] >= 100: + print("", file=sys.stderr) + elif is_ssh_path(root): + ssh_user, ssh_host, remote_path = parse_ssh_path(root) + _emit(f"Scanning {root} ...") + try: + client = connect(ssh_host, ssh_user, ssh_key=ssh_key, ssh_password=ssh_password) + except ImportError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + sftp = client.open_sftp() + progress = [0] + try: + root_node = build_tree_ssh( + sftp, + remote_path, + exclude=frozenset(exclude), + depth=depth, + _progress=progress, + ) + finally: + sftp.close() + client.close() + if progress[0] >= 100: + print("", file=sys.stderr) + elif is_git_ref_path(root): + _emit(f"Scanning git repo {root} ...") + try: + root_node, git_ref_title = build_tree_git_ref( + root, exclude=frozenset(exclude), depth=depth + ) + except Exception as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + display_title = git_ref_title + elif is_archive_path(root): + archive_path = Path(root) + if not archive_path.exists(): + typer.echo(f"Path does not exist: {root}", err=True) + raise typer.Exit(1) + if not archive_path.is_file(): + typer.echo(f"Not a file: {root}", err=True) + raise typer.Exit(1) + _emit(f"Reading archive {root} ...") + try: + root_node = build_tree_archive( + archive_path, exclude=frozenset(exclude), depth=depth, password=password + ) + except PasswordRequired as exc: + if password is not None: + typer.echo("Error: incorrect password.", err=True) + raise typer.Exit(1) from exc + if no_input: + typer.echo( + "Error: archive requires a password. Pass --password or --password-file.", + err=True, + ) + raise typer.Exit(1) from exc + pw = typer.prompt("Password", hide_input=True) + try: + root_node = build_tree_archive( + archive_path, exclude=frozenset(exclude), depth=depth, password=pw + ) + except PasswordRequired as exc2: + typer.echo("Error: incorrect password.", err=True) + raise typer.Exit(1) from exc2 + except (ImportError, OSError, RuntimeError) as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from exc + else: + root_path = Path(root) + if not root_path.exists(): + typer.echo(f"Path does not exist: {root}", err=True) + raise typer.Exit(1) + if not root_path.is_dir(): + if not root_path.is_file(): + typer.echo(f"Not a file or directory: {root}", err=True) + raise typer.Exit(1) + rp = root_path.resolve() + try: + file_size = max(1, rp.stat().st_size) + except OSError: + file_size = 1 + ext = rp.suffix.lower() if rp.suffix else NO_EXT + file_node = Node(name=rp.name, path=rp, size=file_size, is_dir=False, extension=ext) + root_node = Node( + name=rp.parent.name, + path=rp.parent, + size=file_size, + is_dir=True, + children=[file_node], + ) + _emit(f"Scanning {root} ...") + else: + excluded = frozenset(exclude) + _emit(f"Scanning {root} ...") + root_node = build_tree(root_path.resolve(), excluded, depth) + + t_scan = time.monotonic() - t_scan_start + return root_node, t_scan, display_title diff --git a/src/dirplot/helpers/time.py b/src/dirplot/helpers/time.py new file mode 100644 index 0000000..9e1a05d --- /dev/null +++ b/src/dirplot/helpers/time.py @@ -0,0 +1,24 @@ +"""Human-readable time period parsing.""" + +import re +from datetime import datetime, timedelta, timezone + +LAST_RE = re.compile(r"^(\d+)(mo|m|h|d|w)$") + + +def parse_last_period(value: str) -> datetime: + """Parse a human period string into an absolute UTC datetime. + + Units: m=minutes, h=hours, d=days, w=weeks, mo=months (30d each). + Examples: '10d', '24h', '2w', '1mo', '30m' + """ + match = LAST_RE.match(value.strip().lower()) + if not match: + raise ValueError( + f"Invalid --period value {value!r}. " + "Expected a number + unit: h, d, w, mo (e.g. 24h, 10d, 2w, 1mo)." + ) + amount = int(match.group(1)) + unit = match.group(2) + seconds = {"m": 60, "h": 3600, "d": 86400, "w": 7 * 86400, "mo": 30 * 86400}[unit] + return datetime.now(tz=timezone.utc) - timedelta(seconds=amount * seconds) diff --git a/src/dirplot/hg_scanner.py b/src/dirplot/hg_scanner.py new file mode 100644 index 0000000..2ab5049 --- /dev/null +++ b/src/dirplot/hg_scanner.py @@ -0,0 +1,268 @@ +"""Build a Node tree from a Mercurial changeset and compute per-changeset change highlights.""" + +import hashlib +import os +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path + +from dirplot.filters import matches_exclude +from dirplot.git_scanner import build_node_tree +from dirplot.scanner import Node + + +def hg_log( + repo: Path, + revision_range: str | None = None, + max_count: int | None = None, + last: datetime | None = None, +) -> list[tuple[str, int, str]]: + """Return changesets as (node_hash, unix_timestamp, subject), oldest-first.""" + if revision_range is not None: + revset = revision_range + elif last is not None: + iso = last.strftime("%Y-%m-%d %H:%M:%S") + revset = f"sort(date('>{iso}'), date)" + else: + revset = "sort(all(), date)" + + cmd = [ + "hg", + "log", + "-R", + str(repo), + "--template", + "{node} {date|hgdate} {desc|firstline}\n", + "-r", + revset, + ] + if max_count is not None: + cmd += ["--limit", str(max_count)] + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + commits: list[tuple[str, int, str]] = [] + for line in result.stdout.splitlines(): + if not line.strip(): + continue + # Line format: "{node} {unix_ts} {tz_offset} {subject}" + # hgdate emits "{unix} {offset}" — take only the first token as timestamp. + parts = line.split(" ", 3) + if len(parts) < 2: + continue + node = parts[0] + try: + ts = int(parts[1]) + except ValueError: + ts = 0 + subject = parts[3] if len(parts) > 3 else "" + commits.append((node, ts, subject)) + return commits + + +def hg_initial_files( + repo: Path, + commit: str, + exclude: frozenset[str] = frozenset(), +) -> dict[str, int]: + """Return ``{relative_filepath: size}`` for all tracked files at *commit*. + + Uses ``hg archive`` to extract the full tree into a temp directory, then + walks the result to measure file sizes. Only called once per animation + (for the first commit). Subsequent commits should use + :func:`hg_apply_diff` to update incrementally. + """ + with tempfile.TemporaryDirectory(prefix="dirplot-hg-") as tmpdir_str: + # hg archive with -t files does not support --prefix. + # It creates one subdirectory named "{reponame}-{localrev}/" inside + # the destination. We pass a non-existent sub-path so hg creates it, + # then strip that top-level prefix directory when building paths. + archive_dest = os.path.join(tmpdir_str, "archive") + subprocess.run( + [ + "hg", + "archive", + "-R", + str(repo), + "-r", + commit, + "-t", + "files", + archive_dest, + ], + capture_output=True, + text=True, + check=True, + ) + archive_root = Path(archive_dest) + # Strip the single prefix directory hg creates (e.g. "repo-3/"). + top_dirs = [d for d in archive_root.iterdir() if d.is_dir()] + if len(top_dirs) == 1: + archive_root = top_dirs[0] + + files: dict[str, int] = {} + for dirpath, _dirs, filenames in os.walk(str(archive_root)): + for fname in filenames: + full = Path(dirpath) / fname + rel_posix = full.relative_to(archive_root).as_posix() + if rel_posix == ".hg_archival.txt": + continue + if matches_exclude(rel_posix, exclude): + continue + try: + size = max(1, full.stat().st_size) + except OSError: + size = 1 + files[rel_posix] = size + return files + + +def hg_apply_diff( + repo: Path, + files: dict[str, int], + prev_commit: str, + curr_commit: str, + exclude: frozenset[str] = frozenset(), +) -> dict[str, str]: + """Mutate *files* in-place; return highlights ``{abs_posix_path: event_type}``. + + Uses ``hg status --copies`` to detect adds, modifications, removals, and + renames between *prev_commit* and *curr_commit*. File sizes for added and + modified files are fetched one at a time via ``hg cat``. + + Rename detection: when an ``A`` line is followed by an indented line, the + indented path is the rename source (marked deleted). The subsequent ``R`` + line for the same source path is then ignored as already handled. + """ + result = subprocess.run( + [ + "hg", + "status", + "--copies", + "-R", + str(repo), + "--rev", + prev_commit, + "--rev", + curr_commit, + ], + capture_output=True, + text=True, + check=True, + ) + + highlights: dict[str, str] = {} + to_add: list[str] = [] + rename_sources: set[str] = set() + + lines = result.stdout.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + if not line.strip(): + i += 1 + continue + + if line.startswith("A "): + fp = line[2:] + # If the next line is indented it is the rename/copy source. + if i + 1 < len(lines) and lines[i + 1].startswith(" "): + source = lines[i + 1].lstrip() + rename_sources.add(source) + i += 1 + if not matches_exclude(fp, exclude): + to_add.append(fp) + highlights[(repo / fp).as_posix()] = "created" + if not matches_exclude(source, exclude): + files.pop(source, None) + highlights[(repo / source).as_posix()] = "deleted" + else: + if not matches_exclude(fp, exclude): + to_add.append(fp) + highlights[(repo / fp).as_posix()] = "created" + + elif line.startswith("M "): + fp = line[2:] + if not matches_exclude(fp, exclude): + to_add.append(fp) + highlights[(repo / fp).as_posix()] = "modified" + + elif line.startswith("R "): + fp = line[2:] + if fp not in rename_sources and not matches_exclude(fp, exclude): + files.pop(fp, None) + highlights[(repo / fp).as_posix()] = "deleted" + + i += 1 + + for fp in to_add: + r = subprocess.run( + ["hg", "cat", "-r", curr_commit, fp], + capture_output=True, + check=True, + cwd=str(repo), + ) + files[fp] = max(1, len(r.stdout)) + + return highlights + + +def is_hg_repo(path: Path) -> bool: + """Return True if *path* is the root of a Mercurial repository.""" + return (path / ".hg").is_dir() + + +def build_tree_hg_worktree( + repo: Path, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, +) -> "Node": + """Build a Node tree from the working tree, restricted to hg-tracked files. + + Uses ``hg locate`` to enumerate tracked paths, then reads actual on-disk + sizes. Untracked files are ignored — matches ``hg diff`` semantics. + """ + result = subprocess.run( + ["hg", "locate", "-0"], + capture_output=True, + cwd=str(repo), + ) + files: dict[str, int] = {} + for filepath in result.stdout.split(b"\x00"): + rel = filepath.decode("utf-8", errors="replace").strip() + if not rel: + continue + if matches_exclude(rel, exclude): + continue + abs_path = repo / rel + try: + size = max(1, abs_path.stat().st_size) + except OSError: + size = 1 + files[rel] = size + return build_node_tree(repo, files, depth) + + +def hg_worktree_hashes(repo: Path) -> dict[str, str]: + """Return ``{relative_filepath: sha1}`` for tracked files in the hg working tree. + + Hashes the on-disk content of each tracked file so change detection is + accurate regardless of file size. + """ + result = subprocess.run( + ["hg", "locate", "-0"], + capture_output=True, + cwd=str(repo), + ) + hashes: dict[str, str] = {} + for filepath in result.stdout.split(b"\x00"): + rel = filepath.decode("utf-8", errors="replace").strip() + if not rel: + continue + abs_path = repo / rel + try: + content = abs_path.read_bytes() + hashes[rel] = hashlib.sha1(content).hexdigest() + except OSError: + pass + return hashes diff --git a/src/dirplot/k8s.py b/src/dirplot/k8s.py new file mode 100644 index 0000000..f828211 --- /dev/null +++ b/src/dirplot/k8s.py @@ -0,0 +1,282 @@ +"""Kubernetes pod directory scanning via kubectl.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path, PurePosixPath + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + + +def _kubectl_cmd() -> str: + """Return the kubectl executable name, raising if not found.""" + import shutil + + if shutil.which("kubectl") is None: + raise FileNotFoundError( + "kubectl not found in PATH. Install kubectl from https://kubernetes.io/docs/tasks/tools/" + ) + return "kubectl" + + +def is_pod_path(s: str) -> bool: + """Return True if *s* looks like a Kubernetes pod path.""" + return s.startswith("pod://") + + +def parse_pod_path(s: str) -> tuple[str, str | None, str]: + """Parse a pod path string into *(pod_name, namespace, remote_path)*. + + Accepted formats:: + + pod://pod-name/path # default namespace + pod://pod-name:/path # default namespace, colon separator + pod://pod-name@namespace/path # explicit namespace + pod://pod-name@namespace:/path # explicit namespace, colon separator + """ + rest = s[len("pod://") :] + + # Split off namespace: "pod@namespace..." → pod_name, "namespace..." + namespace: str | None = None + if "@" in rest: + pod_name, rest = rest.split("@", 1) + # rest is now "namespace/path" or "namespace:/path" + if ":" in rest.split("/")[0]: + namespace, remote_path = rest.split(":", 1) + else: + parts = rest.split("/", 1) + namespace = parts[0] + remote_path = "/" + parts[1] if len(parts) > 1 else "/" + else: + # rest is "pod-name/path" or "pod-name:/path" + if ":" in rest.split("/")[0]: + pod_name, remote_path = rest.split(":", 1) + else: + parts = rest.split("/", 1) + pod_name = parts[0] + remote_path = "/" + parts[1] if len(parts) > 1 else "/" + + if not remote_path.startswith("/"): + remote_path = "/" + remote_path + + return pod_name, namespace, remote_path + + +def _check_pod(kubectl: str, pod_name: str, namespace: str | None) -> None: + """Raise FileNotFoundError if the pod does not exist or is not running.""" + cmd = [kubectl, "get", "pod", pod_name] + if namespace: + cmd += ["-n", namespace] + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + ns_hint = f" in namespace {namespace!r}" if namespace else "" + raise FileNotFoundError(f"Kubernetes pod not found or not running: {pod_name!r}{ns_hint}") + + +def _run_find( + kubectl: str, + pod_name: str, + namespace: str | None, + container: str | None, + remote_path: str, + depth: int | None, +) -> subprocess.CompletedProcess[str]: + """Run find inside the pod, trying GNU find then BusyBox fallback. + + GNU find (Debian/Ubuntu/RHEL images) supports ``-printf`` for efficient + single-pass output. BusyBox find (Alpine images) does not; we fall back to + a POSIX sh + stat loop that works on any image with a shell. + """ + max_depth_args = ["-maxdepth", str(depth)] if depth is not None else [] + + base_cmd = [kubectl, "exec", pod_name] + if namespace: + base_cmd += ["-n", namespace] + if container: + base_cmd += ["-c", container] + base_cmd += ["--"] + + # GNU find: single pass, output is "rel_path\tsize\ttype" + result = subprocess.run( + base_cmd + + [ + "find", + remote_path, + *max_depth_args, + "-not", + "-type", + "l", + "-printf", + r"%P\t%s\t%y\n", + ], + capture_output=True, + text=True, + ) + if result.returncode == 0 or "unrecognized" not in result.stderr: + return result + + # BusyBox fallback: sh + stat loop + max_depth_sh = f"-maxdepth {depth}" if depth is not None else "" + script = ( + 'p="$1"; pfx="${p%/}/"\n' + f'find "$p" {max_depth_sh} -not -type l | while IFS= read -r f; do\n' + ' r="${f#${pfx}}"\n' + ' case "$r" in "") continue;; "$f") continue;; /*) continue;; esac\n' + ' s=$(stat -c%s "$f" 2>/dev/null) || s=1\n' + ' [ -d "$f" ] && t=d || t=f\n' + ' printf "%s\\t%s\\t%s\\n" "$r" "$s" "$t"\n' + "done" + ) + return subprocess.run( + base_cmd + ["sh", "-c", script, "_", remote_path], + capture_output=True, + text=True, + ) + + +def build_tree_pod( + pod_name: str, + remote_path: str, + namespace: str | None = None, + container: str | None = None, + exclude: frozenset[str] = frozenset(), + *, + depth: int | None = None, + _progress: list[int] | None = None, +) -> Node: + """Build a :class:`~dirplot.scanner.Node` tree from a Kubernetes pod path. + + Uses ``kubectl exec`` to run ``find`` inside the pod, so the pod must be + running and have a POSIX ``find`` binary available. + + Args: + pod_name: Pod name. + remote_path: Absolute path inside the pod. + namespace: Kubernetes namespace. ``None`` uses the current context default. + container: Container name for multi-container pods. ``None`` uses the default. + exclude: Set of absolute paths inside the pod to skip. + depth: Maximum recursion depth. ``None`` means unlimited. + _progress: Internal one-element counter for progress reporting. + """ + kubectl = _kubectl_cmd() + _check_pod(kubectl, pod_name, namespace) + + result = _run_find(kubectl, pod_name, namespace, container, remote_path, depth) + if result.returncode != 0: + err = result.stderr.strip() + if ( + result.returncode == 126 + or "executable file not found" in err + or "not found in $PATH" in err + ): + raise OSError( + f"No shell or 'find' utility in pod {pod_name!r} — the container is likely " + "distroless (scratch/distroless image with no OS tools). " + "dirplot requires a POSIX 'find' binary inside the container." + ) + raise OSError(f"find failed in pod {pod_name!r} at {remote_path!r}: {err}") + + entries: list[tuple[str, int, bool]] = [] + for line in result.stdout.splitlines(): + parts = line.split("\t", 2) + if len(parts) != 3: + continue + rel_path, size_str, ftype = parts + if not rel_path: + continue + name = PurePosixPath(rel_path).name + if name.startswith("."): + continue + if matches_exclude(rel_path, exclude): + continue + try: + size = int(size_str) + except ValueError: + size = 1 + entries.append((rel_path, max(size, 1), ftype == "d")) + + if _progress is not None: + _progress[0] += 1 + if _progress[0] % 100 == 0: + print( + f"\r scanned {_progress[0]} entries…", + end="", + file=sys.stderr, + flush=True, + ) + + return _entries_to_tree(remote_path, entries) + + +def _entries_to_tree(root_path: str, entries: list[tuple[str, int, bool]]) -> Node: + """Convert a flat list of *(rel_path, size, is_dir)* entries into a Node tree.""" + dir_nodes: dict[str, Node] = {} + root_name = PurePosixPath(root_path).name or root_path + root_node = Node(name=root_name, path=Path(root_path), size=0, is_dir=True, children=[]) + dir_nodes[""] = root_node + + entries_sorted = sorted(entries, key=lambda e: e[0]) + + for rel_path, size, is_dir in entries_sorted: + pure = PurePosixPath(rel_path) + name = pure.name + parent_rel = str(pure.parent) if str(pure.parent) != "." else "" + abs_path = root_path.rstrip("/") + "/" + rel_path + + _ensure_dir(dir_nodes, root_path, parent_rel) + parent_node = dir_nodes.get(parent_rel, root_node) + + if is_dir: + node = Node( + name=name, + path=Path(abs_path), + size=0, + is_dir=True, + children=[], + ) + dir_nodes[rel_path] = node + else: + ext = pure.suffix.lower() or NO_EXT + node = Node( + name=name, + path=Path(abs_path), + size=size, + is_dir=False, + extension=ext, + ) + + parent_node.children.append(node) + + _compute_sizes(root_node) + return root_node + + +def _ensure_dir(dir_nodes: dict[str, Node], root_path: str, rel_path: str) -> None: + """Create missing intermediate directory nodes.""" + if rel_path in dir_nodes or rel_path == "": + return + pure = PurePosixPath(rel_path) + parent_rel = str(pure.parent) if str(pure.parent) != "." else "" + _ensure_dir(dir_nodes, root_path, parent_rel) + + abs_path = root_path.rstrip("/") + "/" + rel_path + node = Node( + name=pure.name, + path=Path(abs_path), + size=0, + is_dir=True, + children=[], + ) + dir_nodes[rel_path] = node + dir_nodes[parent_rel].children.append(node) + + +def _compute_sizes(node: Node) -> int: + """Recursively set directory sizes to the sum of their children's sizes.""" + if not node.is_dir: + return node.size + total = sum(_compute_sizes(c) for c in node.children) + node.size = max(total, 1) + return node.size diff --git a/src/dirplot/main.py b/src/dirplot/main.py index ac9c452..2cedbfb 100644 --- a/src/dirplot/main.py +++ b/src/dirplot/main.py @@ -1,153 +1,31 @@ """CLI entry point.""" -from pathlib import Path - -import matplotlib.pyplot as plt -import typer - -from dirplot import __version__ -from dirplot.display import display_inline, display_window -from dirplot.render import create_treemap -from dirplot.scanner import apply_log_sizes, build_tree, collect_extensions -from dirplot.terminal import get_terminal_pixel_size - -app = typer.Typer( - context_settings={"help_option_names": ["-h", "--help"]}, - rich_markup_mode="rich", -) - - -def _version_callback(value: bool) -> None: - if value: - typer.echo(__version__) - raise typer.Exit() - - -_EPILOG = ( - "[bold]Examples[/bold]\n\n" - " dirplot . [dim]# open in system viewer[/dim]\n\n" - " dirplot . --no-show --output treemap.png [dim]# save to file[/dim]\n\n" - " dirplot . --inline [dim]# render inline (iTerm2 / Kitty / Ghostty)[/dim]\n\n" - " dirplot . --legend [dim]# show extension colour legend[/dim]\n\n" - " dirplot . --exclude .venv --exclude .git [dim]# skip paths[/dim]\n\n" - " dirplot . --colormap Set2 --font-size 14 [dim]# custom colours and label size[/dim]\n\n" - " dirplot . --size 1920x1080 --no-show --output out.png [dim]# fixed resolution[/dim]\n\n" - " dirplot . --no-header --inline [dim]# suppress info lines before the plot[/dim]\n\n" - " dirplot . --no-cushion [dim]# makes tiles look flat[/dim]" -) - - -@app.command(epilog=_EPILOG) -def main( - root: Path = typer.Argument(..., help="Root directory to map"), - version: bool = typer.Option( - False, - "--version", - "-V", - callback=_version_callback, - is_eager=True, - help="Show version and exit", - ), - output: Path | None = typer.Option(None, "--output", "-o", help="Output PNG path (optional)"), - show: bool = typer.Option(True, "--show/--no-show", help="Display the image after rendering"), - inline: bool = typer.Option( - False, - "--inline", - help="Show in terminal (auto-detects iTerm2/Kitty protocol) instead of a separate window", - ), - legend: bool = typer.Option(False, "--legend/--no-legend", help="Show extension legend"), - font_size: int = typer.Option( - 12, "--font-size", "-s", help="Directory label font size in pixels (default: 12)" - ), - colormap: str = typer.Option( - "tab20", - "--colormap", - "-c", - help=( - "Matplotlib colormap for file-extension colours (default: tab20). " - "The default uses the GitHub Linguist palette for known extensions; " - "any other colormap overrides Linguist and applies to all extensions. " - "Qualitative maps (tab10, tab20, Set1-3, Paired, Accent, Dark2, Pastel1-2) " - "give distinct hues. " - "Sequential maps (viridis, plasma, inferno, Blues, Greens, …) " - "blend across a gradient. " - "Diverging maps (coolwarm, RdBu, Spectral, …) " - "have two contrasting hues. " - "Run with an invalid name to see all options." - ), - ), - exclude: list[Path] = typer.Option([], "--exclude", "-e", help="Paths to exclude (repeatable)"), - size: str | None = typer.Option( - None, - "--size", - help="Output size as WIDTHxHEIGHT in pixels (e.g. 1920x1080). Defaults to terminal size.", - metavar="WIDTHxHEIGHT", - ), - header: bool = typer.Option( - True, "--header/--no-header", help="Print info lines before rendering (default: on)" - ), - cushion: bool = typer.Option( - True, - "--cushion/--no-cushion", - help="Apply van Wijk cushion shading: gives each tile a raised 3-D look.", - ), - log: bool = typer.Option( - False, - "--log/--no-log", - help="Use log of file sizes for layout, making small files more visible.", - ), -) -> None: - """Create a nested treemap bitmap for a directory tree.""" - if colormap not in plt.colormaps(): - valid = ", ".join(sorted(plt.colormaps())) - typer.echo(f"Unknown colormap '{colormap}'. Valid options:\n{valid}", err=True) - raise typer.Exit(1) - if not root.exists(): - typer.echo(f"Path does not exist: {root}", err=True) - raise typer.Exit(1) - if not root.is_dir(): - typer.echo(f"Not a directory: {root}", err=True) - raise typer.Exit(1) - - excluded = frozenset(p.resolve() for p in exclude) - if header: - typer.echo(f"Scanning {root} ...") - root_node = build_tree(root.resolve(), excluded) - if log: - apply_log_sizes(root_node) - total_files = len(collect_extensions(root_node)) - if header: - typer.echo(f"Found {total_files:,} files, total size: {root_node.size:,} bytes") - - if size is not None: - try: - w_str, h_str = size.lower().split("x", 1) - width_px, height_px = int(w_str), int(h_str) - except ValueError: - typer.echo( - f"Invalid --size value '{size}'. Expected format: WIDTHxHEIGHT (e.g. 1920x1080)", - err=True, - ) - raise typer.Exit(1) from None - if header: - typer.echo(f"Output size: {width_px}x{height_px}px") - else: - term_w, term_h, row_px = get_terminal_pixel_size() - width_px = term_w + 1 - height_px = term_h - 3 * row_px - if header: - typer.echo(f"Terminal size: {width_px}x{height_px}px") - - buf = create_treemap(root_node, width_px, height_px, font_size, colormap, legend, cushion) - - if output is not None: - output.write_bytes(buf.read()) - if header: - typer.echo(f"Saved dirplot to {output}") - buf.seek(0) - - if show: - if inline: - display_inline(buf) - else: - display_window(buf) +# ruff: noqa: F401 +# Import command modules so their @app.command decorators register against the app. +import dirplot.commands.diff +import dirplot.commands.metrics +import dirplot.commands.misc +import dirplot.commands.replay +import dirplot.commands.treemap +import dirplot.commands.vcs +import dirplot.commands.watch +from dirplot._overview import add_overview_command +from dirplot.app import app as app + +add_overview_command(app) + +# Reorder help output regardless of definition order. +_CMD_ORDER = [ + "demo", + "overview", + "termsize", + "map", + "diff", + "metrics", + "git", + "hg", + "watch", + "replay", + "read-meta", +] +app.registered_commands.sort(key=lambda c: _CMD_ORDER.index(c.name or "")) diff --git a/src/dirplot/pathlist.py b/src/dirplot/pathlist.py new file mode 100644 index 0000000..fcb5339 --- /dev/null +++ b/src/dirplot/pathlist.py @@ -0,0 +1,154 @@ +"""Parse path lists produced by ``tree`` and ``find`` into Path objects.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Literal + +# Tree box-drawing characters used to detect tree format +_TREE_CHARS = re.compile(r"[├└│]") +# Strip leading tree decorations (box-drawing + spaces + dashes) +_TREE_PREFIX = re.compile(r"^[│ ]*[├└]──\s*") +# Strip optional [size] column emitted by tree -s / tree -h +_TREE_SIZE = re.compile(r"^\[\s*[\d.,]+[KMGTPkBb]*\s*\]\s*") +# Trailing comment: ` # ...` (space-hash-space) at the end of a name. +# Filenames containing '#' without a leading space are left intact. +_TRAILING_COMMENT = re.compile(r"\s+#\s.*$") + + +def _strip_comment(name: str) -> str: + """Remove a trailing ``# comment`` from a tree line name.""" + return _TRAILING_COMMENT.sub("", name).rstrip() + + +def detect_format(lines: list[str]) -> Literal["find", "tree", "tree_f"]: + """Return the format of a path list. + + * ``"tree"`` – ``tree`` default output (indented names, first line is root) + * ``"tree_f"`` – ``tree -f`` output (tree decorations + full paths) + * ``"find"`` – ``find`` output (one path per line, no decorations) + """ + for line in lines: + if _TREE_CHARS.search(line): + # Strip tree decoration and optional size bracket to get the name/path + name = _TREE_PREFIX.sub("", line).strip() + name = _TREE_SIZE.sub("", name).strip() + name = _strip_comment(name) + if name.startswith("/"): + return "tree_f" + return "tree" + return "find" + + +def parse_find(lines: list[str]) -> list[Path]: + """Parse ``find`` output: one path per line.""" + paths: list[Path] = [] + for line in lines: + line = line.rstrip("\n") + if not line or line.isspace(): + continue + paths.append(Path(line)) + return paths + + +def parse_tree(lines: list[str]) -> list[Path]: + """Parse ``tree`` or ``tree -f`` or ``tree -s/-h`` output into paths. + + Handles: + * ``tree`` – first line is root path, rest are indented names + * ``tree -f`` – box-drawing decorations followed by full absolute paths + * ``tree -s/-h`` – optional ``[size]`` column before the name + * ``tree --noreport`` compatible (summary lines like "N directories, M files" are skipped) + """ + non_empty = [line.rstrip("\n") for line in lines if line.strip()] + if not non_empty: + return [] + + fmt = detect_format(non_empty) + + if fmt == "tree_f": + return _parse_tree_full_paths(non_empty) + + # Default tree format: first line is root (may have [size] prefix with tree -s) + root_line = _TREE_SIZE.sub("", non_empty[0].strip()).strip() + root_line = _strip_comment(root_line) + root = Path(root_line) + + paths: list[Path] = [root] + # Stack maps depth → current Path at that depth + stack: dict[int, Path] = {0: root} + + for line in non_empty[1:]: + if not _TREE_CHARS.search(line): + # Could be a summary line ("3 directories, 5 files") – skip + continue + + # Determine depth by counting leading │/space groups (each unit = 4 chars) + prefix_match = re.match(r"^([│ ]*)[├└]", line) + if not prefix_match: + continue + indent = prefix_match.group(1) + depth = len(indent) // 4 + 1 # depth 1 = direct child of root + + # Strip tree decoration + name = _TREE_PREFIX.sub("", line).strip() + # Strip optional [size] column + name = _TREE_SIZE.sub("", name).strip() + # Strip trailing comment + name = _strip_comment(name) + if not name: + continue + + parent = stack.get(depth - 1, root) + full_path = parent / name + stack[depth] = full_path + paths.append(full_path) + + return paths + + +def _parse_tree_full_paths(lines: list[str]) -> list[Path]: + """Parse ``tree -f`` output where each decorated line contains a full path.""" + paths: list[Path] = [] + for line in lines: + if _TREE_CHARS.search(line): + name = _TREE_PREFIX.sub("", line).strip() + name = _TREE_SIZE.sub("", name).strip() + name = _strip_comment(name) + if name: + paths.append(Path(name)) + else: + # First line (root) or summary line + stripped = line.strip() + if stripped.startswith("/"): + paths.append(Path(stripped)) + return paths + + +def minimal_roots(paths: list[Path]) -> list[Path]: + """Return the minimal set of paths: drop any path whose ancestor is already present. + + This prevents passing both ``/dir`` and ``/dir/file`` to ``build_tree_multi``, + which would cause an ``IndexError`` in the combine step. + """ + resolved = sorted({p.resolve() for p in paths}, key=lambda p: len(p.parts)) + result: list[Path] = [] + for p in resolved: + if not any(p != r and p.is_relative_to(r) for r in result): + result.append(p) + return result + + +def parse_pathlist(lines: list[str]) -> list[Path]: + """Auto-detect format and return the minimal set of root paths. + + Dispatches to :func:`parse_find` or :func:`parse_tree` based on content. + Applies :func:`minimal_roots` to deduplicate ancestor/descendant pairs. + """ + non_empty = [line for line in lines if line.strip()] + if not non_empty: + return [] + fmt = detect_format(non_empty) + raw = parse_find(lines) if fmt == "find" else parse_tree(lines) + return minimal_roots(raw) diff --git a/src/dirplot/render.py b/src/dirplot/render.py deleted file mode 100644 index 27c75d2..0000000 --- a/src/dirplot/render.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Treemap layout and PNG rendering.""" - -import io -from collections import defaultdict -from pathlib import Path - -import numpy as np -import squarify -from PIL import Image, ImageDraw, ImageFont - -from dirplot.colors import RGBAColor, assign_colors -from dirplot.scanner import Node, collect_extensions - -SWATCH_PX = 8 # legend colour swatch size in pixels -LEG_PAD = 3 # legend internal padding in pixels - -_FONTS_DIR = Path(__file__).parent / "fonts" -_FONT_REGULAR = _FONTS_DIR / "JetBrainsMono-Regular.ttf" -_FONT_BOLD = _FONTS_DIR / "JetBrainsMono-Bold.ttf" -_FONT_ITALIC = _FONTS_DIR / "JetBrainsMono-Italic.ttf" -_FONT_BOLD_ITALIC = _FONTS_DIR / "JetBrainsMono-BoldItalic.ttf" - - -def _font(size: int, bold: bool = False, italic: bool = False) -> ImageFont.FreeTypeFont: - size = max(6, size) - if bold and italic: - path = _FONT_BOLD_ITALIC - elif bold: - path = _FONT_BOLD - elif italic: - path = _FONT_ITALIC - else: - path = _FONT_REGULAR - return ImageFont.truetype(str(path), size=size) - - -def _label_color(rgb: tuple[int, int, int]) -> tuple[int, int, int]: - """Return black or white text color based on the background luminance.""" - gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] - return (0, 0, 0) if gray >= 128 else (255, 255, 255) - - -def _text_w(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> int: - bb = draw.textbbox((0, 0), text, font=font) - return int(bb[2] - bb[0]) - - -def _wrap(name: str, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont, max_w: int) -> str: - """Wrap *name* into lines that each fit within *max_w* pixels.""" - if max_w < 4 or _text_w(draw, name, font) <= max_w: - return name - delimiters = "._ -" - lines: list[str] = [] - remaining = name - while remaining: - if _text_w(draw, remaining, font) <= max_w: - lines.append(remaining) - break - # Binary-search the longest prefix that fits - lo, hi = 1, len(remaining) - while lo < hi: - mid = (lo + hi + 1) // 2 - if _text_w(draw, remaining[:mid], font) <= max_w: - lo = mid - else: - hi = mid - 1 - chunk = remaining[:lo] - split = max((chunk.rfind(d) for d in delimiters), default=-1) - if split > 0: - lines.append(remaining[:split]) - remaining = remaining[split:] - else: - lines.append(chunk) - remaining = remaining[lo:] - return "\n".join(lines) - - -def _truncate( - name: str, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont, max_w: int -) -> str: - """Truncate *name* with an ellipsis so it fits within *max_w* pixels on one line.""" - if max_w < 4 or _text_w(draw, name, font) <= max_w: - return name - ellipsis = "…" - lo, hi = 0, len(name) - while lo < hi: - mid = (lo + hi + 1) // 2 - if _text_w(draw, name[:mid] + ellipsis, font) <= max_w: - lo = mid - else: - hi = mid - 1 - return name[:lo] + ellipsis - - -def _apply_cushion(img: Image.Image, x: int, y: int, w: int, h: int) -> None: - """Apply van Wijk-style quadratic cushion shading to a tile in-place.""" - if w < 4 or h < 4: - return - xs = np.arange(w, dtype=float) - ys = np.arange(h, dtype=float) - gx, gy = np.meshgrid(xs, ys) - - # Quadratic surface: h = Ix*(gx)*(w-1-gx) + Iy*(gy)*(h-1-gy) - # Surface normal components (un-normalized): (-dh/dx, -dh/dy, 1) - # Use Ix = C/w (not C/w²) so edge slope is size-independent: same visible - # shading depth on large tiles as on small ones. - Ix, Iy = 0.12 / w, 0.12 / h - nx = Ix * (w - 1 - 2 * gx) - ny = Iy * (h - 1 - 2 * gy) - - # Light direction: top-left, slightly above the surface - lx, ly, lz = 1.0, 1.0, 1.2 - mag = (lx**2 + ly**2 + lz**2) ** 0.5 - lx, ly, lz = lx / mag, ly / mag, lz / mag - - brightness = nx * lx + ny * ly + lz # dot(normal, light) - brightness = np.clip(brightness, 0.0, None) - brightness /= brightness.mean() # preserve average luminance - - tile = img.crop((x, y, x + w, y + h)) - arr = np.array(tile, dtype=float) - arr[:, :, :3] *= brightness[:, :, np.newaxis] - np.clip(arr, 0, 255, out=arr) - img.paste(Image.fromarray(arr.astype(np.uint8)), (x, y)) - - -def draw_node( - draw: ImageDraw.ImageDraw, - node: Node, - x: int, - y: int, - w: int, - h: int, - color_map: dict[str, RGBAColor], - font: ImageFont.FreeTypeFont, - font_size: int = 12, - cushion: bool = True, - img: Image.Image | None = None, -) -> None: - """Recursively draw *node* and its children into *draw*. - - Args: - draw: PIL ImageDraw to draw into. - node: Current tree node. - x, y: Top-left corner in pixels. - w, h: Width and height in pixels. - color_map: Extension → RGBA colour mapping. - font: Font for directory name labels. - scale: Global font scale factor. - """ - if w < 2 or h < 2: - return - - if not node.is_dir: - rgba = color_map.get(node.extension, (0.5, 0.5, 0.5, 1.0)) - rgb = (int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)) - draw.rectangle([x, y, x + w - 1, y + h - 1], fill=rgb) - if cushion and img is not None: - _apply_cushion(img, x, y, w, h) - # Adaptive label: font size scales with tile size, capped by scale factor - if w > 20 and h > 10: - fsize = max(6, min(font_size + 2, w // 10)) - ffont = _font(fsize) - label = _wrap(node.name, draw, ffont, w - 4) - draw.text( - (x + w // 2, y + h // 2), - label, - fill=_label_color(rgb), - font=ffont, - anchor="mm", - align="center", - ) - return - - # Directory: 1-px white outer border + 1-px black inner border - draw.rectangle([x, y, x + w - 1, y + h - 1], outline=(255, 255, 255), width=1) - if w >= 4 and h >= 4: - draw.rectangle([x + 1, y + 1, x + w - 2, y + h - 2], outline=(0, 0, 0), width=1) - - # Header label — height driven by the font size - header_h = font.size + 4 - if h > 2 + header_h: - label = _truncate(node.name, draw, font, w - 8) - draw.text( - (x + w // 2, y + 2 + header_h // 2), - label, - fill=(224, 224, 224), - font=font, - anchor="mm", - align="center", - ) - - # Inner content area: starts just inside the 2-px border, ends ON the - # right/bottom inner-border pixel so the pre-fill and the inner border - # share that pixel rather than stacking two black pixels there. - ix = x + 2 - iy = y + 2 + header_h - iw = w - 3 - ih = h - 3 - header_h - - if iw < 2 or ih < 2: - return - - positive_children = [c for c in node.children if c.size > 0] - if not positive_children: - return - - sizes = [c.size for c in positive_children] - normed = squarify.normalize_sizes(sizes, iw, ih) - rects = squarify.squarify(normed, ix, iy, iw, ih) - - # Black background provides the 1-px separator between adjacent children - draw.rectangle([ix, iy, ix + iw - 1, iy + ih - 1], fill=(0, 0, 0)) - - for rect, child in zip(rects, positive_children, strict=False): - rx = round(rect["x"]) - ry = round(rect["y"]) - rw = round(rect["x"] + rect["dx"]) - rx - rh = round(rect["y"] + rect["dy"]) - ry - draw_node(draw, child, rx, ry, rw - 1, rh - 1, color_map, font, font_size, cushion, img) - - -def _best_corner(root_node: Node, width_px: int, height_px: int) -> str: - """Return the corner string for the largest top-level tile.""" - positive = [c for c in root_node.children if c.size > 0] - if not positive: - return "lower-right" - pad, header_h = 2, 16 - iw = width_px - 2 * pad - ih = height_px - 2 * pad - header_h - normed = squarify.normalize_sizes([c.size for c in positive], iw, ih) - rects = squarify.squarify(normed, pad, pad, iw, ih) - corners = { - "upper-left": (1, 1), - "upper-right": (width_px - 2, 1), - "lower-left": (1, height_px - 2), - "lower-right": (width_px - 2, height_px - 2), - } - best, best_area = "lower-right", -1.0 - for loc, (px, py) in corners.items(): - for r in rects: - if r["x"] <= px <= r["x"] + r["dx"] and r["y"] <= py <= r["y"] + r["dy"]: - area = r["dx"] * r["dy"] - if area > best_area: - best_area, best = area, loc - break - return best - - -def _draw_legend( - draw: ImageDraw.ImageDraw, - ext_sizes: dict[str, int], - color_map: dict[str, RGBAColor], - width_px: int, - height_px: int, - corner: str, - font: ImageFont.FreeTypeFont, -) -> None: - top = sorted(ext_sizes, key=lambda e: ext_sizes[e], reverse=True)[:12] - if not top: - return - - ncols = 2 - col_entries = [top[i::ncols] for i in range(ncols)] - n_rows = max(len(c) for c in col_entries) - bb = draw.textbbox((0, 0), "Ag", font=font) - text_h = bb[3] - bb[1] - row_h = max(SWATCH_PX, text_h) + LEG_PAD - col_w = max(_text_w(draw, ext, font) + SWATCH_PX + LEG_PAD * 2 for ext in top) - box_w = ncols * col_w + LEG_PAD - box_h = n_rows * row_h + LEG_PAD * 2 - - margin = 4 - bx = (width_px - box_w - margin) if "right" in corner else margin - by = (height_px - box_h - margin) if "lower" in corner else margin - - draw.rectangle([bx, by, bx + box_w - 1, by + box_h - 1], fill=(20, 20, 36)) - draw.rectangle([bx, by, bx + box_w - 1, by + box_h - 1], outline=(80, 80, 80), width=1) - - for ci, col in enumerate(col_entries): - for ri, ext in enumerate(col): - rgba = color_map.get(ext, (0.5, 0.5, 0.5, 1.0)) - rgb = (int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)) - ex = bx + LEG_PAD + ci * col_w - row_mid = by + LEG_PAD + ri * row_h + (row_h - LEG_PAD) // 2 - sy = row_mid - SWATCH_PX // 2 - draw.rectangle( - [ex, sy, ex + SWATCH_PX - 1, sy + SWATCH_PX - 1], - fill=rgb, - outline=(255, 255, 255), - width=1, - ) - draw.text( - (ex + SWATCH_PX + LEG_PAD, row_mid), - ext, - fill=(220, 220, 220), - font=font, - anchor="lm", - ) - - -def create_treemap( - root_node: Node, - width_px: int, - height_px: int, - font_size: int = 12, - colormap: str = "tab20", - legend: bool = False, - cushion: bool = True, -) -> io.BytesIO: - """Render a nested squarified treemap and return it as a PNG in a BytesIO buffer. - - Args: - root_node: Root of the directory tree. - width_px: Output image width in pixels. - height_px: Output image height in pixels. - font_size: Directory label font size in pixels. - colormap: Matplotlib colormap name for file-extension colours. - legend: Whether to draw an extension colour legend. - - Returns: - BytesIO containing the rendered PNG, seeked to position 0. - """ - exts = collect_extensions(root_node) - color_map = assign_colors(exts, colormap) - - img = Image.new("RGB", (width_px, height_px), color=(26, 26, 46)) - idraw = ImageDraw.Draw(img) - font = _font(font_size, bold=True) - - draw_node(idraw, root_node, 0, 0, width_px, height_px, color_map, font, font_size, cushion, img) - - if legend: - ext_sizes: dict[str, int] = defaultdict(int) - - def _sum(node: Node) -> None: - if not node.is_dir: - ext_sizes[node.extension] += node.size - for c in node.children: - _sum(c) - - _sum(root_node) - corner = _best_corner(root_node, width_px, height_px) - leg_font = _font(max(6, font_size - 2)) - _draw_legend(idraw, ext_sizes, color_map, width_px, height_px, corner, leg_font) - - buf = io.BytesIO() - img.save(buf, format="PNG") - buf.seek(0) - return buf diff --git a/src/dirplot/render_png.py b/src/dirplot/render_png.py new file mode 100644 index 0000000..e795758 --- /dev/null +++ b/src/dirplot/render_png.py @@ -0,0 +1,942 @@ +"""Treemap layout and PNG rendering.""" + +import io +import math +import platform +import struct +import sys +import zlib +from collections import defaultdict +from datetime import datetime, timezone +from importlib.metadata import version as _pkg_version +from pathlib import Path + +import numpy as np +import squarify +from numpy.typing import NDArray +from PIL import Image, ImageDraw, ImageFont, PngImagePlugin + +from dirplot.colors import RGBAColor, assign_colors +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE, DEFAULT_LEGEND_MAX_ROWS +from dirplot.scanner import BREADCRUMB_SEP, Node, collect_extensions, count_nodes, max_depth + +DIRPLOT_URL = "https://github.com/deeplook/dirplot" + + +def build_metadata() -> dict[str, str]: + """Return a dict of metadata fields to embed in PNG/SVG output.""" + return { + "Date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "Software": f"dirplot {_pkg_version('dirplot')}", + "URL": DIRPLOT_URL, + "Python": sys.version.split()[0], + "OS": f"{platform.system()} {platform.release()}", + "Command": " ".join([Path(sys.argv[0]).name, *sys.argv[1:]]), + } + + +SWATCH_PX = 8 # legend colour swatch size in pixels +LEG_PAD = 3 # legend internal padding in pixels + +_FONTS_DIR = Path(__file__).parent / "fonts" +_FONT_REGULAR = _FONTS_DIR / "JetBrainsMono-Regular.ttf" +_FONT_BOLD = _FONTS_DIR / "JetBrainsMono-Bold.ttf" +_FONT_ITALIC = _FONTS_DIR / "JetBrainsMono-Italic.ttf" +_FONT_BOLD_ITALIC = _FONTS_DIR / "JetBrainsMono-BoldItalic.ttf" + + +def _font(size: int, bold: bool = False, italic: bool = False) -> ImageFont.FreeTypeFont: + size = max(6, size) + if bold and italic: + path = _FONT_BOLD_ITALIC + elif bold: + path = _FONT_BOLD + elif italic: + path = _FONT_ITALIC + else: + path = _FONT_REGULAR + return ImageFont.truetype(str(path), size=size) + + +def _human_bytes(n: int) -> str: + """Return a human-readable byte count: '4.0 MB', '1.2 GB', etc.""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(n) < 1024 or unit == "TB": + return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" + n /= 1024 # type: ignore[assignment] + return f"{n:.1f} TB" # unreachable but satisfies type checkers + + +def _label_color(rgb: tuple[int, int, int]) -> tuple[int, int, int]: + """Return black or white text color based on the background luminance.""" + gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2] + return (0, 0, 0) if gray >= 128 else (255, 255, 255) + + +def _text_w(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> int: + bb = draw.textbbox((0, 0), text, font=font) + return int(bb[2] - bb[0]) + + +def _fit_font( + name: str, + draw: ImageDraw.ImageDraw, + max_size: int, + max_w: int, + max_h: int, +) -> tuple[ImageFont.FreeTypeFont, str]: + """Return (font, wrapped_label) at the largest size ≤ max_size where the + wrapped text fits within max_w × max_h pixels. + + Uses one textbbox measurement at max_size to estimate n_lines after + wrapping, then calculates the required font size directly — no loop. + """ + if max_h < 6 or max_w < 4: + ffont = _font(6) + return ffont, _wrap(name, draw, ffont, max_w) + # Step 1: estimate fsize from single-line measurement at max_size + ffont = _font(max_size) + bb = draw.textbbox((0, 0), name, font=ffont) + tw, th = bb[2] - bb[0], bb[3] - bb[1] + n_lines = max(1, math.ceil(tw / max_w)) + fsize = max(6, min(max_size, int(max_h / n_lines * max_size / max(th, 1)))) + # Steps 2-3: wrap and measure actual height; correct up to twice if still overflowing + for _ in range(2): + ffont = _font(fsize) + label = _wrap(name, draw, ffont, max_w) + bb = draw.textbbox((0, 0), label, font=ffont, spacing=0) + actual_h = bb[3] - bb[1] + if actual_h <= max_h or actual_h == 0: + return ffont, label + fsize = max(6, int(fsize * max_h / actual_h)) + # Text can't fit even at minimum size — skip the label to avoid overflow + return _font(fsize), "" + + +def _wrap(name: str, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont, max_w: int) -> str: + """Wrap *name* into lines that each fit within *max_w* pixels.""" + if max_w < 4: + return "" + if _text_w(draw, name, font) <= max_w: + return name + delimiters = "._ -" + lines: list[str] = [] + remaining = name + while remaining: + if _text_w(draw, remaining, font) <= max_w: + lines.append(remaining) + break + # Binary-search the longest prefix that fits + lo, hi = 1, len(remaining) + while lo < hi: + mid = (lo + hi + 1) // 2 + if _text_w(draw, remaining[:mid], font) <= max_w: + lo = mid + else: + hi = mid - 1 + chunk = remaining[:lo] + split = max((chunk.rfind(d) for d in delimiters), default=-1) + if split > 0: + lines.append(remaining[:split]) + remaining = remaining[split:] + else: + lines.append(chunk) + remaining = remaining[lo:] + return "\n".join(lines) + + +def _truncate( + name: str, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont, max_w: int +) -> str: + """Truncate *name* with an ellipsis so it fits within *max_w* pixels on one line.""" + if max_w < 4: + return "" + if _text_w(draw, name, font) <= max_w: + return name + ellipsis = "…" + lo, hi = 0, len(name) + while lo < hi: + mid = (lo + hi + 1) // 2 + if _text_w(draw, name[:mid] + ellipsis, font) <= max_w: + lo = mid + else: + hi = mid - 1 + return name[:lo] + ellipsis + + +def _truncate_breadcrumb( + name: str, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont, max_w: int +) -> str: + """Truncate a breadcrumb label (`` / ``-separated parts) to fit *max_w* pixels. + + Tries the full label first, then collapses middle segments to ``…``, and + finally falls back to ``_truncate`` for plain names or when even the + ``first / … / last`` form is too long. + """ + parts = name.split(BREADCRUMB_SEP) + if len(parts) <= 1: + return _truncate(name, draw, font, max_w) + if _text_w(draw, name, font) <= max_w: + return name + candidate = parts[0] + BREADCRUMB_SEP + "…" + BREADCRUMB_SEP + parts[-1] + if _text_w(draw, candidate, font) <= max_w: + return candidate + return _truncate(candidate, draw, font, max_w) + + +def _cushion_brightness(w: int, h: int, scale: float = 1.0) -> NDArray[np.float32]: + """Return a (h, w) float32 brightness map for van Wijk cushion shading.""" + xs = np.arange(w, dtype=np.float32) + ys = np.arange(h, dtype=np.float32) + gx, gy = np.meshgrid(xs, ys) + Ix, Iy = 0.12 * scale / w, 0.12 * scale / h + nx = Ix * (w - 1 - 2 * gx) + ny = Iy * (h - 1 - 2 * gy) + lx, ly, lz = 1.0, 1.0, 1.2 + mag = (lx**2 + ly**2 + lz**2) ** 0.5 + lx, ly, lz = lx / mag, ly / mag, lz / mag + brightness = nx * lx + ny * ly + lz + np.clip(brightness, 0.0, None, out=brightness) + brightness /= float(brightness.mean()) + return brightness # type: ignore[no-any-return] + + +def _apply_cushion(img: Image.Image, x: int, y: int, w: int, h: int) -> None: + """Apply van Wijk-style quadratic cushion shading to a tile in-place (PIL path).""" + if w < 4 or h < 4: + return + brightness = _cushion_brightness(w, h) + tile = img.crop((x, y, x + w, y + h)) + arr = np.array(tile, dtype=np.float32) + arr[:, :, :3] *= brightness[:, :, np.newaxis] + np.clip(arr, 0, 255, out=arr) + img.paste(Image.fromarray(arr.astype(np.uint8)), (x, y)) + + +def _apply_cushion_inplace( + arr: np.ndarray, x: int, y: int, w: int, h: int, scale: float = 1.0 +) -> None: + """Apply van Wijk cushion shading directly to a region of a numpy (H, W, 3) array. + + Avoids the PIL crop/paste round-trip of :func:`_apply_cushion`. Call this + after converting the full image to numpy once, then converting back once — + much cheaper than one PIL round-trip per tile when there are many tiles. + """ + if w < 4 or h < 4: + return + brightness = _cushion_brightness(w, h, scale) + region = arr[y : y + h, x : x + w].astype(np.float32) + region *= brightness[:, :, np.newaxis] + np.clip(region, 0, 255, out=region) + arr[y : y + h, x : x + w] = region.astype(np.uint8) + + +def draw_node( + draw: ImageDraw.ImageDraw, + node: Node, + x: int, + y: int, + w: int, + h: int, + color_map: dict[str, RGBAColor], + font: ImageFont.FreeTypeFont, + font_size: int = DEFAULT_FONT_SIZE, + cushion: bool = True, + img: Image.Image | None = None, + root_label: str | None = None, + rect_map: dict[str, tuple[int, int, int, int]] | None = None, + dir_rect_map: dict[str, tuple[int, int, int, int]] | None = None, + dark: bool = True, +) -> None: + """Recursively draw *node* and its children into *draw*. + + Args: + draw: PIL ImageDraw to draw into. + node: Current tree node. + x, y: Top-left corner in pixels. + w, h: Width and height in pixels. + color_map: Extension → RGBA colour mapping. + font: Font for directory name labels. + root_label: When set, overrides the directory header label for this + (root) node only; children always use their own name. + rect_map: When provided, populated with ``str(path) → (x, y, w, h)`` + for every leaf node drawn. + """ + if w < 2 or h < 2: + return + + if not node.is_dir: + if rect_map is not None: + rect_map[node.path.as_posix()] = (x, y, w, h) + rgba = color_map.get(node.extension, (0.5, 0.5, 0.5, 1.0)) + rgb = (int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)) + draw.rectangle([x, y, x + w - 1, y + h - 1], fill=rgb) + if cushion and img is not None: + _apply_cushion(img, x, y, w, h) + # 1-px border so adjacent same-colored tiles always have a visible boundary + if w >= 3 and h >= 3: + border = (max(0, rgb[0] - 60), max(0, rgb[1] - 60), max(0, rgb[2] - 60)) + draw.rectangle([x, y, x + w - 1, y + h - 1], outline=border) + # Adaptive label: largest font that fits the tile without overflow + if w > 20 and h > 10: + # Try horizontal first + ffont_h, label_h = _fit_font(node.name, draw, font_size + 2, w - 4, h - 4) + # For tall narrow tiles, also try vertical; prefer whichever wraps less + use_vertical = False + if h >= w * 2 and img is not None: + ffont_v, label_v = _fit_font(node.name, draw, font_size + 2, h - 4, w - 4) + if label_v: + h_lines = label_h.count("\n") + 1 if label_h else 999 + v_lines = label_v.count("\n") + 1 + if v_lines < h_lines or (v_lines == h_lines and ffont_v.size > ffont_h.size): + use_vertical = True + if use_vertical: + # Tall, narrow tile — rotate label 90° CCW so it runs along the height + tmp = Image.new("RGBA", (h, w), (0, 0, 0, 0)) + ImageDraw.Draw(tmp).text( + (h // 2, w // 2), + label_v, + fill=_label_color(rgb), + font=ffont_v, + anchor="mm", + align="center", + spacing=0, + ) + rotated = tmp.rotate(90, expand=True) + assert img is not None + img.paste(rotated, (x, y), mask=rotated) + else: + # Horizontal label: available text-run = w-4, constraining dim = h-4 + draw.text( + (x + w // 2, y + h // 2), + label_h, + fill=_label_color(rgb), + font=ffont_h, + anchor="mm", + align="center", + spacing=0, + ) + return + + if dir_rect_map is not None: + dir_rect_map[str(node.path)] = (x, y, w, h) + + # Directory: 1-px outer border + 1-px inner border (colours swap in light mode) + outer_col = (255, 255, 255) if dark else (0, 0, 0) + inner_col = (0, 0, 0) if dark else (255, 255, 255) + draw.rectangle([x, y, x + w - 1, y + h - 1], outline=outer_col, width=1) + if w >= 4 and h >= 4: + draw.rectangle([x + 1, y + 1, x + w - 2, y + h - 2], outline=inner_col, width=1) + + # Header label — height driven by the font size + header_h = font.size + 4 + if h > 2 + header_h: + label = _truncate_breadcrumb( + root_label if root_label is not None else node.name, draw, font, w - 8 + ) + header_text_col = (224, 224, 224) if dark else (32, 32, 32) + draw.text( + (x + w // 2, y + 2 + header_h // 2), + label, + fill=header_text_col, + font=font, + anchor="mm", + align="center", + ) + + # Inner content area: starts just inside the 2-px border, ends ON the + # right/bottom inner-border pixel so the pre-fill and the inner border + # share that pixel rather than stacking two black pixels there. + ix = x + 2 + iy = y + 2 + header_h + iw = w - 3 + ih = h - 3 - header_h + + if iw < 2 or ih < 2: + return + + positive_children = [c for c in node.children if c.size > 0] + if not positive_children: + return + + sizes = [c.size for c in positive_children] + normed = squarify.normalize_sizes(sizes, iw, ih) + rects = squarify.squarify(normed, ix, iy, iw, ih) + + # Background provides the 1-px separator between adjacent children + sep_col = (0, 0, 0) if dark else (255, 255, 255) + draw.rectangle([ix, iy, ix + iw - 1, iy + ih - 1], fill=sep_col) + + for rect, child in zip(rects, positive_children, strict=False): + rx = round(rect["x"]) + ry = round(rect["y"]) + rw = round(rect["x"] + rect["dx"]) - rx + rh = round(rect["y"] + rect["dy"]) - ry + draw_node( + draw, + child, + rx, + ry, + rw - 1, + rh - 1, + color_map, + font, + font_size, + cushion, + img, + rect_map=rect_map, + dir_rect_map=dir_rect_map, + dark=dark, + ) + + +def _collect_ext_counts(node: Node) -> dict[str, int]: + counts: dict[str, int] = defaultdict(int) + + def _walk(n: Node) -> None: + if not n.is_dir: + counts[n.extension] += 1 + for c in n.children: + _walk(c) + + _walk(node) + return dict(counts) + + +def _best_corner(root_node: Node, width_px: int, height_px: int) -> str: + """Return the corner string for the largest top-level tile.""" + positive = [c for c in root_node.children if c.size > 0] + if not positive: + return "lower-right" + pad, header_h = 2, 16 + iw = width_px - 2 * pad + ih = height_px - 2 * pad - header_h + normed = squarify.normalize_sizes([c.size for c in positive], iw, ih) + rects = squarify.squarify(normed, pad, pad, iw, ih) + corners = { + "upper-left": (1, 1), + "upper-right": (width_px - 2, 1), + "lower-left": (1, height_px - 2), + "lower-right": (width_px - 2, height_px - 2), + } + best, best_area = "lower-right", -1.0 + for loc, (px, py) in corners.items(): + for r in rects: + if r["x"] <= px <= r["x"] + r["dx"] and r["y"] <= py <= r["y"] + r["dy"]: + area = r["dx"] * r["dy"] + if area > best_area: + best_area, best = area, loc + break + return best + + +def _draw_legend( + draw: ImageDraw.ImageDraw, + ext_counts: dict[str, int], + color_map: dict[str, RGBAColor], + width_px: int, + height_px: int, + corner: str, + font: ImageFont.FreeTypeFont, + max_rows: int = DEFAULT_LEGEND_MAX_ROWS, + dark: bool = True, +) -> None: + margin = 4 + bb = draw.textbbox((0, 0), "Ag", font=font) + text_h = bb[3] - bb[1] + row_h = max(SWATCH_PX, text_h) + LEG_PAD + rows_that_fit = max(1, int((height_px - 2 * margin - LEG_PAD * 2 - row_h) // row_h)) + limit = min(max_rows, rows_that_fit) + + ranked = sorted(ext_counts, key=lambda e: (-ext_counts[e], e)) + top = ranked[:limit] + n_more = len(ranked) - limit + if not top: + return + + more_label = f"(+{n_more})" if n_more > 0 else "" + + label_w = max(_text_w(draw, ext, font) for ext in top) + if more_label: + label_w = max(label_w, _text_w(draw, more_label, font)) + count_w = max(_text_w(draw, str(ext_counts[e]), font) for e in top) + box_w = SWATCH_PX + LEG_PAD + label_w + LEG_PAD + count_w + LEG_PAD * 2 + n_rows = len(top) + (1 if more_label else 0) + box_h = n_rows * row_h + LEG_PAD * 2 + + bx = (width_px - box_w - margin) if "right" in corner else margin + by = (height_px - box_h - margin) if "lower" in corner else margin + + leg_bg = (20, 20, 36) if dark else (240, 240, 240) + leg_border = (80, 80, 80) if dark else (160, 160, 160) + leg_ext_text = (220, 220, 220) if dark else (40, 40, 40) + leg_count_text = (160, 160, 160) if dark else (80, 80, 80) + leg_more_text = (120, 120, 120) if dark else (100, 100, 100) + leg_swatch_outline = (255, 255, 255) if dark else (0, 0, 0) + draw.rectangle([bx, by, bx + box_w - 1, by + box_h - 1], fill=leg_bg) + draw.rectangle([bx, by, bx + box_w - 1, by + box_h - 1], outline=leg_border, width=1) + + for ri, ext in enumerate(top): + rgba = color_map.get(ext, (0.5, 0.5, 0.5, 1.0)) + rgb = (int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)) + ex = bx + LEG_PAD + row_mid = by + LEG_PAD + ri * row_h + (row_h - LEG_PAD) // 2 + sy = row_mid - SWATCH_PX // 2 + draw.rectangle( + [ex, sy, ex + SWATCH_PX - 1, sy + SWATCH_PX - 1], + fill=rgb, + outline=leg_swatch_outline, + width=1, + ) + draw.text( + (ex + SWATCH_PX + LEG_PAD, row_mid), + ext, + fill=leg_ext_text, + font=font, + anchor="lm", + ) + draw.text( + (bx + box_w - LEG_PAD, row_mid), + str(ext_counts[ext]), + fill=leg_count_text, + font=font, + anchor="rm", + ) + + if more_label: + row_mid = by + LEG_PAD + len(top) * row_h + (row_h - LEG_PAD) // 2 + draw.text( + (bx + LEG_PAD + SWATCH_PX + LEG_PAD, row_mid), + more_label, + fill=leg_more_text, + font=font, + anchor="lm", + ) + + +HIGHLIGHT_COLORS: dict[str, tuple[int, int, int]] = { + "created": (0, 220, 0), + "modified": (0, 128, 255), + "deleted": (255, 0, 0), + "moved": (255, 165, 0), +} + +HIGHLIGHT_BORDER = 3 # pixels + + +def _draw_highlights( + draw: ImageDraw.ImageDraw, + rect_map: dict[str, tuple[int, int, int, int]], + highlights: dict[str, str], +) -> None: + """Draw coloured borders around tiles whose paths appear in *highlights*. + + *highlights* maps ``str(path) → event_type`` where event_type is one of + ``"created"``, ``"modified"``, ``"deleted"``, or ``"moved"``. + + For deleted files (not in *rect_map*), the parent directory is + highlighted instead. + """ + for path, event_type in highlights.items(): + if path not in rect_map: + continue + x, y, w, h = rect_map[path] + color = HIGHLIGHT_COLORS.get(event_type, (255, 255, 0)) + border = min(HIGHLIGHT_BORDER, w // 3, h // 3) + if border < 1: + continue + for i in range(border): + draw.rectangle( + [x + i, y + i, x + w - 1 - i, y + h - 1 - i], + outline=color, + ) + + +def _build_root_label( + name: str, + n_files: int, + n_dirs: int, + total_bytes: int, + depth: int, + logscale: float, + title_suffix: str | None, + draw: "ImageDraw.ImageDraw", + font: "ImageFont.FreeTypeFont", + max_w: int, +) -> str: + """Return the longest root-header label that fits within *max_w* pixels. + + Fields are dropped in order of decreasing priority until the text fits: + 1. Full label with raw bytes, logscale, and title_suffix + 2. Drop raw byte count + 3. Drop logscale indicator + 4. Drop title_suffix + 5. Drop depth + 6. Drop dir count + 7. Fallback plain truncation + """ + human = _human_bytes(total_bytes) + ls = f" logscale:{logscale:g}×" if logscale > 1 else "" + sf = f"[{title_suffix}] " if title_suffix else "" + + candidates = [ + f"{sf}{name} \u2014 {n_files:,} files, {n_dirs:,} dirs," + f" {human} ({total_bytes:,} bytes), depth: {depth}{ls}", + f"{sf}{name} \u2014 {n_files:,} files, {n_dirs:,} dirs, {human}, depth: {depth}{ls}", + f"{sf}{name} \u2014 {n_files:,} files, {n_dirs:,} dirs, {human}, depth: {depth}", + f"{sf}{name} \u2014 {n_files:,} files, {n_dirs:,} dirs, {human}", + f"{sf}{name} \u2014 {n_files:,} files, {human}", + ] + for candidate in candidates: + if _text_w(draw, candidate, font) <= max_w: + return candidate + return _truncate(candidates[-1], draw, font, max_w) + + +def create_treemap( + root_node: Node, + width_px: int, + height_px: int, + font_size: int = DEFAULT_FONT_SIZE, + colormap: str = DEFAULT_COLORMAP, + legend: int | None = None, + cushion: bool = True, + tree_depth: int | None = None, + highlights: dict[str, str] | None = None, + rect_map_out: dict[str, tuple[int, int, int, int]] | None = None, + title_suffix: str | None = None, + progress: float | None = None, + dark: bool = True, + logscale: float = 0.0, +) -> io.BytesIO: + """Render a nested squarified treemap and return it as a PNG in a BytesIO buffer. + + Args: + root_node: Root of the directory tree. + width_px: Output image width in pixels. + height_px: Output image height in pixels. + font_size: Directory label font size in pixels. + colormap: Matplotlib colormap name for file-extension colours. + legend: Max entries in the file-count legend, or None to disable. + highlights: Optional mapping of ``str(path) → event_type`` for tiles + that should receive a coloured border (created/modified/deleted). + rect_map_out: When provided, populated with ``str(path) → (x, y, w, h)`` + for every node drawn. Useful for highlighting tiles in a later pass. + + Returns: + BytesIO containing the rendered PNG, seeked to position 0. + """ + exts = collect_extensions(root_node) + color_map = assign_colors(exts, colormap) + + canvas_bg = (26, 26, 46) if dark else (255, 255, 255) + img = Image.new("RGB", (width_px, height_px), color=canvas_bg) + idraw = ImageDraw.Draw(img) + font = _font(font_size, bold=True) + + n_files, n_dirs = count_nodes(root_node) + total_bytes = root_node.original_size if root_node.original_size > 0 else root_node.size + depth = tree_depth if tree_depth is not None else max_depth(root_node) + root_label = _build_root_label( + root_node.name, + n_files, + n_dirs, + total_bytes, + depth, + logscale, + title_suffix, + idraw, + font, + width_px - 8, + ) + # Always collect tile positions — needed for batch cushion and/or highlights. + _tile_rects: dict[str, tuple[int, int, int, int]] = {} + _dir_rects: dict[str, tuple[int, int, int, int]] = {} + draw_node( + idraw, + root_node, + 0, + 0, + width_px, + height_px, + color_map, + font, + font_size, + False, # cushion deferred: applied in one batch pass below + img, + root_label=root_label, + rect_map=_tile_rects, + dir_rect_map=_dir_rects, + dark=dark, + ) + + # Batch cushion: directories first at half strength (broad, structural shading), + # then leaf file tiles at full strength (per-file detail). + if cushion and (_tile_rects or _dir_rects): + arr = np.array(img) + for tx, ty, tw, th in _dir_rects.values(): + _apply_cushion_inplace(arr, tx, ty, tw, th, scale=0.5) + for tx, ty, tw, th in _tile_rects.values(): + _apply_cushion_inplace(arr, tx, ty, tw, th) + img = Image.fromarray(arr) + idraw = ImageDraw.Draw(img) + + if rect_map_out is not None: + rect_map_out.update(_tile_rects) + rect_map_out.update(_dir_rects) + + if highlights: + _draw_highlights(idraw, {**_tile_rects, **_dir_rects}, highlights) + + if progress is not None: + clipped = max(0.0, min(1.0, progress)) + filled = round(clipped * width_px) + # Draw dark track across the full width first, then overlay the filled portion. + # Without the track the bar is invisible because the root-tile border is white. + idraw.rectangle([(0, 0), (width_px - 1, 1)], fill=(50, 50, 70)) + if filled > 0: + idraw.rectangle([(0, 0), (filled - 1, 1)], fill=(255, 255, 255)) + + if legend is not None: + overlay_font = _font(max(6, font_size - 2)) + corner = _best_corner(root_node, width_px, height_px) + ext_counts = _collect_ext_counts(root_node) + _draw_legend( + idraw, ext_counts, color_map, width_px, height_px, corner, overlay_font, legend, dark + ) + + pnginfo = PngImagePlugin.PngInfo() + for key, value in build_metadata().items(): + pnginfo.add_itxt(key, value) + + buf = io.BytesIO() + img.save(buf, format="PNG", pnginfo=pnginfo) + buf.seek(0) + return buf + + +# ── Streaming APNG writer ───────────────────────────────────────────────────── +# Works directly with PNG binary chunks so no PIL Image objects need to be held +# in memory beyond a single frame at a time. + +_PNG_SIG = b"\x89PNG\r\n\x1a\n" + + +def _apng_make_chunk(chunk_type: bytes, data: bytes) -> bytes: + crc = zlib.crc32(chunk_type + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", crc) + + +def _apng_iter_chunks(data: bytes) -> list[tuple[bytes, bytes]]: + """Return (chunk_type, chunk_data) pairs from raw PNG bytes.""" + pos = 8 # skip PNG signature + chunks = [] + while pos + 12 <= len(data): + (length,) = struct.unpack_from(">I", data, pos) + chunk_type = data[pos + 4 : pos + 8] + chunk_data = data[pos + 8 : pos + 8 + length] + pos += 12 + length + chunks.append((chunk_type, chunk_data)) + return chunks + + +def write_apng(output: Path, frame_bytes: list[bytes], durations_ms: list[int]) -> None: + """Write *frame_bytes* as an APNG file, processing one frame at a time. + + Avoids loading all frames as decoded PIL Images simultaneously — each frame + is processed as compressed PNG bytes and written directly as APNG chunks. + For a single frame, ``output`` is written as a plain PNG with no APNG chunks. + """ + n = len(frame_bytes) + if n == 1: + output.write_bytes(frame_bytes[0]) + return + + seq = 0 + with open(output, "wb") as f: + f.write(_PNG_SIG) + + # Collect IHDR and standard colour-space chunks from the first frame. + ihdr_data = b"" + pre_idat: list[tuple[bytes, bytes]] = [] + for chunk_type, chunk_data in _apng_iter_chunks(frame_bytes[0]): + if chunk_type == b"IHDR": + ihdr_data = chunk_data + pre_idat.append((chunk_type, chunk_data)) + elif chunk_type in {b"pHYs", b"sRGB", b"gAMA", b"cHRM", b"iCCP"}: + pre_idat.append((chunk_type, chunk_data)) + elif chunk_type == b"IDAT": + break + + for chunk_type, chunk_data in pre_idat: + f.write(_apng_make_chunk(chunk_type, chunk_data)) + + # acTL – animation control (num_frames, num_plays=0 → loop forever) + f.write(_apng_make_chunk(b"acTL", struct.pack(">II", n, 0))) + + width, height = struct.unpack_from(">II", ihdr_data) + + for i, (frame_data, duration_ms) in enumerate(zip(frame_bytes, durations_ms, strict=False)): + # fcTL – frame control + fctl = struct.pack( + ">IIIIIHHbb", + seq, + width, + height, + 0, + 0, + duration_ms, + 1000, # delay = duration_ms / 1000 s + 0, + 0, # dispose_op=NONE, blend_op=SOURCE + ) + f.write(_apng_make_chunk(b"fcTL", fctl)) + seq += 1 + + for chunk_type, chunk_data in _apng_iter_chunks(frame_data): + if chunk_type == b"IDAT": + if i == 0: + f.write(_apng_make_chunk(b"IDAT", chunk_data)) + else: + f.write(_apng_make_chunk(b"fdAT", struct.pack(">I", seq) + chunk_data)) + seq += 1 + + f.write(_apng_make_chunk(b"IEND", b"")) + + +def _frames_as_rgba(frame_bytes: list[bytes]) -> list[bytes]: + """Re-encode *frame_bytes* as RGBA PNGs (required before adding a transparent fade).""" + result = [] + for fb in frame_bytes: + img = Image.open(io.BytesIO(fb)).convert("RGBA") + buf = io.BytesIO() + img.save(buf, format="PNG") + result.append(buf.getvalue()) + return result + + +def make_fade_out_frames( + last_frame: bytes, + n_frames: int = 4, + duration_ms: int = 1000, + target_color: tuple[int, int, int] | tuple[int, int, int, int] = (0, 0, 0), +) -> tuple[list[bytes], list[int]]: + """Return *n_frames* PNG frames that fade *last_frame* toward *target_color*. + + When *target_color* is a 4-tuple whose alpha component is 0 the frames are + RGBA PNGs that fade to fully transparent. All other colours produce RGB + frames. Call :func:`_frames_as_rgba` on the existing animation frames + before appending transparent fade frames so that every frame in the APNG + shares the same colour type. + + Returns ``(frames, per_frame_durations_ms)`` where durations sum to + *duration_ms*. + """ + fade_transparent = len(target_color) == 4 and target_color[3] == 0 + src = Image.open(io.BytesIO(last_frame)).convert("RGBA") + + frames: list[bytes] = [] + for i in range(1, n_frames + 1): + ratio = i / n_frames # 0.25 … 1.0 + if fade_transparent: + faded = src.copy() + alpha_ch = faded.split()[3].point(lambda a, r=ratio: int(a * (1.0 - r))) + faded.putalpha(alpha_ch) + else: + overlay = Image.new("RGBA", src.size, (*target_color[:3], int(255 * ratio))) + faded = Image.alpha_composite(src, overlay).convert("RGB") + buf = io.BytesIO() + faded.save(buf, format="PNG") + frames.append(buf.getvalue()) + + frame_dur = max(1, duration_ms // n_frames) + durations = [frame_dur] * n_frames + # Absorb any rounding residual into the last frame. + durations[-1] = max(1, duration_ms - frame_dur * (n_frames - 1)) + return frames, durations + + +def write_mp4( + output: Path, + frame_bytes: list[bytes], + durations_ms: list[int], + crf: int = 23, + codec: str = "libx264", + metadata: dict[str, str] | None = None, +) -> None: + """Write *frame_bytes* as an MP4 video file using ffmpeg. + + Args: + output: Destination ``.mp4`` (or ``.mov``) path. + frame_bytes: Sequence of PNG-encoded frames. + durations_ms: Per-frame display duration in milliseconds. + crf: Constant Rate Factor controlling quality. Lower = better quality + and larger file. Typical range: 0 (lossless) – 51 (worst). + Default 23 is a good balance for flat-colour treemaps. + For ``libx265`` the perceptually equivalent default is 28. + codec: FFmpeg video codec. ``"libx264"`` (H.264, default) is the most + compatible; ``"libx265"`` (H.265/HEVC) gives ~40 % smaller files at + the same perceived quality. + metadata: Optional key/value pairs to embed as MP4 metadata atoms + (passed to ffmpeg via ``-metadata key=value``). + + Raises: + RuntimeError: If ffmpeg is not found on PATH or exits non-zero. + """ + import shutil + import subprocess + import tempfile + + if not shutil.which("ffmpeg"): + raise RuntimeError( + "ffmpeg not found on PATH. Install it to write MP4 files:\n" + " macOS: brew install ffmpeg\n" + " Linux: apt install ffmpeg / dnf install ffmpeg" + ) + + with tempfile.TemporaryDirectory(prefix="dirplot-mp4-") as tmpdir: + tmp = Path(tmpdir) + lines: list[str] = [] + for i, (png_bytes, dur_ms) in enumerate(zip(frame_bytes, durations_ms, strict=True)): + frame_path = tmp / f"frame{i:06d}.png" + frame_path.write_bytes(png_bytes) + lines.append(f"file '{frame_path}'\n") + lines.append(f"duration {dur_ms / 1000:.6f}\n") + # The concat demuxer ignores the duration of the last entry; repeat the + # last frame so ffmpeg has a file reference to close the stream cleanly. + if frame_bytes: + lines.append(f"file '{tmp / f'frame{len(frame_bytes) - 1:06d}.png'}'\n") + + concat_file = tmp / "concat.txt" + concat_file.write_text("".join(lines)) + + cmd = [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-c:v", + codec, + "-crf", + str(crf), + "-pix_fmt", + "yuv420p", + # libx264/libx265 require even dimensions + "-vf", + "scale=trunc(iw/2)*2:trunc(ih/2)*2", + ] + if metadata: + cmd += ["-movflags", "use_metadata_tags"] + for key, value in metadata.items(): + cmd += ["-metadata", f"{key}={value}"] + cmd.append(str(output)) + result = subprocess.run(cmd, capture_output=True) + if result.returncode != 0: + raise RuntimeError( + f"ffmpeg exited with code {result.returncode}:\n" + f"{result.stderr.decode(errors='replace')}" + ) diff --git a/src/dirplot/replay_scanner.py b/src/dirplot/replay_scanner.py new file mode 100644 index 0000000..709d5ba --- /dev/null +++ b/src/dirplot/replay_scanner.py @@ -0,0 +1,191 @@ +"""Replay a JSONL filesystem event log as an animated treemap.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any + +from dirplot.filters import matches_exclude + + +def _parse_timestamp(value: str | float) -> float: + """Return a Unix timestamp float from either an ISO 8601 string or a legacy float.""" + if isinstance(value, str): + return datetime.fromisoformat(value).timestamp() + return float(value) + + +def parse_events(path: Path) -> list[tuple[float, str, str, str]]: + """Parse JSONL events → [(timestamp, type, path, dest_path)], sorted by time.""" + events: list[tuple[float, str, str, str]] = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + obj = json.loads(line) + events.append( + ( + _parse_timestamp(obj["timestamp"]), + obj["type"], + obj["path"], + obj.get("dest_path", ""), + ) + ) + events.sort(key=lambda e: e[0]) + return events + + +def scan_to_flat(root: Path, exclude: frozenset[str] = frozenset()) -> dict[str, int]: + """Walk *root* and return ``{rel_path: size}`` with forward-slash separators.""" + files: dict[str, int] = {} + for dirpath, dirnames, filenames in os.walk(str(root)): + dirnames[:] = [ + d + for d in dirnames + if not matches_exclude( + str(Path(dirpath).relative_to(root) / d).replace(os.sep, "/"), exclude + ) + ] + for fname in filenames: + fpath = Path(dirpath) / fname + rel = str(fpath.relative_to(root)).replace(os.sep, "/") + if matches_exclude(rel, exclude): + continue + try: + size = max(1, fpath.stat().st_size) + except OSError: + size = 1 + rel = str(fpath.relative_to(root)).replace(os.sep, "/") + files[rel] = size + return files + + +def bucket_events( + events: list[tuple[float, str, str, str]], + bucket_size: float, +) -> list[tuple[float, list[tuple[float, str, str, str]]]]: + """Group *events* into non-overlapping time buckets of *bucket_size* seconds. + + Returns ``[(bucket_start_ts, [events...])]``. + """ + if not events: + return [] + buckets: list[tuple[float, list[tuple[float, str, str, str]]]] = [] + current_start = events[0][0] + current: list[tuple[float, str, str, str]] = [] + for ev in events: + if ev[0] >= current_start + bucket_size: + if current: + buckets.append((current_start, current)) + current_start = ev[0] + current = [] + current.append(ev) + if current: + buckets.append((current_start, current)) + return buckets + + +def apply_events( + files: dict[str, int], + root: Path, + events: list[tuple[float, str, str, str]], + exclude: frozenset[str], +) -> dict[str, str]: + """Apply *events* to *files* in-place. + + Returns highlights ``{abs_path_str: type}`` (absolute paths, matching + the keys used by ``rect_map`` in the renderer). + """ + root_str = str(root) + highlights: dict[str, str] = {} + for _ts, event_type, path_str, dest_str in events: + if not path_str.startswith(root_str): + continue + p = Path(path_str) + try: + rel = str(p.relative_to(root)).replace(os.sep, "/") + except ValueError: + continue + if matches_exclude(rel, exclude): + continue + + if event_type == "deleted": + files.pop(rel, None) + highlights[path_str] = "deleted" + elif event_type == "moved" and dest_str: + dest = Path(dest_str) + try: + dest_rel = str(dest.relative_to(root)).replace(os.sep, "/") + except ValueError: + dest_rel = None + old_size = files.pop(rel, None) + if dest_rel is not None and not matches_exclude(dest_rel, exclude): + files[dest_rel] = old_size if old_size is not None else 1 + highlights[dest_str] = "modified" + else: # created, modified + try: + size = max(1, p.stat().st_size) + except OSError: + size = 1 + files[rel] = size + highlights[path_str] = event_type + + return highlights + + +RectMap = dict[str, tuple[int, int, int, int]] + + +def _render_replay_frame_worker(args: tuple[Any, ...]) -> tuple[int, bytes, RectMap]: + """Top-level picklable worker for parallel replay frame rendering.""" + ( + root_str, + files, + highlights, + ts, + orig_i, + progress, + depth, + logscale, + width_px, + height_px, + font_size, + colormap, + cushion, + dark, + ) = args + + from datetime import datetime + from pathlib import Path + + from dirplot.git_scanner import build_node_tree + from dirplot.render_png import create_treemap + from dirplot.scanner import apply_log_sizes + + root = Path(root_str) + node = build_node_tree(root, files, depth) + if logscale > 1: + apply_log_sizes(node, logscale) + + rect_map: RectMap = {} + dt_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + buf = create_treemap( + node, + width_px, + height_px, + font_size, + colormap, + None, + cushion, + highlights=highlights or None, + rect_map_out=rect_map, + title_suffix=dt_str, + progress=progress, + dark=dark, + logscale=logscale, + ) + return (orig_i, buf.read(), rect_map) diff --git a/src/dirplot/s3.py b/src/dirplot/s3.py new file mode 100644 index 0000000..7253bb6 --- /dev/null +++ b/src/dirplot/s3.py @@ -0,0 +1,173 @@ +"""AWS S3 directory tree scanning via boto3 (optional dependency).""" + +from __future__ import annotations + +import sys +from pathlib import Path, PurePosixPath +from typing import Any + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + + +def _require_boto3() -> Any: + try: + import boto3 + + return boto3 + except ImportError: + raise ImportError( + "S3 support requires boto3. Install it with:\n pip install dirplot[s3]" + ) from None + + +def is_s3_path(path_str: str) -> bool: + """Return True if *path_str* is an S3 URI.""" + return path_str.startswith("s3://") + + +def parse_s3_path(path_str: str) -> tuple[str, str]: + """Parse an S3 URI into *(bucket, prefix)*. + + The prefix always ends with ``/`` when non-empty so that + ``list_objects_v2`` treats it as a directory boundary. + + Examples:: + + "s3://my-bucket" → ("my-bucket", "") + "s3://my-bucket/" → ("my-bucket", "") + "s3://my-bucket/path" → ("my-bucket", "path/") + "s3://my-bucket/path/" → ("my-bucket", "path/") + """ + without_scheme = path_str[len("s3://") :] + bucket, _, prefix = without_scheme.partition("/") + if prefix and not prefix.endswith("/"): + prefix += "/" + return bucket, prefix + + +def make_s3_client(profile: str | None = None, no_sign: bool = False) -> Any: + """Return a boto3 S3 client, optionally using a named AWS profile. + + Pass *no_sign=True* for anonymous access to public buckets + (equivalent to ``aws s3 --no-sign-request``). + """ + boto3 = _require_boto3() + from botocore import UNSIGNED + from botocore.config import Config + + session = boto3.Session(profile_name=profile) + config = Config(signature_version=UNSIGNED) if no_sign else None + return session.client("s3", config=config) + + +def build_tree_s3( + s3: Any, + bucket: str, + prefix: str = "", + exclude: frozenset[str] = frozenset(), + *, + depth: int | None = None, + _progress: list[int] | None = None, + _root_prefix: str | None = None, +) -> Node: + """Recursively build a :class:`~dirplot.scanner.Node` tree from an S3 prefix. + + Uses ``list_objects_v2`` with ``Delimiter='/'`` so that each call + returns one "directory level" — files in ``Contents`` and + subdirectories in ``CommonPrefixes`` — mirroring how + :func:`~dirplot.scanner.build_tree` works locally. + + Args: + s3: A boto3 S3 client. + bucket: S3 bucket name. + prefix: Key prefix to scan (must end with ``/`` or be empty). + exclude: Glob patterns to skip (names, relative paths, or ``**`` globs). + depth: Maximum recursion depth. ``None`` means unlimited. + ``depth=1`` lists direct children without recursing into subdirs. + _progress: Internal one-element counter for progress reporting. + _root_prefix: Original prefix for relative-path computation (internal). + """ + if _root_prefix is None: + _root_prefix = prefix + paginator = s3.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=bucket, Prefix=prefix, Delimiter="/") + + raw_files: list[dict[str, Any]] = [] + raw_dirs: list[dict[str, Any]] = [] + for page in pages: + for obj in page.get("Contents", []): + if obj["Key"] != prefix: # skip the directory marker itself + raw_files.append(obj) + raw_dirs.extend(page.get("CommonPrefixes", [])) + + children: list[Node] = [] + + for obj in sorted(raw_files, key=lambda o: o["Key"]): + name = PurePosixPath(obj["Key"]).name + if not name or name.startswith("."): + continue + rel = obj["Key"][len(_root_prefix) :] + if matches_exclude(rel, exclude): + continue + + if _progress is not None: + _progress[0] += 1 + if _progress[0] % 100 == 0: + print( + f"\r scanned {_progress[0]} entries…", + end="", + file=sys.stderr, + flush=True, + ) + + ext = PurePosixPath(name).suffix.lower() or NO_EXT + children.append( + Node( + name=name, + path=Path(obj["Key"]), + size=max(1, obj.get("Size", 0)), + is_dir=False, + extension=ext, + ) + ) + + for cp in sorted(raw_dirs, key=lambda p: p["Prefix"]): + subprefix = cp["Prefix"] + name = PurePosixPath(subprefix.rstrip("/")).name + if not name or name.startswith("."): + continue + rel = subprefix.rstrip("/")[len(_root_prefix) :] + if matches_exclude(rel, exclude): + continue + + if depth is not None and depth <= 1: + child: Node = Node( + name=name, + path=Path(subprefix), + size=1, + is_dir=True, + extension="", + ) + else: + child = build_tree_s3( + s3, + bucket, + subprefix, + exclude, + depth=None if depth is None else depth - 1, + _progress=_progress, + _root_prefix=_root_prefix, + ) + children.append(child) + + total = sum(c.size for c in children) or 1 + node_name = PurePosixPath(prefix.rstrip("/")).name or bucket + return Node( + name=node_name, + path=Path(prefix or bucket), + size=total, + is_dir=True, + extension="", + children=children, + ) diff --git a/src/dirplot/scanner.py b/src/dirplot/scanner.py index 2a8520c..73d4fbf 100644 --- a/src/dirplot/scanner.py +++ b/src/dirplot/scanner.py @@ -3,6 +3,36 @@ import math from dataclasses import dataclass, field from pathlib import Path +from typing import TypedDict + +from dirplot.filters import matches_exclude + +NO_EXT = "(no ext)" +BREADCRUMB_SEP = " / " + + +class _ExtEntry(TypedDict): + ext: str + count: int + size_bytes: int + + +class _FileEntry(TypedDict): + path: str + size_bytes: int + pct: float + + +class TreeMetricsDict(TypedDict): + files: int + dirs: int + empty_dirs: int + total_size_bytes: int + depth: int + scan_time_s: float + top_extensions: list[_ExtEntry] + largest_files: list[_FileEntry] + largest_dirs: list[_FileEntry] @dataclass @@ -15,14 +45,21 @@ class Node: is_dir: bool extension: str = "" children: list["Node"] = field(default_factory=list) + original_size: int = 0 # set by apply_log_sizes; 0 means size was never transformed -def build_tree(root: Path, exclude: frozenset[Path] = frozenset()) -> Node: +def build_tree( + root: Path, + exclude: frozenset[str] = frozenset(), + depth: int | None = None, +) -> Node: """Recursively build a Node tree from *root*. Args: root: Directory to scan. - exclude: Resolved absolute paths to skip entirely. + exclude: Glob patterns to skip (names, relative paths, or ``**`` globs). + depth: Maximum recursion depth. ``None`` means unlimited. + ``depth=1`` lists direct children without recursing into subdirs. Returns: Root node whose ``size`` is the total size of all descendants. @@ -35,18 +72,21 @@ def build_tree(root: Path, exclude: frozenset[Path] = frozenset()) -> Node: return Node(name=root.name, path=root, size=0, is_dir=True) for entry in sorted(entries, key=lambda e: e.name): - if entry.resolve() in exclude: + if matches_exclude(str(entry.relative_to(root)), exclude): continue if entry.is_symlink(): continue if entry.is_dir(): - child = build_tree(entry, exclude) + if depth is not None and depth <= 1: + child = Node(name=entry.name, path=entry, size=1, is_dir=True) + else: + child = build_tree(entry, exclude, None if depth is None else depth - 1) elif entry.is_file(): try: size = max(1, entry.stat().st_size) except OSError: size = 1 - ext = entry.suffix.lower() if entry.suffix else "(no ext)" + ext = entry.suffix.lower() if entry.suffix else NO_EXT child = Node(name=entry.name, path=entry, size=size, is_dir=False, extension=ext) else: continue @@ -56,14 +96,198 @@ def build_tree(root: Path, exclude: frozenset[Path] = frozenset()) -> Node: return Node(name=root.name, path=root, size=total, is_dir=True, children=children) -def apply_log_sizes(node: Node) -> None: - """Replace file sizes with their natural log in-place, then recompute directory totals.""" - if not node.is_dir: - node.size = max(1, round(math.log(max(1, node.size)))) +def prune_to_subtrees(node: Node, paths: set[str]) -> Node: + """Return *node* keeping only the subtrees at *paths* (relative to *node*). + + Paths may be multi-level (e.g. ``"src/dirplot/fonts"``). Intermediate + nodes are kept as synthetic wrappers containing only the requested chain. + If a path targets a node directly (e.g. ``"src"``), its full subtree is kept. + Unknown paths are silently ignored. The root's size is recalculated. + """ + # Group paths by their first component → remainder (empty string = keep whole subtree) + groups: dict[str, set[str]] = {} + for p in paths: + parts = Path(p).parts + if not parts: + continue + first = parts[0] + rest = str(Path(*parts[1:])) if len(parts) > 1 else "" + groups.setdefault(first, set()).add(rest) + + kept: list[Node] = [] + for child in node.children: + if child.name not in groups: + continue + sub_paths = groups[child.name] + if "" in sub_paths or not child.is_dir: + kept.append(child) + else: + kept.append(prune_to_subtrees(child, sub_paths)) + + return Node( + name=node.name, + path=node.path, + size=sum(c.size for c in kept), + is_dir=True, + children=kept, + ) + + +def build_tree_multi( + roots: list[Path], + exclude: frozenset[str] = frozenset(), + depth: int | None = None, +) -> Node: + """Scan each path in *roots* independently, then wrap them under their common parent. + + Intermediate directories between the common parent and each root are + represented as synthetic (empty-except-for-the-chain) nodes — they are + not scanned for other contents. + """ + import os + + resolved = [r.resolve() for r in roots] + + if len(resolved) == 1: + return build_tree(resolved[0], exclude, depth) + + common = Path(os.path.commonpath([str(r) for r in resolved])) + + # Scan each target independently (dirs recurse; files become leaf nodes) + def _scan_one(r: Path) -> Node: + if r.is_dir(): + return build_tree(r, exclude, depth) + try: + file_size = max(1, r.stat().st_size) + except OSError: + file_size = 1 + ext = r.suffix.lower() if r.suffix else NO_EXT + return Node(name=r.name, path=r, size=file_size, is_dir=False, extension=ext) + + scanned: list[tuple[Path, Node]] = [(r, _scan_one(r)) for r in resolved] + + def _combine(parent: Path, targets: list[tuple[Path, Node]]) -> Node: + """Build a synthetic Node for *parent* containing exactly the given *targets*.""" + groups: dict[Path, list[tuple[Path, Node]]] = {} + for tpath, tnode in targets: + direct = parent / tpath.relative_to(parent).parts[0] + groups.setdefault(direct, []).append((tpath, tnode)) + + children: list[Node] = [] + for child_path, child_targets in sorted(groups.items(), key=lambda kv: kv[0].name): + if len(child_targets) == 1 and child_targets[0][0] == child_path: + # Direct child IS the scanned target + children.append(child_targets[0][1]) + else: + # Need a synthetic intermediate node + children.append(_combine(child_path, child_targets)) + + total = sum(c.size for c in children) + return Node(name=parent.name, path=parent, size=total, is_dir=True, children=children) + + return _combine(common, scanned) + + +def apply_log_sizes(node: Node, logscale: float = 4.0) -> None: + """Replace file sizes with log-scaled values in-place, then recompute directory totals. + + The original byte count is preserved in ``node.original_size`` so that renderers + can display the real size rather than the log-transformed layout value. + + Args: + node: The root node of the tree to transform. + logscale: Controls the compression ratio. After transformation the ratio of + the largest to smallest file-size layout value equals *logscale*. Must + be greater than 1. Default is 4. + """ + # First pass: collect all leaf sizes to determine the transformation parameters. + leaf_sizes: list[int] = [] + + def _collect(n: Node) -> None: + if not n.is_dir: + leaf_sizes.append(max(1, n.size)) + else: + for c in n.children: + _collect(c) + + _collect(node) + + if not leaf_sizes: return + + min_s = min(leaf_sizes) + max_s = max(leaf_sizes) + log_range = math.log(max_s) - math.log(min_s) if max_s > min_s else 1.0 + # Scale factor: normalise log values to [1, logscale], then multiply by 1000 + # to keep layout values in a similar integer magnitude to the old implementation. + scale = 1000.0 * (logscale - 1) / log_range if log_range > 0 else 1.0 + + def _apply(n: Node) -> None: + if not n.is_dir: + n.original_size = n.size + log_val = math.log(max(1, n.size)) - math.log(min_s) + n.size = max(1, round(1000 + log_val * scale)) + return + n.original_size = n.size + for c in n.children: + _apply(c) + n.size = sum(c.size for c in n.children) + + _apply(node) + + +def count_nodes(node: Node) -> tuple[int, int]: + """Return *(n_files, n_dirs)* for the subtree rooted at *node*. + + *node* itself is not counted — only its descendants. + """ + files = 0 + dirs = 0 for child in node.children: - apply_log_sizes(child) - node.size = sum(c.size for c in node.children) + if child.is_dir: + dirs += 1 + cf, cd = count_nodes(child) + files += cf + dirs += cd + else: + files += 1 + return files, dirs + + +def _apply_breadcrumbs_recursive(node: Node) -> Node: + """Recursively collapse single-subdirectory chains (internal helper).""" + node.children = [_apply_breadcrumbs_recursive(c) for c in node.children] + dir_children = [c for c in node.children if c.is_dir] + file_children = [c for c in node.children if not c.is_dir] + if node.is_dir and len(dir_children) == 1 and len(file_children) == 0: + child = dir_children[0] + node.name = f"{node.name}{BREADCRUMB_SEP}{child.name}" + node.children = child.children + return node + + +def apply_breadcrumbs(node: Node) -> Node: + """Collapse single-subdirectory chains into one node with a combined name. + + A directory that has exactly one directory child and no file children is + merged with that child: the names are joined with `` / `` and the child's + children become this node's children. The process is bottom-up so chains + of any length accumulate naturally. + + The root node itself is never collapsed — only its descendants are. + """ + node.children = [_apply_breadcrumbs_recursive(c) for c in node.children] + return node + + +def max_depth(node: Node) -> int: + """Return the maximum depth of the tree rooted at *node*. + + A leaf node (no children) has depth 0. + """ + if not node.children: + return 0 + return 1 + max(max_depth(c) for c in node.children) def collect_extensions(node: Node) -> list[str]: @@ -74,3 +298,140 @@ def collect_extensions(node: Node) -> list[str]: for child in node.children: exts.extend(collect_extensions(child)) return exts + + +def _fmt_size(n: int) -> str: + """Format byte count as a human-readable string.""" + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 # type: ignore[assignment] + return f"{n:.1f} TB" + + +def _collect_files(node: Node) -> list[Node]: + """Return a flat list of all file nodes under *node*.""" + if not node.is_dir: + return [node] + result: list[Node] = [] + for child in node.children: + result.extend(_collect_files(child)) + return result + + +def _collect_dirs(node: Node) -> list[Node]: + """Return a flat list of all directory nodes under *node* (excluding root).""" + result: list[Node] = [] + for child in node.children: + if child.is_dir: + result.append(child) + result.extend(_collect_dirs(child)) + return result + + +def tree_metrics_dict( + root_node: Node, + t_scan: float, + top_n: int = 10, + sort_by: str = "count", +) -> TreeMetricsDict: + """Return a dict of metrics for the scanned tree. + + *sort_by* controls extension ordering: ``"count"`` (default) or ``"size"``. + """ + from collections import Counter, defaultdict + + n_files, n_dirs = count_nodes(root_node) + depth = max_depth(root_node) + all_files = _collect_files(root_node) + all_dirs = _collect_dirs(root_node) + empty_dirs = sum(1 for d in all_dirs if not d.children) + total = root_node.size or 1 # guard against zero-size trees + + # Extension stats: count + total bytes + ext_sizes: dict[str, int] = defaultdict(int) + ext_counts_raw: Counter[str] = Counter() + for f in all_files: + ext_counts_raw[f.extension] += 1 + ext_sizes[f.extension] += f.size + + if sort_by == "size": + sorted_exts = sorted(ext_sizes.items(), key=lambda kv: kv[1], reverse=True)[:top_n] + else: + sorted_exts = [(e, ext_sizes[e]) for e, _ in ext_counts_raw.most_common(top_n)] + + top_extensions: list[_ExtEntry] = [ + _ExtEntry( + ext=ext if ext else NO_EXT, + count=ext_counts_raw[ext], + size_bytes=ext_sizes[ext], + ) + for ext, _ in sorted_exts + ] + + largest_files = sorted(all_files, key=lambda n: n.size, reverse=True)[:top_n] + largest_dirs = sorted(all_dirs, key=lambda n: n.size, reverse=True)[:top_n] + + return { + "files": n_files, + "dirs": n_dirs, + "empty_dirs": empty_dirs, + "total_size_bytes": root_node.size, + "depth": depth, + "scan_time_s": round(t_scan, 3), + "top_extensions": top_extensions, + "largest_files": [ + { + "path": str(f.path), + "size_bytes": f.size, + "pct": round(100 * f.size / total, 1), + } + for f in largest_files + ], + "largest_dirs": [ + { + "path": str(d.path), + "size_bytes": d.size, + "pct": round(100 * d.size / total, 1), + } + for d in largest_dirs + ], + } + + +def tree_metrics( + root_node: Node, + t_scan: float, + top_n: int = 10, + sort_by: str = "count", +) -> str: + """Return a human-readable metrics string for the scanned tree. + + *sort_by* controls extension ordering: ``"count"`` (default) or ``"size"``. + """ + m = tree_metrics_dict(root_node, t_scan, top_n=top_n, sort_by=sort_by) + total = m["total_size_bytes"] or 1 + + lines: list[str] = [ + f" Files: {m['files']:,}", + f" Dirs: {m['dirs']:,} ({m['empty_dirs']:,} empty)", + f" Total size: {_fmt_size(total)}", + f" Depth: {m['depth']}", + f" Scan time: {m['scan_time_s']:.2f}s", + f" Top extensions ({len(m['top_extensions'])}) [by {sort_by}]:", + ] + for e in m["top_extensions"]: + label = e["ext"] + lines.append(f" {label:<20} {e['count']:>6,} {_fmt_size(e['size_bytes'])}") + + lines.append(" Largest files:") + for f in m["largest_files"]: + size_str = _fmt_size(f["size_bytes"]) + lines.append(f" {size_str:<10} {f['pct']:>5.1f}% {f['path']}") + + lines.append(" Largest dirs:") + for d in m["largest_dirs"]: + size_str = _fmt_size(d["size_bytes"]) + lines.append(f" {size_str:<10} {d['pct']:>5.1f}% {d['path']}") + + return "\n".join(lines) diff --git a/src/dirplot/ssh.py b/src/dirplot/ssh.py new file mode 100644 index 0000000..8f92055 --- /dev/null +++ b/src/dirplot/ssh.py @@ -0,0 +1,235 @@ +"""SSH remote directory scanning via paramiko (optional dependency).""" + +from __future__ import annotations + +import os +import re +import stat +import sys +import urllib.parse +from getpass import getpass, getuser +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any + +from dirplot.filters import matches_exclude +from dirplot.scanner import NO_EXT, Node + +if TYPE_CHECKING: + pass # paramiko types only used as strings below + + +def _require_paramiko() -> Any: + try: + import paramiko + + return paramiko + except ImportError: + raise ImportError( + "SSH support requires paramiko. Install it with:\n pip install dirplot[ssh]" + ) from None + + +def load_ssh_config(host: str) -> dict[str, Any]: + """Parse ~/.ssh/config and return resolved options for *host*.""" + try: + from paramiko import SSHConfig + except ImportError: + return {} + + config_path = os.path.expanduser("~/.ssh/config") + cfg = SSHConfig() + if os.path.exists(config_path): + with open(config_path) as f: + cfg.parse(f) + return cfg.lookup(host) # type: ignore[no-any-return] + + +def is_ssh_path(path_str: str) -> bool: + """Return True if *path_str* looks like an SSH URI.""" + if path_str.startswith("ssh://"): + return True + # SCP syntax is user@host:/path. Do not treat local paths containing a + # drive colon or git ref marker (for example C:\repo@HEAD) as SSH. + return re.match(r"^[^/\\:@]+@[^/\\:@]+:.+", path_str) is not None + + +def parse_ssh_path(path_str: str) -> tuple[str, str, str]: + """Parse an SSH path string into *(user, host, remote_path)*. + + Accepted formats:: + + ssh://user@host/path + user@host:/path + """ + if path_str.startswith("ssh://"): + parsed = urllib.parse.urlparse(path_str) + user = parsed.username or getuser() + host = parsed.hostname or "" + remote_path = parsed.path or "/" + return user, host, remote_path + + # SCP format: user@host:/path + userhost, remote_path = path_str.split(":", 1) + user, host = userhost.split("@", 1) + return user, host, remote_path + + +def connect( + host: str, + user: str, + *, + ssh_key: str | None = None, + ssh_password: str | None = None, + port: int | None = None, +) -> Any: + """Open and return a connected ``paramiko.SSHClient``. + + Resolution order for credentials: + + 1. *ssh_key* argument + 2. ``IdentityFile`` from ``~/.ssh/config`` + 3. ssh-agent (paramiko picks this up automatically) + 4. *ssh_password* argument + 5. Interactive password prompt as last resort + """ + paramiko = _require_paramiko() + + ssh_cfg = load_ssh_config(host) + resolved_host = str(ssh_cfg.get("hostname", host)) + resolved_user = user or str(ssh_cfg.get("user") or os.environ.get("USER") or "root") + resolved_port = port or int(ssh_cfg.get("port", 22)) + + identity_files: list[str] = ssh_cfg.get("identityfile", []) + key_file = ssh_key or (os.path.expanduser(identity_files[0]) if identity_files else None) + password = ssh_password + + client = paramiko.SSHClient() + client.load_system_host_keys() + client.set_missing_host_key_policy(paramiko.RejectPolicy()) + + try: + client.connect( + resolved_host, + port=resolved_port, + username=resolved_user, + key_filename=key_file, + password=password, + ) + except paramiko.AuthenticationException: + password = getpass(f"Password for {resolved_user}@{resolved_host}: ") + client.connect( + resolved_host, + port=resolved_port, + username=resolved_user, + password=password, + ) + except paramiko.SSHException as exc: + msg = str(exc) + if "not found in known_hosts" in msg or "Unknown server" in msg: + raise SystemExit( + f"Host '{resolved_host}' not in known_hosts.\n" + f"Run: ssh-keyscan {resolved_host} >> ~/.ssh/known_hosts" + ) from exc + raise + + return client + + +def build_tree_ssh( + sftp: Any, + remote_path: str, + exclude: frozenset[str] = frozenset(), + *, + depth: int | None = None, + _progress: list[int] | None = None, + _root: str | None = None, +) -> Node: + """Recursively build a :class:`~dirplot.scanner.Node` tree via SFTP. + + Args: + sftp: An open ``paramiko.SFTPClient``. + remote_path: Absolute path on the remote host. + exclude: Glob patterns to skip (names, relative paths, or ``**`` globs). + depth: Maximum recursion depth. ``None`` means unlimited. + ``depth=1`` lists direct children without recursing into subdirs. + _progress: Internal one-element counter for progress reporting. + _root: Original root path for relative-path computation (internal). + """ + if _root is None: + _root = remote_path + try: + attrs = sftp.listdir_attr(remote_path) + except PermissionError: + return Node( + name=PurePosixPath(remote_path).name or remote_path, + path=Path(remote_path), + size=1, + is_dir=True, + extension="", + children=[], + ) + except (OSError, EOFError) as exc: + raise OSError(f"SSH connection lost while scanning {remote_path}: {exc}") from exc + + children: list[Node] = [] + for attr in sorted(attrs, key=lambda a: a.filename): + full = remote_path.rstrip("/") + "/" + attr.filename + rel = full[len(_root.rstrip("/")) + 1 :] + + if matches_exclude(rel, exclude) or attr.filename.startswith("."): + continue + + mode: int | None = attr.st_mode + if mode is not None and stat.S_ISLNK(mode): + continue + + if _progress is not None: + _progress[0] += 1 + if _progress[0] % 100 == 0: + print( + f"\r scanned {_progress[0]} entries…", + end="", + file=sys.stderr, + flush=True, + ) + + if mode is not None and stat.S_ISDIR(mode): + if depth is not None and depth <= 1: + # Depth limit reached — include the dir node but don't recurse. + child = Node( + name=attr.filename, + path=Path(full), + size=1, + is_dir=True, + extension="", + ) + else: + child = build_tree_ssh( + sftp, + full, + exclude, + depth=None if depth is None else depth - 1, + _progress=_progress, + _root=_root, + ) + else: + ext = PurePosixPath(attr.filename).suffix.lower() or NO_EXT + child = Node( + name=attr.filename, + path=Path(full), + size=attr.st_size or 1, + is_dir=False, + extension=ext, + ) + + children.append(child) + + total = sum(c.size for c in children) or 1 + return Node( + name=PurePosixPath(remote_path).name or remote_path, + path=Path(remote_path), + size=total, + is_dir=True, + extension="", + children=children, + ) diff --git a/src/dirplot/svg_render.py b/src/dirplot/svg_render.py new file mode 100644 index 0000000..0301dd8 --- /dev/null +++ b/src/dirplot/svg_render.py @@ -0,0 +1,711 @@ +"""Treemap layout and SVG rendering using drawsvg.""" + +import html +import io +from collections import defaultdict + +import drawsvg +import squarify + +from dirplot.colors import RGBAColor, assign_colors +from dirplot.defaults import DEFAULT_COLORMAP, DEFAULT_FONT_SIZE, DEFAULT_LEGEND_MAX_ROWS +from dirplot.render_png import _human_bytes, build_metadata +from dirplot.scanner import BREADCRUMB_SEP, Node, collect_extensions, count_nodes, max_depth + +_CHAR_ASPECT = 0.6 # approximate width/height ratio for monospace font +_FONT_FAMILY = "JetBrains Mono, Consolas, monospace" + +SWATCH_PX = 8 +LEG_PAD = 3 + +# Van Wijk cushion: brightness ranges from ×0.80 (bottom-right) to ×1.20 (top-left). +# We approximate this with a diagonal overlay: white at opacity=0.20 blends into +# black at opacity=0.20, with a transparent midpoint so the center is unchanged. +_CUSHION_HIGHLIGHT = 0.20 # white overlay opacity at top-left (file tiles) +_CUSHION_SHADOW = 0.20 # black overlay opacity at bottom-right (file tiles) +_CUSHION_HIGHLIGHT_DIR = 0.10 # half-strength for directory-level shading +_CUSHION_SHADOW_DIR = 0.10 + +# --------------------------------------------------------------------------- +# Interactive effects: CSS + JS +# --------------------------------------------------------------------------- + +_HOVER_CSS = """\ +.tile { + cursor: pointer; + transition: filter 0.10s ease; +} +.tile:hover { + filter: brightness(1.22) drop-shadow(0 0 3px rgba(255,255,255,0.45)); +} +.dir-tile { + cursor: default; + transition: filter 0.10s ease; +} +.dir-tile:hover { + filter: brightness(1.18); +} +""" + +# JavaScript for the floating SVG tooltip. +# Reads data-* attributes set on .tile and .dir-tile elements. +_TOOLTIP_JS = """\ +(function () { + var TIP_W = 226, TIP_H = 58; + + function humanSize(b) { + b = +b; + if (b < 1024) return b + '\u202fB'; + if (b < 1048576) return (b / 1024).toFixed(1) + '\u202fKB'; + if (b < 1073741824) return (b / 1048576).toFixed(1) + '\u202fMB'; + return (b / 1073741824).toFixed(2) + '\u202fGB'; + } + + var tip, tipL0, tipL1, tipL2; + + function init() { + tip = document.getElementById('_dp_tip'); + tipL0 = document.getElementById('_dp_tip_l0'); + tipL1 = document.getElementById('_dp_tip_l1'); + tipL2 = document.getElementById('_dp_tip_l2'); + if (!tip) return; + + document.querySelectorAll('.tile, .dir-tile').forEach(function (el) { + el.addEventListener('mouseenter', onEnter); + el.addEventListener('mousemove', onMove); + el.addEventListener('mouseleave', onLeave); + }); + } + + function toSVGPt(evt) { + var svg = evt.currentTarget.ownerSVGElement || evt.currentTarget; + var pt = svg.createSVGPoint(); + pt.x = evt.clientX; pt.y = evt.clientY; + return pt.matrixTransform(svg.getScreenCTM().inverse()); + } + + function place(evt) { + var sp = toSVGPt(evt); + var svg = evt.currentTarget.ownerSVGElement || evt.currentTarget; + var vb = svg.viewBox.baseVal; + var tx = sp.x + 14, ty = sp.y + 14; + if (tx + TIP_W + 4 > vb.width) tx = sp.x - TIP_W - 6; + if (ty + TIP_H + 4 > vb.height) ty = sp.y - TIP_H - 6; + tip.setAttribute('transform', 'translate(' + tx + ',' + ty + ')'); + } + + function onEnter(evt) { + var d = evt.currentTarget.dataset; + var isDir = d.isDir === '1'; + tipL0.textContent = d.name + (isDir ? '/' : ''); + if (isDir) { + tipL1.textContent = humanSize(d.size) + ' total'; + tipL2.textContent = (+d.count) + ' item' + (+d.count !== 1 ? 's' : ''); + } else { + tipL1.textContent = humanSize(d.size); + tipL2.textContent = d.ext || '(no extension)'; + } + place(evt); + tip.setAttribute('visibility', 'visible'); + } + + function onMove(evt) { place(evt); } + function onLeave() { tip.setAttribute('visibility', 'hidden'); } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); +""" + + +def _append_tooltip_element(d: drawsvg.Drawing, font_size: int) -> None: + """Add a reusable floating SVG tooltip group to *d* (must be last element).""" + fs = max(9, font_size - 1) + tip = drawsvg.Group(id="_dp_tip", visibility="hidden", pointer_events="none") + tip.append( + drawsvg.Rectangle( + 0, + 0, + 226, + 58, + id="_dp_tip_bg", + fill="#141424", + rx=4, + stroke="#606070", + stroke_width=1, + fill_opacity=0.72, + ) + ) + tip.append( + drawsvg.Text( + "", + fs, + 10, + fs + 4, + id="_dp_tip_l0", + fill="#e8e8e8", + font_family=_FONT_FAMILY, + font_weight="bold", + ) + ) + tip.append( + drawsvg.Text( + "", + fs - 1, + 10, + fs + 4 + fs + 3, + id="_dp_tip_l1", + fill="#a0a8b0", + font_family=_FONT_FAMILY, + ) + ) + tip.append( + drawsvg.Text( + "", + fs - 1, + 10, + fs + 4 + (fs + 3) * 2, + id="_dp_tip_l2", + fill="#707880", + font_family=_FONT_FAMILY, + ) + ) + d.append(tip) + + +# --------------------------------------------------------------------------- +# Cushion gradient +# --------------------------------------------------------------------------- + + +def _make_cushion_gradient() -> drawsvg.LinearGradient: + """Return a diagonal gradient that approximates the van Wijk cushion shading. + + Uses ``gradientUnits="objectBoundingBox"`` so the same object scales to any + tile rectangle without redefinition. + """ + grad: drawsvg.LinearGradient = drawsvg.LinearGradient( + 0, 0, 1, 1, gradientUnits="objectBoundingBox" + ) + grad.add_stop(0.0, "white", _CUSHION_HIGHLIGHT) # top-left highlight + grad.add_stop(0.5, "black", 0.0) # transparent centre — no tint + grad.add_stop(1.0, "black", _CUSHION_SHADOW) # bottom-right shadow + return grad + + +def _make_cushion_gradient_dir() -> drawsvg.LinearGradient: + """Half-strength version of the cushion gradient for directory-level shading.""" + grad: drawsvg.LinearGradient = drawsvg.LinearGradient( + 0, 0, 1, 1, gradientUnits="objectBoundingBox" + ) + grad.add_stop(0.0, "white", _CUSHION_HIGHLIGHT_DIR) + grad.add_stop(0.5, "black", 0.0) + grad.add_stop(1.0, "black", _CUSHION_SHADOW_DIR) + return grad + + +# --------------------------------------------------------------------------- +# Colour helpers +# --------------------------------------------------------------------------- + + +def _hex(rgba: RGBAColor) -> str: + r, g, b = int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255) + return f"#{r:02x}{g:02x}{b:02x}" + + +def _label_color(rgba: RGBAColor) -> str: + """Return black or white text color based on the background luminance.""" + gray = 0.299 * rgba[0] * 255 + 0.587 * rgba[1] * 255 + 0.114 * rgba[2] * 255 + return "#000000" if gray >= 128 else "#ffffff" + + +# --------------------------------------------------------------------------- +# Text helpers +# --------------------------------------------------------------------------- + + +def _wrap(name: str, font_size: int, max_w: float) -> list[str]: + """Wrap *name* into lines fitting *max_w*, approximating monospace char width.""" + char_w = font_size * _CHAR_ASPECT + max_chars = max(1, int(max_w / char_w)) + if len(name) <= max_chars: + return [name] + delimiters = "._ -" + lines: list[str] = [] + remaining = name + while remaining: + if len(remaining) <= max_chars: + lines.append(remaining) + break + chunk = remaining[:max_chars] + split = max((chunk.rfind(d) for d in delimiters), default=-1) + if split > 0: + lines.append(remaining[:split]) + remaining = remaining[split:] + else: + lines.append(chunk) + remaining = remaining[max_chars:] + return lines + + +def _truncate(name: str, font_size: int, max_w: float) -> str: + """Truncate *name* with an ellipsis so it fits within *max_w*.""" + char_w = font_size * _CHAR_ASPECT + max_chars = max(1, int(max_w / char_w)) + if len(name) <= max_chars: + return name + return name[: max(0, max_chars - 1)] + "\u2026" + + +def _truncate_breadcrumb_svg(name: str, font_size: int, max_w: float) -> str: + """Truncate a breadcrumb label (`` / ``-separated parts) to fit *max_w*. + + Tries the full label first, then collapses middle segments to ``…``, and + finally falls back to ``_truncate`` for plain names or when even the + ``first / … / last`` form is too long. + """ + char_w = font_size * _CHAR_ASPECT + parts = name.split(BREADCRUMB_SEP) + if len(parts) <= 1: + return _truncate(name, font_size, max_w) + if len(name) * char_w <= max_w: + return name + candidate = parts[0] + BREADCRUMB_SEP + "\u2026" + BREADCRUMB_SEP + parts[-1] + if len(candidate) * char_w <= max_w: + return candidate + return _truncate(candidate, font_size, max_w) + + +# --------------------------------------------------------------------------- +# Recursive draw +# --------------------------------------------------------------------------- + + +def _draw_node_svg( + d: drawsvg.Drawing, + node: Node, + x: float, + y: float, + w: float, + h: float, + color_map: dict[str, RGBAColor], + font_size: int = 12, + cushion_grad: drawsvg.LinearGradient | None = None, + cushion_dir_grad: drawsvg.LinearGradient | None = None, + root_label: str | None = None, + dark: bool = True, +) -> None: + """Recursively draw *node* and its children into *d*.""" + if w < 2 or h < 2: + return + + if not node.is_dir: + rgba = color_map.get(node.extension, (0.5, 0.5, 0.5, 1.0)) + fill = _hex(rgba) + + display_size = node.original_size if node.original_size > 0 else node.size + border = (max(0, rgba[0] - 0.24), max(0, rgba[1] - 0.24), max(0, rgba[2] - 0.24)) + stroke = _hex((*border, 1.0)) if w >= 3 and h >= 3 else "none" + rect = drawsvg.Rectangle( + x, + y, + w, + h, + fill=fill, + stroke=stroke, + stroke_width="1", + class_="tile", + data_name=html.escape(node.name), + data_size=str(display_size), + data_ext=html.escape(node.extension), + data_is_dir="0", + ) + d.append(rect) + + if cushion_grad is not None and w >= 4 and h >= 4: + d.append(drawsvg.Rectangle(x, y, w, h, fill=cushion_grad, pointer_events="none")) + + if w > 20 and h > 10: + fsize = max(6, min(font_size + 2, int(w // 10))) + text_fill = _label_color(rgba) + lines = _wrap(node.name, fsize, w - 4) + + clip = drawsvg.ClipPath() + clip.append(drawsvg.Rectangle(x + 1, y + 1, w - 2, h - 2)) + d.append(clip) + + if len(lines) == 1: + d.append( + drawsvg.Text( + lines[0], + fsize, + x + w / 2, + y + h / 2, + text_anchor="middle", + dominant_baseline="middle", + fill=text_fill, + font_family=_FONT_FAMILY, + clip_path=clip, + pointer_events="none", + ) + ) + else: + line_h = fsize * 1.2 + total_h = len(lines) * line_h + ty = y + h / 2 - total_h / 2 + fsize * 0.85 + t = drawsvg.Text( + "", + fsize, + x + w / 2, + ty, + text_anchor="middle", + fill=text_fill, + font_family=_FONT_FAMILY, + clip_path=clip, + pointer_events="none", + ) + for i, line in enumerate(lines): + t.append(drawsvg.TSpan(line, x=x + w / 2, dy="0" if i == 0 else "1.2em")) + d.append(t) + return + + # Directory: 1-px outer border + 1-px inner border (colours swap in light mode) + outer_stroke = "white" if dark else "black" + inner_stroke = "black" if dark else "white" + d.append(drawsvg.Rectangle(x, y, w, h, fill="none", stroke=outer_stroke, stroke_width=1)) + if w >= 4 and h >= 4: + d.append( + drawsvg.Rectangle( + x + 1, y + 1, w - 2, h - 2, fill="none", stroke=inner_stroke, stroke_width=1 + ) + ) + + header_h = font_size + 4 + if h > 2 + header_h: + # Header background — also acts as the hover + tooltip target for the dir + n_children = len(node.children) + display_size = node.original_size if node.original_size > 0 else node.size + hdr_bg = "#1c1c2e" if dark else "#e8e8f0" + hdr_text = "#e0e0e0" if dark else "#1c1c2e" + hdr = drawsvg.Rectangle( + x + 2, + y + 2, + w - 4, + header_h, + fill=hdr_bg, + class_="dir-tile", + data_name=html.escape(node.name), + data_size=str(display_size), + data_is_dir="1", + data_count=str(n_children), + data_ext="", + ) + d.append(hdr) + + label = _truncate_breadcrumb_svg( + root_label if root_label is not None else node.name, font_size, w - 8 + ) + hclip = drawsvg.ClipPath() + hclip.append(drawsvg.Rectangle(x + 2, y + 2, w - 4, header_h)) + d.append(hclip) + d.append( + drawsvg.Text( + label, + font_size, + x + w / 2, + y + 2 + header_h / 2, + text_anchor="middle", + dominant_baseline="middle", + fill=hdr_text, + font_family=_FONT_FAMILY, + font_weight="bold", + clip_path=hclip, + pointer_events="none", + ) + ) + + ix = x + 2 + iy = y + 2 + header_h + iw = w - 3 + ih = h - 3 - header_h + + if iw < 2 or ih < 2: + return + + positive_children = [c for c in node.children if c.size > 0] + if not positive_children: + return + + sizes = [c.size for c in positive_children] + normed = squarify.normalize_sizes(sizes, iw, ih) + rects = squarify.squarify(normed, ix, iy, iw, ih) + + # Background provides 1-px separator between adjacent children + sep_fill = "black" if dark else "white" + d.append(drawsvg.Rectangle(ix, iy, iw, ih, fill=sep_fill)) + + for rect, child in zip(rects, positive_children, strict=False): + rx = round(rect["x"]) + ry = round(rect["y"]) + rw = round(rect["x"] + rect["dx"]) - rx - 1 + rh = round(rect["y"] + rect["dy"]) - ry - 1 + _draw_node_svg( + d, + child, + rx, + ry, + rw, + rh, + color_map, + font_size, + cushion_grad, + cushion_dir_grad, + dark=dark, + ) + + # Directory-level cushion overlay (half strength) drawn after children so it + # sits on top as a transparent wash, giving large directories structural shading. + if cushion_dir_grad is not None and w >= 4 and h >= 4: + d.append(drawsvg.Rectangle(x, y, w, h, fill=cushion_dir_grad, pointer_events="none")) + + +# --------------------------------------------------------------------------- +# Legend +# --------------------------------------------------------------------------- + + +def _draw_legend_svg( + d: drawsvg.Drawing, + ext_counts: dict[str, int], + color_map: dict[str, RGBAColor], + width_px: int, + height_px: int, + font_size: int, + corner: str, + max_rows: int = DEFAULT_LEGEND_MAX_ROWS, + dark: bool = True, +) -> None: + margin = 4 + char_w = font_size * _CHAR_ASPECT + row_h = max(SWATCH_PX, font_size) + LEG_PAD + rows_that_fit = max(1, (height_px - 2 * margin - LEG_PAD * 2 - row_h) // row_h) + limit = min(max_rows, rows_that_fit) + + ranked = sorted(ext_counts, key=lambda e: (-ext_counts[e], e)) + top = ranked[:limit] + n_more = len(ranked) - limit + if not top: + return + + more_label = f"(+{n_more})" if n_more > 0 else "" + + label_w = max(len(ext) * char_w for ext in top) + if more_label: + label_w = max(label_w, len(more_label) * char_w) + count_w = max(len(str(ext_counts[e])) * char_w for e in top) + box_w = SWATCH_PX + LEG_PAD + label_w + LEG_PAD + count_w + LEG_PAD * 2 + n_rows = len(top) + (1 if more_label else 0) + box_h = n_rows * row_h + LEG_PAD * 2 + + bx = (width_px - box_w - margin) if "right" in corner else margin + by = (height_px - box_h - margin) if "lower" in corner else margin + + leg_bg = "#141424" if dark else "#f0f0f0" + leg_border = "#505050" if dark else "#a0a0a0" + leg_ext_text = "#dcdcdc" if dark else "#242424" + leg_count_text = "#a0a0a0" if dark else "#606060" + leg_swatch_outline = "white" if dark else "black" + d.append(drawsvg.Rectangle(bx, by, box_w, box_h, fill=leg_bg)) + d.append( + drawsvg.Rectangle(bx, by, box_w, box_h, fill="none", stroke=leg_border, stroke_width=1) + ) + + for ri, ext in enumerate(top): + rgba = color_map.get(ext, (0.5, 0.5, 0.5, 1.0)) + fill = _hex(rgba) + ex = bx + LEG_PAD + row_mid = by + LEG_PAD + ri * row_h + (row_h - LEG_PAD) / 2 + sy = row_mid - SWATCH_PX / 2 + d.append( + drawsvg.Rectangle( + ex, sy, SWATCH_PX, SWATCH_PX, fill=fill, stroke=leg_swatch_outline, stroke_width=1 + ) + ) + d.append( + drawsvg.Text( + ext, + font_size, + ex + SWATCH_PX + LEG_PAD, + row_mid, + text_anchor="start", + dominant_baseline="middle", + fill=leg_ext_text, + font_family=_FONT_FAMILY, + ) + ) + d.append( + drawsvg.Text( + str(ext_counts[ext]), + font_size, + bx + box_w - LEG_PAD, + row_mid, + text_anchor="end", + dominant_baseline="middle", + fill=leg_count_text, + font_family=_FONT_FAMILY, + ) + ) + + if more_label: + row_mid = by + LEG_PAD + len(top) * row_h + (row_h - LEG_PAD) / 2 + d.append( + drawsvg.Text( + more_label, + font_size, + bx + LEG_PAD + SWATCH_PX + LEG_PAD, + row_mid, + text_anchor="start", + dominant_baseline="middle", + fill="#787878", + font_family=_FONT_FAMILY, + ) + ) + + +def _collect_ext_counts(node: Node) -> dict[str, int]: + counts: dict[str, int] = defaultdict(int) + + def _walk(n: Node) -> None: + if not n.is_dir: + counts[n.extension] += 1 + for c in n.children: + _walk(c) + + _walk(node) + return dict(counts) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def create_treemap_svg( + root_node: Node, + width_px: int, + height_px: int, + font_size: int = DEFAULT_FONT_SIZE, + colormap: str = DEFAULT_COLORMAP, + legend: int | None = None, + cushion: bool = True, + tree_depth: int | None = None, + dark: bool = True, +) -> io.BytesIO: + """Render a nested squarified treemap and return it as SVG in a BytesIO buffer. + + The output is an interactive SVG: + + * **Native tooltips** — ```` on every tile; shown by the browser on hover. + * **CSS hover highlight** — tiles brighten and gain a soft glow on mouse-over. + * **Floating tooltip panel** — JS-driven panel that tracks the cursor and shows + name, size (human-readable), and file type / item count. + + Cushion shading is approximated via a diagonal SVG gradient overlay that + matches the van Wijk quadratic surface lighting used in the PNG renderer + (×1.20 highlight at top-left, ×0.80 shadow at bottom-right). + + Args: + root_node: Root of the directory tree. + width_px: Output image width in pixels. + height_px: Output image height in pixels. + font_size: Directory label font size in pixels. + colormap: Matplotlib colormap name for file-extension colours. + legend: Whether to draw an extension colour legend. + cushion: Apply van Wijk-style cushion shading via a gradient overlay. + + Returns: + BytesIO containing the rendered SVG, seeked to position 0. + """ + exts = collect_extensions(root_node) + color_map = assign_colors(exts, colormap) + + d: drawsvg.Drawing = drawsvg.Drawing(width_px, height_px) + + # 0. Metadata + meta_fields = "".join( + f" <dirplot:{k}>{html.escape(v)}</dirplot:{k}>\n" for k, v in build_metadata().items() + ) + d.append( + drawsvg.Raw( + "<metadata>\n" + ' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"' + ' xmlns:dirplot="https://github.com/deeplook/dirplot#">\n' + " <rdf:Description>\n" + f"{meta_fields}" + " </rdf:Description>\n" + " </rdf:RDF>\n" + "</metadata>\n" + ) + ) + + # 1. CSS hover effects + d.append_css(_HOVER_CSS) + + # 2. Background + canvas_bg = "#1a1a2e" if dark else "#ffffff" + d.append(drawsvg.Rectangle(0, 0, width_px, height_px, fill=canvas_bg)) + + # 3. Optional cushion gradients (defined once each, reused by all tiles) + cushion_grad: drawsvg.LinearGradient | None = None + cushion_dir_grad: drawsvg.LinearGradient | None = None + if cushion: + cushion_grad = _make_cushion_gradient() + d.append(cushion_grad) + cushion_dir_grad = _make_cushion_gradient_dir() + d.append(cushion_dir_grad) + + # 4. Treemap tiles + n_files, n_dirs = count_nodes(root_node) + total_bytes = root_node.original_size if root_node.original_size > 0 else root_node.size + depth = tree_depth if tree_depth is not None else max_depth(root_node) + root_label = ( + f"{root_node.name} \u2014 {n_files:,} files, {n_dirs:,} dirs," + f" {_human_bytes(total_bytes)} ({total_bytes:,} bytes), depth: {depth}" + ) + _draw_node_svg( + d, + root_node, + 0, + 0, + width_px, + height_px, + color_map, + font_size, + cushion_grad, + cushion_dir_grad, + root_label=root_label, + dark=dark, + ) + + # 5. Optional legend + if legend is not None: + overlay_font = max(6, font_size - 2) + ext_counts = _collect_ext_counts(root_node) + _draw_legend_svg( + d, ext_counts, color_map, width_px, height_px, overlay_font, "lower-right", legend, dark + ) + + # 6. Floating tooltip element — must be last so it renders above all tiles + _append_tooltip_element(d, font_size) + + # 8. Tooltip JavaScript + d.append_javascript(_TOOLTIP_JS) + + svg_content = d.as_svg() + buf = io.BytesIO(svg_content.encode("utf-8")) + buf.seek(0) + return buf diff --git a/src/dirplot/terminal.py b/src/dirplot/terminal.py index 0c49c37..1eca853 100644 --- a/src/dirplot/terminal.py +++ b/src/dirplot/terminal.py @@ -1,28 +1,55 @@ """Terminal size detection.""" -import fcntl import os import struct import sys -import termios -def get_terminal_pixel_size() -> tuple[int, int, int]: - """Return *(width_px, height_px, row_height_px)* of the current terminal. +def get_terminal_size() -> tuple[int, int, int, int]: + """Return *(cols, rows, width_px, height_px)* of the current terminal. Falls back to a character-cell estimate when the ioctl is unavailable (e.g. when stdout is not a tty). """ try: + import fcntl + import termios + buf = b"\x00" * 8 result = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, buf) - rows, _cols, width_px, height_px = struct.unpack("HHHH", result) - if width_px > 0 and height_px > 0 and rows > 0: - return width_px, height_px, height_px // rows + rows, cols, width_px, height_px = struct.unpack("HHHH", result) + if cols > 0 and rows > 0: + if width_px == 0 or height_px == 0: + width_px = cols * 8 + height_px = rows * 16 + return cols, rows, width_px, height_px except Exception: # noqa: BLE001 pass try: size = os.get_terminal_size() - return size.columns * 8, size.lines * 16, 16 + return size.columns, size.lines, size.columns * 8, size.lines * 16 except Exception: # noqa: BLE001 - return 1280, 720, 16 + pass + try: + cols = int(os.environ["COLUMNS"]) + rows = int(os.environ["LINES"]) + return cols, rows, cols * 8, rows * 16 + except (KeyError, ValueError): + pass + return 160, 45, 1280, 720 + + +def get_terminal_pixel_size() -> tuple[int, int, int]: + """Return *(width_px, height_px, row_height_px)* of the current terminal. + + Falls back to a character-cell estimate when the ioctl is unavailable + (e.g. when stdout is not a tty). + """ + cols, rows, width_px, height_px = get_terminal_size() + return width_px, height_px, height_px // rows if rows > 0 else 16 + + +def default_canvas_size() -> tuple[int, int]: + """Return (width_px, height_px) for the default terminal canvas size.""" + w, h, row_px = get_terminal_pixel_size() + return w + 1, h - 3 * row_px diff --git a/src/dirplot/watch.py b/src/dirplot/watch.py new file mode 100644 index 0000000..de7c390 --- /dev/null +++ b/src/dirplot/watch.py @@ -0,0 +1,211 @@ +"""Filesystem watcher that regenerates a treemap on every file change.""" + +import json +import sys +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + from watchdog.events import FileSystemEvent, FileSystemEventHandler + from watchdog.observers import Observer +except ImportError: + Observer = None # type: ignore[assignment] + +from dirplot.render_png import create_treemap +from dirplot.scanner import apply_log_sizes, build_tree_multi +from dirplot.svg_render import create_treemap_svg + + +class TreemapEventHandler(FileSystemEventHandler): + def __init__( + self, + roots: list[Path], + output: Path | None = None, + *, + exclude: frozenset[str] = frozenset(), + width_px: int, + height_px: int, + font_size: int, + colormap: str, + cushion: bool, + logscale: float = 0.0, + debounce: float = 0.0, + event_log: Path | None = None, + depth: int | None = None, + dark: bool = True, + ) -> None: + super().__init__() + self.roots = roots + self.output = output + self.exclude = exclude + self.depth = depth + self.width_px = width_px + self.height_px = height_px + self.font_size = font_size + self.colormap = colormap + self.cushion = cushion + self.dark = dark + self.use_svg = output is not None and output.suffix.lower() == ".svg" + self.logscale = logscale + self.debounce = debounce + self.event_log = event_log + self._events: list[dict[str, Any]] = [] + self._timer: threading.Timer | None = None + self._render_thread: threading.Thread | None = None + self._lock = threading.Lock() + # Pending file-change highlights: path → event_type, consumed each frame. + self._pending_highlights: dict[str, str] = {} + # Rect map from the most recent frame. + self._prev_rect_map: dict[str, tuple[int, int, int, int]] = {} + + def _regenerate(self) -> None: + # Consume pending highlights for this frame. + with self._lock: + all_highlights = dict(self._pending_highlights) if self._pending_highlights else {} + self._pending_highlights.clear() + + current_highlights = {p: v for p, v in all_highlights.items() if v != "deleted"} or None + + try: + node = build_tree_multi(self.roots, self.exclude, self.depth) + if self.logscale > 1: + apply_log_sizes(node, self.logscale) + rect_map: dict[str, tuple[int, int, int, int]] = {} + if self.use_svg and self.output is not None: + buf = create_treemap_svg( + node, + self.width_px, + self.height_px, + self.font_size, + self.colormap, + None, + self.cushion, + dark=self.dark, + ) + self.output.write_bytes(buf.read()) + else: + buf = create_treemap( + node, + self.width_px, + self.height_px, + self.font_size, + self.colormap, + None, + self.cushion, + highlights=current_highlights, + rect_map_out=rect_map, + dark=self.dark, + logscale=self.logscale, + ) + if self.output is not None: + self.output.write_bytes(buf.read()) + print(f"Updated {self.output}", file=sys.stderr) + self._prev_rect_map = rect_map + except Exception as exc: # noqa: BLE001 + print(f"Error regenerating treemap: {exc}", file=sys.stderr) + + def _record_event(self, verb: str, event: FileSystemEvent) -> None: + src = event.src_path + dest = getattr(event, "dest_path", None) + src_s = src.decode() if isinstance(src, bytes) else src + dest_s = dest.decode() if isinstance(dest, bytes) else dest + with self._lock: + self._events.append( + { + "timestamp": datetime.now(timezone.utc).isoformat(), + "type": verb, + "path": src_s, + "dest_path": dest_s, + } + ) + + def _schedule_regenerate(self) -> None: + if self.debounce <= 0: + self._regenerate() + return + with self._lock: + if self._timer is not None: + self._timer.cancel() + self._timer = threading.Timer(self.debounce, self._on_timer_fire) + self._timer.start() + + def _on_timer_fire(self) -> None: + with self._lock: + self._timer = None + self._render_thread = threading.current_thread() + self._regenerate() + with self._lock: + self._render_thread = None + + def flush(self) -> None: + """Fire any pending debounced regeneration.""" + with self._lock: + if self._timer is not None: + self._timer.cancel() + self._timer = None + pending = True + else: + pending = False + render_thread = self._render_thread + if pending: + self._regenerate() + elif render_thread is not None: + render_thread.join() + if self.event_log is not None and self._events: + with self._lock: + snapshot = list(self._events) + lines = [json.dumps(e, ensure_ascii=False) for e in snapshot] + self.event_log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + def _log_event(self, verb: str, event: FileSystemEvent) -> None: + src = event.src_path + dest = getattr(event, "dest_path", None) + src_s = src.decode() if isinstance(src, bytes) else src + dest_s = dest.decode() if isinstance(dest, bytes) else dest + msg = f"{verb}: {src_s}" if not dest_s else f"{verb}: {src_s} → {dest_s}" + print(msg, file=sys.stderr) + + def _track_highlight(self, verb: str, event: FileSystemEvent) -> None: + src = event.src_path + src_s = src.decode() if isinstance(src, bytes) else src + with self._lock: + if verb == "moved": + self._pending_highlights[src_s] = "deleted" + dest = getattr(event, "dest_path", None) + dest_s = dest.decode() if isinstance(dest, bytes) else dest + if dest_s: + self._pending_highlights[dest_s] = "created" + elif verb == "modified" and src_s in self._pending_highlights: + pass # don't overwrite "created" with "modified" + else: + self._pending_highlights[src_s] = verb + + def on_created(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._log_event("created", event) + self._record_event("created", event) + self._track_highlight("created", event) + self._schedule_regenerate() + + def on_deleted(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._log_event("deleted", event) + self._record_event("deleted", event) + self._track_highlight("deleted", event) + self._schedule_regenerate() + + def on_modified(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._log_event("modified", event) + self._record_event("modified", event) + self._track_highlight("modified", event) + self._schedule_regenerate() + + def on_moved(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._log_event("moved", event) + self._record_event("moved", event) + self._track_highlight("moved", event) + self._schedule_regenerate() diff --git a/tests/conftest.py b/tests/conftest.py index 06533de..008ae0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,236 @@ """Shared pytest fixtures.""" +from __future__ import annotations + +import io +import shutil +import subprocess +import tarfile +import tempfile +import zipfile from pathlib import Path import pytest +# --------------------------------------------------------------------------- +# Tree used by sample_tree and sample_archives — keep in sync. +# --------------------------------------------------------------------------- +_SAMPLE_FILES: list[tuple[str, bytes]] = [ + ("README.md", b"x" * 50), + ("docs/guide.md", b"x" * 80), + ("src/app.py", b"x" * 100), + ("src/util.py", b"x" * 200), + (".hidden", b"x" * 10), +] + + +def _zip_bytes() -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in _SAMPLE_FILES: + zf.writestr(name, data) + return buf.getvalue() + + +def _tar_bytes(mode: str) -> bytes: + buf = io.BytesIO() + with tarfile.open(mode=mode, fileobj=buf) as tf: + for name, data in _SAMPLE_FILES: + info = tarfile.TarInfo(name=name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +@pytest.fixture(scope="session") +def sample_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path]: + """Session-scoped fixture: one archive per supported format → {ext: Path}. + + Mirrors the logic in scripts/make_fixtures.py so tests always work in CI + without running the script first. + """ + base = tmp_path_factory.mktemp("fixtures") + archives: dict[str, Path] = {} + + zip_data = _zip_bytes() + + # ZIP and synonyms + for ext in ( + ".zip", + ".jar", + ".war", + ".ear", + ".whl", + ".apk", + ".epub", + ".xpi", + ".nupkg", + ".vsix", + ".ipa", + ".aab", + ): + p = base / f"sample{ext}" + p.write_bytes(zip_data) + archives[ext] = p + + # tar variants + for ext, mode in [ + (".tar", "w:"), + (".tar.gz", "w:gz"), + (".tgz", "w:gz"), + (".tar.bz2", "w:bz2"), + (".tbz2", "w:bz2"), + (".tar.xz", "w:xz"), + (".txz", "w:xz"), + ]: + p = base / f"sample{ext}" + p.write_bytes(_tar_bytes(mode)) + archives[ext] = p + + # 7z + import py7zr + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in _SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + p = base / "sample.7z" + with py7zr.SevenZipFile(p, "w") as sz: + for name, _ in _SAMPLE_FILES: + sz.write(tmp_path / name, name) + archives[".7z"] = p + + # RAR (only if the rar CLI is available) + rar_cli = shutil.which("rar") + if rar_cli: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in _SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + p = base / "sample.rar" + subprocess.run( + [rar_cli, "a", "-r", str(p), "."], cwd=tmp_path, check=True, capture_output=True + ) + archives[".rar"] = p + + # .tar.zst / .tzst (only if bsdtar with zstd support is available) + bsdtar_cli = shutil.which("bsdtar") + if bsdtar_cli: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in _SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + names = [name for name, _ in _SAMPLE_FILES] + for ext in (".tar.zst", ".tzst"): + p = base / f"sample{ext}" + result = subprocess.run( + [bsdtar_cli, "-cf", str(p), "--zstd"] + names, + cwd=str(tmp_path), + capture_output=True, + ) + if result.returncode == 0: + archives[ext] = p + + # .a — Unix ar archive; `ar` is always available (binutils / Xcode CLT) + ar_cli = shutil.which("ar") + if ar_cli: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + members = [] + for name, data in _SAMPLE_FILES: + flat = name.replace("/", "_") + (tmp_path / flat).write_bytes(data) + members.append(flat) + p = base / "sample.a" + subprocess.run( + [ar_cli, "rcs", str(p)] + members, + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + archives[".a"] = p + + return archives + + +ENCRYPTED_PASSWORD = "secret" + + +@pytest.fixture(scope="session") +def encrypted_archives(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Path]: + """Session-scoped fixture: one password-protected archive per supported family. + + Password for all archives: ``ENCRYPTED_PASSWORD`` (``"secret"``). + + - ``.7z`` — py7zr encrypts both headers and data; listing requires password. + - ``.zip`` — standard ZIP encrypts only file data, not the central directory; + our reader only touches metadata, so the archive is readable + even without a password. + - ``.rar`` — created with ``-hp`` (header encryption); listing requires password. + Skipped if the ``rar`` CLI is not installed. + """ + base = tmp_path_factory.mktemp("encrypted") + archives: dict[str, Path] = {} + + # --- 7z (py7zr always available) --- + import py7zr + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in _SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + p = base / "encrypted.7z" + with py7zr.SevenZipFile(p, "w", password=ENCRYPTED_PASSWORD) as sz: + for name, _ in _SAMPLE_FILES: + sz.write(tmp_path / name, name) + archives[".7z"] = p + + # --- ZIP (requires zip CLI; standard encryption, data-only) --- + zip_cli = shutil.which("zip") + if zip_cli: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in _SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + p = base / "encrypted.zip" + subprocess.run( + [zip_cli, "-r", "-P", ENCRYPTED_PASSWORD, str(p), "."], + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + archives[".zip"] = p + + # --- RAR with header encryption (requires rar CLI) --- + rar_cli = shutil.which("rar") + if rar_cli: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for name, data in _SAMPLE_FILES: + f = tmp_path / name + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + p = base / "encrypted.rar" + subprocess.run( + [rar_cli, "a", "-r", f"-hp{ENCRYPTED_PASSWORD}", str(p), "."], + cwd=str(tmp_path), + check=True, + capture_output=True, + ) + archives[".rar"] = p + + return archives + @pytest.fixture() def sample_tree(tmp_path: Path) -> Path: diff --git a/tests/fixtures/events.jsonl b/tests/fixtures/events.jsonl new file mode 100644 index 0000000..707ceae --- /dev/null +++ b/tests/fixtures/events.jsonl @@ -0,0 +1,14 @@ +{"timestamp": 1000.0, "type": "created", "path": "src/main.py"} +{"timestamp": 1001.5, "type": "created", "path": "src/utils.py"} +{"timestamp": 1003.0, "type": "created", "path": "tests/test_main.py"} +{"timestamp": 1060.0, "type": "modified", "path": "src/main.py"} +{"timestamp": 1062.0, "type": "modified", "path": "src/utils.py"} +{"timestamp": 1120.0, "type": "created", "path": "src/models.py"} +{"timestamp": 1180.0, "type": "moved", "path": "src/utils.py", "dest_path": "src/helpers.py"} +{"timestamp": 1240.0, "type": "modified", "path": "src/main.py"} +{"timestamp": 1241.0, "type": "modified", "path": "src/helpers.py"} +{"timestamp": 1300.0, "type": "deleted", "path": "src/models.py"} +{"timestamp": 1360.0, "type": "created", "path": "src/models/__init__.py"} +{"timestamp": 1361.0, "type": "created", "path": "src/models/user.py"} +{"timestamp": 1420.0, "type": "modified", "path": "tests/test_main.py"} +{"timestamp": 1480.0, "type": "created", "path": "tests/test_models.py"} diff --git a/tests/fixtures/sample.7z b/tests/fixtures/sample.7z new file mode 100644 index 0000000..00ce67a Binary files /dev/null and b/tests/fixtures/sample.7z differ diff --git a/tests/fixtures/sample.a b/tests/fixtures/sample.a new file mode 100644 index 0000000..6c066b0 Binary files /dev/null and b/tests/fixtures/sample.a differ diff --git a/tests/fixtures/sample.aab b/tests/fixtures/sample.aab new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.aab differ diff --git a/tests/fixtures/sample.apk b/tests/fixtures/sample.apk new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.apk differ diff --git a/tests/fixtures/sample.cpio b/tests/fixtures/sample.cpio new file mode 100644 index 0000000..35eb082 Binary files /dev/null and b/tests/fixtures/sample.cpio differ diff --git a/tests/fixtures/sample.ear b/tests/fixtures/sample.ear new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.ear differ diff --git a/tests/fixtures/sample.epub b/tests/fixtures/sample.epub new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.epub differ diff --git a/tests/fixtures/sample.ipa b/tests/fixtures/sample.ipa new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.ipa differ diff --git a/tests/fixtures/sample.iso b/tests/fixtures/sample.iso new file mode 100644 index 0000000..625ef06 Binary files /dev/null and b/tests/fixtures/sample.iso differ diff --git a/tests/fixtures/sample.jar b/tests/fixtures/sample.jar new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.jar differ diff --git a/tests/fixtures/sample.nupkg b/tests/fixtures/sample.nupkg new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.nupkg differ diff --git a/tests/fixtures/sample.rar b/tests/fixtures/sample.rar new file mode 100644 index 0000000..316e660 Binary files /dev/null and b/tests/fixtures/sample.rar differ diff --git a/tests/fixtures/sample.tar b/tests/fixtures/sample.tar new file mode 100644 index 0000000..0ee093b Binary files /dev/null and b/tests/fixtures/sample.tar differ diff --git a/tests/fixtures/sample.tar.bz2 b/tests/fixtures/sample.tar.bz2 new file mode 100644 index 0000000..d85b51d Binary files /dev/null and b/tests/fixtures/sample.tar.bz2 differ diff --git a/tests/fixtures/sample.tar.gz b/tests/fixtures/sample.tar.gz new file mode 100644 index 0000000..a5fa3da Binary files /dev/null and b/tests/fixtures/sample.tar.gz differ diff --git a/tests/fixtures/sample.tar.xz b/tests/fixtures/sample.tar.xz new file mode 100644 index 0000000..ea4017a Binary files /dev/null and b/tests/fixtures/sample.tar.xz differ diff --git a/tests/fixtures/sample.tar.zst b/tests/fixtures/sample.tar.zst new file mode 100644 index 0000000..6b978e7 Binary files /dev/null and b/tests/fixtures/sample.tar.zst differ diff --git a/tests/fixtures/sample.tbz2 b/tests/fixtures/sample.tbz2 new file mode 100644 index 0000000..d85b51d Binary files /dev/null and b/tests/fixtures/sample.tbz2 differ diff --git a/tests/fixtures/sample.tgz b/tests/fixtures/sample.tgz new file mode 100644 index 0000000..a5fa3da Binary files /dev/null and b/tests/fixtures/sample.tgz differ diff --git a/tests/fixtures/sample.txz b/tests/fixtures/sample.txz new file mode 100644 index 0000000..ea4017a Binary files /dev/null and b/tests/fixtures/sample.txz differ diff --git a/tests/fixtures/sample.tzst b/tests/fixtures/sample.tzst new file mode 100644 index 0000000..4388a40 Binary files /dev/null and b/tests/fixtures/sample.tzst differ diff --git a/tests/fixtures/sample.vsix b/tests/fixtures/sample.vsix new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.vsix differ diff --git a/tests/fixtures/sample.war b/tests/fixtures/sample.war new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.war differ diff --git a/tests/fixtures/sample.whl b/tests/fixtures/sample.whl new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.whl differ diff --git a/tests/fixtures/sample.xar b/tests/fixtures/sample.xar new file mode 100644 index 0000000..71afaa4 Binary files /dev/null and b/tests/fixtures/sample.xar differ diff --git a/tests/fixtures/sample.xpi b/tests/fixtures/sample.xpi new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.xpi differ diff --git a/tests/fixtures/sample.zip b/tests/fixtures/sample.zip new file mode 100644 index 0000000..f2dd17b Binary files /dev/null and b/tests/fixtures/sample.zip differ diff --git a/tests/test_archives.py b/tests/test_archives.py new file mode 100644 index 0000000..29e6edb --- /dev/null +++ b/tests/test_archives.py @@ -0,0 +1,590 @@ +"""Tests for archive scanning (zip, tar.gz, 7z, rar).""" + +from __future__ import annotations + +import io +import shutil +import subprocess +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from dirplot.archives import PasswordRequired, build_tree_archive, is_archive_path +from tests.conftest import ENCRYPTED_PASSWORD + +# --------------------------------------------------------------------------- +# is_archive_path +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name", + [ + "foo.zip", + "foo.tar", + "foo.tar.gz", + "foo.tgz", + "foo.tar.bz2", + "foo.tbz2", + "foo.tar.xz", + "foo.txz", + "foo.7z", + "foo.rar", + "foo.jar", + "foo.war", + "foo.ear", + "foo.whl", + "foo.apk", + "foo.epub", + "foo.xpi", + "FOO.ZIP", # case-insensitive + # more ZIP aliases + "foo.nupkg", + "foo.vsix", + "foo.ipa", + "foo.aab", + # tar.zst + "foo.tar.zst", + "foo.tzst", + # libarchive-handled formats + "foo.dmg", + "foo.pkg", + "foo.img", + "foo.iso", + "foo.xar", + "foo.cpio", + "foo.rpm", + "foo.cab", + "foo.lha", + "foo.lzh", + "foo.a", + "foo.ar", + ], +) +def test_is_archive_path_true(name: str) -> None: + assert is_archive_path(name) + assert is_archive_path(f"/some/dir/{name}") + + +@pytest.mark.parametrize("name", ["foo.py", "foo.txt", "foo", "foo.tar.bad", ""]) +def test_is_archive_path_false(name: str) -> None: + assert not is_archive_path(name) + + +# --------------------------------------------------------------------------- +# Helpers to build in-memory archives +# --------------------------------------------------------------------------- + + +def _make_zip(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: + """Write a zip to tmp_path/test.zip and return the path.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + for name, data in members: + zf.writestr(name, data) + dest = tmp_path / "test.zip" + dest.write_bytes(buf.getvalue()) + return dest + + +def _make_tar_gz(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: + """Write a tar.gz to tmp_path/test.tar.gz and return the path.""" + buf = io.BytesIO() + with tarfile.open(mode="w:gz", fileobj=buf) as tf: + for name, data in members: + info = tarfile.TarInfo(name=name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + dest = tmp_path / "test.tar.gz" + dest.write_bytes(buf.getvalue()) + return dest + + +# --------------------------------------------------------------------------- +# zip tests +# --------------------------------------------------------------------------- + + +def test_zip_flat(tmp_path: Path) -> None: + archive = _make_zip(tmp_path, [("a.txt", b"hello"), ("b.py", b"world!")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert names == {"a.txt", "b.py"} + assert node.size > 0 + + +def test_zip_nested(tmp_path: Path) -> None: + archive = _make_zip( + tmp_path, + [ + ("src/foo.py", b"x" * 100), + ("src/bar.py", b"y" * 200), + ("README.md", b"z" * 50), + ], + ) + node = build_tree_archive(archive) + child_names = {c.name for c in node.children} + assert "src" in child_names + assert "README.md" in child_names + src_node = next(c for c in node.children if c.name == "src") + assert {c.name for c in src_node.children} == {"foo.py", "bar.py"} + + +def test_zip_depth_limit(tmp_path: Path) -> None: + archive = _make_zip(tmp_path, [("a/b/c.txt", b"deep")]) + node = build_tree_archive(archive, depth=1) + assert len(node.children) == 1 + assert node.children[0].is_dir + # depth=1 means children of root dir are present but not recursed into + assert node.children[0].children == [] + + +def test_zip_excludes(tmp_path: Path) -> None: + archive = _make_zip(tmp_path, [("keep.txt", b"a"), ("skip.txt", b"b")]) + node = build_tree_archive(archive, exclude=frozenset({"skip.txt"})) + names = {c.name for c in node.children} + assert "keep.txt" in names + assert "skip.txt" not in names + + +def test_zip_dotfile_skipped(tmp_path: Path) -> None: + archive = _make_zip(tmp_path, [(".hidden", b"secret"), ("visible.txt", b"ok")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert ".hidden" not in names + assert "visible.txt" in names + + +def test_zip_zero_size_becomes_one(tmp_path: Path) -> None: + archive = _make_zip(tmp_path, [("empty.txt", b"")]) + node = build_tree_archive(archive) + assert node.children[0].size == 1 + + +def test_zip_root_name(tmp_path: Path) -> None: + archive = _make_zip(tmp_path, [("a.txt", b"hi")]) + node = build_tree_archive(archive) + assert node.name == "test" # stem of test.zip + + +# --------------------------------------------------------------------------- +# tar.gz tests +# --------------------------------------------------------------------------- + + +def test_tar_gz_flat(tmp_path: Path) -> None: + archive = _make_tar_gz(tmp_path, [("a.txt", b"hello"), ("b.py", b"world!")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert names == {"a.txt", "b.py"} + + +def test_tar_gz_nested(tmp_path: Path) -> None: + archive = _make_tar_gz( + tmp_path, + [("src/foo.py", b"x" * 100), ("README.md", b"z" * 50)], + ) + node = build_tree_archive(archive) + child_names = {c.name for c in node.children} + assert "src" in child_names + assert "README.md" in child_names + + +def test_tar_gz_depth_limit(tmp_path: Path) -> None: + archive = _make_tar_gz(tmp_path, [("a/b/c.txt", b"deep")]) + node = build_tree_archive(archive, depth=1) + assert len(node.children) == 1 + assert node.children[0].is_dir + assert node.children[0].children == [] + + +def test_tar_gz_excludes(tmp_path: Path) -> None: + archive = _make_tar_gz(tmp_path, [("keep.txt", b"a"), ("skip.txt", b"b")]) + node = build_tree_archive(archive, exclude=frozenset({"skip.txt"})) + names = {c.name for c in node.children} + assert "keep.txt" in names + assert "skip.txt" not in names + + +def test_tar_gz_dotfile_skipped(tmp_path: Path) -> None: + archive = _make_tar_gz(tmp_path, [(".hidden", b"secret"), ("visible.txt", b"ok")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert ".hidden" not in names + assert "visible.txt" in names + + +def test_tar_gz_root_name(tmp_path: Path) -> None: + archive = _make_tar_gz(tmp_path, [("a.txt", b"hi")]) + node = build_tree_archive(archive) + assert node.name == "test" # double-stem of test.tar.gz + + +# --------------------------------------------------------------------------- +# 7z tests (skipped if py7zr not installed) +# --------------------------------------------------------------------------- + + +def test_7z_basic(tmp_path: Path) -> None: + import py7zr + + archive = tmp_path / "test.7z" + content = tmp_path / "sample.txt" + content.write_bytes(b"hello 7z") + with py7zr.SevenZipFile(archive, mode="w") as sz: + sz.write(content, "sample.txt") + + node = build_tree_archive(archive) + assert node.name == "test" + names = {c.name for c in node.children} + assert "sample.txt" in names + + +def test_7z_dotfile_skipped(tmp_path: Path) -> None: + import py7zr + + archive = tmp_path / "test.7z" + hidden = tmp_path / ".hidden" + visible = tmp_path / "visible.txt" + hidden.write_bytes(b"secret") + visible.write_bytes(b"ok") + with py7zr.SevenZipFile(archive, mode="w") as sz: + sz.write(hidden, ".hidden") + sz.write(visible, "visible.txt") + + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert ".hidden" not in names + assert "visible.txt" in names + + +# --------------------------------------------------------------------------- +# RAR tests (skipped if the `rar` CLI is not installed) +# --------------------------------------------------------------------------- + +rar_cli = shutil.which("rar") +skip_no_rar = pytest.mark.skipif(rar_cli is None, reason="rar CLI not installed") + + +def _make_rar(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: + """Write a rar to tmp_path/test.rar using the rar CLI and return the path.""" + assert rar_cli is not None + src = tmp_path / "_src" + src.mkdir() + for name, data in members: + dest = src / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + archive = tmp_path / "test.rar" + subprocess.run( + [rar_cli, "a", "-r", str(archive), "."], cwd=src, check=True, capture_output=True + ) + return archive + + +@skip_no_rar +def test_rar_flat(tmp_path: Path) -> None: + archive = _make_rar(tmp_path, [("a.txt", b"hello"), ("b.py", b"world")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert "a.txt" in names + assert "b.py" in names + + +@skip_no_rar +def test_rar_nested(tmp_path: Path) -> None: + archive = _make_rar(tmp_path, [("src/foo.py", b"x" * 100), ("README.md", b"z" * 50)]) + node = build_tree_archive(archive) + child_names = {c.name for c in node.children} + assert "src" in child_names + assert "README.md" in child_names + + +@skip_no_rar +def test_rar_dotfile_skipped(tmp_path: Path) -> None: + archive = _make_rar(tmp_path, [(".hidden", b"secret"), ("visible.txt", b"ok")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert ".hidden" not in names + assert "visible.txt" in names + + +@skip_no_rar +def test_rar_root_name(tmp_path: Path) -> None: + archive = _make_rar(tmp_path, [("a.txt", b"hi")]) + node = build_tree_archive(archive) + assert node.name == "test" + + +# --------------------------------------------------------------------------- +# Fixture-based smoke tests: every supported format via sample_archives +# --------------------------------------------------------------------------- + +# All extensions produced by make_fixtures.py / the sample_archives fixture. +# RAR is excluded here because it requires the rar CLI; tested separately below. +_ALL_EXTENSIONS = [ + ".zip", + ".jar", + ".war", + ".ear", + ".whl", + ".apk", + ".epub", + ".xpi", + ".nupkg", + ".vsix", + ".ipa", + ".aab", + ".tar", + ".tar.gz", + ".tgz", + ".tar.bz2", + ".tbz2", + ".tar.xz", + ".txz", + ".7z", +] + + +@pytest.mark.parametrize("ext", _ALL_EXTENSIONS) +def test_fixture_tree_structure(ext: str, sample_archives: dict[str, Path]) -> None: + """Every supported format yields the expected top-level names and sizes.""" + node = build_tree_archive(sample_archives[ext]) + top = {c.name for c in node.children} + # .hidden must be absent; README.md, docs/, src/ must be present + assert ".hidden" not in top + assert {"README.md", "docs", "src"} <= top + # src/ should contain app.py and util.py + src = next(c for c in node.children if c.name == "src") + assert {c.name for c in src.children} == {"app.py", "util.py"} + # root size = 50+80+100+200 = 430 bytes + assert node.size == 430 + + +def test_fixture_rar_tree_structure(sample_archives: dict[str, Path]) -> None: + """RAR fixture yields the same structure (skipped if rar CLI absent).""" + if ".rar" not in sample_archives: + pytest.skip("rar CLI not installed") + node = build_tree_archive(sample_archives[".rar"]) + top = {c.name for c in node.children} + assert ".hidden" not in top + assert {"README.md", "docs", "src"} <= top + + +# --------------------------------------------------------------------------- +# Cross-format consistency: all archives must produce identical trees +# --------------------------------------------------------------------------- + +from dirplot.scanner import Node as _Node # noqa: E402 + + +def _tree_repr(node: _Node) -> tuple[str, int, tuple[object, ...]]: + """Recursively convert a Node into a comparable (name, size, children) tuple.""" + return (node.name, node.size, tuple(sorted(_tree_repr(c) for c in node.children))) + + +def test_all_formats_same_content(sample_archives: dict[str, Path]) -> None: + """Every archive in sample_archives must produce an identical node tree. + + `.a` / `.ar` archives are intentionally excluded: the `ar` format is flat + (no directory hierarchy), so the tree structure necessarily differs. + """ + reprs = { + ext: _tree_repr(build_tree_archive(path)) + for ext, path in sample_archives.items() + if ext not in (".a", ".ar") + } + reference_ext, reference = next(iter(reprs.items())) + for ext, rep in reprs.items(): + assert rep == reference, ( + f"Archive {ext!r} differs from {reference_ext!r}:\n" + f" {ext}: size={rep[1]}, top-level={[c[0] for c in rep[2]]}\n" + f" {reference_ext}: size={reference[1]}, top-level={[c[0] for c in reference[2]]}" + ) + + +# --------------------------------------------------------------------------- +# libarchive tests (.cpio, .iso) – skipped if bsdtar CLI is not available +# --------------------------------------------------------------------------- + +bsdtar_cli = shutil.which("bsdtar") +skip_no_bsdtar = pytest.mark.skipif(bsdtar_cli is None, reason="bsdtar CLI not installed") + + +def _make_cpio(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: + """Create a cpio archive at tmp_path/test.cpio using bsdtar and return the path.""" + assert bsdtar_cli is not None + src = tmp_path / "_src_cpio" + src.mkdir() + for name, data in members: + dest = src / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + archive = tmp_path / "test.cpio" + subprocess.run( + [bsdtar_cli, "-cf", str(archive), "--format", "cpio"] + [name for name, _ in members], + cwd=str(src), + check=True, + capture_output=True, + ) + return archive + + +def _make_iso(tmp_path: Path, members: list[tuple[str, bytes]]) -> Path: + """Create an ISO 9660 archive at tmp_path/test.iso using bsdtar and return the path.""" + assert bsdtar_cli is not None + src = tmp_path / "_src_iso" + src.mkdir() + for name, data in members: + dest = src / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + archive = tmp_path / "test.iso" + subprocess.run( + [bsdtar_cli, "-cf", str(archive), "--format", "iso9660", "-C", str(src), "."], + check=True, + capture_output=True, + ) + return archive + + +@skip_no_bsdtar +def test_cpio_flat(tmp_path: Path) -> None: + archive = _make_cpio(tmp_path, [("a.txt", b"hello"), ("b.py", b"world!")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert "a.txt" in names + assert "b.py" in names + + +@skip_no_bsdtar +def test_cpio_nested(tmp_path: Path) -> None: + archive = _make_cpio(tmp_path, [("src/foo.py", b"x" * 100), ("README.md", b"z" * 50)]) + node = build_tree_archive(archive) + child_names = {c.name for c in node.children} + assert "src" in child_names + assert "README.md" in child_names + + +@skip_no_bsdtar +def test_cpio_root_name(tmp_path: Path) -> None: + archive = _make_cpio(tmp_path, [("a.txt", b"hi")]) + node = build_tree_archive(archive) + assert node.name == "test" + + +@skip_no_bsdtar +def test_iso_flat(tmp_path: Path) -> None: + archive = _make_iso(tmp_path, [("a.txt", b"hello"), ("b.py", b"world!")]) + node = build_tree_archive(archive) + names = {c.name for c in node.children} + assert "a.txt" in names + assert "b.py" in names + + +@skip_no_bsdtar +def test_iso_nested(tmp_path: Path) -> None: + archive = _make_iso(tmp_path, [("src/foo.py", b"x" * 100), ("README.md", b"z" * 50)]) + node = build_tree_archive(archive) + child_names = {c.name for c in node.children} + assert "src" in child_names + assert "README.md" in child_names + + +@skip_no_bsdtar +def test_iso_root_name(tmp_path: Path) -> None: + archive = _make_iso(tmp_path, [("a.txt", b"hi")]) + node = build_tree_archive(archive) + assert node.name == "test" + + +def test_libarchive_missing_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure a clear ImportError is raised when libarchive-c is not installed.""" + import builtins + + real_import = builtins.__import__ + + def mock_import(name: str, *args: object, **kwargs: object) -> object: + if name == "libarchive": + raise ImportError("No module named 'libarchive'") + return real_import(name, *args, **kwargs) + + # Create a dummy .cpio so _archive_type returns "libarchive" + dummy = tmp_path / "dummy.cpio" + dummy.write_bytes(b"") + + monkeypatch.setattr(builtins, "__import__", mock_import) + with pytest.raises(ImportError, match="libarchive-c is required"): + build_tree_archive(dummy) + + +# --------------------------------------------------------------------------- +# Encrypted archive tests +# --------------------------------------------------------------------------- + + +def test_7z_encrypted_metadata_readable_without_password( + encrypted_archives: dict[str, Path], +) -> None: + """py7zr does not encrypt archive headers by default — only file content is + encrypted. Our reader calls ``sz.list()`` only (never extracts), so the + file listing is accessible without a password. + """ + node = build_tree_archive(encrypted_archives[".7z"]) + assert {c.name for c in node.children} >= {"README.md", "docs", "src"} + + +def test_7z_encrypted_with_password(encrypted_archives: dict[str, Path]) -> None: + node = build_tree_archive(encrypted_archives[".7z"], password=ENCRYPTED_PASSWORD) + assert {c.name for c in node.children} >= {"README.md", "docs", "src"} + + +def test_zip_encrypted_metadata_readable_without_password( + encrypted_archives: dict[str, Path], +) -> None: + """Standard ZIP encryption covers only file data, not the central directory. + + Our reader only touches metadata (names + uncompressed sizes), so an + encrypted ZIP is fully usable even without a password. + """ + if ".zip" not in encrypted_archives: + pytest.skip("encrypted zip fixture unavailable (zip CLI not found)") + node = build_tree_archive(encrypted_archives[".zip"]) + assert {c.name for c in node.children} >= {"README.md", "docs", "src"} + + +def test_zip_encrypted_with_password(encrypted_archives: dict[str, Path]) -> None: + if ".zip" not in encrypted_archives: + pytest.skip("encrypted zip fixture unavailable (zip CLI not found)") + node = build_tree_archive(encrypted_archives[".zip"], password=ENCRYPTED_PASSWORD) + assert {c.name for c in node.children} >= {"README.md", "docs", "src"} + + +def test_rar_encrypted_no_password_hides_entries( + encrypted_archives: dict[str, Path], +) -> None: + """RAR with header encryption (-hp) and no password: rarfile opens the + archive but returns an empty listing — the tree root has no children. + No exception is raised; the archive appears empty. + """ + if ".rar" not in encrypted_archives: + pytest.skip("encrypted rar fixture unavailable (rar CLI not found)") + node = build_tree_archive(encrypted_archives[".rar"]) + assert node.children == [] + + +def test_rar_encrypted_correct_password(encrypted_archives: dict[str, Path]) -> None: + if ".rar" not in encrypted_archives: + pytest.skip("encrypted rar fixture unavailable (rar CLI not found)") + node = build_tree_archive(encrypted_archives[".rar"], password=ENCRYPTED_PASSWORD) + assert {c.name for c in node.children} >= {"README.md", "docs", "src"} + + +def test_rar_encrypted_wrong_password_raises(encrypted_archives: dict[str, Path]) -> None: + """A wrong password on a header-encrypted RAR raises ``PasswordRequired``.""" + if ".rar" not in encrypted_archives: + pytest.skip("encrypted rar fixture unavailable (rar CLI not found)") + with pytest.raises(PasswordRequired): + build_tree_archive(encrypted_archives[".rar"], password="wrong") diff --git a/tests/test_cli.py b/tests/test_cli.py index cd1937a..c1e8230 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ """Tests for the Typer CLI entry point.""" +import re from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch +import pytest from typer.testing import CliRunner from dirplot.main import app @@ -10,44 +12,114 @@ runner = CliRunner() +def test_version_flag() -> None: + from dirplot import __version__ + + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.output + + def test_cli_invalid_path() -> None: - result = runner.invoke(app, ["/nonexistent/__dirplot_test__", "--no-show"]) + result = runner.invoke(app, ["map", "/nonexistent/__dirplot_test__", "--no-show"]) assert result.exit_code == 1 assert "does not exist" in result.output -def test_cli_not_a_directory(tmp_path: Path) -> None: +def test_cli_single_file(tmp_path: Path) -> None: f = tmp_path / "file.txt" f.write_text("hello") - result = runner.invoke(app, [str(f), "--no-show"]) - assert result.exit_code == 1 - assert "Not a directory" in result.output + result = runner.invoke(app, ["map", str(f), "--no-show"]) + assert result.exit_code == 0 + + +def test_cli_multiple_files(tmp_path: Path) -> None: + f1 = tmp_path / "a.txt" + f2 = tmp_path / "b.txt" + f1.write_bytes(b"x" * 100) + f2.write_bytes(b"x" * 200) + result = runner.invoke(app, ["map", str(f1), str(f2), "--no-show"]) + assert result.exit_code == 0 def test_cli_bad_colormap(sample_tree: Path) -> None: - result = runner.invoke(app, [str(sample_tree), "--no-show", "--colormap", "not_a_cmap"]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--colormap", "not_a_cmap"]) assert result.exit_code == 1 assert "Unknown colormap" in result.output def test_cli_runs_successfully(sample_tree: Path) -> None: - result = runner.invoke(app, [str(sample_tree), "--no-show"]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show"]) assert result.exit_code == 0 assert "Found" in result.output assert "files" in result.output +def test_cli_stdout_png(sample_tree: Path, tmp_path: Path) -> None: + # Binary PNG can't cleanly round-trip through CliRunner's text capture; + # pipe via --output - to a real file to verify the bytes. + out = tmp_path / "via_stdout.png" + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-m", + "dirplot", + "map", + str(sample_tree), + "--no-show", + "--output", + "-", + "--size", + "100x100", + ], + capture_output=True, + ) + assert result.returncode == 0 + assert result.stdout[:8] == b"\x89PNG\r\n\x1a\n" + out.write_bytes(result.stdout) + assert out.stat().st_size > 0 + + +def test_cli_stdout_svg(sample_tree: Path) -> None: + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-m", + "dirplot", + "map", + str(sample_tree), + "--no-show", + "--output", + "-", + "--format", + "svg", + "--size", + "100x100", + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.startswith("<?xml") + assert "<?xml" not in result.stderr + + def test_cli_saves_output(sample_tree: Path, tmp_path: Path) -> None: output = tmp_path / "out.png" - result = runner.invoke(app, [str(sample_tree), "--no-show", "--output", str(output)]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(output)]) assert result.exit_code == 0 assert output.exists() assert output.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" def test_cli_exclude(sample_tree: Path) -> None: - src = sample_tree / "src" - result = runner.invoke(app, [str(sample_tree), "--no-show", "--exclude", str(src)]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--exclude", "src"]) assert result.exit_code == 0 # Only docs/ and README.md remain: 80 + 50 = 130 bytes assert "130" in result.output @@ -56,42 +128,293 @@ def test_cli_exclude(sample_tree: Path) -> None: def test_cli_show_window(sample_tree: Path) -> None: mock_img = MagicMock() with patch("PIL.Image.open", return_value=mock_img): - result = runner.invoke(app, [str(sample_tree), "--show"]) + result = runner.invoke(app, ["map", str(sample_tree), "--show"]) assert result.exit_code == 0 mock_img.show.assert_called_once() def test_cli_show_inline(sample_tree: Path) -> None: - from unittest.mock import mock_open - - m = mock_open() - with patch("builtins.open", m): - result = runner.invoke(app, [str(sample_tree), "--show", "--inline"]) + with patch("dirplot.display.display_inline"): + result = runner.invoke(app, ["map", str(sample_tree), "--show", "--inline"]) assert result.exit_code == 0 def test_cli_custom_colormap(sample_tree: Path) -> None: - result = runner.invoke(app, [str(sample_tree), "--no-show", "--colormap", "viridis"]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--colormap", "viridis"]) assert result.exit_code == 0 def test_cli_custom_scale(sample_tree: Path) -> None: - result = runner.invoke(app, [str(sample_tree), "--no-show", "--font-size", "14"]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--font-size", "14"]) assert result.exit_code == 0 def test_cli_custom_size(sample_tree: Path) -> None: - result = runner.invoke(app, [str(sample_tree), "--no-show", "--size", "800x600"]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--size", "800x600"]) assert result.exit_code == 0 assert "800x600" in result.output def test_cli_invalid_size(sample_tree: Path) -> None: - result = runner.invoke(app, [str(sample_tree), "--no-show", "--size", "notasize"]) + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--size", "notasize"]) assert result.exit_code == 1 assert "Invalid --size" in result.output +def test_cli_termsize() -> None: + result = runner.invoke(app, ["termsize"]) + assert result.exit_code == 0 + assert "cols" in result.output + assert "rows" in result.output + assert "×" in result.output + + +def test_overview() -> None: + result = runner.invoke(app, ["overview"]) + assert result.exit_code == 0 + assert "Application Overview" in result.output + assert "Name : dirplot" in result.output + assert "Nested treemap visualizations for directory trees and archives" in result.output + assert "Version :" in result.output + assert "(unnamed)" not in result.output + assert "(no description)" not in result.output + assert "map" in result.output + assert "git" in result.output + + +def test_read_meta_png(sample_tree: Path, tmp_path: Path) -> None: + output = tmp_path / "out.png" + runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(output)]) + result = runner.invoke(app, ["read-meta", str(output)]) + assert result.exit_code == 0 + assert "Date:" in result.output + assert "Software:" in result.output + assert "dirplot" in result.output + assert "Command:" in result.output + + +def test_read_meta_svg(sample_tree: Path, tmp_path: Path) -> None: + output = tmp_path / "out.svg" + runner.invoke( + app, ["map", str(sample_tree), "--no-show", "--output", str(output), "--format", "svg"] + ) + result = runner.invoke(app, ["read-meta", str(output)]) + assert result.exit_code == 0 + assert "Date:" in result.output + assert "Software:" in result.output + assert "dirplot" in result.output + assert "Command:" in result.output + + +def test_read_meta_missing_file() -> None: + result = runner.invoke(app, ["read-meta", "/nonexistent/file.png"]) + assert result.exit_code == 1 + + +def test_read_meta_unsupported_type(tmp_path: Path) -> None: + f = tmp_path / "file.txt" + f.write_text("hello") + result = runner.invoke(app, ["read-meta", str(f)]) + assert result.exit_code == 1 + assert "Unsupported" in result.output + + +def test_read_meta_png_no_metadata(tmp_path: Path) -> None: + from PIL import Image + + img = Image.new("RGB", (10, 10)) + p = tmp_path / "plain.png" + img.save(p, format="PNG") + result = runner.invoke(app, ["read-meta", str(p)]) + assert result.exit_code == 1 + assert "No dirplot metadata" in result.output + + +def test_read_meta_svg_no_metadata(tmp_path: Path) -> None: + p = tmp_path / "plain.svg" + p.write_text('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>') + result = runner.invoke(app, ["read-meta", str(p)]) + assert result.exit_code == 1 + assert "No dirplot metadata" in result.output + + +def test_read_meta_multiple_files(sample_tree: Path, tmp_path: Path) -> None: + out1 = tmp_path / "out1.png" + out2 = tmp_path / "out2.png" + runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(out1)]) + runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(out2)]) + result = runner.invoke(app, ["read-meta", str(out1), str(out2)]) + assert result.exit_code == 0 + assert f"==> {out1} <==" in result.output + assert f"==> {out2} <==" in result.output + assert result.output.count("Date:") == 2 + + +def test_read_meta_multiple_files_partial_error(sample_tree: Path, tmp_path: Path) -> None: + out1 = tmp_path / "out1.png" + runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(out1)]) + result = runner.invoke(app, ["read-meta", str(out1), "/nonexistent/file.png"]) + assert result.exit_code == 1 + assert "Date:" in result.output + assert "file not found" in result.output + + +def test_watch_single_path(sample_tree: Path, tmp_path: Path) -> None: + output = tmp_path / "out.png" + mock_obs = MagicMock() + with ( + patch("dirplot.watch.build_tree_multi"), + patch("dirplot.watch.create_treemap") as mock_render, + patch("watchdog.observers.Observer", return_value=mock_obs), + patch("dirplot.commands.watch.time.sleep", side_effect=KeyboardInterrupt), + ): + mock_render.return_value = MagicMock(read=lambda: b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + result = runner.invoke( + app, ["watch", str(sample_tree), "--snapshot", str(output), "--size", "100x100"] + ) + assert result.exit_code == 0 + mock_obs.schedule.assert_called_once_with(ANY, str(sample_tree.resolve()), recursive=True) + + +def test_watch_multiple_paths(tmp_path: Path) -> None: + dir_a = tmp_path / "a" + dir_b = tmp_path / "b" + dir_a.mkdir() + dir_b.mkdir() + output = tmp_path / "out.png" + mock_obs = MagicMock() + with ( + patch("dirplot.watch.build_tree_multi"), + patch("dirplot.watch.create_treemap") as mock_render, + patch("watchdog.observers.Observer", return_value=mock_obs), + patch("dirplot.commands.watch.time.sleep", side_effect=KeyboardInterrupt), + ): + mock_render.return_value = MagicMock(read=lambda: b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + result = runner.invoke( + app, + ["watch", str(dir_a), str(dir_b), "--snapshot", str(output), "--size", "100x100"], + ) + assert result.exit_code == 0 + scheduled_paths = {call.args[1] for call in mock_obs.schedule.call_args_list} + assert scheduled_paths == {str(dir_a.resolve()), str(dir_b.resolve())} + + +def test_watch_debounce_coalesces(sample_tree: Path, tmp_path: Path) -> None: + import time + from unittest.mock import patch + + from dirplot.watch import TreemapEventHandler + + output = tmp_path / "out.png" + with ( + patch("dirplot.watch.build_tree_multi"), + patch("dirplot.watch.create_treemap") as mock_render, + ): + mock_render.return_value = MagicMock(read=lambda: b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + handler = TreemapEventHandler( + [sample_tree.resolve()], + output, + width_px=100, + height_px=100, + font_size=12, + colormap="tab20", + cushion=True, + debounce=0.1, + ) + regenerate_calls = [] + original = handler._regenerate + handler._regenerate = lambda: regenerate_calls.append(1) or original() + + for _ in range(5): + handler._schedule_regenerate() + + assert len(regenerate_calls) == 0 + time.sleep(0.3) + assert len(regenerate_calls) == 1 + + +def test_watch_flush_fires_pending(sample_tree: Path, tmp_path: Path) -> None: + from unittest.mock import patch + + from dirplot.watch import TreemapEventHandler + + output = tmp_path / "out.png" + with ( + patch("dirplot.watch.build_tree_multi"), + patch("dirplot.watch.create_treemap") as mock_render, + ): + mock_render.return_value = MagicMock(read=lambda: b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + handler = TreemapEventHandler( + [sample_tree.resolve()], + output, + width_px=100, + height_px=100, + font_size=12, + colormap="tab20", + cushion=True, + debounce=60.0, + ) + regenerate_calls = [] + original = handler._regenerate + handler._regenerate = lambda: regenerate_calls.append(1) or original() + + handler._schedule_regenerate() + assert len(regenerate_calls) == 0 + + handler.flush() + assert len(regenerate_calls) == 1 + + +def test_watch_event_log_written(tmp_path: Path) -> None: + from dirplot.watch import TreemapEventHandler + + output = tmp_path / "out.png" + log_file = tmp_path / "events.jsonl" + handler = TreemapEventHandler( + [tmp_path], + output, + width_px=100, + height_px=100, + font_size=12, + colormap="tab20", + cushion=True, + event_log=log_file, + ) + handler._events = [ + {"timestamp": 1.0, "type": "created", "path": "/foo/bar.txt", "dest_path": None}, + {"timestamp": 2.0, "type": "modified", "path": "/foo/bar.txt", "dest_path": None}, + ] + handler.flush() + + assert log_file.exists() + lines = log_file.read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + import json + + first = json.loads(lines[0]) + assert first["type"] == "created" + assert first["path"] == "/foo/bar.txt" + + +def test_watch_invalid_path(tmp_path: Path) -> None: + output = tmp_path / "out.png" + result = runner.invoke( + app, ["watch", "/nonexistent/__dirplot_test__", "--snapshot", str(output)] + ) + assert result.exit_code == 1 + assert "not a directory" in result.output + + +def test_watch_missing_watchdog(sample_tree: Path, tmp_path: Path) -> None: + output = tmp_path / "out.png" + with patch.dict("sys.modules", {"watchdog.observers": None}): + result = runner.invoke( + app, ["watch", str(sample_tree), "--snapshot", str(output), "--size", "100x100"] + ) + assert result.exit_code == 1 + assert "watchdog" in result.output + + def test_main_module() -> None: """__main__.py delegates to app.""" from dirplot.__main__ import main @@ -99,3 +422,427 @@ def test_main_module() -> None: with patch("dirplot.__main__.app") as mock_app: main() mock_app.assert_called_once() + + +# --------------------------------------------------------------------------- +# --paths-from and stdin path-list mode +# --------------------------------------------------------------------------- + + +def test_paths_from_find_format(tmp_path: Path, sample_tree: Path) -> None: + """--paths-from FILE with find-style output (one path per line).""" + paths_file = tmp_path / "paths.txt" + # Write two real sub-paths from sample_tree + children = sorted(sample_tree.iterdir())[:2] + paths_file.write_text("\n".join(str(c) for c in children) + "\n") + result = runner.invoke( + app, ["map", "--paths-from", str(paths_file), "--no-show", "--size", "100x100"] + ) + assert result.exit_code == 0, result.output + + +def test_paths_from_stdin_find_format(tmp_path: Path, sample_tree: Path) -> None: + """Pipe find-style path list via stdin.""" + import subprocess + import sys + + children = sorted(sample_tree.iterdir())[:2] + stdin_data = "\n".join(str(c) for c in children) + "\n" + result = subprocess.run( + [sys.executable, "-m", "dirplot", "map", "--no-show", "--size", "100x100"], + input=stdin_data.encode(), + capture_output=True, + ) + assert result.returncode == 0, result.stderr.decode() + + +def test_paths_from_tree_format(tmp_path: Path, sample_tree: Path) -> None: + """--paths-from FILE with tree-style output.""" + import subprocess + + # Generate real tree output from sample_tree + try: + tree_result = subprocess.run(["tree", str(sample_tree)], capture_output=True, text=True) + except FileNotFoundError: + pytest.skip("tree command not available") + if tree_result.returncode != 0: + pytest.skip("tree command not available") + + paths_file = tmp_path / "tree.txt" + paths_file.write_text(tree_result.stdout) + result = runner.invoke( + app, ["map", "--paths-from", str(paths_file), "--no-show", "--size", "100x100"] + ) + assert result.exit_code == 0, result.output + + +def test_paths_from_conflicts_with_positional(sample_tree: Path, tmp_path: Path) -> None: + """Combining --paths-from with positional args must error.""" + paths_file = tmp_path / "paths.txt" + paths_file.write_text(str(sample_tree) + "\n") + result = runner.invoke( + app, ["map", str(sample_tree), "--paths-from", str(paths_file), "--no-show"] + ) + assert result.exit_code == 1 + assert "cannot combine" in result.output.lower() + + +def test_paths_from_nonexistent_file(sample_tree: Path) -> None: + result = runner.invoke( + app, ["map", "--paths-from", "/nonexistent/__dirplot_test_paths__.txt", "--no-show"] + ) + assert result.exit_code == 1 + assert "does not exist" in result.output + + +@pytest.mark.skipif( + not __import__("shutil").which("ffmpeg") or not __import__("shutil").which("ffprobe"), + reason="ffmpeg/ffprobe not found", +) +def test_read_meta_mp4(tmp_path: Path) -> None: + """read-meta extracts all dirplot metadata fields from an MP4 file.""" + import io + + from PIL import Image + + from dirplot.render_png import build_metadata, write_mp4 + + frame = io.BytesIO() + Image.new("RGB", (64, 64), (0, 100, 200)).save(frame, format="PNG") + out = tmp_path / "anim.mp4" + write_mp4(out, [frame.getvalue()], [500], metadata=build_metadata()) + + result = runner.invoke(app, ["read-meta", str(out)]) + assert result.exit_code == 0 + for key in ("Date", "Software", "URL", "Python", "OS", "Command"): + assert f"{key}:" in result.output + + +# --------------------------------------------------------------------------- +# _proportional_durations +# --------------------------------------------------------------------------- + + +def test_proportional_durations_basic() -> None: + from dirplot.helpers.animation import proportional_durations + + durations = proportional_durations([1.0, 2.0, 3.0], total_ms=6000) + assert sum(durations) == 6000 + assert len(durations) == 3 + assert all(d >= 200 for d in durations) + + +def test_proportional_durations_floor_applied() -> None: + """Very small gaps are raised to floor_ms; total still sums to target.""" + from dirplot.helpers.animation import proportional_durations + + # Many tiny gaps and one large gap + gaps = [0.01] * 10 + [100.0] + durations = proportional_durations(gaps, total_ms=5000) + assert sum(durations) == 5000 + assert all(d >= 200 for d in durations) + + +def test_proportional_durations_all_equal() -> None: + from dirplot.helpers.animation import proportional_durations + + durations = proportional_durations([1.0, 1.0, 1.0, 1.0], total_ms=4000) + assert sum(durations) == 4000 + + +# --------------------------------------------------------------------------- +# replay command +# --------------------------------------------------------------------------- + + +def _write_jsonl(path: Path, events: list[dict]) -> None: + import json + + path.write_text("\n".join(json.dumps(e) for e in events) + "\n") + + +def test_replay_basic(tmp_path: Path) -> None: + """replay renders a JSONL event log to an APNG.""" + root = tmp_path / "root" + root.mkdir() + (root / "a.py").write_bytes(b"x" * 500) + (root / "b.py").write_bytes(b"x" * 300) + + log = tmp_path / "events.jsonl" + _write_jsonl( + log, + [ + {"timestamp": 1.0, "type": "created", "path": str(root / "a.py")}, + {"timestamp": 2.0, "type": "modified", "path": str(root / "b.py")}, + {"timestamp": 70.0, "type": "modified", "path": str(root / "a.py")}, + ], + ) + out = tmp_path / "replay.apng" + result = runner.invoke( + app, + [ + "replay", + str(log), + "--output", + str(out), + "--size", + "200x150", + "--workers", + "1", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + + +def test_replay_missing_log(tmp_path: Path) -> None: + result = runner.invoke( + app, + [ + "replay", + str(tmp_path / "missing.jsonl"), + "--output", + str(tmp_path / "out.apng"), + "--size", + "200x150", + ], + ) + assert result.exit_code == 1 + + +def test_replay_bad_output_extension(tmp_path: Path) -> None: + log = tmp_path / "events.jsonl" + log.write_text("{}\n") + result = runner.invoke( + app, + [ + "replay", + str(log), + "--output", + str(tmp_path / "out.gif"), + "--size", + "200x150", + ], + ) + assert result.exit_code == 1 + + +def test_replay_total_duration(tmp_path: Path) -> None: + """replay with --total-duration exercises _proportional_durations.""" + root = tmp_path / "root" + root.mkdir() + (root / "f.py").write_bytes(b"x" * 200) + + log = tmp_path / "events.jsonl" + _write_jsonl( + log, + [ + {"timestamp": 0.0, "type": "created", "path": str(root / "f.py")}, + {"timestamp": 100.0, "type": "modified", "path": str(root / "f.py")}, + {"timestamp": 300.0, "type": "modified", "path": str(root / "f.py")}, + ], + ) + out = tmp_path / "replay.apng" + result = runner.invoke( + app, + [ + "replay", + str(log), + "--output", + str(out), + "--size", + "200x150", + "--total-duration", + "3", + "--workers", + "1", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + + +def test_replay_fixture_jsonl(tmp_path: Path) -> None: + """replay produces a non-empty APNG from the checked-in fixture event log. + + The fixture uses relative paths; this test rewrites them as absolute paths + under tmp_path and creates stub files so the scanner has real sizes to work with. + """ + import json + + fixture = Path(__file__).parent / "fixtures" / "events.jsonl" + root = tmp_path / "repo" + + # Create stub files for every path mentioned in the fixture + with open(fixture) as f: + raw_events = [json.loads(line) for line in f if line.strip()] + + all_rel = {e["path"] for e in raw_events} | { + e["dest_path"] for e in raw_events if "dest_path" in e + } + for rel in all_rel: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"x" * 200) + + # Write a new JSONL with absolute paths + abs_log = tmp_path / "events_abs.jsonl" + abs_events = [] + for e in raw_events: + ev = dict(e) + ev["path"] = str(root / e["path"]) + if "dest_path" in e: + ev["dest_path"] = str(root / e["dest_path"]) + abs_events.append(ev) + abs_log.write_text("\n".join(json.dumps(e) for e in abs_events) + "\n") + + out = tmp_path / "replay.apng" + result = runner.invoke( + app, + ["replay", str(abs_log), "--output", str(out), "--size", "200x150", "--workers", "1"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_demo_help() -> None: + result = runner.invoke(app, ["demo", "--help"]) + assert result.exit_code == 0 + plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output) + assert "--output" in plain + assert "--github-url" in plain + + +def test_demo_runs(tmp_path: Path) -> None: + mock_result = MagicMock() + mock_result.returncode = 0 + + with patch("subprocess.run", return_value=mock_result) as mock_run: + result = runner.invoke( + app, + [ + "demo", + "--output", + str(tmp_path / "demo-out"), + "--github-url", + "https://github.com/deeplook/dirplot", + ], + ) + + assert result.exit_code == 0, result.output + assert (tmp_path / "demo-out").is_dir() + assert mock_run.call_count == 8 # one call per non-skipped example + assert "Done" in result.output + + +# --------------------------------------------------------------------------- +# metrics command +# --------------------------------------------------------------------------- + + +def test_metrics_basic(sample_tree: Path) -> None: + result = runner.invoke(app, ["metrics", str(sample_tree)]) + assert result.exit_code == 0 + assert "Files:" in result.output + assert "Dirs:" in result.output + assert "Total size:" in result.output + assert "Largest files:" in result.output + assert "Largest dirs:" in result.output + + +def test_metrics_invalid_path() -> None: + result = runner.invoke(app, ["metrics", "/nonexistent/__dirplot_test__"]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + +def test_metrics_exclude(sample_tree: Path) -> None: + # Exclude src/ by name pattern — its 300 bytes should disappear from total + result_excl = runner.invoke(app, ["metrics", str(sample_tree), "-e", "src"]) + assert result_excl.exit_code == 0 + # Without src (300 bytes), total is 130 B; "300" and "app.py"/"util.py" gone + assert "app.py" not in result_excl.output + assert "util.py" not in result_excl.output + + +def test_metrics_top_n(sample_tree: Path) -> None: + result = runner.invoke(app, ["metrics", str(sample_tree), "--top", "1"]) + assert result.exit_code == 0 + assert "Top extensions (1)" in result.output + + +def test_metrics_depth(sample_tree: Path) -> None: + # With depth=1 we see only the top-level children; src/ appears as empty dir + result = runner.invoke(app, ["metrics", str(sample_tree), "--depth", "1"]) + assert result.exit_code == 0 + assert "Files:" in result.output + + +def test_metrics_sort_by_count(sample_tree: Path) -> None: + result = runner.invoke(app, ["metrics", str(sample_tree), "--sort-by", "count"]) + assert result.exit_code == 0 + assert "by count" in result.output + + +def test_metrics_sort_by_size(sample_tree: Path) -> None: + result = runner.invoke(app, ["metrics", str(sample_tree), "--sort-by", "size"]) + assert result.exit_code == 0 + assert "by size" in result.output + + +def test_metrics_sort_by_invalid(sample_tree: Path) -> None: + result = runner.invoke(app, ["metrics", str(sample_tree), "--sort-by", "bogus"]) + assert result.exit_code == 1 + assert "Invalid --sort-by" in result.output + + +def test_metrics_json(sample_tree: Path) -> None: + import json + + result = runner.invoke(app, ["metrics", str(sample_tree), "--json"]) + assert result.exit_code == 0 + # Strip the "Scanning ..." line (goes to stdout via CliRunner) + json_part = "\n".join( + line for line in result.output.splitlines() if not line.startswith("Scanning") + ) + data = json.loads(json_part) + assert data["files"] == 4 + assert data["dirs"] == 2 + assert "top_extensions" in data + assert "largest_files" in data + assert "largest_dirs" in data + + +def test_metrics_json_ext_has_size(sample_tree: Path) -> None: + import json + + result = runner.invoke(app, ["metrics", str(sample_tree), "--json"]) + assert result.exit_code == 0 + json_part = "\n".join( + line for line in result.output.splitlines() if not line.startswith("Scanning") + ) + data = json.loads(json_part) + for ext in data["top_extensions"]: + assert "size_bytes" in ext + assert "count" in ext + + +def test_metrics_json_pct(sample_tree: Path) -> None: + import json + + result = runner.invoke(app, ["metrics", str(sample_tree), "--json"]) + assert result.exit_code == 0 + json_part = "\n".join( + line for line in result.output.splitlines() if not line.startswith("Scanning") + ) + data = json.loads(json_part) + for f in data["largest_files"]: + assert 0.0 <= f["pct"] <= 100.0 + + +def test_map_metrics_flag(sample_tree: Path) -> None: + result = runner.invoke(app, ["map", str(sample_tree), "--no-show", "--metrics"]) + assert result.exit_code == 0 + assert "Largest files:" in result.output + assert "%" in result.output diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 0000000..39e5e8b --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,313 @@ +"""Tests for the ``dirplot diff`` command.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from dirplot.main import app + +runner = CliRunner() + +_git_available = bool(shutil.which("git")) + +_GIT_ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", +} + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, env=_GIT_ENV) + + +@pytest.fixture() +def git_repo(tmp_path: Path) -> Path: + """Minimal git repo with two commits and an untracked file.""" + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test") + + # First commit: two files + (repo / "alpha.py").write_bytes(b"a" * 200) + (repo / "beta.py").write_bytes(b"b" * 300) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "initial") + + # Second commit: modify alpha (same size, different content), add gamma + (repo / "alpha.py").write_bytes(b"z" * 200) # same size, changed content + (repo / "gamma.py").write_bytes(b"g" * 100) + _git(repo, "add", ".") + _git(repo, "commit", "-m", "update") + + # Uncommitted change: modify beta + (repo / "beta.py").write_bytes(b"b" * 400) + + # Untracked file — should never appear in diff output + (repo / "untracked.txt").write_bytes(b"noise") + + return repo + + +@pytest.fixture() +def tree_a(tmp_path: Path) -> Path: + root = tmp_path / "a" + root.mkdir() + (root / "same.py").write_bytes(b"x" * 500) + (root / "changed.py").write_bytes(b"x" * 300) + (root / "removed.py").write_bytes(b"x" * 200) + sub = root / "sub" + sub.mkdir() + (sub / "sub_same.py").write_bytes(b"x" * 100) + (sub / "sub_removed.py").write_bytes(b"x" * 150) + return root + + +@pytest.fixture() +def tree_b(tmp_path: Path) -> Path: + root = tmp_path / "b" + root.mkdir() + (root / "same.py").write_bytes(b"x" * 500) + (root / "changed.py").write_bytes(b"x" * 999) # size changed + (root / "added.py").write_bytes(b"x" * 400) + sub = root / "sub" + sub.mkdir() + (sub / "sub_same.py").write_bytes(b"x" * 100) + (sub / "sub_added.py").write_bytes(b"x" * 250) + return root + + +def test_diff_produces_png(tree_a: Path, tree_b: Path, tmp_path: Path) -> None: + out = tmp_path / "diff.png" + result = runner.invoke( + app, + ["diff", str(tree_a), str(tree_b), "--output", str(out), "--size", "300x200", "--no-show"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_diff_reports_counts(tree_a: Path, tree_b: Path, tmp_path: Path) -> None: + out = tmp_path / "diff.png" + result = runner.invoke( + app, + ["diff", str(tree_a), str(tree_b), "--output", str(out), "--size", "300x200", "--no-show"], + ) + assert result.exit_code == 0, result.output + # 2 added (added.py, sub/sub_added.py), 2 removed (removed.py, sub/sub_removed.py), 1 changed + assert "2 added" in result.output + assert "2 removed" in result.output + assert "1 changed" in result.output + + +def test_diff_invalid_tree_a(tmp_path: Path) -> None: + result = runner.invoke( + app, + ["diff", str(tmp_path / "nonexistent"), str(tmp_path), "--size", "300x200", "--no-show"], + ) + assert result.exit_code == 1 + + +def test_diff_invalid_tree_b(tmp_path: Path) -> None: + result = runner.invoke( + app, + ["diff", str(tmp_path), str(tmp_path / "nonexistent"), "--size", "300x200", "--no-show"], + ) + assert result.exit_code == 1 + + +def test_diff_identical_trees(tmp_path: Path) -> None: + root = tmp_path / "src" + root.mkdir() + (root / "a.py").write_bytes(b"x" * 100) + out = tmp_path / "diff.png" + result = runner.invoke( + app, ["diff", str(root), str(root), "--output", str(out), "--size", "300x200", "--no-show"] + ) + assert result.exit_code == 0, result.output + assert "0 added" in result.output + assert "0 removed" in result.output + assert "0 changed" in result.output + + +def test_diff_svg_output(tree_a: Path, tree_b: Path, tmp_path: Path) -> None: + out = tmp_path / "diff.svg" + result = runner.invoke( + app, + ["diff", str(tree_a), str(tree_b), "--output", str(out), "--size", "300x200", "--no-show"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + content = out.read_text() + assert "<svg" in content + + +def test_diff_no_context(tree_a: Path, tree_b: Path, tmp_path: Path) -> None: + """--no-context produces a smaller image (fewer tiles) than --context.""" + out_ctx = tmp_path / "diff_ctx.png" + out_noctx = tmp_path / "diff_noctx.png" + runner.invoke( + app, + [ + "diff", + str(tree_a), + str(tree_b), + "--output", + str(out_ctx), + "--size", + "300x200", + "--no-show", + ], + ) + runner.invoke( + app, + [ + "diff", + str(tree_a), + str(tree_b), + "--output", + str(out_noctx), + "--size", + "300x200", + "--no-show", + "--no-context", + ], + ) + assert out_ctx.exists() and out_noctx.exists() + # --no-context excludes unchanged files so the image should differ + assert out_ctx.read_bytes() != out_noctx.read_bytes() + + +def test_diff_light_mode(tree_a: Path, tree_b: Path, tmp_path: Path) -> None: + out = tmp_path / "diff.png" + result = runner.invoke( + app, + [ + "diff", + str(tree_a), + str(tree_b), + "--output", + str(out), + "--size", + "300x200", + "--no-show", + "--light", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + + +# --- git-aware diff tests --- + + +@pytest.mark.skipif(not _git_available, reason="git CLI not found") +def test_diff_single_arg_git_repo(git_repo: Path, tmp_path: Path) -> None: + """Single-argument form diffs working tree against HEAD.""" + out = tmp_path / "diff.png" + result = runner.invoke( + app, ["diff", str(git_repo), "--output", str(out), "--size", "300x200", "--no-show"] + ) + assert result.exit_code == 0, result.output + assert out.exists() + # beta.py was modified in the working tree + assert "1 changed" in result.output + + +@pytest.mark.skipif(not _git_available, reason="git CLI not found") +def test_diff_single_arg_non_repo_fails(tmp_path: Path) -> None: + """Single-argument form fails for a plain non-git directory.""" + plain = tmp_path / "plain" + plain.mkdir() + result = runner.invoke(app, ["diff", str(plain), "--size", "300x200", "--no-show"]) + assert result.exit_code == 1 + assert "Error" in result.output + + +@pytest.mark.skipif(not _git_available, reason="git CLI not found") +def test_diff_git_ref_syntax(git_repo: Path, tmp_path: Path) -> None: + """path@ref syntax compares two git commits.""" + out = tmp_path / "diff.png" + result = runner.invoke( + app, + [ + "diff", + f"{git_repo}@HEAD~1", + f"{git_repo}@HEAD", + "--output", + str(out), + "--size", + "300x200", + "--no-show", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + # HEAD added gamma.py and changed alpha.py + assert "1 added" in result.output + assert "1 changed" in result.output + + +@pytest.mark.skipif(not _git_available, reason="git CLI not found") +def test_diff_hash_based_change_detection(git_repo: Path, tmp_path: Path) -> None: + """Files with same size but different content are detected as changed.""" + out = tmp_path / "diff.png" + result = runner.invoke( + app, + [ + "diff", + f"{git_repo}@HEAD~1", + f"{git_repo}@HEAD", + "--output", + str(out), + "--size", + "300x200", + "--no-show", + ], + ) + assert result.exit_code == 0, result.output + # alpha.py has same size (200 bytes) at both commits but different content + assert "1 changed" in result.output + + +@pytest.mark.skipif(not _git_available, reason="git CLI not found") +def test_diff_untracked_files_excluded(git_repo: Path, tmp_path: Path) -> None: + """Untracked files in a git repo must never appear in the diff.""" + out = tmp_path / "diff.png" + result = runner.invoke( + app, ["diff", str(git_repo), "--output", str(out), "--size", "300x200", "--no-show"] + ) + assert result.exit_code == 0, result.output + # untracked.txt must not count as added + assert "0 added" in result.output + + +@pytest.mark.skipif(not _git_available, reason="git CLI not found") +def test_diff_matching_hashes_not_changed(git_repo: Path, tmp_path: Path) -> None: + """Files with matching hashes are not flagged as changed even if sizes differ. + + Verifies the _is_changed logic: when both hashes are available and equal, + the file is treated as unchanged regardless of size discrepancy. + This is the same code path triggered by Git LFS (pointer size vs disk size). + """ + from dirplot.git_scanner import git_file_hashes, git_worktree_hashes + + # Confirm that an unmodified tracked file has the same hash in both sides + hashes_head = git_file_hashes(git_repo, "HEAD") + hashes_wt = git_worktree_hashes(git_repo) + + # gamma.py was added in HEAD and is unmodified in the working tree + assert "gamma.py" in hashes_head + assert "gamma.py" in hashes_wt + assert hashes_head["gamma.py"] == hashes_wt["gamma.py"] diff --git a/tests/test_display.py b/tests/test_display.py index ccef75a..541f395 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,7 +1,9 @@ """Tests for inline terminal display with /dev/tty unavailable.""" +import builtins import io import sys +from contextlib import contextmanager from unittest.mock import patch import pytest @@ -22,20 +24,43 @@ def _buf() -> io.BytesIO: return io.BytesIO(PNG_BYTES) +@contextmanager +def _no_tty(): + """Simulate no tty on all platforms. + + On Unix, patching os.open is sufficient. + On Windows the code uses builtins.open("CONOUT$", ...) instead of os.open, + so we also intercept that call. + """ + _real_open = builtins.open + + def _open_no_conout(file, *args, **kwargs): + if file == "CONOUT$": + raise OSError("no tty") + return _real_open(file, *args, **kwargs) + + with patch("os.open", side_effect=OSError("no tty")): + if sys.platform == "win32": + with patch("builtins.open", side_effect=_open_no_conout): + yield + else: + yield + + # --------------------------------------------------------------------------- # _open_tty_write_binary / _open_tty_write_text # --------------------------------------------------------------------------- def test_open_tty_write_binary_falls_back_to_stdout_buffer() -> None: - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): f, owned = _open_tty_write_binary() assert f is sys.stdout.buffer assert owned is False def test_open_tty_write_text_falls_back_to_stdout() -> None: - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): f, owned = _open_tty_write_text() assert f is sys.stdout assert owned is False @@ -47,7 +72,7 @@ def test_open_tty_write_text_falls_back_to_stdout() -> None: def test_display_kitty_no_tty_writes_to_stdout(capsys: pytest.CaptureFixture) -> None: - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): display_kitty(_buf()) raw = capsys.readouterr().out.encode(sys.stdout.encoding or "utf-8", errors="replace") @@ -61,7 +86,7 @@ def test_display_kitty_no_tty_writes_to_stdout(capsys: pytest.CaptureFixture) -> def test_display_iterm2_no_tty_writes_to_stdout(capsys: pytest.CaptureFixture) -> None: - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): _display_iterm2(_buf()) out = capsys.readouterr().out @@ -77,28 +102,28 @@ def test_display_iterm2_no_tty_writes_to_stdout(capsys: pytest.CaptureFixture) - def test_detect_protocol_no_tty_kitty_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("KITTY_WINDOW_ID", "1") monkeypatch.delenv("TERM_PROGRAM", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): assert _detect_inline_protocol() == "kitty" def test_detect_protocol_no_tty_iterm2_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TERM_PROGRAM", "iTerm.app") monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): assert _detect_inline_protocol() == "iterm2" def test_detect_protocol_no_tty_ghostty_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TERM_PROGRAM", "ghostty") monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): assert _detect_inline_protocol() == "kitty" def test_detect_protocol_no_tty_no_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TERM_PROGRAM", raising=False) monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): assert _detect_inline_protocol() == "" @@ -112,7 +137,7 @@ def test_display_inline_no_tty_routes_kitty( ) -> None: monkeypatch.setenv("KITTY_WINDOW_ID", "1") monkeypatch.delenv("TERM_PROGRAM", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): display_inline(_buf()) raw = capsys.readouterr().out.encode(sys.stdout.encoding or "utf-8", errors="replace") @@ -124,7 +149,7 @@ def test_display_inline_no_tty_routes_iterm2( ) -> None: monkeypatch.setenv("TERM_PROGRAM", "iTerm.app") monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): display_inline(_buf()) out = capsys.readouterr().out @@ -137,7 +162,7 @@ def test_display_inline_no_tty_no_env_falls_back_to_iterm2( """With no env hints, display_inline falls through to _display_iterm2.""" monkeypatch.delenv("TERM_PROGRAM", raising=False) monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): display_inline(_buf()) out = capsys.readouterr().out diff --git a/tests/test_docker.py b/tests/test_docker.py new file mode 100644 index 0000000..8ac623e --- /dev/null +++ b/tests/test_docker.py @@ -0,0 +1,384 @@ +"""Tests for Docker container directory scanning.""" + +from __future__ import annotations + +import shutil +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from dirplot.docker import ( + _entries_to_tree, + build_tree_docker, + is_docker_path, + parse_docker_path, +) + +# --------------------------------------------------------------------------- +# Integration fixture +# --------------------------------------------------------------------------- + + +_CONTAINER_NAME = "dirplot-integration-test" + + +_PROBE_NAME = "dirplot-exec-probe" + + +def _docker_available() -> bool: + """Return True if docker CLI is in PATH, daemon responds, and exec works. + + Runs a detached smoke-test container and execs into it to verify that + ``docker exec`` actually works end-to-end. Docker Desktop with the + containerd image store can pass ``docker info`` yet still fail on exec; + this catches that case. + """ + if shutil.which("docker") is None: + return False + try: + if subprocess.run(["docker", "info"], capture_output=True, timeout=10).returncode != 0: + return False + + # Clean up any leftover probe container + subprocess.run(["docker", "rm", "-f", _PROBE_NAME], capture_output=True) + + # Start a detached container + r = subprocess.run( + ["docker", "run", "-d", "--name", _PROBE_NAME, "python:3.12-slim", "sleep", "30"], + capture_output=True, + text=True, + timeout=30, + ) + if r.returncode != 0: + return False + + # Verify exec works + r2 = subprocess.run( + ["docker", "exec", _PROBE_NAME, "echo", "ok"], + capture_output=True, + text=True, + timeout=10, + ) + return r2.returncode == 0 and r2.stdout.strip() == "ok" + except Exception: + return False + finally: + subprocess.run(["docker", "rm", "-f", _PROBE_NAME], capture_output=True) + + +@pytest.fixture(scope="session") +def docker_container(): + """Run a temporary container with known files for integration tests. + + Uses python:3.12-slim so file creation via python3 -c is guaranteed. + Skips the whole session if Docker is unavailable or exec is broken. + """ + if not _docker_available(): + pytest.skip("Docker not available or exec is broken (e.g. Docker Desktop containerd mode)") + + # Clean up any leftover container from a previous interrupted run + subprocess.run(["docker", "rm", "-f", _CONTAINER_NAME], capture_output=True) + + result = subprocess.run( + ["docker", "run", "-d", "--name", _CONTAINER_NAME, "python:3.12-slim", "sleep", "infinity"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.skip(f"Could not start container: {result.stderr.strip()}") + + # Create a known directory tree inside the container + subprocess.run( + [ + "docker", + "exec", + _CONTAINER_NAME, + "python3", + "-c", + ( + "import os; " + "os.makedirs('/testdata/src', exist_ok=True); " + "open('/testdata/src/app.py', 'wb').write(b'x' * 100); " + "open('/testdata/src/util.py', 'wb').write(b'x' * 200); " + "open('/testdata/README.md', 'wb').write(b'y' * 50); " + ), + ], + check=True, + ) + + yield _CONTAINER_NAME + + subprocess.run(["docker", "rm", "-f", _CONTAINER_NAME], capture_output=True) + + +# --------------------------------------------------------------------------- +# URI helpers +# --------------------------------------------------------------------------- + + +def test_is_docker_path_valid() -> None: + assert is_docker_path("docker://my-container:/app") + + +def test_is_docker_path_no_colon() -> None: + assert is_docker_path("docker://my-container/app") + + +@pytest.mark.parametrize("path", ["/local/path", "ssh://user@host/path", "s3://bucket"]) +def test_is_docker_path_non_docker(path: str) -> None: + assert not is_docker_path(path) + + +def test_parse_docker_path_with_colon() -> None: + container, path = parse_docker_path("docker://my-container:/app/static") + assert container == "my-container" + assert path == "/app/static" + + +def test_parse_docker_path_without_colon() -> None: + container, path = parse_docker_path("docker://my-container/app/static") + assert container == "my-container" + assert path == "/app/static" + + +def test_parse_docker_path_root() -> None: + container, path = parse_docker_path("docker://my-container:/") + assert container == "my-container" + assert path == "/" + + +def test_parse_docker_path_no_path() -> None: + container, path = parse_docker_path("docker://my-container:") + assert container == "my-container" + assert path == "/" + + +# --------------------------------------------------------------------------- +# _entries_to_tree +# --------------------------------------------------------------------------- + + +def test_entries_to_tree_flat() -> None: + entries = [ + ("file.py", 1000, False), + ("README.md", 500, False), + ] + node = _entries_to_tree("/project", entries) + assert node.is_dir + assert node.size == 1500 + assert {c.name for c in node.children} == {"file.py", "README.md"} + + +def test_entries_to_tree_extensions() -> None: + entries = [ + ("script.py", 100, False), + ("Makefile", 50, False), + ] + node = _entries_to_tree("/project", entries) + py = next(c for c in node.children if c.name == "script.py") + mk = next(c for c in node.children if c.name == "Makefile") + assert py.extension == ".py" + assert mk.extension == "(no ext)" + + +def test_entries_to_tree_nested() -> None: + entries = [ + ("src", 0, True), + ("src/app.py", 200, False), + ("README.md", 50, False), + ] + node = _entries_to_tree("/project", entries) + assert node.size == 250 + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.size == 200 + assert src.children[0].name == "app.py" + + +def test_entries_to_tree_missing_intermediate_dirs() -> None: + # find output may not always list intermediate directories explicitly + entries = [ + ("a/b/file.txt", 100, False), + ] + node = _entries_to_tree("/root", entries) + a = next(c for c in node.children if c.name == "a") + assert a.is_dir + b = next(c for c in a.children if c.name == "b") + assert b.is_dir + assert b.children[0].name == "file.txt" + + +# --------------------------------------------------------------------------- +# build_tree_docker (mocked subprocess) +# --------------------------------------------------------------------------- + + +def _mock_run(find_stdout: str = "", returncode: int = 0, inspect_rc: int = 0): + """Return a side_effect function for subprocess.run that fakes docker calls.""" + + def _side_effect(cmd, **kwargs): + result = MagicMock() + if "inspect" in cmd: + result.returncode = inspect_rc + result.stdout = "" + result.stderr = "no such container" if inspect_rc != 0 else "" + else: + # docker exec ... find ... + result.returncode = returncode + result.stdout = find_stdout + result.stderr = "find error" if returncode != 0 else "" + return result + + return _side_effect + + +def test_build_tree_docker_flat() -> None: + output = "file.py\t1000\tf\nREADME.md\t500\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_docker("my-container", "/app") + assert node.is_dir + assert node.size == 1500 + assert {c.name for c in node.children} == {"file.py", "README.md"} + + +def test_build_tree_docker_skips_dotfiles() -> None: + output = ".hidden\t100\tf\nvisible.txt\t200\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_docker("my-container", "/app") + names = {c.name for c in node.children} + assert "visible.txt" in names + assert ".hidden" not in names + + +def test_build_tree_docker_exclude() -> None: + output = "keep.py\t100\tf\nskip.py\t200\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_docker("my-container", "/app", exclude=frozenset({"skip.py"})) + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names + + +def test_build_tree_docker_nested() -> None: + output = "src\t0\td\nsrc/app.py\t300\tf\ntop.txt\t100\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_docker("my-container", "/app") + assert node.size == 400 + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + + +def test_build_tree_docker_find_failure() -> None: + with ( + patch("subprocess.run", side_effect=_mock_run(returncode=1)), + pytest.raises(OSError, match="find failed"), + ): + build_tree_docker("my-container", "/missing") + + +def test_build_tree_docker_container_not_found() -> None: + with ( + patch("subprocess.run", side_effect=_mock_run(inspect_rc=1)), + pytest.raises(FileNotFoundError), + ): + build_tree_docker("no-such-container", "/app") + + +def test_build_tree_docker_progress_reported() -> None: + lines = "\n".join(f"file{i}.txt\t100\tf" for i in range(101)) + progress: list[int] = [0] + with patch("subprocess.run", side_effect=_mock_run(lines + "\n")): + build_tree_docker("my-container", "/app", _progress=progress) + assert progress[0] == 101 + + +def test_build_tree_docker_depth_passed_to_find() -> None: + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + r = MagicMock() + r.returncode = 0 + r.stdout = "" + r.stderr = "" + return r + + with patch("subprocess.run", side_effect=_capture): + build_tree_docker("my-container", "/app", depth=2) + + find_call = next(c for c in calls if "exec" in c) + assert "-xdev" in find_call + assert "-maxdepth" in find_call + assert "2" in find_call + + +# --------------------------------------------------------------------------- +# Integration tests (require a running Docker daemon) +# --------------------------------------------------------------------------- + + +@pytest.mark.docker +def test_docker_integration_structure(docker_container: str) -> None: + """Scanning the test container returns the expected directory structure.""" + node = build_tree_docker(docker_container, "/testdata") + assert node.is_dir + names = {c.name for c in node.children} + assert "src" in names + assert "README.md" in names + + +@pytest.mark.docker +def test_docker_integration_file_sizes(docker_container: str) -> None: + """Files inside the container have the exact sizes we wrote.""" + node = build_tree_docker(docker_container, "/testdata") + readme = next(c for c in node.children if c.name == "README.md") + assert readme.size == 50 + src = next(c for c in node.children if c.name == "src") + app = next(c for c in src.children if c.name == "app.py") + util = next(c for c in src.children if c.name == "util.py") + assert app.size == 100 + assert util.size == 200 + + +@pytest.mark.docker +def test_docker_integration_extensions(docker_container: str) -> None: + """File extensions are correctly detected inside a real container.""" + node = build_tree_docker(docker_container, "/testdata") + readme = next(c for c in node.children if c.name == "README.md") + assert readme.extension == ".md" + src = next(c for c in node.children if c.name == "src") + app = next(c for c in src.children if c.name == "app.py") + assert app.extension == ".py" + + +@pytest.mark.docker +def test_docker_integration_total_size(docker_container: str) -> None: + """Root node size equals sum of all file sizes (100 + 200 + 50 = 350).""" + node = build_tree_docker(docker_container, "/testdata") + assert node.size == 350 + + +@pytest.mark.docker +def test_docker_integration_exclude(docker_container: str) -> None: + """Excluded paths are omitted from the result.""" + node = build_tree_docker(docker_container, "/testdata", exclude=frozenset({"README.md"})) + names = {c.name for c in node.children} + assert "README.md" not in names + assert "src" in names + + +@pytest.mark.docker +def test_docker_integration_depth(docker_container: str) -> None: + """Depth limit prevents recursion into subdirectories.""" + node = build_tree_docker(docker_container, "/testdata", depth=1) + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.children == [] + + +@pytest.mark.docker +def test_docker_integration_missing_container() -> None: + """A non-existent container name raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + build_tree_docker("dirplot-no-such-container-xyz", "/app") diff --git a/tests/test_drawing.py b/tests/test_drawing.py index 8609654..5ebfd39 100644 --- a/tests/test_drawing.py +++ b/tests/test_drawing.py @@ -2,7 +2,7 @@ import io from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import numpy as np import pytest @@ -10,8 +10,9 @@ from dirplot.colors import assign_colors from dirplot.display import display_inline, display_window -from dirplot.render import draw_node +from dirplot.render_png import draw_node from dirplot.scanner import Node +from tests.test_display import _no_tty def _make_draw(w: int = 100, h: int = 100) -> tuple[Image.Image, ImageDraw.ImageDraw]: @@ -42,16 +43,19 @@ def test_draw_node_skips_tiny_rect() -> None: assert _px(img, 5, 5) == bg -def test_draw_node_file_has_no_border() -> None: +def test_draw_node_file_border_is_dark_not_white() -> None: img, draw = _make_draw(50, 50) node = Node(name="f.py", path=Path("f.py"), size=10, is_dir=False, extension=".py") color_map = assign_colors([".py"]) draw_node(draw, node, 0, 0, 50, 50, color_map, _font()) rgba = color_map[".py"] - expected = (int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)) - # Edge pixels should be the fill colour, not white - assert _px(img, 0, 0) == expected, "top-left corner should be fill colour, not a border" - assert _px(img, 49, 49) == expected, "bottom-right corner should be fill colour, not a border" + fill = (int(rgba[0] * 255), int(rgba[1] * 255), int(rgba[2] * 255)) + dark = (max(0, fill[0] - 60), max(0, fill[1] - 60), max(0, fill[2] - 60)) + # Edge pixels are the darkened outline colour, not white (no dir-style white border) + assert _px(img, 0, 0) == dark, "top-left corner should be the dark outline, not white" + assert _px(img, 49, 49) == dark, "bottom-right corner should be the dark outline, not white" + # Interior pixel away from the label should be the fill colour + assert _px(img, 2, 2) == fill, "interior (away from label) should be the fill colour" def test_draw_node_file_interior_is_fill_color() -> None: @@ -131,7 +135,7 @@ def test_display_inline(capsys: pytest.CaptureFixture, monkeypatch: pytest.Monke monkeypatch.delenv("TERM_PROGRAM", raising=False) monkeypatch.delenv("KITTY_WINDOW_ID", raising=False) buf = io.BytesIO(b"fake-png-data") - with patch("os.open", side_effect=OSError("no tty")): + with _no_tty(): display_inline(buf) written = capsys.readouterr().out assert "\x1b]1337;" in written diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..41ae55c --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,52 @@ +"""Tests for the matches_exclude path-pattern filtering utility.""" + +from __future__ import annotations + +from dirplot.filters import matches_exclude + + +def test_empty_patterns_returns_false() -> None: + assert matches_exclude("src/foo.py", frozenset()) is False + + +def test_plain_name_matches_any_component() -> None: + assert matches_exclude(".git", frozenset({".git"})) is True + assert matches_exclude("src/.git", frozenset({".git"})) is True + assert matches_exclude("a/b/c/.git/d", frozenset({".git"})) is True + + +def test_plain_name_no_false_positive() -> None: + assert matches_exclude("src/keep.py", frozenset({".git"})) is False + + +def test_glob_wildcard_plain_name() -> None: + assert matches_exclude("mypackage.egg-info", frozenset({"*.egg-info"})) is True + assert matches_exclude("src/mypackage.egg-info", frozenset({"*.egg-info"})) is True + assert matches_exclude("src/mypackage.egg-info/PKG-INFO", frozenset({"*.egg-info"})) is True + + +def test_relative_path_with_slash() -> None: + assert matches_exclude("src/vendor", frozenset({"src/vendor"})) is True + assert matches_exclude("src/vendor/lib.py", frozenset({"src/vendor"})) is False + + +def test_relative_path_fnmatch() -> None: + assert matches_exclude("src/foo.py", frozenset({"src/*.py"})) is True + assert matches_exclude("src/bar.txt", frozenset({"src/*.py"})) is False + + +def test_double_star_any_depth() -> None: + assert matches_exclude("__pycache__", frozenset({"**/__pycache__"})) is True + assert matches_exclude("src/__pycache__", frozenset({"**/__pycache__"})) is True + assert matches_exclude("a/b/c/__pycache__", frozenset({"**/__pycache__"})) is True + + +def test_double_star_no_false_positive() -> None: + assert matches_exclude("src/other", frozenset({"**/__pycache__"})) is False + + +def test_multiple_patterns_any_match() -> None: + patterns = frozenset({".git", "*.egg-info"}) + assert matches_exclude(".git", patterns) is True + assert matches_exclude("mypackage.egg-info", patterns) is True + assert matches_exclude("src/foo.py", patterns) is False diff --git a/tests/test_gdrive.py b/tests/test_gdrive.py new file mode 100644 index 0000000..a9e99f0 --- /dev/null +++ b/tests/test_gdrive.py @@ -0,0 +1,293 @@ +"""Tests for Google Drive scanning via the gog CLI.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from dirplot.gdrive import ( + _entries_to_tree, + build_tree_gdrive, + is_gdrive_path, + parse_gdrive_path, +) + +# --------------------------------------------------------------------------- +# URI helpers +# --------------------------------------------------------------------------- + + +def test_is_gdrive_path_root() -> None: + assert is_gdrive_path("gdrive://") + + +def test_is_gdrive_path_folder_id() -> None: + assert is_gdrive_path("gdrive://1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms") + + +@pytest.mark.parametrize("path", ["/local/path", "s3://bucket", "docker://c:/app", "github://o/r"]) +def test_is_gdrive_path_non_gdrive(path: str) -> None: + assert not is_gdrive_path(path) + + +def test_parse_gdrive_path_root() -> None: + assert parse_gdrive_path("gdrive://") is None + + +def test_parse_gdrive_path_root_trailing_slash() -> None: + assert parse_gdrive_path("gdrive:///") is None + + +def test_parse_gdrive_path_folder_id() -> None: + fid = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + assert parse_gdrive_path(f"gdrive://{fid}") == fid + + +# --------------------------------------------------------------------------- +# _entries_to_tree +# --------------------------------------------------------------------------- + + +def test_entries_to_tree_flat() -> None: + entries = [ + ("report.pdf", 2000, False), + ("notes.txt", 500, False), + ] + node = _entries_to_tree("My Drive", entries) + assert node.is_dir + assert node.name == "My Drive" + assert node.size == 2500 + assert {c.name for c in node.children} == {"report.pdf", "notes.txt"} + + +def test_entries_to_tree_extensions() -> None: + entries = [ + ("data.csv", 1000, False), + ("Makefile", 100, False), + ] + node = _entries_to_tree("My Drive", entries) + csv_node = next(c for c in node.children if c.name == "data.csv") + mk_node = next(c for c in node.children if c.name == "Makefile") + assert csv_node.extension == ".csv" + assert mk_node.extension == "(no ext)" + + +def test_entries_to_tree_nested() -> None: + entries = [ + ("Projects", 0, True), + ("Projects/app.py", 300, False), + ("README.md", 50, False), + ] + node = _entries_to_tree("My Drive", entries) + assert node.size == 350 + projects = next(c for c in node.children if c.name == "Projects") + assert projects.is_dir + assert projects.size == 300 + assert projects.children[0].name == "app.py" + + +def test_entries_to_tree_missing_intermediate_dirs() -> None: + """Items may appear without their parent folder being listed explicitly.""" + entries = [("a/b/file.txt", 100, False)] + node = _entries_to_tree("My Drive", entries) + a = next(c for c in node.children if c.name == "a") + assert a.is_dir + b = next(c for c in a.children if c.name == "b") + assert b.is_dir + assert b.children[0].name == "file.txt" + + +# --------------------------------------------------------------------------- +# build_tree_gdrive (mocked subprocess) +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _mock_gog_which(request: pytest.FixtureRequest): + """Pretend gog is installed for all build_tree_gdrive tests except the not-found test.""" + if request.node.name == "test_build_tree_gdrive_gog_not_found": + yield + return + with patch("shutil.which", return_value="/usr/local/bin/gog"): + yield + + +def _gog_response(items: list[dict], truncated: bool = False) -> MagicMock: + """Build a mock subprocess.CompletedProcess with gog JSON output.""" + result = MagicMock() + result.returncode = 0 + result.stdout = json.dumps({"items": items, "truncated": truncated}) + result.stderr = "" + return result + + +_FOLDER_MIME = "application/vnd.google-apps.folder" +_DOC_MIME = "application/vnd.google-apps.document" + + +def test_build_tree_gdrive_flat() -> None: + items = [ + {"path": "report.pdf", "mimeType": "application/pdf", "size": 2000, "depth": 1}, + {"path": "notes.txt", "mimeType": "text/plain", "size": 500, "depth": 1}, + ] + with patch("subprocess.run", return_value=_gog_response(items)): + node = build_tree_gdrive() + assert node.is_dir + assert node.name == "My Drive" + assert node.size == 2500 + assert {c.name for c in node.children} == {"report.pdf", "notes.txt"} + + +def test_build_tree_gdrive_nested() -> None: + items = [ + {"path": "Projects", "mimeType": _FOLDER_MIME, "size": 0, "depth": 1}, + {"path": "Projects/app.py", "mimeType": "text/x-python", "size": 1200, "depth": 2}, + {"path": "README.md", "mimeType": "text/plain", "size": 300, "depth": 1}, + ] + with patch("subprocess.run", return_value=_gog_response(items)): + node = build_tree_gdrive() + assert node.size == 1500 + projects = next(c for c in node.children if c.name == "Projects") + assert projects.is_dir + assert projects.size == 1200 + + +def test_build_tree_gdrive_native_formats_get_size_1() -> None: + """Google-native formats (Docs, Sheets, …) have size=0 — shown as 1 byte.""" + items = [ + {"path": "doc.gdoc", "mimeType": _DOC_MIME, "size": 0, "depth": 1}, + {"path": "real.pdf", "mimeType": "application/pdf", "size": 500, "depth": 1}, + ] + with patch("subprocess.run", return_value=_gog_response(items)): + node = build_tree_gdrive() + doc = next(c for c in node.children if c.name == "doc.gdoc") + assert doc.size == 1 + + +def test_build_tree_gdrive_skips_dotfiles() -> None: + items = [ + {"path": ".hidden", "mimeType": "text/plain", "size": 100, "depth": 1}, + {"path": "visible.txt", "mimeType": "text/plain", "size": 200, "depth": 1}, + ] + with patch("subprocess.run", return_value=_gog_response(items)): + node = build_tree_gdrive() + names = {c.name for c in node.children} + assert "visible.txt" in names + assert ".hidden" not in names + + +def test_build_tree_gdrive_exclude() -> None: + items = [ + {"path": "keep.py", "mimeType": "text/x-python", "size": 100, "depth": 1}, + {"path": "skip.py", "mimeType": "text/x-python", "size": 200, "depth": 1}, + ] + with patch("subprocess.run", return_value=_gog_response(items)): + node = build_tree_gdrive(exclude=frozenset({"skip.py"})) + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names + + +def test_build_tree_gdrive_folder_id() -> None: + """Folder ID is passed as --parent to gog.""" + fid = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + return _gog_response([]) + + with patch("subprocess.run", side_effect=_capture): + build_tree_gdrive(fid) + + assert "--parent" in calls[0] + assert fid in calls[0] + + +def test_build_tree_gdrive_root_no_parent_flag() -> None: + """Root scan (folder_id=None) must not pass --parent to gog.""" + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + return _gog_response([]) + + with patch("subprocess.run", side_effect=_capture): + build_tree_gdrive() + + assert "--parent" not in calls[0] + + +def test_build_tree_gdrive_depth_passed() -> None: + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + return _gog_response([]) + + with patch("subprocess.run", side_effect=_capture): + build_tree_gdrive(depth=3) + + assert "--depth" in calls[0] + idx = calls[0].index("--depth") + assert calls[0][idx + 1] == "3" + + +def test_build_tree_gdrive_unlimited_depth_uses_zero() -> None: + """dirplot depth=None maps to gog --depth 0 (unlimited).""" + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + return _gog_response([]) + + with patch("subprocess.run", side_effect=_capture): + build_tree_gdrive(depth=None) + + idx = calls[0].index("--depth") + assert calls[0][idx + 1] == "0" + + +def test_build_tree_gdrive_gog_not_found() -> None: + with ( + patch("shutil.which", return_value=None), + pytest.raises(FileNotFoundError, match="gog"), + ): + build_tree_gdrive() + + +def test_build_tree_gdrive_gog_failure() -> None: + bad = MagicMock() + bad.returncode = 1 + bad.stdout = "" + bad.stderr = "auth required" + with ( + patch("subprocess.run", return_value=bad), + pytest.raises(OSError, match="gog drive tree failed"), + ): + build_tree_gdrive() + + +def test_build_tree_gdrive_invalid_json() -> None: + bad = MagicMock() + bad.returncode = 0 + bad.stdout = "not json" + bad.stderr = "" + with ( + patch("subprocess.run", return_value=bad), + pytest.raises(OSError, match="invalid JSON"), + ): + build_tree_gdrive() + + +def test_build_tree_gdrive_progress_reported() -> None: + items = [ + {"path": f"file{i}.txt", "mimeType": "text/plain", "size": 100, "depth": 1} + for i in range(101) + ] + progress: list[int] = [0] + with patch("subprocess.run", return_value=_gog_response(items)): + build_tree_gdrive(_progress=progress) + assert progress[0] == 101 diff --git a/tests/test_git_github.py b/tests/test_git_github.py new file mode 100644 index 0000000..3dc5e36 --- /dev/null +++ b/tests/test_git_github.py @@ -0,0 +1,49 @@ +"""Integration test: dirplot git with a real GitHub URL (requires network + git CLI).""" + +import shutil +import socket +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from dirplot.main import app + +runner = CliRunner() + + +def _github_reachable() -> bool: + try: + with socket.create_connection(("github.com", 443), timeout=5): + return True + except OSError: + return False + + +pytestmark = pytest.mark.skipif( + not shutil.which("git") or not _github_reachable(), + reason="git CLI not found or GitHub not reachable", +) + + +def test_git_github_animate(tmp_path: Path) -> None: + """Blobless-clone dirplot/dirplot and animate the last 10 commits as an APNG.""" + out = tmp_path / "history.png" + result = runner.invoke( + app, + [ + "git", + "github://deeplook/dirplot", + "--output", + str(out), + "--range", + "HEAD~9..HEAD", + "--first", + "10", + "--size", + "400x300", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 diff --git a/tests/test_git_local.py b/tests/test_git_local.py new file mode 100644 index 0000000..f5e37ee --- /dev/null +++ b/tests/test_git_local.py @@ -0,0 +1,480 @@ +"""Tests for dirplot git with local repositories, including the .@ref syntax.""" + +import os +import shutil +import subprocess +from datetime import datetime as dt +from datetime import timedelta, timezone +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from dirplot.helpers.time import parse_last_period +from dirplot.main import app + +runner = CliRunner() + +pytestmark = pytest.mark.skipif( + not shutil.which("git"), + reason="git CLI not found", +) + +_ffmpeg_available = bool(shutil.which("ffmpeg")) + + +@pytest.fixture() +def local_repo(tmp_path: Path) -> Path: + """Create a minimal local git repo with two branches and a few commits each.""" + repo = tmp_path / "repo" + repo.mkdir() + + env_overrides = { + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + } + + def git(*args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + env={**__import__("os").environ, **env_overrides}, + ) + + git("init", "-b", "main") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + + # Two commits on main + (repo / "hello.py").write_text("print('hello')\n") + git("add", "hello.py") + git("commit", "-m", "add hello.py") + + (repo / "world.py").write_text("print('world')\n") + git("add", "world.py") + git("commit", "-m", "add world.py") + + # Feature branch with one more commit + git("checkout", "-b", "feature") + (repo / "feature.py").write_text("print('feature')\n") + git("add", "feature.py") + git("commit", "-m", "add feature.py") + + git("checkout", "main") + return repo + + +def test_git_local_at_branch(local_repo: Path, tmp_path: Path) -> None: + """dirplot git path@branch renders the branch without --range.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["git", f"{local_repo}@feature", "--output", str(out), "--size", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_git_local_at_branch_animate(local_repo: Path, tmp_path: Path) -> None: + """dirplot git path@branch --range produces a multi-frame APNG.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "git", + f"{local_repo}@feature", + "--output", + str(out), + "--range", + "feature", + "--size", + "200x150", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_git_local_at_branch_range_precedence(local_repo: Path, tmp_path: Path) -> None: + """--range takes precedence over the @ref in the path.""" + out = tmp_path / "out.png" + # @feature would include feature.py, but --range main limits to main commits only + result = runner.invoke( + app, + [ + "git", + f"{local_repo}@feature", + "--output", + str(out), + "--range", + "main", + "--size", + "200x150", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_git_local_no_at_syntax(local_repo: Path, tmp_path: Path) -> None: + """Plain path without @ still works (regression guard).""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--size", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +@pytest.mark.skipif(not _ffmpeg_available, reason="ffmpeg not found") +def test_git_local_animate_mp4(local_repo: Path, tmp_path: Path) -> None: + """dirplot git --range produces a valid .mp4 file.""" + out = tmp_path / "out.mp4" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--range", "HEAD", "--size", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +@pytest.mark.skipif(not _ffmpeg_available, reason="ffmpeg not found") +def test_git_local_animate_mp4_crf(local_repo: Path, tmp_path: Path) -> None: + """--crf controls MP4 quality: lower CRF produces a larger file.""" + out_hq = tmp_path / "hq.mp4" + out_lq = tmp_path / "lq.mp4" + common = ["git", str(local_repo), "--range", "HEAD", "--size", "200x150"] + runner.invoke(app, common + ["--output", str(out_hq), "--crf", "0"]) + runner.invoke(app, common + ["--output", str(out_lq), "--crf", "51"]) + assert out_hq.stat().st_size > out_lq.stat().st_size + + +# --------------------------------------------------------------------------- +# parse_last_period unit tests +# --------------------------------------------------------------------------- + + +def test_parse_last_period_hours() -> None: + before = dt.now(tz=timezone.utc) + result = parse_last_period("24h") + expected = before - timedelta(hours=24) + assert abs((result - expected).total_seconds()) < 5 + + +def test_parse_last_period_days() -> None: + before = dt.now(tz=timezone.utc) + result = parse_last_period("10d") + expected = before - timedelta(days=10) + assert abs((result - expected).total_seconds()) < 5 + + +def test_parse_last_period_weeks() -> None: + result = parse_last_period("2w") + expected = dt.now(tz=timezone.utc) - timedelta(weeks=2) + assert abs((result - expected).total_seconds()) < 5 + + +def test_parse_last_period_months() -> None: + """'1mo' parses as 30 days, not mis-read as '1m' (minutes).""" + result = parse_last_period("1mo") + expected = dt.now(tz=timezone.utc) - timedelta(days=30) + assert abs((result - expected).total_seconds()) < 5 + + +def test_parse_last_period_minutes() -> None: + result = parse_last_period("30m") + expected = dt.now(tz=timezone.utc) - timedelta(minutes=30) + assert abs((result - expected).total_seconds()) < 5 + + +def test_parse_last_period_returns_utc() -> None: + result = parse_last_period("1h") + assert result.tzinfo == timezone.utc + + +def test_parse_last_period_invalid_unit() -> None: + with pytest.raises(ValueError, match="Invalid --period"): + parse_last_period("3y") + + +def test_parse_last_period_no_number() -> None: + with pytest.raises(ValueError, match="Invalid --period"): + parse_last_period("d") + + +def test_parse_last_period_empty() -> None: + with pytest.raises(ValueError, match="Invalid --period"): + parse_last_period("") + + +# --------------------------------------------------------------------------- +# --period integration tests +# --------------------------------------------------------------------------- + + +def test_git_period_includes_recent_commits(local_repo: Path, tmp_path: Path) -> None: + """--period 1h includes commits made moments ago and triggers animation.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--size", "200x150", "--period", "1h"], + ) + assert result.exit_code == 0, result.output + assert out.exists() and out.stat().st_size > 0 + + +def test_git_period_invalid_value(local_repo: Path, tmp_path: Path) -> None: + """--period with an unrecognised unit exits 1 with a clear error.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--size", "200x150", "--period", "3y"], + ) + assert result.exit_code == 1 + assert "Invalid --period" in result.output + + +def test_git_period_combined_with_first(local_repo: Path, tmp_path: Path) -> None: + """--period and --first can be combined (date filter + count cap).""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "git", + str(local_repo), + "--output", + str(out), + "--size", + "200x150", + "--period", + "1h", + "--first", + "1", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() and out.stat().st_size > 0 + + +@pytest.fixture() +def repo_with_old_base(tmp_path: Path) -> Path: + """Repo whose initial commit is dated year 2000; two recent commits sit on top.""" + repo = tmp_path / "oldrepo" + repo.mkdir() + base_env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + } + + def git(*args: str, extra_env: dict | None = None) -> None: + env = {**base_env, **(extra_env or {})} + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, env=env) + + git("init", "-b", "main") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + + # Initial commit dated far in the past + (repo / "old.py").write_text("# old\n") + git("add", "old.py") + git( + "commit", + "-m", + "old commit", + extra_env={ + "GIT_AUTHOR_DATE": "2000-01-01T00:00:00+0000", + "GIT_COMMITTER_DATE": "2000-01-01T00:00:00+0000", + }, + ) + + # Two recent commits on top (current time) + (repo / "a.py").write_text("a\n") + git("add", "a.py") + git("commit", "-m", "recent a") + + (repo / "b.py").write_text("b\n") + git("add", "b.py") + git("commit", "-m", "recent b") + + return repo + + +def test_git_period_excludes_old_commits(repo_with_old_base: Path, tmp_path: Path) -> None: + """--period 1h returns only the 2 recent commits, excluding the year-2000 base.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "git", + str(repo_with_old_base), + "--output", + str(out), + "--size", + "200x150", + "--period", + "1h", + ], + ) + assert result.exit_code == 0, result.output + # 3 commits total on HEAD, only 2 pass the --period 1h filter + assert "Animating 2 of 3" in result.output + assert "filtered by --period 1h" in result.output + + +# --------------------------------------------------------------------------- +# Validation error tests +# --------------------------------------------------------------------------- + + +def test_git_inline_with_range_rejected(local_repo: Path) -> None: + """--inline is rejected when --range is given (animation mode).""" + result = runner.invoke( + app, + ["git", str(local_repo), "--inline", "--range", "main", "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "single-frame" in result.output or "--inline" in result.output + + +def test_git_inline_with_period_rejected(local_repo: Path) -> None: + """--inline is rejected when --period is given (animation mode).""" + result = runner.invoke( + app, + ["git", str(local_repo), "--inline", "--period", "1h", "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "single-frame" in result.output or "--inline" in result.output + + +def test_git_output_required_without_inline(local_repo: Path) -> None: + """--output is required when --inline is not given.""" + result = runner.invoke(app, ["git", str(local_repo), "--size", "200x150"]) + assert result.exit_code == 1 + assert "--output" in result.output or "required" in result.output + + +def test_git_first_last_mutually_exclusive(local_repo: Path, tmp_path: Path) -> None: + """--first and --last together exit 1.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "git", + str(local_repo), + "--output", + str(out), + "--range", + "main", + "--first", + "1", + "--last", + "1", + "--size", + "200x150", + ], + ) + assert result.exit_code == 1 + assert "mutually exclusive" in result.output + + +def test_git_first_without_animation_mode_rejected(local_repo: Path, tmp_path: Path) -> None: + """--first without --range or --period exits 1.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--first", "1", "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "--range" in result.output or "--period" in result.output + + +def test_git_last_without_animation_mode_rejected(local_repo: Path, tmp_path: Path) -> None: + """--last without --range or --period exits 1.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["git", str(local_repo), "--output", str(out), "--last", "1", "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "--range" in result.output or "--period" in result.output + + +# --------------------------------------------------------------------------- +# --last N functional tests +# --------------------------------------------------------------------------- + + +def test_git_last_n_animate(local_repo: Path, tmp_path: Path) -> None: + """--range main --last 1 produces a 1-frame animation.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "git", + str(local_repo), + "--output", + str(out), + "--range", + "main", + "--last", + "1", + "--size", + "200x150", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_git_first_vs_last_select_different_commits(local_repo: Path, tmp_path: Path) -> None: + """--first 1 picks the oldest commit; --last 1 picks the newest.""" + out_first = tmp_path / "first.png" + out_last = tmp_path / "last.png" + result_first = runner.invoke( + app, + [ + "git", + str(local_repo), + "--output", + str(out_first), + "--range", + "main", + "--first", + "1", + "--size", + "200x150", + ], + ) + result_last = runner.invoke( + app, + [ + "git", + str(local_repo), + "--output", + str(out_last), + "--range", + "main", + "--last", + "1", + "--size", + "200x150", + ], + ) + assert result_first.exit_code == 0, result_first.output + assert result_last.exit_code == 0, result_last.output + # The subjects reported in output differ: "add hello.py" vs "add world.py" + assert "add hello.py" in result_first.output + assert "add world.py" in result_last.output diff --git a/tests/test_git_scanner.py b/tests/test_git_scanner.py new file mode 100644 index 0000000..4b20da7 --- /dev/null +++ b/tests/test_git_scanner.py @@ -0,0 +1,360 @@ +"""Tests for git_scanner: log parsing, diff application, node tree building, rendering.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from dirplot.git_scanner import ( + _blob_sizes, + _render_frame_worker, + build_node_tree, + build_tree_from_git, + git_apply_diff, + git_initial_files, + git_log, +) + +pytestmark = pytest.mark.skipif(not shutil.which("git"), reason="git CLI not found") + + +# --------------------------------------------------------------------------- +# Fixture: minimal git repo +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + r = tmp_path / "repo" + r.mkdir() + env = { + **__import__("os").environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + } + + def git(*args: str) -> None: + subprocess.run(["git", "-C", str(r), *args], check=True, capture_output=True, env=env) + + git("init", "-b", "main") + git("config", "user.email", "test@example.com") + git("config", "user.name", "Test") + + (r / "hello.py").write_text("print('hello')\n") + git("add", "hello.py") + git("commit", "-m", "first commit") + + (r / "world.py").write_text("print('world')\n") + git("add", "world.py") + git("commit", "-m", "second commit") + + return r + + +# --------------------------------------------------------------------------- +# git_log — edge cases via mock +# --------------------------------------------------------------------------- + + +def test_git_log_blank_lines_skipped() -> None: + """Blank lines in git log output do not produce entries.""" + mock_result = MagicMock() + mock_result.stdout = "\n1234abc 1700000000 first\n\n5678def 1700000001 second\n" + with patch("subprocess.run", return_value=mock_result): + commits = git_log(Path("/fake/repo")) + assert len(commits) == 2 + assert commits[0][0] == "1234abc" + assert commits[1][0] == "5678def" + + +def test_git_log_invalid_timestamp_falls_back_to_zero() -> None: + """Non-numeric timestamp in git log output → ts=0 (no crash).""" + mock_result = MagicMock() + mock_result.stdout = "abc123 NOT_A_NUMBER the subject\n" + with patch("subprocess.run", return_value=mock_result): + commits = git_log(Path("/fake/repo")) + assert commits[0][1] == 0 + + +# --------------------------------------------------------------------------- +# git_initial_files — edge cases via mock +# --------------------------------------------------------------------------- + + +def _ls_tree_result(stdout: str) -> MagicMock: + m = MagicMock() + m.stdout = stdout + return m + + +def test_git_initial_files_blank_lines_skipped() -> None: + out = "\n100644 blob abc123 42\thello.py\n\n" + with patch("subprocess.run", return_value=_ls_tree_result(out)): + files = git_initial_files(Path("/repo"), "HEAD") + assert "hello.py" in files + + +def test_git_initial_files_non_blob_skipped() -> None: + out = "040000 tree abc123 -\tsome_dir\n100644 blob def456 10\tfile.py\n" + with patch("subprocess.run", return_value=_ls_tree_result(out)): + files = git_initial_files(Path("/repo"), "HEAD") + assert "file.py" in files + assert "some_dir" not in files + + +def test_git_initial_files_excluded_skipped() -> None: + out = "100644 blob abc 10\texcluded_dir/file.py\n100644 blob def 20\tkeep.py\n" + with patch("subprocess.run", return_value=_ls_tree_result(out)): + files = git_initial_files(Path("/repo"), "HEAD", exclude=frozenset(["excluded_dir"])) + assert "keep.py" in files + assert "excluded_dir/file.py" not in files + + +def test_git_initial_files_invalid_size_fallback() -> None: + out = "100644 blob abc NOTANINT\tfile.py\n" + with patch("subprocess.run", return_value=_ls_tree_result(out)): + files = git_initial_files(Path("/repo"), "HEAD") + assert files.get("file.py") == 1 + + +def test_git_initial_files_missing_tab_skipped() -> None: + out = "100644 blob abc 10 no_tab_here\n" + with patch("subprocess.run", return_value=_ls_tree_result(out)): + files = git_initial_files(Path("/repo"), "HEAD") + assert not files + + +# --------------------------------------------------------------------------- +# _blob_sizes +# --------------------------------------------------------------------------- + + +def test_blob_sizes_empty() -> None: + assert _blob_sizes(Path("/repo"), []) == {} + + +def test_blob_sizes_with_repo(repo: Path) -> None: + result = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + sha = result.stdout.strip() + # Get a real blob hash + ls = subprocess.run( + ["git", "-C", str(repo), "ls-tree", "-r", "--long", sha], + capture_output=True, + text=True, + check=True, + ) + line = ls.stdout.splitlines()[0] + blob_hash = line.split()[2] + sizes = _blob_sizes(repo, [blob_hash]) + assert blob_hash in sizes + assert sizes[blob_hash] >= 1 + + +# --------------------------------------------------------------------------- +# git_apply_diff — status types via mock +# --------------------------------------------------------------------------- + + +def _diff_result(stdout: str) -> MagicMock: + m = MagicMock() + m.stdout = stdout + return m + + +def _cat_file_result(blob_hash: str, size: int) -> MagicMock: + m = MagicMock() + m.stdout = f"{blob_hash} blob {size}\n" + return m + + +def test_git_apply_diff_added() -> None: + diff_out = ":000000 100644 0000000 abc1234 A\tnew_file.py\n" + files: dict[str, int] = {} + with patch( + "subprocess.run", side_effect=[_diff_result(diff_out), _cat_file_result("abc1234", 42)] + ): + highlights = git_apply_diff(Path("/repo"), files, "prev", "curr") + assert files.get("new_file.py") == 42 + assert "/repo/new_file.py" in highlights + + +def test_git_apply_diff_modified() -> None: + diff_out = ":100644 100644 old1234 new1234 M\texisting.py\n" + files = {"existing.py": 10} + with patch( + "subprocess.run", side_effect=[_diff_result(diff_out), _cat_file_result("new1234", 99)] + ): + highlights = git_apply_diff(Path("/repo"), files, "prev", "curr") + assert files.get("existing.py") == 99 + assert highlights.get("/repo/existing.py") == "modified" + + +def test_git_apply_diff_deleted() -> None: + diff_out = ":100644 000000 abc1234 0000000 D\tgone.py\n" + files = {"gone.py": 50} + with patch("subprocess.run", return_value=_diff_result(diff_out)): + highlights = git_apply_diff(Path("/repo"), files, "prev", "curr") + assert "gone.py" not in files + assert highlights.get("/repo/gone.py") == "deleted" + + +def test_git_apply_diff_renamed() -> None: + diff_out = ":100644 100644 old1234 new1234 R090\told.py\tnew.py\n" + files = {"old.py": 30} + with patch( + "subprocess.run", side_effect=[_diff_result(diff_out), _cat_file_result("new1234", 30)] + ): + highlights = git_apply_diff(Path("/repo"), files, "prev", "curr") + assert "old.py" not in files + assert files.get("new.py") == 30 + assert highlights.get("/repo/old.py") == "deleted" + assert highlights.get("/repo/new.py") == "created" + + +def test_git_apply_diff_copied() -> None: + diff_out = ":100644 100644 src1234 dst1234 C100\tsrc.py\tcopy.py\n" + files = {"src.py": 20} + with patch( + "subprocess.run", side_effect=[_diff_result(diff_out), _cat_file_result("dst1234", 20)] + ): + highlights = git_apply_diff(Path("/repo"), files, "prev", "curr") + assert files.get("copy.py") == 20 + assert highlights.get("/repo/copy.py") == "created" + + +def test_git_apply_diff_excluded() -> None: + diff_out = ":000000 100644 0000000 abc1234 A\texcl/secret.py\n" + files: dict[str, int] = {} + with patch("subprocess.run", return_value=_diff_result(diff_out)): + highlights = git_apply_diff( + Path("/repo"), files, "prev", "curr", exclude=frozenset(["excl"]) + ) + assert not files + assert not highlights + + +def test_git_apply_diff_blank_line_skipped() -> None: + diff_out = "\n:000000 100644 0000000 abc1234 A\tvalid.py\n" + files: dict[str, int] = {} + with patch( + "subprocess.run", side_effect=[_diff_result(diff_out), _cat_file_result("abc1234", 5)] + ): + git_apply_diff(Path("/repo"), files, "prev", "curr") + assert "valid.py" in files + + +# --------------------------------------------------------------------------- +# build_node_tree +# --------------------------------------------------------------------------- + + +def test_build_node_tree_depth_limit(tmp_path: Path) -> None: + files = {"a/b/c/deep.py": 100, "top.py": 50} + node = build_node_tree(tmp_path, files, depth=2) + assert node.is_dir + # With depth=2, "a/b/c/deep.py" is truncated to "a/b" + names = {c.name for c in node.children} + assert "a" in names + assert "top.py" in names + + +def test_build_node_tree_duplicate_leaf_accumulates(tmp_path: Path) -> None: + """Two entries mapping to the same truncated leaf accumulate sizes.""" + files = {"a/b/x.py": 100, "a/b/y.py": 200} + node = build_node_tree(tmp_path, files, depth=2) + # Both truncate to "a/b" — sizes are accumulated on the directory + assert node.size >= 300 + + +def test_build_node_tree_empty(tmp_path: Path) -> None: + node = build_node_tree(tmp_path, {}) + assert node.is_dir + assert node.size >= 1 + + +# --------------------------------------------------------------------------- +# _render_frame_worker +# --------------------------------------------------------------------------- + + +def test_render_frame_worker(tmp_path: Path) -> None: + files = {"hello.py": 100, "world.py": 50} + import time + + args = ( + str(tmp_path), + files, + {}, # highlights + "abc1234", # sha + int(time.time()), # ts + 0, # orig_i + 0.5, # progress + None, # depth + 0.0, # logscale (disabled) + 200, # width_px + 150, # height_px + 12, # font_size + "tab20", # colormap + False, # cushion + True, # dark + ) + orig_i, png_bytes, rect_map = _render_frame_worker(args) + assert orig_i == 0 + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n" + + +def test_render_frame_worker_log_scale(tmp_path: Path) -> None: + files = {"big.py": 10_000, "small.py": 1} + import time + + args = ( + str(tmp_path), + files, + None, + "def5678", + int(time.time()), + 2, + 1.0, + None, + 4.0, # logscale + 200, + 150, + 12, + "tab20", + True, # cushion + True, # dark + ) + orig_i, png_bytes, _ = _render_frame_worker(args) + assert orig_i == 2 + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n" + + +# --------------------------------------------------------------------------- +# build_tree_from_git +# --------------------------------------------------------------------------- + + +def test_build_tree_from_git(repo: Path) -> None: + result = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + sha = result.stdout.strip() + node = build_tree_from_git(repo, sha) + assert node.is_dir + assert node.size >= 1 + names = {c.name for c in node.children} + assert "hello.py" in names or "world.py" in names diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..a5c8aa6 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,292 @@ +"""Tests for GitHub repository tree scanning.""" + +import json +import subprocess +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from dirplot.github import _gh_cli_token, build_tree_github, is_github_path, parse_github_path + +# --------------------------------------------------------------------------- +# Mock helper +# --------------------------------------------------------------------------- + + +def mock_urlopen(responses: dict[str, Any]): + """Patch urllib.request.urlopen mapping URL substrings to JSON responses.""" + + def fake_urlopen(req: Any) -> Any: + url = req.full_url if hasattr(req, "full_url") else str(req) + for pattern, data in responses.items(): + if pattern in url: + resp = MagicMock() + resp.read.return_value = json.dumps(data).encode() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + raise AssertionError(f"Unexpected URL in test: {url}") + + return patch("urllib.request.urlopen", side_effect=fake_urlopen) + + +def repo_response(default_branch: str = "main") -> dict[str, Any]: + return {"default_branch": default_branch, "full_name": "owner/repo"} + + +def tree_response(items: list[dict[str, Any]], truncated: bool = False) -> dict[str, Any]: + return {"sha": "abc123", "tree": items, "truncated": truncated} + + +def blob(path: str, size: int) -> dict[str, Any]: + return {"path": path, "type": "blob", "size": size, "sha": "x"} + + +def tree(path: str) -> dict[str, Any]: + return {"path": path, "type": "tree", "sha": "x"} + + +# --------------------------------------------------------------------------- +# URI helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "s", + [ + "github://owner/repo", + "github://owner/repo@main", + "https://github.com/owner/repo", + "https://github.com/owner/repo/tree/main", + ], +) +def test_is_github_path(s: str) -> None: + assert is_github_path(s) + + +@pytest.mark.parametrize("s", ["/local/path", "s3://bucket", "ssh://user@host/path", "."]) +def test_is_not_github_path(s: str) -> None: + assert not is_github_path(s) + + +def test_parse_github_url_scheme_no_branch() -> None: + assert parse_github_path("github://owner/repo") == ("owner", "repo", None, "") + + +def test_parse_github_url_scheme_with_branch() -> None: + assert parse_github_path("github://owner/repo@dev") == ("owner", "repo", "dev", "") + + +def test_parse_github_url_no_branch() -> None: + assert parse_github_path("https://github.com/owner/repo") == ("owner", "repo", None, "") + + +def test_parse_github_url_with_branch() -> None: + assert parse_github_path("https://github.com/owner/repo/tree/feature-x") == ( + "owner", + "repo", + "feature-x", + "", + ) + + +def test_parse_github_url_with_at_ref() -> None: + assert parse_github_path("https://github.com/owner/repo@v0.4.0") == ( + "owner", + "repo", + "v0.4.0", + "", + ) + + +def test_parse_github_url_scheme_with_subpath() -> None: + assert parse_github_path("github://owner/repo/sub/path") == ("owner", "repo", None, "sub/path") + + +def test_parse_github_url_scheme_with_ref_and_subpath() -> None: + assert parse_github_path("github://owner/repo@v1.0/sub/path") == ( + "owner", + "repo", + "v1.0", + "sub/path", + ) + + +def test_parse_github_url_with_branch_and_subpath() -> None: + assert parse_github_path("https://github.com/owner/repo/tree/main/src/foo") == ( + "owner", + "repo", + "main", + "src/foo", + ) + + +# --------------------------------------------------------------------------- +# build_tree_github +# --------------------------------------------------------------------------- + + +def test_build_tree_github_resolves_default_branch() -> None: + with mock_urlopen( + { + "/repos/owner/repo\x00": repo_response("trunk"), # won't match + "git/trees": tree_response([blob("README.md", 100)]), + "/repos/owner/repo": repo_response("trunk"), + } + ): + node, branch = build_tree_github("owner", "repo") + assert branch == "trunk" + + +def test_build_tree_github_uses_explicit_branch() -> None: + with mock_urlopen({"git/trees": tree_response([blob("README.md", 50)])}): + node, branch = build_tree_github("owner", "repo", "my-branch") + assert branch == "my-branch" + + +def test_build_tree_github_flat() -> None: + items = [blob("app.py", 1000), blob("README.md", 500)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main") + + assert node.is_dir + assert node.name == "repo" + assert node.size == 1500 + assert {c.name for c in node.children} == {"app.py", "README.md"} + + +def test_build_tree_github_extensions() -> None: + items = [blob("script.py", 100), blob("Makefile", 50)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main") + + py = next(c for c in node.children if c.name == "script.py") + mk = next(c for c in node.children if c.name == "Makefile") + assert py.extension == ".py" + assert mk.extension == "(no ext)" + + +def test_build_tree_github_nested() -> None: + items = [tree("src"), blob("src/app.py", 200), blob("src/util.py", 100), blob("README.md", 50)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main") + + assert node.size == 350 + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.size == 300 + assert {c.name for c in src.children} == {"app.py", "util.py"} + + +def test_build_tree_github_skips_dotfiles() -> None: + items = [blob(".env", 100), blob("app.py", 200), tree(".github"), blob(".github/ci.yml", 50)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main") + + names = {c.name for c in node.children} + assert "app.py" in names + assert ".env" not in names + assert ".github" not in names + + +def test_build_tree_github_exclude() -> None: + items = [blob("keep.py", 100), blob("skip.py", 200)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main", exclude=frozenset({"skip.py"})) + + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names + + +def test_build_tree_github_depth_limit() -> None: + items = [tree("src"), blob("src/app.py", 200), blob("top.txt", 50)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main", depth=1) + + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.children == [] + + +def test_build_tree_github_zero_size_defaults_to_1() -> None: + items = [blob("empty.txt", 0)] + with mock_urlopen({"git/trees": tree_response(items)}): + node, _ = build_tree_github("owner", "repo", "main") + + assert node.children[0].size == 1 + + +def test_build_tree_github_truncated_warns(capsys: pytest.CaptureFixture[str]) -> None: + items = [blob("file.py", 100)] + with mock_urlopen({"git/trees": tree_response(items, truncated=True)}): + build_tree_github("owner", "repo", "main") + + +# --------------------------------------------------------------------------- +# _gh_cli_token +# --------------------------------------------------------------------------- + + +def test_gh_cli_token_not_installed() -> None: + """Returns None when gh is not on PATH.""" + with patch("subprocess.run", side_effect=FileNotFoundError): + assert _gh_cli_token() is None + + +def test_gh_cli_token_authenticated() -> None: + """Returns the stripped token when gh exits 0.""" + mock_result = MagicMock(returncode=0, stdout="ghp_testtoken123\n") + with patch("subprocess.run", return_value=mock_result): + assert _gh_cli_token() == "ghp_testtoken123" + + +def test_gh_cli_token_not_authenticated() -> None: + """Returns None when gh exits non-zero (not logged in).""" + mock_result = MagicMock(returncode=1, stdout="") + with patch("subprocess.run", return_value=mock_result): + assert _gh_cli_token() is None + + +def test_gh_cli_token_timeout() -> None: + """Returns None when gh times out.""" + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 5)): + assert _gh_cli_token() is None + + +def test_gh_cli_token_empty_output() -> None: + """Returns None when gh outputs only whitespace.""" + mock_result = MagicMock(returncode=0, stdout=" \n") + with patch("subprocess.run", return_value=mock_result): + assert _gh_cli_token() is None + + +def test_build_tree_github_falls_back_to_gh_cli_token() -> None: + """build_tree_github uses the gh CLI token when GITHUB_TOKEN is absent.""" + items = [blob("README.md", 100)] + captured_headers: list[str] = [] + + def fake_urlopen(req: Any) -> Any: + auth = req.get_header("Authorization") + if auth: + captured_headers.append(auth) + resp = MagicMock() + if "git/trees" in req.full_url: + resp.read.return_value = json.dumps(tree_response(items)).encode() + else: + resp.read.return_value = json.dumps(repo_response()).encode() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + gh_token = MagicMock(return_value="ghp_fromcli") + with ( + patch("urllib.request.urlopen", side_effect=fake_urlopen), + patch("os.environ.get", return_value=None), + patch("dirplot.github._gh_cli_token", gh_token), + ): + build_tree_github("owner", "repo", "main") + + gh_token.assert_called_once() + assert any("ghp_fromcli" in h for h in captured_headers) diff --git a/tests/test_hg_local.py b/tests/test_hg_local.py new file mode 100644 index 0000000..76f22fc --- /dev/null +++ b/tests/test_hg_local.py @@ -0,0 +1,326 @@ +"""Integration tests for dirplot hg with a local Mercurial repository.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from dirplot.main import app + +runner = CliRunner() + +pytestmark = pytest.mark.skipif( + not shutil.which("hg"), + reason="hg CLI not found", +) + +_ffmpeg_available = bool(shutil.which("ffmpeg")) + + +@pytest.fixture() +def local_hg_repo(tmp_path: Path) -> Path: + """Create a minimal local Mercurial repo with two commits.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["hg", "init", str(repo)], check=True, capture_output=True) + (repo / "hello.py").write_text("print('hello')\n") + subprocess.run(["hg", "add", "hello.py"], check=True, capture_output=True, cwd=str(repo)) + subprocess.run( + [ + "hg", + "commit", + "-m", + "first", + "-u", + "T <t@t.com>", + "-d", + "2024-01-01 00:00:00 +0000", + ], + check=True, + capture_output=True, + cwd=str(repo), + ) + (repo / "world.py").write_text("print('world')\n") + subprocess.run(["hg", "add", "world.py"], check=True, capture_output=True, cwd=str(repo)) + subprocess.run( + [ + "hg", + "commit", + "-m", + "second", + "-u", + "T <t@t.com>", + ], + check=True, + capture_output=True, + cwd=str(repo), + ) + return repo + + +def test_hg_local_static_png(local_hg_repo: Path, tmp_path: Path) -> None: + """dirplot hg renders a static PNG for the final changeset.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--size", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_hg_local_animate_apng(local_hg_repo: Path, tmp_path: Path) -> None: + """dirplot hg --range produces a multi-frame APNG.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "hg", + str(local_hg_repo), + "--output", + str(out), + "--range", + "0:tip", + "--size", + "200x150", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_hg_local_at_rev_syntax(local_hg_repo: Path, tmp_path: Path) -> None: + """path@rev syntax passes a revision range to hg_log.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["hg", f"{local_hg_repo}@tip", "--output", str(out), "--size", "200x150"], + ) + assert result.exit_code == 0, result.output + assert out.exists() + assert out.stat().st_size > 0 + + +def test_hg_not_installed(local_hg_repo: Path, tmp_path: Path) -> None: + """When hg is not on PATH, exit 1 with a helpful install hint.""" + out = tmp_path / "out.png" + with pytest.MonkeyPatch().context() as mp: + mp.setattr(shutil, "which", lambda cmd: None if cmd == "hg" else shutil.which(cmd)) + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "hg not found" in result.output or "not found" in result.output + + +def test_hg_not_a_repo(tmp_path: Path) -> None: + """Pointing at a non-hg directory exits 1 with a clear error.""" + not_a_repo = tmp_path / "notarepo" + not_a_repo.mkdir() + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["hg", str(not_a_repo), "--output", str(out), "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "not a Mercurial repository" in result.output + + +@pytest.fixture() +def local_hg_repo_3(tmp_path: Path) -> Path: + """Local Mercurial repo with three commits (for --first/--last ordering tests).""" + repo = tmp_path / "repo3" + repo.mkdir() + subprocess.run(["hg", "init", str(repo)], check=True, capture_output=True) + + def commit(filename: str, content: str, message: str, date: str) -> None: + (repo / filename).write_text(content) + subprocess.run(["hg", "add", filename], check=True, capture_output=True, cwd=str(repo)) + subprocess.run( + ["hg", "commit", "-m", message, "-u", "T <t@t.com>", "-d", date], + check=True, + capture_output=True, + cwd=str(repo), + ) + + commit("a.py", "a\n", "first", "2024-01-01 00:00:00 +0000") + commit("b.py", "b\n", "second", "2024-01-02 00:00:00 +0000") + commit("c.py", "c\n", "third", "2024-01-03 00:00:00 +0000") + return repo + + +# --------------------------------------------------------------------------- +# --period tests +# --------------------------------------------------------------------------- + + +def test_hg_period_includes_recent_commits(local_hg_repo: Path, tmp_path: Path) -> None: + """--period 1h animates recently-made commits.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--size", "200x150", "--period", "1h"], + ) + assert result.exit_code == 0, result.output + assert out.exists() and out.stat().st_size > 0 + + +def test_hg_period_invalid_value(local_hg_repo: Path, tmp_path: Path) -> None: + """--period with an unrecognised unit exits 1.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--size", "200x150", "--period", "3y"], + ) + assert result.exit_code == 1 + assert "Invalid --period" in result.output + + +# --------------------------------------------------------------------------- +# Validation error tests +# --------------------------------------------------------------------------- + + +def test_hg_inline_with_range_rejected(local_hg_repo: Path) -> None: + """--inline is rejected when --range is given (animation mode).""" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--inline", "--range", "0:tip", "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "single-frame" in result.output or "--inline" in result.output + + +def test_hg_first_last_mutually_exclusive(local_hg_repo: Path, tmp_path: Path) -> None: + """--first and --last together exit 1.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "hg", + str(local_hg_repo), + "--output", + str(out), + "--range", + "0:tip", + "--first", + "1", + "--last", + "1", + "--size", + "200x150", + ], + ) + assert result.exit_code == 1 + assert "mutually exclusive" in result.output + + +def test_hg_first_without_animation_mode_rejected(local_hg_repo: Path, tmp_path: Path) -> None: + """--first without --range or --period exits 1.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + ["hg", str(local_hg_repo), "--output", str(out), "--first", "1", "--size", "200x150"], + ) + assert result.exit_code == 1 + assert "--range" in result.output or "--period" in result.output + + +# --------------------------------------------------------------------------- +# --first / --last functional tests +# --------------------------------------------------------------------------- + + +def test_hg_first_n_animate(local_hg_repo_3: Path, tmp_path: Path) -> None: + """--range 0:tip --first 2 produces a 2-frame animation.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "hg", + str(local_hg_repo_3), + "--output", + str(out), + "--range", + "0:tip", + "--first", + "2", + "--size", + "200x150", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() and out.stat().st_size > 0 + assert "Animating 2" in result.output + + +def test_hg_last_n_animate(local_hg_repo_3: Path, tmp_path: Path) -> None: + """--range 0:tip --last 1 produces a 1-frame animation.""" + out = tmp_path / "out.png" + result = runner.invoke( + app, + [ + "hg", + str(local_hg_repo_3), + "--output", + str(out), + "--range", + "0:tip", + "--last", + "1", + "--size", + "200x150", + ], + ) + assert result.exit_code == 0, result.output + assert out.exists() and out.stat().st_size > 0 + assert "Animating 1" in result.output + + +def test_hg_first_vs_last_select_different_changesets( + local_hg_repo_3: Path, tmp_path: Path +) -> None: + """--first 1 picks the oldest changeset; --last 1 picks the newest.""" + out_first = tmp_path / "first.png" + out_last = tmp_path / "last.png" + result_first = runner.invoke( + app, + [ + "hg", + str(local_hg_repo_3), + "--output", + str(out_first), + "--range", + "0:tip", + "--first", + "1", + "--size", + "200x150", + ], + ) + result_last = runner.invoke( + app, + [ + "hg", + str(local_hg_repo_3), + "--output", + str(out_last), + "--range", + "0:tip", + "--last", + "1", + "--size", + "200x150", + ], + ) + assert result_first.exit_code == 0, result_first.output + assert result_last.exit_code == 0, result_last.output + assert "first" in result_first.output + assert "third" in result_last.output diff --git a/tests/test_hg_scanner.py b/tests/test_hg_scanner.py new file mode 100644 index 0000000..910321d --- /dev/null +++ b/tests/test_hg_scanner.py @@ -0,0 +1,228 @@ +"""Tests for hg_scanner: log parsing, diff application, initial file listing.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from dirplot.hg_scanner import hg_apply_diff, hg_initial_files, hg_log + +pytestmark = pytest.mark.skipif(not shutil.which("hg"), reason="hg not found") + + +# --------------------------------------------------------------------------- +# hg_log — edge cases via mock +# --------------------------------------------------------------------------- + + +def test_hg_log_blank_lines_skipped() -> None: + """Blank lines in hg log output do not produce entries.""" + mock_result = MagicMock() + mock_result.stdout = "\nabc123 1700000000 -18000 first\n\ndef456 1700000001 0 second\n" + with patch("subprocess.run", return_value=mock_result): + commits = hg_log(Path("/fake/repo")) + assert len(commits) == 2 + assert commits[0][0] == "abc123" + assert commits[1][0] == "def456" + + +def test_hg_log_invalid_timestamp_falls_back_to_zero() -> None: + """Non-numeric timestamp in hg log output → ts=0 (no crash).""" + mock_result = MagicMock() + mock_result.stdout = "abc123 NOT_A_NUMBER -18000 the subject\n" + with patch("subprocess.run", return_value=mock_result): + commits = hg_log(Path("/fake/repo")) + assert commits[0][1] == 0 + + +def test_hg_log_hgdate_two_token_format() -> None: + """hgdate 'unix offset' format — only the unix timestamp is used.""" + mock_result = MagicMock() + mock_result.stdout = "abc123 1700000000 -18000 First commit message\n" + with patch("subprocess.run", return_value=mock_result): + commits = hg_log(Path("/fake/repo")) + assert len(commits) == 1 + assert commits[0][1] == 1700000000 + assert commits[0][2] == "First commit message" + + +def test_hg_log_subject_preserved() -> None: + """Subject line with spaces is returned intact.""" + mock_result = MagicMock() + mock_result.stdout = "abc 1700000000 0 Fix a nasty bug in parser\n" + with patch("subprocess.run", return_value=mock_result): + commits = hg_log(Path("/fake/repo")) + assert commits[0][2] == "Fix a nasty bug in parser" + + +# --------------------------------------------------------------------------- +# hg_initial_files — mock subprocess, real filesystem +# --------------------------------------------------------------------------- + + +def _make_archive(tmp_path: Path, structure: dict[str, int]) -> Path: + """Create a fake hg archive tree inside tmp_path and return the tmpdir root.""" + # hg archive creates: {tmpdir}/archive/{prefix}/ with files inside. + prefix_dir = tmp_path / "archive" / "repo-0" + for rel_path, size in structure.items(): + full = prefix_dir / rel_path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_bytes(b"x" * size) + return tmp_path + + +def test_hg_initial_files_basic(tmp_path: Path) -> None: + """Files in the archive prefix dir are collected with correct sizes.""" + _make_archive(tmp_path, {"hello.py": 42, "subdir/world.py": 20}) + mock_proc = MagicMock() + + with ( + patch("subprocess.run", return_value=mock_proc), + patch("tempfile.TemporaryDirectory") as mock_td, + ): + mock_td.return_value.__enter__.return_value = str(tmp_path) + mock_td.return_value.__exit__.return_value = False + files = hg_initial_files(Path("/repo"), "abc123") + + assert "hello.py" in files + assert files["hello.py"] == 42 + assert "subdir/world.py" in files + assert files["subdir/world.py"] == 20 + + +def test_hg_initial_files_skips_hg_archival(tmp_path: Path) -> None: + """.hg_archival.txt added by hg archive is excluded from results.""" + _make_archive(tmp_path, {".hg_archival.txt": 100, "main.py": 10}) + mock_proc = MagicMock() + + with ( + patch("subprocess.run", return_value=mock_proc), + patch("tempfile.TemporaryDirectory") as mock_td, + ): + mock_td.return_value.__enter__.return_value = str(tmp_path) + mock_td.return_value.__exit__.return_value = False + files = hg_initial_files(Path("/repo"), "abc123") + + assert ".hg_archival.txt" not in files + assert "main.py" in files + + +def test_hg_initial_files_excludes_top_level_dir(tmp_path: Path) -> None: + """Top-level directory in the exclude set is omitted.""" + _make_archive(tmp_path, {"vendor/lib.py": 5, "src/app.py": 8}) + mock_proc = MagicMock() + + with ( + patch("subprocess.run", return_value=mock_proc), + patch("tempfile.TemporaryDirectory") as mock_td, + ): + mock_td.return_value.__enter__.return_value = str(tmp_path) + mock_td.return_value.__exit__.return_value = False + files = hg_initial_files(Path("/repo"), "abc123", exclude=frozenset(["vendor"])) + + assert "vendor/lib.py" not in files + assert "src/app.py" in files + + +# --------------------------------------------------------------------------- +# hg_apply_diff — status types via mock +# --------------------------------------------------------------------------- + + +def _status_result(stdout: str) -> MagicMock: + m = MagicMock() + m.stdout = stdout + return m + + +def _cat_result(content: bytes) -> MagicMock: + m = MagicMock() + m.stdout = content + return m + + +def test_hg_apply_diff_added() -> None: + status_out = "A new_file.py\n" + files: dict[str, int] = {} + with patch( + "subprocess.run", + side_effect=[_status_result(status_out), _cat_result(b"x" * 55)], + ): + highlights = hg_apply_diff(Path("/repo"), files, "prev", "curr") + assert files.get("new_file.py") == 55 + assert highlights.get("/repo/new_file.py") == "created" + + +def test_hg_apply_diff_modified() -> None: + status_out = "M existing.py\n" + files = {"existing.py": 10} + with patch( + "subprocess.run", + side_effect=[_status_result(status_out), _cat_result(b"y" * 99)], + ): + highlights = hg_apply_diff(Path("/repo"), files, "prev", "curr") + assert files.get("existing.py") == 99 + assert highlights.get("/repo/existing.py") == "modified" + + +def test_hg_apply_diff_deleted() -> None: + status_out = "R gone.py\n" + files = {"gone.py": 50} + with patch("subprocess.run", return_value=_status_result(status_out)): + highlights = hg_apply_diff(Path("/repo"), files, "prev", "curr") + assert "gone.py" not in files + assert highlights.get("/repo/gone.py") == "deleted" + + +def test_hg_apply_diff_renamed() -> None: + status_out = "A new.py\n old.py\nR old.py\n" + files = {"old.py": 30} + with patch( + "subprocess.run", + side_effect=[_status_result(status_out), _cat_result(b"z" * 30)], + ): + highlights = hg_apply_diff(Path("/repo"), files, "prev", "curr") + assert "old.py" not in files + assert files.get("new.py") == 30 + assert highlights.get("/repo/old.py") == "deleted" + assert highlights.get("/repo/new.py") == "created" + + +def test_hg_apply_diff_excluded() -> None: + status_out = "A excl/secret.py\nM excl/other.py\nR excl/gone.py\n" + files: dict[str, int] = {"excl/gone.py": 1} + with patch("subprocess.run", return_value=_status_result(status_out)): + highlights = hg_apply_diff( + Path("/repo"), files, "prev", "curr", exclude=frozenset(["excl"]) + ) + assert not highlights + # gone.py stays because its top-level dir is excluded + assert "excl/gone.py" in files + + +def test_hg_apply_diff_blank_lines_skipped() -> None: + status_out = "\nA valid.py\n\n" + files: dict[str, int] = {} + with patch( + "subprocess.run", + side_effect=[_status_result(status_out), _cat_result(b"a" * 7)], + ): + hg_apply_diff(Path("/repo"), files, "prev", "curr") + assert "valid.py" in files + + +def test_hg_apply_diff_rename_r_line_not_double_deleted() -> None: + """The R line for a rename source must not emit a second 'deleted' highlight.""" + status_out = "A new.py\n old.py\nR old.py\n" + files = {"old.py": 20} + with patch( + "subprocess.run", + side_effect=[_status_result(status_out), _cat_result(b"x" * 20)], + ): + highlights = hg_apply_diff(Path("/repo"), files, "prev", "curr") + # old.py appears exactly once in highlights as 'deleted' + deleted_entries = [k for k, v in highlights.items() if v == "deleted"] + assert len(deleted_entries) == 1 diff --git a/tests/test_k8s.py b/tests/test_k8s.py new file mode 100644 index 0000000..c82e2dd --- /dev/null +++ b/tests/test_k8s.py @@ -0,0 +1,447 @@ +"""Tests for Kubernetes pod directory scanning.""" + +from __future__ import annotations + +import shutil +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from dirplot.k8s import ( + _entries_to_tree, + build_tree_pod, + is_pod_path, + parse_pod_path, +) + +# --------------------------------------------------------------------------- +# Integration fixture +# --------------------------------------------------------------------------- + +_POD_NAME = "dirplot-k8s-integration-test" + + +def _kubectl_available() -> bool: + """Return True if kubectl is in PATH and can reach a cluster.""" + if shutil.which("kubectl") is None: + return False + try: + result = subprocess.run( + ["kubectl", "cluster-info"], + capture_output=True, + timeout=10, + ) + return result.returncode == 0 + except Exception: + return False + + +@pytest.fixture(scope="session") +def k8s_pod(): + """Run a temporary pod with known files for integration tests. + + Uses nginx (alpine-based) which is available in minikube's local registry. + Skips the whole session if kubectl/cluster is unavailable. + """ + if not _kubectl_available(): + pytest.skip("kubectl not available or no cluster reachable") + + # Clean up any leftover pod from a previous interrupted run + subprocess.run( + ["kubectl", "delete", "pod", _POD_NAME, "--ignore-not-found"], capture_output=True + ) + + result = subprocess.run( + [ + "kubectl", + "run", + _POD_NAME, + "--image=python:3.12-slim", + "--restart=Never", + "--", + "sleep", + "infinity", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + pytest.skip(f"Could not create pod: {result.stderr.strip()}") + + # Wait until Running + wait = subprocess.run( + ["kubectl", "wait", "--for=condition=Ready", f"pod/{_POD_NAME}", "--timeout=90s"], + capture_output=True, + text=True, + ) + if wait.returncode != 0: + subprocess.run( + ["kubectl", "delete", "pod", _POD_NAME, "--ignore-not-found"], capture_output=True + ) + pytest.skip(f"Pod did not become ready: {wait.stderr.strip()}") + + # Create a known directory tree inside the pod + subprocess.run( + [ + "kubectl", + "exec", + _POD_NAME, + "--", + "python3", + "-c", + ( + "import os; " + "os.makedirs('/testdata/src', exist_ok=True); " + "open('/testdata/src/app.py', 'wb').write(b'x' * 100); " + "open('/testdata/src/util.py', 'wb').write(b'x' * 200); " + "open('/testdata/README.md', 'wb').write(b'y' * 50); " + ), + ], + check=True, + ) + + yield _POD_NAME + + subprocess.run( + ["kubectl", "delete", "pod", _POD_NAME, "--ignore-not-found", "--grace-period=0"], + capture_output=True, + ) + + +# --------------------------------------------------------------------------- +# URI helpers – is_pod_path +# --------------------------------------------------------------------------- + + +def test_is_pod_path_valid() -> None: + assert is_pod_path("pod://mypod:/app") + + +def test_is_pod_path_without_colon() -> None: + assert is_pod_path("pod://mypod/app") + + +def test_is_pod_path_with_namespace() -> None: + assert is_pod_path("pod://mypod@default/app") + + +@pytest.mark.parametrize("path", ["/local/path", "docker://container:/app", "s3://bucket"]) +def test_is_pod_path_non_pod(path: str) -> None: + assert not is_pod_path(path) + + +# --------------------------------------------------------------------------- +# URI helpers – parse_pod_path +# --------------------------------------------------------------------------- + + +def test_parse_pod_path_slash_separator() -> None: + pod, ns, path = parse_pod_path("pod://mypod/app/static") + assert pod == "mypod" + assert ns is None + assert path == "/app/static" + + +def test_parse_pod_path_colon_separator() -> None: + pod, ns, path = parse_pod_path("pod://mypod:/app/static") + assert pod == "mypod" + assert ns is None + assert path == "/app/static" + + +def test_parse_pod_path_namespace_slash() -> None: + pod, ns, path = parse_pod_path("pod://mypod@default/app/static") + assert pod == "mypod" + assert ns == "default" + assert path == "/app/static" + + +def test_parse_pod_path_namespace_colon() -> None: + pod, ns, path = parse_pod_path("pod://mypod@default:/app/static") + assert pod == "mypod" + assert ns == "default" + assert path == "/app/static" + + +def test_parse_pod_path_root_only() -> None: + pod, ns, path = parse_pod_path("pod://mypod:") + assert pod == "mypod" + assert ns is None + assert path == "/" + + +def test_parse_pod_path_no_path() -> None: + pod, ns, path = parse_pod_path("pod://mypod/") + assert pod == "mypod" + assert ns is None + assert path == "/" + + +def test_parse_pod_path_namespace_no_path() -> None: + pod, ns, path = parse_pod_path("pod://mypod@staging/") + assert pod == "mypod" + assert ns == "staging" + assert path == "/" + + +# --------------------------------------------------------------------------- +# _entries_to_tree +# --------------------------------------------------------------------------- + + +def test_entries_to_tree_flat() -> None: + entries = [("file.py", 1000, False), ("README.md", 500, False)] + node = _entries_to_tree("/project", entries) + assert node.is_dir + assert node.size == 1500 + assert {c.name for c in node.children} == {"file.py", "README.md"} + + +def test_entries_to_tree_extensions() -> None: + entries = [("script.py", 100, False), ("Makefile", 50, False)] + node = _entries_to_tree("/project", entries) + py = next(c for c in node.children if c.name == "script.py") + mk = next(c for c in node.children if c.name == "Makefile") + assert py.extension == ".py" + assert mk.extension == "(no ext)" + + +def test_entries_to_tree_nested() -> None: + entries = [("src", 0, True), ("src/app.py", 200, False), ("README.md", 50, False)] + node = _entries_to_tree("/project", entries) + assert node.size == 250 + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.size == 200 + assert src.children[0].name == "app.py" + + +def test_entries_to_tree_missing_intermediate_dirs() -> None: + entries = [("a/b/file.txt", 100, False)] + node = _entries_to_tree("/root", entries) + a = next(c for c in node.children if c.name == "a") + assert a.is_dir + b = next(c for c in a.children if c.name == "b") + assert b.is_dir + assert b.children[0].name == "file.txt" + + +# --------------------------------------------------------------------------- +# build_tree_pod (mocked subprocess) +# --------------------------------------------------------------------------- + + +def _mock_run(find_stdout: str = "", returncode: int = 0, get_pod_rc: int = 0): + """Return a side_effect function for subprocess.run that fakes kubectl calls.""" + + def _side_effect(cmd, **kwargs): + result = MagicMock() + if "get" in cmd and "pod" in cmd: + result.returncode = get_pod_rc + result.stdout = "" + result.stderr = "not found" if get_pod_rc != 0 else "" + else: + # kubectl exec ... find ... + result.returncode = returncode + result.stdout = find_stdout + result.stderr = "find error" if returncode != 0 else "" + return result + + return _side_effect + + +def test_build_tree_pod_flat() -> None: + output = "file.py\t1000\tf\nREADME.md\t500\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_pod("mypod", "/app") + assert node.is_dir + assert node.size == 1500 + assert {c.name for c in node.children} == {"file.py", "README.md"} + + +def test_build_tree_pod_skips_dotfiles() -> None: + output = ".hidden\t100\tf\nvisible.txt\t200\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_pod("mypod", "/app") + names = {c.name for c in node.children} + assert "visible.txt" in names + assert ".hidden" not in names + + +def test_build_tree_pod_exclude() -> None: + output = "keep.py\t100\tf\nskip.py\t200\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_pod("mypod", "/app", exclude=frozenset({"skip.py"})) + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names + + +def test_build_tree_pod_nested() -> None: + output = "src\t0\td\nsrc/app.py\t300\tf\ntop.txt\t100\tf\n" + with patch("subprocess.run", side_effect=_mock_run(output)): + node = build_tree_pod("mypod", "/app") + assert node.size == 400 + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + + +def test_build_tree_pod_find_failure() -> None: + with ( + patch("subprocess.run", side_effect=_mock_run(returncode=1)), + pytest.raises(OSError, match="find failed"), + ): + build_tree_pod("mypod", "/missing") + + +def test_build_tree_pod_not_found() -> None: + with ( + patch("subprocess.run", side_effect=_mock_run(get_pod_rc=1)), + pytest.raises(FileNotFoundError), + ): + build_tree_pod("no-such-pod", "/app") + + +def test_build_tree_pod_progress_reported() -> None: + lines = "\n".join(f"file{i}.txt\t100\tf" for i in range(101)) + progress: list[int] = [0] + with patch("subprocess.run", side_effect=_mock_run(lines + "\n")): + build_tree_pod("mypod", "/app", _progress=progress) + assert progress[0] == 101 + + +def test_build_tree_pod_depth_passed_to_kubectl() -> None: + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + r = MagicMock() + r.returncode = 0 + r.stdout = "" + r.stderr = "" + return r + + with patch("subprocess.run", side_effect=_capture): + build_tree_pod("mypod", "/app", depth=2) + + exec_call = next(c for c in calls if "exec" in c) + assert "-maxdepth" in exec_call + assert "2" in exec_call + + +def test_build_tree_pod_namespace_passed_to_kubectl() -> None: + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + r = MagicMock() + r.returncode = 0 + r.stdout = "" + r.stderr = "" + return r + + with patch("subprocess.run", side_effect=_capture): + build_tree_pod("mypod", "/app", namespace="staging") + + for call in calls: + assert "-n" in call + assert "staging" in call + + +def test_build_tree_pod_container_passed_to_kubectl() -> None: + calls: list[list[str]] = [] + + def _capture(cmd, **kwargs): + calls.append(list(cmd)) + r = MagicMock() + r.returncode = 0 + r.stdout = "" + r.stderr = "" + return r + + with patch("subprocess.run", side_effect=_capture): + build_tree_pod("mypod", "/app", container="sidecar") + + exec_call = next(c for c in calls if "exec" in c) + assert "-c" in exec_call + assert "sidecar" in exec_call + + +# --------------------------------------------------------------------------- +# Integration tests (require kubectl + a running cluster) +# --------------------------------------------------------------------------- + + +@pytest.mark.k8s +def test_k8s_integration_structure(k8s_pod: str) -> None: + """Scanning the test pod returns the expected directory structure.""" + node = build_tree_pod(k8s_pod, "/testdata") + assert node.is_dir + names = {c.name for c in node.children} + assert "src" in names + assert "README.md" in names + + +@pytest.mark.k8s +def test_k8s_integration_file_sizes(k8s_pod: str) -> None: + """Files inside the pod have the exact sizes we wrote.""" + node = build_tree_pod(k8s_pod, "/testdata") + readme = next(c for c in node.children if c.name == "README.md") + assert readme.size == 50 + src = next(c for c in node.children if c.name == "src") + app = next(c for c in src.children if c.name == "app.py") + util = next(c for c in src.children if c.name == "util.py") + assert app.size == 100 + assert util.size == 200 + + +@pytest.mark.k8s +def test_k8s_integration_extensions(k8s_pod: str) -> None: + """File extensions are correctly detected inside a real pod.""" + node = build_tree_pod(k8s_pod, "/testdata") + readme = next(c for c in node.children if c.name == "README.md") + assert readme.extension == ".md" + src = next(c for c in node.children if c.name == "src") + app = next(c for c in src.children if c.name == "app.py") + assert app.extension == ".py" + + +@pytest.mark.k8s +def test_k8s_integration_total_size(k8s_pod: str) -> None: + """Root node size equals sum of all file sizes (100 + 200 + 50 = 350).""" + node = build_tree_pod(k8s_pod, "/testdata") + assert node.size == 350 + + +@pytest.mark.k8s +def test_k8s_integration_exclude(k8s_pod: str) -> None: + """Excluded paths are omitted from the result.""" + node = build_tree_pod(k8s_pod, "/testdata", exclude=frozenset({"/testdata/README.md"})) + names = {c.name for c in node.children} + assert "README.md" not in names + assert "src" in names + + +@pytest.mark.k8s +def test_k8s_integration_depth(k8s_pod: str) -> None: + """Depth limit prevents recursion into subdirectories.""" + node = build_tree_pod(k8s_pod, "/testdata", depth=1) + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.children == [] + + +@pytest.mark.k8s +def test_k8s_integration_namespace(k8s_pod: str) -> None: + """Explicit namespace=default succeeds for a pod in the default namespace.""" + node = build_tree_pod(k8s_pod, "/testdata", namespace="default") + assert node.is_dir + assert node.size == 350 + + +@pytest.mark.k8s +def test_k8s_integration_missing_pod() -> None: + """A non-existent pod name raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + build_tree_pod("dirplot-no-such-pod-xyz", "/app") diff --git a/tests/test_pathlist.py b/tests/test_pathlist.py new file mode 100644 index 0000000..96f0a11 --- /dev/null +++ b/tests/test_pathlist.py @@ -0,0 +1,196 @@ +"""Tests for the pathlist parser (tree/find format detection and parsing).""" + +from __future__ import annotations + +from pathlib import Path + +from dirplot.pathlist import detect_format, parse_find, parse_pathlist, parse_tree + +# --------------------------------------------------------------------------- +# detect_format +# --------------------------------------------------------------------------- + + +def test_detect_format_find(): + lines = ["/home/user/foo.py", "/home/user/bar.txt", "./relative/path"] + assert detect_format(lines) == "find" + + +def test_detect_format_tree(): + lines = [ + "/home/user", + "├── src", + "│ └── main.py", + "└── README.md", + ] + assert detect_format(lines) == "tree" + + +def test_detect_format_tree_full_paths(): + lines = [ + "/home/user", + "├── /home/user/src", + "│ └── /home/user/src/main.py", + "└── /home/user/README.md", + ] + assert detect_format(lines) == "tree_f" + + +# --------------------------------------------------------------------------- +# parse_find +# --------------------------------------------------------------------------- + + +def test_parse_find_basic(tmp_path: Path): + f1 = tmp_path / "a.txt" + f2 = tmp_path / "b.py" + f1.write_text("x") + f2.write_text("y") + lines = [str(f1), str(f2), ""] + result = parse_find(lines) + assert result == [f1, f2] + + +def test_parse_find_skips_blank_lines(): + result = parse_find(["", " ", "/tmp/foo"]) + assert result == [Path("/tmp/foo")] + + +# --------------------------------------------------------------------------- +# parse_tree (default indentation format) +# --------------------------------------------------------------------------- + +_TREE_DEFAULT = """\ +/tmp/myroot +├── dir1 +│ ├── file1.txt +│ └── file2.py +├── dir2 +│ └── sub +│ └── deep.js +└── README.md +""" + + +def test_parse_tree_default(): + lines = _TREE_DEFAULT.splitlines() + paths = parse_tree(lines) + assert Path("/tmp/myroot/dir1") in paths + assert Path("/tmp/myroot/dir1/file1.txt") in paths + assert Path("/tmp/myroot/dir1/file2.py") in paths + assert Path("/tmp/myroot/dir2") in paths + assert Path("/tmp/myroot/dir2/sub") in paths + assert Path("/tmp/myroot/dir2/sub/deep.js") in paths + assert Path("/tmp/myroot/README.md") in paths + + +_TREE_SIZES = """\ +/tmp/myroot +├── [ 4096] dir1 +│ ├── [ 1234] file1.txt +│ └── [ 5678] file2.py +└── [ 512] README.md +""" + + +def test_parse_tree_with_sizes(): + lines = _TREE_SIZES.splitlines() + paths = parse_tree(lines) + assert Path("/tmp/myroot/dir1") in paths + assert Path("/tmp/myroot/dir1/file1.txt") in paths + assert Path("/tmp/myroot/dir1/file2.py") in paths + assert Path("/tmp/myroot/README.md") in paths + + +_TREE_FULL_PATHS = """\ +/tmp/myroot +├── /tmp/myroot/dir1 +│ ├── /tmp/myroot/dir1/file1.txt +│ └── /tmp/myroot/dir1/file2.py +└── /tmp/myroot/README.md +""" + + +def test_parse_tree_full_paths(): + lines = _TREE_FULL_PATHS.splitlines() + paths = parse_tree(lines) + assert Path("/tmp/myroot/dir1") in paths + assert Path("/tmp/myroot/dir1/file1.txt") in paths + assert Path("/tmp/myroot/dir1/file2.py") in paths + assert Path("/tmp/myroot/README.md") in paths + + +_TREE_COMMENTS = """\ +.crosspoint/ +├── epub_12471232/ # Each EPUB is cached to a subdirectory named epub_<hash> +│ ├── progress.bin # Stores reading progress (chapter, page, etc.) +│ ├── cover.bmp # Book cover image (once generated) +│ ├── book.bin # Book metadata (title, author, spine, etc.) +│ └── sections/ # All chapter data is stored here +│ ├── 0.bin # Chapter data +│ └── 1.bin # named by spine index +│ +└── epub_189013891/ +""" + + +def test_parse_tree_strips_comments(): + lines = _TREE_COMMENTS.splitlines() + paths = parse_tree(lines) + names = [p.name for p in paths] + assert "epub_12471232" in names + assert "progress.bin" in names + assert "sections" in names + assert "0.bin" in names + assert "1.bin" in names + assert "epub_189013891" in names + # Comments must not leak into path names + assert not any("#" in str(p) for p in paths) + + +def test_parse_tree_preserves_hash_in_filename(): + lines = [ + "/tmp/root", + "├── file#2.txt", + "└── notes.md", + ] + paths = parse_tree(lines) + assert Path("/tmp/root/file#2.txt") in paths + + +def test_parse_tree_skips_summary_lines(): + lines = [ + "/tmp/myroot", + "└── file.txt", + "", + "1 directory, 1 file", + ] + paths = parse_tree(lines) + assert Path("/tmp/myroot/file.txt") in paths + # Summary line must not appear as a path + assert not any("directory" in str(p) for p in paths) + + +# --------------------------------------------------------------------------- +# parse_pathlist (auto-dispatch) +# --------------------------------------------------------------------------- + + +def test_parse_pathlist_dispatches_find(): + result = parse_pathlist(["/tmp/a", "/tmp/b"]) + # minimal_roots resolves paths; use resolved comparisons + assert Path("/tmp/a").resolve() in result + assert Path("/tmp/b").resolve() in result + + +def test_parse_pathlist_dispatches_tree(): + lines = _TREE_DEFAULT.splitlines() + paths = parse_pathlist(lines) + # minimal_roots collapses to the root since all entries are descendants + assert len(paths) == 1 + assert paths[0] == Path("/tmp/myroot").resolve() + + +def test_parse_pathlist_empty(): + assert parse_pathlist([]) == [] + assert parse_pathlist(["", " "]) == [] diff --git a/tests/test_render.py b/tests/test_render.py deleted file mode 100644 index faaff10..0000000 --- a/tests/test_render.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Tests for treemap rendering.""" - -import io -from pathlib import Path - -from PIL import Image - -from dirplot.render import _label_color, create_treemap -from dirplot.scanner import build_tree - - -def test_label_color_light_background() -> None: - """Light colors (high luminance) should get black text.""" - assert _label_color((255, 255, 255)) == (0, 0, 0) # white bg → black text - assert _label_color((200, 200, 200)) == (0, 0, 0) # light gray → black text - - -def test_label_color_dark_background() -> None: - """Dark colors (low luminance) should get white text.""" - assert _label_color((0, 0, 0)) == (255, 255, 255) # black bg → white text - assert _label_color((50, 50, 50)) == (255, 255, 255) # dark gray → white text - - -def test_label_color_boundary() -> None: - """Colors near the 128 luminance boundary switch sides correctly.""" - assert _label_color((200, 200, 200)) == (0, 0, 0) # clearly light → black - assert _label_color((100, 100, 100)) == (255, 255, 255) # clearly dark → white - - -def test_create_treemap_returns_png(sample_tree: Path) -> None: - root = build_tree(sample_tree) - buf = create_treemap(root, width_px=320, height_px=240) - assert isinstance(buf, io.BytesIO) - header = buf.read(8) - assert header == b"\x89PNG\r\n\x1a\n", "Buffer should start with PNG magic bytes" - - -def test_create_treemap_custom_colormap(sample_tree: Path) -> None: - root = build_tree(sample_tree) - buf = create_treemap(root, width_px=320, height_px=240, colormap="viridis") - buf.seek(0) - assert buf.read(8) == b"\x89PNG\r\n\x1a\n" - - -def test_create_treemap_scale(sample_tree: Path) -> None: - root = build_tree(sample_tree) - buf = create_treemap(root, width_px=320, height_px=240, font_size=18) - buf.seek(0) - assert buf.read(8) == b"\x89PNG\r\n\x1a\n" - - -def test_create_treemap_empty_dir(tmp_path: Path) -> None: - """An empty directory should not raise.""" - root = build_tree(tmp_path) - buf = create_treemap(root, width_px=320, height_px=240) - buf.seek(0) - assert buf.read(8) == b"\x89PNG\r\n\x1a\n" - - -def test_treemap_exact_dimensions(sample_tree: Path) -> None: - """Saved PNG must be exactly width_px × height_px — no right/bottom margin trimming.""" - root = build_tree(sample_tree) - for w, h in [(200, 150), (320, 240), (101, 73)]: - buf = create_treemap(root, width_px=w, height_px=h) - img = Image.open(buf) - assert img.size == (w, h), f"Expected {w}×{h}, got {img.size}" - - -def test_treemap_legend(sample_tree: Path) -> None: - """legend=True should produce a valid PNG of the correct size.""" - root = build_tree(sample_tree) - buf = create_treemap(root, width_px=320, height_px=240, legend=True) - img = Image.open(buf) - assert img.size == (320, 240) - - -def test_treemap_visual() -> None: - """Render tests/example/ and save the result for manual inspection. - - Output: tests/visual_sample.png - """ - example = Path(__file__).parent / "example" - root = build_tree(example) - buf = create_treemap(root, width_px=800, height_px=500, legend=True) - - out = Path(__file__).parent / "example_dirplot.png" - out.write_bytes(buf.read()) - - assert out.exists() - img = Image.open(out) - assert img.size == (800, 500) diff --git a/tests/test_render_png.py b/tests/test_render_png.py new file mode 100644 index 0000000..0619a8d --- /dev/null +++ b/tests/test_render_png.py @@ -0,0 +1,360 @@ +"""Tests for treemap rendering.""" + +import io +import shutil +from pathlib import Path + +import pytest +import squarify +from PIL import Image + +from dirplot.colors import assign_colors +from dirplot.render_png import ( + _frames_as_rgba, + _label_color, + build_metadata, + create_treemap, + make_fade_out_frames, + write_mp4, +) +from dirplot.scanner import build_tree + + +def test_label_color_light_background() -> None: + """Light colors (high luminance) should get black text.""" + assert _label_color((255, 255, 255)) == (0, 0, 0) # white bg → black text + assert _label_color((200, 200, 200)) == (0, 0, 0) # light gray → black text + + +def test_label_color_dark_background() -> None: + """Dark colors (low luminance) should get white text.""" + assert _label_color((0, 0, 0)) == (255, 255, 255) # black bg → white text + assert _label_color((50, 50, 50)) == (255, 255, 255) # dark gray → white text + + +def test_label_color_boundary() -> None: + """Colors near the 128 luminance boundary switch sides correctly.""" + assert _label_color((200, 200, 200)) == (0, 0, 0) # clearly light → black + assert _label_color((100, 100, 100)) == (255, 255, 255) # clearly dark → white + + +def test_create_treemap_returns_png(sample_tree: Path) -> None: + root = build_tree(sample_tree) + buf = create_treemap(root, width_px=320, height_px=240) + assert isinstance(buf, io.BytesIO) + header = buf.read(8) + assert header == b"\x89PNG\r\n\x1a\n", "Buffer should start with PNG magic bytes" + + +def test_create_treemap_custom_colormap(sample_tree: Path) -> None: + root = build_tree(sample_tree) + buf = create_treemap(root, width_px=320, height_px=240, colormap="viridis") + buf.seek(0) + assert buf.read(8) == b"\x89PNG\r\n\x1a\n" + + +def test_create_treemap_scale(sample_tree: Path) -> None: + root = build_tree(sample_tree) + buf = create_treemap(root, width_px=320, height_px=240, font_size=18) + buf.seek(0) + assert buf.read(8) == b"\x89PNG\r\n\x1a\n" + + +def test_create_treemap_empty_dir(tmp_path: Path) -> None: + """An empty directory should not raise.""" + root = build_tree(tmp_path) + buf = create_treemap(root, width_px=320, height_px=240) + buf.seek(0) + assert buf.read(8) == b"\x89PNG\r\n\x1a\n" + + +def test_treemap_exact_dimensions(sample_tree: Path) -> None: + """Saved PNG must be exactly width_px × height_px — no right/bottom margin trimming.""" + root = build_tree(sample_tree) + for w, h in [(200, 150), (320, 240), (101, 73)]: + buf = create_treemap(root, width_px=w, height_px=h) + img = Image.open(buf) + assert img.size == (w, h), f"Expected {w}×{h}, got {img.size}" + + +def test_treemap_legend(sample_tree: Path) -> None: + """legend=True should produce a valid PNG of the correct size.""" + root = build_tree(sample_tree) + buf = create_treemap(root, width_px=320, height_px=240, legend=True) + img = Image.open(buf) + assert img.size == (320, 240) + + +def test_treemap_tile_colors(tmp_path: Path) -> None: + """With cushion disabled, each file tile's pixels must match the expected fill color. + + Two files with distinct Linguist-mapped extensions and equal sizes are placed in a + flat directory. squarify is used to replicate draw_node's interior geometry so we + know where each tile lands. Pixels are sampled from the corner regions of each tile + (away from the center where a label may be rendered). + """ + # Files sorted by name → a.js first, b.py second in the squarify layout + (tmp_path / "a.js").write_bytes(b"x" * 1000) + (tmp_path / "b.py").write_bytes(b"x" * 1000) + + width, height, font_size = 400, 200, 12 + root = build_tree(tmp_path) + buf = create_treemap( + root, width_px=width, height_px=height, font_size=font_size, colormap="tab20", cushion=False + ) + img = Image.open(buf) + + # Expected fill colors (same call as create_treemap → assign_colors) + color_map = assign_colors([".js", ".py"], "tab20") + + def to_rgb(ext: str) -> tuple[int, int, int]: + r, g, b, _ = color_map[ext] + return int(r * 255), int(g * 255), int(b * 255) + + # Replicate draw_node's interior geometry for the root directory. + # header_h = font.size + 4; for a FreeType font font.size == the requested size. + header_h = font_size + 4 + ix, iy = 2, 2 + header_h + iw = width - 3 + ih = height - 3 - header_h + + sizes = [1000, 1000] # a.js, b.py (sorted) + normed = squarify.normalize_sizes(sizes, iw, ih) + rects = squarify.squarify(normed, ix, iy, iw, ih) + + for rect, ext in zip(rects, [".js", ".py"], strict=False): + rx = round(rect["x"]) + ry = round(rect["y"]) + rw = round(rect["x"] + rect["dx"]) - rx - 1 # draw_node passes rw-1 to child + rh = round(rect["y"] + rect["dy"]) - ry - 1 + expected = to_rgb(ext) + + # Sample from two corner regions (top-left and bottom-right quadrants) + # to avoid the center where label text may overlay the fill color. + margin = max(3, rw // 8) + sample_points = [ + (rx + margin, ry + margin), + (rx + rw - 1 - margin, ry + rh - 1 - margin), + ] + for px, py in sample_points: + actual = img.getpixel((px, py))[:3] + assert actual == expected, ( + f"Tile for {ext} at ({px},{py}): expected RGB{expected}, got RGB{actual}" + ) + + +def test_treemap_visual() -> None: + """Render tests/example/ and save the result for manual inspection. + + Output: tests/visual_sample.png + """ + example = Path(__file__).parent / "example" + root = build_tree(example) + buf = create_treemap(root, width_px=800, height_px=500, legend=True) + + out = Path(__file__).parent / "example_dirplot.png" + out.write_bytes(buf.read()) + + assert out.exists() + img = Image.open(out) + assert img.size == (800, 500) + + +# --------------------------------------------------------------------------- +# Metadata tests +# --------------------------------------------------------------------------- + +EXPECTED_METADATA_KEYS = {"Date", "Software", "URL", "Python", "OS", "Command"} + + +def test_build_metadata_keys() -> None: + """build_metadata returns all expected keys.""" + meta = build_metadata() + assert set(meta.keys()) == EXPECTED_METADATA_KEYS + + +def test_build_metadata_values_nonempty() -> None: + """build_metadata values are all non-empty strings.""" + for key, value in build_metadata().items(): + assert isinstance(value, str) and value, f"metadata[{key!r}] is empty" + + +def test_build_metadata_software_contains_version() -> None: + """Software field contains 'dirplot' and a version string.""" + software = build_metadata()["Software"] + assert software.startswith("dirplot ") + version_part = software.split(" ", 1)[1] + assert all(c.isdigit() or c == "." for c in version_part) + + +def test_build_metadata_url() -> None: + meta = build_metadata() + assert meta["URL"] == "https://github.com/deeplook/dirplot" + + +def test_png_metadata_embedded() -> None: + """PNG output contains all expected metadata keys as iTXt chunks.""" + example = Path(__file__).parent / "example" + root = build_tree(example) + buf = create_treemap(root, width_px=400, height_px=300) + info = Image.open(buf).info + for key in EXPECTED_METADATA_KEYS: + assert key in info, f"PNG missing metadata key {key!r}" + + +def test_png_metadata_software_value() -> None: + """PNG Software metadata starts with 'dirplot'.""" + example = Path(__file__).parent / "example" + root = build_tree(example) + buf = create_treemap(root, width_px=400, height_px=300) + info = Image.open(buf).info + assert info["Software"].startswith("dirplot ") + + +# ── write_mp4 ──────────────────────────────────────────────────────────────── + + +def _make_png_frame(color: tuple[int, int, int], width: int = 64, height: int = 64) -> bytes: + buf = io.BytesIO() + Image.new("RGB", (width, height), color).save(buf, format="PNG") + return buf.getvalue() + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not found") +def test_write_mp4_produces_file(tmp_path: Path) -> None: + """write_mp4 creates a non-empty .mp4 file from PNG frames.""" + frames = [_make_png_frame((255, 0, 0)), _make_png_frame((0, 255, 0))] + durations = [500, 500] + out = tmp_path / "out.mp4" + write_mp4(out, frames, durations) + assert out.exists() + assert out.stat().st_size > 0 + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not found") +def test_write_mp4_respects_crf(tmp_path: Path) -> None: + """Lower CRF produces a larger (higher-quality) file.""" + frames = [_make_png_frame((r, 100, 100)) for r in range(0, 256, 32)] + durations = [200] * len(frames) + out_hq = tmp_path / "hq.mp4" + out_lq = tmp_path / "lq.mp4" + write_mp4(out_hq, frames, durations, crf=0) + write_mp4(out_lq, frames, durations, crf=51) + assert out_hq.stat().st_size > out_lq.stat().st_size + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not found") +def test_write_mp4_libx265(tmp_path: Path) -> None: + """write_mp4 works with libx265 codec.""" + frames = [_make_png_frame((0, 0, 255)), _make_png_frame((255, 255, 0))] + out = tmp_path / "out.mp4" + write_mp4(out, frames, [300, 300], codec="libx265", crf=28) + assert out.exists() + assert out.stat().st_size > 0 + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not found") +def test_write_mp4_odd_dimensions(tmp_path: Path) -> None: + """write_mp4 handles odd pixel dimensions (pads to even via ffmpeg -vf scale).""" + frames = [_make_png_frame((128, 128, 128), width=65, height=63)] + out = tmp_path / "out.mp4" + write_mp4(out, frames, [500]) + assert out.exists() + assert out.stat().st_size > 0 + + +@pytest.mark.skipif( + not shutil.which("ffmpeg") or not shutil.which("ffprobe"), + reason="ffmpeg/ffprobe not found", +) +def test_write_mp4_metadata_embedded(tmp_path: Path) -> None: + """Metadata passed to write_mp4 is readable via ffprobe.""" + import json + import subprocess + + meta = {"Software": "dirplot test", "URL": "https://example.com", "Command": "dirplot test"} + out = tmp_path / "out.mp4" + write_mp4(out, [_make_png_frame((0, 128, 0))], [500], metadata=meta) + + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(out)], + capture_output=True, + check=True, + ) + tags = json.loads(result.stdout).get("format", {}).get("tags", {}) + for key, value in meta.items(): + assert tags.get(key) == value, f"MP4 missing or wrong metadata for {key!r}" + + +@pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not found") +def test_write_mp4_no_metadata_omits_movflags(tmp_path: Path) -> None: + """write_mp4 without metadata produces a valid file and doesn't break.""" + out = tmp_path / "out.mp4" + write_mp4(out, [_make_png_frame((255, 0, 0))], [500], metadata=None) + assert out.exists() + assert out.stat().st_size > 0 + + +class TestMakeFadeOutFrames: + def test_returns_correct_count(self) -> None: + frame = _make_png_frame((100, 150, 200)) + frames, durs = make_fade_out_frames(frame, n_frames=4, duration_ms=1000) + assert len(frames) == 4 + assert len(durs) == 4 + + def test_durations_sum_to_total(self) -> None: + frame = _make_png_frame((0, 0, 0)) + _, durs = make_fade_out_frames(frame, n_frames=4, duration_ms=1000) + assert sum(durs) == 1000 + + def test_durations_sum_odd_ms(self) -> None: + frame = _make_png_frame((0, 0, 0)) + _, durs = make_fade_out_frames(frame, n_frames=3, duration_ms=1000) + assert sum(durs) == 1000 + + def test_rgb_output_for_opaque_color(self) -> None: + frame = _make_png_frame((255, 255, 255)) + frames, _ = make_fade_out_frames(frame, target_color=(0, 0, 0)) + img = Image.open(io.BytesIO(frames[-1])) + assert img.mode == "RGB" + + def test_rgba_output_for_transparent_target(self) -> None: + frame = _make_png_frame((200, 100, 50)) + frames, _ = make_fade_out_frames(frame, target_color=(0, 0, 0, 0)) + img = Image.open(io.BytesIO(frames[-1])) + assert img.mode == "RGBA" + + def test_last_frame_is_fully_faded(self) -> None: + """Last frame should be fully blended toward target color.""" + frame = _make_png_frame((255, 0, 0)) + frames, _ = make_fade_out_frames(frame, n_frames=4, target_color=(0, 0, 0)) + img = Image.open(io.BytesIO(frames[-1])).convert("RGB") + r, g, b = img.getpixel((32, 32)) + # Should be close to black (target) — allow small rounding tolerance + assert r < 10 and g < 10 and b < 10 + + def test_last_frame_is_fully_transparent(self) -> None: + """Last fade-to-transparent frame should have near-zero alpha.""" + frame = _make_png_frame((200, 200, 200)) + frames, _ = make_fade_out_frames(frame, n_frames=4, target_color=(0, 0, 0, 0)) + img = Image.open(io.BytesIO(frames[-1])).convert("RGBA") + *_, a = img.getpixel((32, 32)) + assert a == 0 + + def test_default_frame_count_scales_with_duration(self) -> None: + """Without an explicit n_frames the caller should compute 4 * duration.""" + frame = _make_png_frame((100, 100, 100)) + # 2-second fade → 8 frames at 4 fps + frames, durs = make_fade_out_frames( + frame, n_frames=max(1, round(2.0 * 4)), duration_ms=2000 + ) + assert len(frames) == 8 + assert sum(durs) == 2000 + + def test_frames_as_rgba_converts_all(self) -> None: + rgb_frames = [_make_png_frame((i * 30, 0, 0)) for i in range(3)] + rgba_frames = _frames_as_rgba(rgb_frames) + assert len(rgba_frames) == 3 + for fb in rgba_frames: + img = Image.open(io.BytesIO(fb)) + assert img.mode == "RGBA" diff --git a/tests/test_replay_scanner.py b/tests/test_replay_scanner.py new file mode 100644 index 0000000..1016bba --- /dev/null +++ b/tests/test_replay_scanner.py @@ -0,0 +1,286 @@ +"""Tests for replay_scanner: event parsing, bucketing, applying, and frame rendering.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from unittest.mock import patch + +from dirplot.replay_scanner import ( + _render_replay_frame_worker, + apply_events, + bucket_events, + parse_events, + scan_to_flat, +) + +# --------------------------------------------------------------------------- +# parse_events +# --------------------------------------------------------------------------- + + +def test_parse_events_basic(tmp_path: Path) -> None: + log = tmp_path / "events.jsonl" + log.write_text( + json.dumps({"timestamp": 1.0, "type": "created", "path": "/a/b.py"}) + + "\n" + + json.dumps({"timestamp": 2.0, "type": "modified", "path": "/a/c.py"}) + + "\n" + ) + events = parse_events(log) + assert len(events) == 2 + assert events[0] == (1.0, "created", "/a/b.py", "") + assert events[1] == (2.0, "modified", "/a/c.py", "") + + +def test_parse_events_sorted(tmp_path: Path) -> None: + """Events are returned sorted by timestamp regardless of file order.""" + log = tmp_path / "events.jsonl" + log.write_text( + json.dumps({"timestamp": 3.0, "type": "modified", "path": "/z"}) + + "\n" + + json.dumps({"timestamp": 1.0, "type": "created", "path": "/a"}) + + "\n" + + json.dumps({"timestamp": 2.0, "type": "deleted", "path": "/b"}) + + "\n" + ) + events = parse_events(log) + assert [e[0] for e in events] == [1.0, 2.0, 3.0] + + +def test_parse_events_blank_lines_skipped(tmp_path: Path) -> None: + log = tmp_path / "events.jsonl" + log.write_text( + "\n" + + json.dumps({"timestamp": 1.0, "type": "created", "path": "/a"}) + + "\n" + + " \n" + + json.dumps({"timestamp": 2.0, "type": "deleted", "path": "/b"}) + + "\n" + ) + events = parse_events(log) + assert len(events) == 2 + + +def test_parse_events_dest_path(tmp_path: Path) -> None: + log = tmp_path / "events.jsonl" + log.write_text( + json.dumps({"timestamp": 1.0, "type": "moved", "path": "/a", "dest_path": "/b"}) + "\n" + ) + events = parse_events(log) + assert events[0] == (1.0, "moved", "/a", "/b") + + +def test_parse_events_empty_file(tmp_path: Path) -> None: + log = tmp_path / "events.jsonl" + log.write_text("") + assert parse_events(log) == [] + + +# --------------------------------------------------------------------------- +# scan_to_flat +# --------------------------------------------------------------------------- + + +def test_scan_to_flat_basic(tmp_path: Path) -> None: + (tmp_path / "a.py").write_bytes(b"x" * 100) + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.py").write_bytes(b"x" * 200) + files = scan_to_flat(tmp_path) + assert files["a.py"] == 100 + assert files["sub/b.py"] == 200 + + +def test_scan_to_flat_forward_slashes(tmp_path: Path) -> None: + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "c.txt").write_bytes(b"x" * 50) + files = scan_to_flat(tmp_path) + assert all("/" in k or "/" not in k for k in files) + assert "sub/c.txt" in files + + +def test_scan_to_flat_excludes_dir(tmp_path: Path) -> None: + (tmp_path / "keep.py").write_bytes(b"x" * 10) + excl = tmp_path / "excluded" + excl.mkdir() + (excl / "secret.py").write_bytes(b"x" * 10) + files = scan_to_flat(tmp_path, exclude=frozenset(["excluded"])) + assert "keep.py" in files + assert not any("excluded" in k for k in files) + + +def test_scan_to_flat_oserror_fallback(tmp_path: Path) -> None: + (tmp_path / "f.py").write_bytes(b"x" * 42) + with patch("os.walk") as mock_walk: + mock_walk.return_value = [(str(tmp_path), [], ["f.py"])] + with patch.object(Path, "stat", side_effect=OSError("no access")): + files = scan_to_flat(tmp_path) + assert files.get("f.py") == 1 + + +# --------------------------------------------------------------------------- +# bucket_events +# --------------------------------------------------------------------------- + + +def test_bucket_events_empty() -> None: + assert bucket_events([], 60.0) == [] + + +def test_bucket_events_single_bucket() -> None: + events = [(1.0, "created", "/a", ""), (2.0, "modified", "/b", "")] + buckets = bucket_events(events, 60.0) + assert len(buckets) == 1 + assert buckets[0][0] == 1.0 + assert len(buckets[0][1]) == 2 + + +def test_bucket_events_multiple_buckets() -> None: + events = [ + (0.0, "created", "/a", ""), + (30.0, "modified", "/b", ""), + (70.0, "created", "/c", ""), + (75.0, "deleted", "/d", ""), + ] + buckets = bucket_events(events, 60.0) + assert len(buckets) == 2 + assert len(buckets[0][1]) == 2 + assert len(buckets[1][1]) == 2 + + +def test_bucket_events_boundary() -> None: + """An event exactly at bucket_start + bucket_size starts a new bucket.""" + events = [(0.0, "created", "/a", ""), (60.0, "modified", "/b", "")] + buckets = bucket_events(events, 60.0) + assert len(buckets) == 2 + + +# --------------------------------------------------------------------------- +# apply_events +# --------------------------------------------------------------------------- + + +def test_apply_events_created(tmp_path: Path) -> None: + f = tmp_path / "new.py" + f.write_bytes(b"x" * 50) + files: dict[str, int] = {} + highlights = apply_events(files, tmp_path, [(1.0, "created", str(f), "")], frozenset()) + assert files["new.py"] == 50 + assert highlights[str(f)] == "created" + + +def test_apply_events_modified(tmp_path: Path) -> None: + f = tmp_path / "existing.py" + f.write_bytes(b"x" * 80) + files = {"existing.py": 40} + apply_events(files, tmp_path, [(1.0, "modified", str(f), "")], frozenset()) + assert files["existing.py"] == 80 + + +def test_apply_events_deleted(tmp_path: Path) -> None: + files = {"bye.py": 100} + path_str = str(tmp_path / "bye.py") + highlights = apply_events(files, tmp_path, [(1.0, "deleted", path_str, "")], frozenset()) + assert "bye.py" not in files + assert highlights[path_str] == "deleted" + + +def test_apply_events_moved(tmp_path: Path) -> None: + src = tmp_path / "old.py" + dst = tmp_path / "new.py" + dst.write_bytes(b"x" * 30) + files = {"old.py": 30} + apply_events(files, tmp_path, [(1.0, "moved", str(src), str(dst))], frozenset()) + assert "old.py" not in files + assert files.get("new.py") == 30 + + +def test_apply_events_outside_root_skipped(tmp_path: Path) -> None: + other = tmp_path.parent / "other.py" + files: dict[str, int] = {} + apply_events(files, tmp_path, [(1.0, "created", str(other), "")], frozenset()) + assert not files + + +def test_apply_events_excluded_skipped(tmp_path: Path) -> None: + f = tmp_path / "secret.py" + f.write_bytes(b"x" * 10) + files: dict[str, int] = {} + apply_events(files, tmp_path, [(1.0, "created", str(f), "")], frozenset(["secret.py"])) + assert not files + + +def test_apply_events_oserror_fallback(tmp_path: Path) -> None: + f = tmp_path / "f.py" + f.write_bytes(b"x" * 10) + files: dict[str, int] = {} + with patch.object(Path, "stat", side_effect=OSError("no access")): + apply_events(files, tmp_path, [(1.0, "created", str(f), "")], frozenset()) + assert files.get("f.py") == 1 + + +def test_apply_events_moved_dest_outside_root(tmp_path: Path) -> None: + """Move where dest is outside root — old entry removed, nothing added.""" + src = tmp_path / "f.py" + dest_outside = tmp_path.parent / "elsewhere.py" + files = {"f.py": 50} + apply_events(files, tmp_path, [(1.0, "moved", str(src), str(dest_outside))], frozenset()) + assert "f.py" not in files + assert not any("elsewhere" in k for k in files) + + +# --------------------------------------------------------------------------- +# _render_replay_frame_worker +# --------------------------------------------------------------------------- + + +def test_render_replay_frame_worker(tmp_path: Path) -> None: + (tmp_path / "a.py").write_bytes(b"x" * 100) + (tmp_path / "b.md").write_bytes(b"x" * 50) + files = {"a.py": 100, "b.md": 50} + args = ( + str(tmp_path), # root_str + files, + {}, # highlights + time.time(), # ts + 0, # orig_i + 0.5, # progress + None, # depth + 0.0, # logscale (disabled) + 200, # width_px + 150, # height_px + 12, # font_size + "tab20", # colormap + False, # cushion + True, # dark + ) + orig_i, png_bytes, rect_map = _render_replay_frame_worker(args) + assert orig_i == 0 + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n" + assert isinstance(rect_map, dict) + + +def test_render_replay_frame_worker_log_scale(tmp_path: Path) -> None: + (tmp_path / "big.py").write_bytes(b"x" * 10_000) + (tmp_path / "small.py").write_bytes(b"x" * 10) + files = {"big.py": 10_000, "small.py": 10} + args = ( + str(tmp_path), + files, + {}, + time.time(), + 1, + 1.0, + None, + 4.0, # logscale + 200, + 150, + 12, + "tab20", + False, # cushion + True, # dark + ) + orig_i, png_bytes, _ = _render_replay_frame_worker(args) + assert orig_i == 1 + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/tests/test_s3.py b/tests/test_s3.py new file mode 100644 index 0000000..bfab5d9 --- /dev/null +++ b/tests/test_s3.py @@ -0,0 +1,184 @@ +"""Tests for AWS S3 directory tree scanning.""" + +from unittest.mock import MagicMock + +import pytest + +from dirplot.s3 import build_tree_s3, is_s3_path, parse_s3_path + + +def make_s3(pages: list[dict]) -> MagicMock: + """Return a mock boto3 S3 client whose paginator yields *pages*.""" + s3 = MagicMock() + paginator = MagicMock() + s3.get_paginator.return_value = paginator + paginator.paginate.return_value = pages + return s3 + + +def obj(key: str, size: int) -> dict: + return {"Key": key, "Size": size} + + +def prefix(p: str) -> dict: + return {"Prefix": p} + + +# --------------------------------------------------------------------------- +# URI helpers +# --------------------------------------------------------------------------- + + +def test_is_s3_path() -> None: + assert is_s3_path("s3://my-bucket/path") + assert is_s3_path("s3://my-bucket") + + +@pytest.mark.parametrize("path", ["/local", "ssh://user@host/path", "."]) +def test_is_not_s3_path(path: str) -> None: + assert not is_s3_path(path) + + +def test_parse_s3_path_with_prefix() -> None: + assert parse_s3_path("s3://my-bucket/path/to/dir") == ("my-bucket", "path/to/dir/") + + +def test_parse_s3_path_trailing_slash() -> None: + assert parse_s3_path("s3://my-bucket/path/") == ("my-bucket", "path/") + + +def test_parse_s3_path_bucket_only() -> None: + assert parse_s3_path("s3://my-bucket") == ("my-bucket", "") + + +def test_parse_s3_path_bucket_with_slash() -> None: + assert parse_s3_path("s3://my-bucket/") == ("my-bucket", "") + + +# --------------------------------------------------------------------------- +# build_tree_s3 +# --------------------------------------------------------------------------- + + +def test_build_tree_s3_flat() -> None: + s3 = make_s3([{"Contents": [obj("project/app.py", 100), obj("project/README.md", 50)]}]) + + node = build_tree_s3(s3, "bucket", "project/") + assert node.is_dir + assert node.name == "project" + assert node.size == 150 + assert {c.name for c in node.children} == {"app.py", "README.md"} + + +def test_build_tree_s3_extensions() -> None: + s3 = make_s3([{"Contents": [obj("project/script.py", 100), obj("project/Makefile", 50)]}]) + + node = build_tree_s3(s3, "bucket", "project/") + py = next(c for c in node.children if c.name == "script.py") + mk = next(c for c in node.children if c.name == "Makefile") + assert py.extension == ".py" + assert mk.extension == "(no ext)" + + +def test_build_tree_s3_skips_dir_marker() -> None: + # S3 sometimes includes a zero-byte object with the same key as the prefix + s3 = make_s3([{"Contents": [obj("project/", 0), obj("project/file.py", 100)]}]) + + node = build_tree_s3(s3, "bucket", "project/") + assert len(node.children) == 1 + assert node.children[0].name == "file.py" + + +def test_build_tree_s3_skips_dotfiles() -> None: + s3 = make_s3([{"Contents": [obj("project/.hidden", 100), obj("project/visible.txt", 200)]}]) + + node = build_tree_s3(s3, "bucket", "project/") + names = {c.name for c in node.children} + assert "visible.txt" in names + assert ".hidden" not in names + + +def test_build_tree_s3_recurses_into_subdirs() -> None: + def paginate(Bucket: str, Prefix: str, Delimiter: str) -> list[dict]: + if Prefix == "project/": + return [ + { + "Contents": [obj("project/top.txt", 100)], + "CommonPrefixes": [prefix("project/src/")], + } + ] + if Prefix == "project/src/": + return [{"Contents": [obj("project/src/app.py", 200)]}] + return [{}] + + s3 = MagicMock() + paginator = MagicMock() + s3.get_paginator.return_value = paginator + paginator.paginate.side_effect = lambda **kw: paginate(**kw) + + node = build_tree_s3(s3, "bucket", "project/") + assert node.size == 300 + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.size == 200 + + +def test_build_tree_s3_exclude() -> None: + s3 = make_s3([{"Contents": [obj("project/keep.py", 100), obj("project/skip.py", 200)]}]) + + node = build_tree_s3(s3, "bucket", "project/", exclude=frozenset({"skip.py"})) + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names + + +def test_build_tree_s3_depth_limit() -> None: + def paginate(Bucket: str, Prefix: str, Delimiter: str) -> list[dict]: + if Prefix == "project/": + return [ + { + "Contents": [obj("project/top.txt", 100)], + "CommonPrefixes": [prefix("project/src/")], + } + ] + return [{}] + + s3 = MagicMock() + paginator = MagicMock() + s3.get_paginator.return_value = paginator + paginator.paginate.side_effect = lambda **kw: paginate(**kw) + + node = build_tree_s3(s3, "bucket", "project/", depth=1) + src = next(c for c in node.children if c.name == "src") + assert src.is_dir + assert src.children == [] + # paginate should only have been called for the root prefix + paginator.paginate.assert_called_once_with(Bucket="bucket", Prefix="project/", Delimiter="/") + + +def test_build_tree_s3_zero_size_defaults_to_1() -> None: + s3 = make_s3([{"Contents": [obj("project/empty.txt", 0)]}]) + + node = build_tree_s3(s3, "bucket", "project/") + assert node.children[0].size == 1 + + +def test_build_tree_s3_empty_prefix_uses_bucket_name() -> None: + s3 = make_s3([{"Contents": [obj("file.txt", 100)]}]) + + node = build_tree_s3(s3, "my-bucket", "") + assert node.name == "my-bucket" + + +def test_build_tree_s3_pagination() -> None: + """Results spread across multiple pages are combined correctly.""" + s3 = make_s3( + [ + {"Contents": [obj("project/a.py", 100)]}, + {"Contents": [obj("project/b.py", 200)]}, + ] + ) + + node = build_tree_s3(s3, "bucket", "project/") + assert node.size == 300 + assert {c.name for c in node.children} == {"a.py", "b.py"} diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 2a7a295..1ac0265 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -4,7 +4,19 @@ import pytest -from dirplot.scanner import Node, build_tree, collect_extensions +from dirplot.scanner import ( + Node, + _collect_dirs, + _collect_files, + _fmt_size, + apply_breadcrumbs, + build_tree, + build_tree_multi, + collect_extensions, + prune_to_subtrees, + tree_metrics, + tree_metrics_dict, +) def test_build_tree_structure(sample_tree: Path) -> None: @@ -27,13 +39,23 @@ def test_build_tree_file_node(sample_tree: Path) -> None: def test_build_tree_exclude(sample_tree: Path) -> None: - excluded = frozenset({(sample_tree / "src").resolve()}) + excluded = frozenset({"src"}) root = build_tree(sample_tree, exclude=excluded) child_names = {c.name for c in root.children} assert "src" not in child_names assert root.size == 130 # 80 + 50 +def test_build_tree_depth_limit(sample_tree: Path) -> None: + root = build_tree(sample_tree, depth=1) + child_names = {c.name for c in root.children} + assert "src" in child_names + assert "docs" in child_names + src = next(c for c in root.children if c.name == "src") + assert src.is_dir + assert src.children == [] # not recursed into + + def test_build_tree_no_ext(tmp_path: Path) -> None: (tmp_path / "Makefile").write_bytes(b"x" * 10) root = build_tree(tmp_path) @@ -128,3 +150,334 @@ def test_single_file_node() -> None: def test_collect_extensions_empty_dir(tmp_path: Path) -> None: root = build_tree(tmp_path) assert collect_extensions(root) == [] + + +def test_prune_to_subtrees_basic(tmp_path: Path) -> None: + (tmp_path / "bar").mkdir() + (tmp_path / "baz").mkdir() + (tmp_path / "qux").mkdir() + (tmp_path / "bar" / "a.py").write_bytes(b"x" * 10) + (tmp_path / "baz" / "b.py").write_bytes(b"x" * 20) + (tmp_path / "qux" / "c.py").write_bytes(b"x" * 99) + + root = build_tree(tmp_path) + pruned = prune_to_subtrees(root, {"bar", "baz"}) + assert {c.name for c in pruned.children} == {"bar", "baz"} + assert pruned.size == 30 + + +def test_prune_to_subtrees_unknown_name_ignored(tmp_path: Path) -> None: + (tmp_path / "bar").mkdir() + (tmp_path / "bar" / "a.py").write_bytes(b"x" * 10) + + root = build_tree(tmp_path) + pruned = prune_to_subtrees(root, {"bar", "nonexistent"}) + assert {c.name for c in pruned.children} == {"bar"} + + +def test_prune_to_subtrees_nested_path(tmp_path: Path) -> None: + (tmp_path / "src" / "dirplot" / "fonts").mkdir(parents=True) + (tmp_path / "src" / "dirplot" / "fonts" / "f.ttf").write_bytes(b"x" * 10) + (tmp_path / "src" / "dirplot" / "other.py").write_bytes(b"x" * 5) + (tmp_path / "src" / "sibling.py").write_bytes(b"x" * 3) + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "t.py").write_bytes(b"x" * 7) + + root = build_tree(tmp_path) + pruned = prune_to_subtrees(root, {"src/dirplot/fonts", "tests"}) + + assert {c.name for c in pruned.children} == {"src", "tests"} + src = next(c for c in pruned.children if c.name == "src") + assert {c.name for c in src.children} == {"dirplot"} + dirplot = src.children[0] + assert {c.name for c in dirplot.children} == {"fonts"} + assert pruned.size == 17 # 10 (font) + 7 (test) + + +def test_prune_to_subtrees_empty_result(tmp_path: Path) -> None: + (tmp_path / "bar").mkdir() + + root = build_tree(tmp_path) + pruned = prune_to_subtrees(root, {"nonexistent"}) + assert pruned.children == [] + assert pruned.size == 0 + + +def test_build_tree_multi_two_siblings(tmp_path: Path) -> None: + (tmp_path / "bar").mkdir() + (tmp_path / "baz").mkdir() + (tmp_path / "qux").mkdir() + (tmp_path / "bar" / "a.py").write_bytes(b"x" * 10) + (tmp_path / "baz" / "b.py").write_bytes(b"x" * 20) + (tmp_path / "qux" / "c.py").write_bytes(b"x" * 99) # must be excluded + + root = build_tree_multi([tmp_path / "bar", tmp_path / "baz"]) + assert root.path == tmp_path + assert {c.name for c in root.children} == {"bar", "baz"} + assert root.size == 30 + + +def test_build_tree_multi_nested_intermediate(tmp_path: Path) -> None: + (tmp_path / "a" / "x").mkdir(parents=True) + (tmp_path / "a" / "y").mkdir() + (tmp_path / "a" / "z").mkdir() # sibling, must be excluded + (tmp_path / "a" / "x" / "f.txt").write_bytes(b"x" * 5) + (tmp_path / "a" / "y" / "g.txt").write_bytes(b"x" * 7) + (tmp_path / "a" / "z" / "h.txt").write_bytes(b"x" * 3) + + root = build_tree_multi([tmp_path / "a" / "x", tmp_path / "a" / "y"]) + assert root.path.resolve() == (tmp_path / "a").resolve() + assert {c.name for c in root.children} == {"x", "y"} + + +def test_build_tree_multi_single_delegates(tmp_path: Path) -> None: + (tmp_path / "file.py").write_bytes(b"x" * 10) + root = build_tree_multi([tmp_path]) + assert root.path == tmp_path + assert len(root.children) == 1 + + +# --------------------------------------------------------------------------- +# apply_breadcrumbs +# --------------------------------------------------------------------------- + + +def _make_dir(name: str, children: list[Node] | None = None) -> Node: + return Node(name=name, path=Path(name), size=1, is_dir=True, children=children or []) + + +def _make_file(name: str) -> Node: + return Node(name=name, path=Path(name), size=1, is_dir=False, extension=".txt") + + +def test_breadcrumbs_collapses_chain() -> None: + # root → a → b → c → [file.txt]; root is never collapsed, but a/b/c merge + file_node = _make_file("file.txt") + c = _make_dir("c", [file_node]) + b = _make_dir("b", [c]) + a = _make_dir("a", [b]) + root = _make_dir("root", [a]) + + result = apply_breadcrumbs(root) + + assert result.name == "root" # root itself is never collapsed + assert len(result.children) == 1 + merged = result.children[0] + assert merged.name == "a / b / c" + assert len(merged.children) == 1 + assert merged.children[0].name == "file.txt" + + +def test_breadcrumbs_no_collapse_with_files() -> None: + # root → a → [file.txt, subdir] — a has file child, must not collapse + file_node = _make_file("file.txt") + subdir = _make_dir("subdir", [_make_file("inner.txt")]) + a = _make_dir("a", [file_node, subdir]) + root = _make_dir("root", [a]) + + result = apply_breadcrumbs(root) + + assert result.name == "root" + child = result.children[0] + assert child.name == "a" + assert {c.name for c in child.children} == {"file.txt", "subdir"} + + +def test_breadcrumbs_no_collapse_multi_children() -> None: + # root → a → [dir1, dir2] — a has two dir children, must not collapse + dir1 = _make_dir("dir1", [_make_file("x.txt")]) + dir2 = _make_dir("dir2", [_make_file("y.txt")]) + a = _make_dir("a", [dir1, dir2]) + root = _make_dir("root", [a]) + + result = apply_breadcrumbs(root) + + assert result.name == "root" + child = result.children[0] + assert child.name == "a" + assert {c.name for c in child.children} == {"dir1", "dir2"} + + +# --------------------------------------------------------------------------- +# _fmt_size +# --------------------------------------------------------------------------- + + +def test_fmt_size_bytes() -> None: + assert _fmt_size(0) == "0.0 B" + assert _fmt_size(512) == "512.0 B" + + +def test_fmt_size_kilobytes() -> None: + assert _fmt_size(1024) == "1.0 KB" + assert _fmt_size(2048) == "2.0 KB" + + +def test_fmt_size_megabytes() -> None: + assert _fmt_size(1024 * 1024) == "1.0 MB" + + +def test_fmt_size_gigabytes() -> None: + assert _fmt_size(1024**3) == "1.0 GB" + + +def test_fmt_size_terabytes() -> None: + assert _fmt_size(1024**4) == "1.0 TB" + + +# --------------------------------------------------------------------------- +# _collect_files / _collect_dirs +# --------------------------------------------------------------------------- + + +def test_collect_files_flat(sample_tree: Path) -> None: + root = build_tree(sample_tree) + files = _collect_files(root) + assert all(not f.is_dir for f in files) + names = {f.name for f in files} + assert "app.py" in names + assert "README.md" in names + + +def test_collect_files_single_file_node() -> None: + node = Node(name="f.py", path=Path("f.py"), size=10, is_dir=False, extension=".py") + assert _collect_files(node) == [node] + + +def test_collect_dirs_excludes_root(sample_tree: Path) -> None: + root = build_tree(sample_tree) + dirs = _collect_dirs(root) + assert all(d.is_dir for d in dirs) + assert root not in dirs + names = {d.name for d in dirs} + assert "src" in names + assert "docs" in names + + +def test_collect_dirs_empty_tree(tmp_path: Path) -> None: + root = build_tree(tmp_path) + assert _collect_dirs(root) == [] + + +# --------------------------------------------------------------------------- +# tree_metrics +# --------------------------------------------------------------------------- + + +def test_tree_metrics_contains_key_fields(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.5) + assert "Files:" in out + assert "Dirs:" in out + assert "Total size:" in out + assert "Depth:" in out + assert "Scan time:" in out + assert "Top extensions" in out + assert "Largest files:" in out + assert "Largest dirs:" in out + + +def test_tree_metrics_file_count(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.0) + # sample_tree has 4 files + assert "4" in out + + +def test_tree_metrics_empty_dir_count(tmp_path: Path) -> None: + (tmp_path / "empty").mkdir() + (tmp_path / "nonempty").mkdir() + (tmp_path / "nonempty" / "f.txt").write_bytes(b"x") + root = build_tree(tmp_path) + out = tree_metrics(root, t_scan=0.0) + assert "1 empty" in out + + +def test_tree_metrics_top_n(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.0, top_n=1) + assert "Top extensions (1)" in out + + +def test_tree_metrics_scan_time(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=1.23) + assert "1.23s" in out + + +def test_tree_metrics_shows_ext_size(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.0) + # Each extension line should include a human-readable size + assert "KB" in out or "MB" in out or "B" in out + + +def test_tree_metrics_shows_percentages(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.0) + assert "%" in out + + +def test_tree_metrics_sort_by_size(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.0, sort_by="size") + assert "by size" in out + + +def test_tree_metrics_sort_by_count(sample_tree: Path) -> None: + root = build_tree(sample_tree) + out = tree_metrics(root, t_scan=0.0, sort_by="count") + assert "by count" in out + + +def test_tree_metrics_dict_keys(sample_tree: Path) -> None: + root = build_tree(sample_tree) + d = tree_metrics_dict(root, t_scan=0.5) + assert d["files"] == 4 + assert d["dirs"] == 2 + assert d["total_size_bytes"] == 430 + assert d["depth"] >= 1 + assert isinstance(d["top_extensions"], list) + assert isinstance(d["largest_files"], list) + assert isinstance(d["largest_dirs"], list) + + +def test_tree_metrics_dict_ext_fields(sample_tree: Path) -> None: + root = build_tree(sample_tree) + d = tree_metrics_dict(root, t_scan=0.0) + for e in d["top_extensions"]: + assert "ext" in e + assert "count" in e + assert "size_bytes" in e + + +def test_tree_metrics_dict_pct(sample_tree: Path) -> None: + root = build_tree(sample_tree) + d = tree_metrics_dict(root, t_scan=0.0) + for f in d["largest_files"]: + assert 0.0 <= f["pct"] <= 100.0 + for dr in d["largest_dirs"]: + assert 0.0 <= dr["pct"] <= 100.0 + + +def test_tree_metrics_dict_sort_by_size(sample_tree: Path) -> None: + root = build_tree(sample_tree) + d = tree_metrics_dict(root, t_scan=0.0, sort_by="size") + sizes = [e["size_bytes"] for e in d["top_extensions"]] + assert sizes == sorted(sizes, reverse=True) + + +def test_breadcrumbs_partial_chain() -> None: + # root → a → b → [dir1, dir2] — b has two children, so a/b merges but stops there + dir1 = _make_dir("dir1", [_make_file("x.txt")]) + dir2 = _make_dir("dir2", [_make_file("y.txt")]) + b = _make_dir("b", [dir1, dir2]) + a = _make_dir("a", [b]) + root = _make_dir("root", [a]) + + result = apply_breadcrumbs(root) + + assert result.name == "root" + child = result.children[0] + assert child.name == "a / b" + assert {c.name for c in child.children} == {"dir1", "dir2"} diff --git a/tests/test_ssh.py b/tests/test_ssh.py new file mode 100644 index 0000000..a988ca1 --- /dev/null +++ b/tests/test_ssh.py @@ -0,0 +1,328 @@ +"""Tests for SSH remote directory scanning.""" + +import getpass +import os +import stat as stat_module +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from dirplot.scanner import build_tree +from dirplot.ssh import build_tree_ssh, is_ssh_path, parse_ssh_path + +# --------------------------------------------------------------------------- +# Localhost integration helpers +# --------------------------------------------------------------------------- + + +def _localhost_sftp(): + """Return an open SFTPClient connected to localhost, or None if unavailable. + + Tries the user's default key files. Returns None (causes test to skip) + if the SSH server is unreachable or auth fails for any reason. + """ + try: + import paramiko # type: ignore[import-untyped] + except ImportError: + return None + + candidate_keys = [ + os.path.expanduser(p) + for p in ("~/.ssh/id_ed25519", "~/.ssh/id_rsa", "~/.ssh/id_ecdsa") + if os.path.exists(os.path.expanduser(p)) + ] + + client = paramiko.SSHClient() + client.load_system_host_keys() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + for key_file in candidate_keys: + try: + client.connect( + "localhost", + username=getpass.getuser(), + key_filename=key_file, + timeout=5, + ) + return client.open_sftp() + except Exception: + continue + + # Last resort: agent or any cached creds + try: + client.connect("localhost", username=getpass.getuser(), timeout=5) + return client.open_sftp() + except Exception: + return None + + +def make_attr( + filename: str, size: int, *, is_dir: bool = False, is_link: bool = False +) -> MagicMock: + attr = MagicMock() + attr.filename = filename + attr.st_size = size + attr.st_mtime = 1_700_000_000.0 + if is_link: + attr.st_mode = stat_module.S_IFLNK | 0o777 + elif is_dir: + attr.st_mode = stat_module.S_IFDIR | 0o755 + else: + attr.st_mode = stat_module.S_IFREG | 0o644 + return attr + + +# --------------------------------------------------------------------------- +# URI helpers +# --------------------------------------------------------------------------- + + +def test_is_ssh_path_ssh_uri() -> None: + assert is_ssh_path("ssh://user@host/path") + + +def test_is_ssh_path_scp_style() -> None: + assert is_ssh_path("user@host:/path") + + +@pytest.mark.parametrize( + "path", + [ + "/local/path", + "relative/path", + ".", + r"C:\Users\runneradmin\AppData\Local\Temp\repo@HEAD", + "/tmp/repo@feature:branch", + ], +) +def test_is_ssh_path_local(path: str) -> None: + assert not is_ssh_path(path) + + +def test_parse_ssh_path_ssh_uri() -> None: + user, host, path = parse_ssh_path("ssh://alice@prod.example.com/var/www") + assert user == "alice" + assert host == "prod.example.com" + assert path == "/var/www" + + +def test_parse_ssh_path_scp_style() -> None: + user, host, path = parse_ssh_path("alice@prod.example.com:/var/www") + assert user == "alice" + assert host == "prod.example.com" + assert path == "/var/www" + + +# --------------------------------------------------------------------------- +# build_tree_ssh +# --------------------------------------------------------------------------- + + +def test_build_tree_ssh_flat_directory() -> None: + sftp = MagicMock() + sftp.listdir_attr.return_value = [ + make_attr("file.py", 1000), + make_attr("README.md", 500), + ] + + node = build_tree_ssh(sftp, "/home/user/project") + assert node.is_dir + assert node.name == "project" + assert node.size == 1500 + assert {c.name for c in node.children} == {"file.py", "README.md"} + + +def test_build_tree_ssh_extensions() -> None: + sftp = MagicMock() + sftp.listdir_attr.return_value = [ + make_attr("script.py", 100), + make_attr("Makefile", 50), + ] + + node = build_tree_ssh(sftp, "/project") + py = next(c for c in node.children if c.name == "script.py") + mk = next(c for c in node.children if c.name == "Makefile") + assert py.extension == ".py" + assert mk.extension == "(no ext)" + + +def test_build_tree_ssh_skips_dotfiles() -> None: + sftp = MagicMock() + sftp.listdir_attr.return_value = [ + make_attr(".hidden", 100), + make_attr("visible.txt", 200), + ] + + node = build_tree_ssh(sftp, "/project") + names = {c.name for c in node.children} + assert "visible.txt" in names + assert ".hidden" not in names + + +def test_build_tree_ssh_skips_symlinks() -> None: + sftp = MagicMock() + sftp.listdir_attr.return_value = [ + make_attr("real.txt", 100), + make_attr("link.txt", 0, is_link=True), + ] + + node = build_tree_ssh(sftp, "/project") + names = {c.name for c in node.children} + assert "real.txt" in names + assert "link.txt" not in names + + +def test_build_tree_ssh_recurses_into_dirs() -> None: + sftp = MagicMock() + + def listdir_attr(path: str) -> list[MagicMock]: + if path == "/project": + return [make_attr("subdir", 0, is_dir=True), make_attr("top.txt", 100)] + if path == "/project/subdir": + return [make_attr("inner.py", 200)] + return [] + + sftp.listdir_attr.side_effect = listdir_attr + + node = build_tree_ssh(sftp, "/project") + assert node.size == 300 + subdir = next(c for c in node.children if c.name == "subdir") + assert subdir.is_dir + assert subdir.size == 200 + + +def test_build_tree_ssh_permission_error() -> None: + sftp = MagicMock() + sftp.listdir_attr.side_effect = PermissionError("denied") + + node = build_tree_ssh(sftp, "/restricted") + assert node.is_dir + assert node.children == [] + assert node.size == 1 + + +def test_build_tree_ssh_none_size_defaults_to_1() -> None: + sftp = MagicMock() + attr = make_attr("file.bin", 0) + attr.st_size = None + sftp.listdir_attr.return_value = [attr] + + node = build_tree_ssh(sftp, "/project") + assert node.children[0].size == 1 + + +def test_build_tree_ssh_exclude() -> None: + sftp = MagicMock() + sftp.listdir_attr.return_value = [ + make_attr("keep.py", 100), + make_attr("skip.py", 200), + ] + + node = build_tree_ssh(sftp, "/project", exclude=frozenset({"skip.py"})) + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names + + +def test_build_tree_ssh_depth_limit() -> None: + sftp = MagicMock() + + def listdir_attr(path: str) -> list[MagicMock]: + if path == "/project": + return [make_attr("subdir", 0, is_dir=True), make_attr("top.txt", 100)] + if path == "/project/subdir": + return [make_attr("deep.py", 500)] + return [] + + sftp.listdir_attr.side_effect = listdir_attr + + # depth=1: list root's direct children, no recursion into subdirs + node = build_tree_ssh(sftp, "/project", depth=1) + subdir = next(c for c in node.children if c.name == "subdir") + assert subdir.is_dir + assert subdir.children == [] + # listdir_attr should only have been called once (for the root) + sftp.listdir_attr.assert_called_once_with("/project") + + +def test_build_tree_ssh_ioerror_raises() -> None: + sftp = MagicMock() + sftp.listdir_attr.side_effect = OSError("connection reset") + + with pytest.raises(IOError, match="SSH connection lost"): + build_tree_ssh(sftp, "/project") + + +# --------------------------------------------------------------------------- +# Localhost integration tests (skipped when SSH to localhost is unavailable) +# --------------------------------------------------------------------------- + + +def test_ssh_localhost_matches_local_scan(tmp_path: Path) -> None: + """SSH scan of a local directory must produce the same tree as build_tree().""" + sftp = _localhost_sftp() + if sftp is None: + pytest.skip("SSH to localhost not available") + + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_bytes(b"x" * 100) + (tmp_path / "src" / "util.py").write_bytes(b"x" * 200) + (tmp_path / "README.md").write_bytes(b"x" * 50) + + try: + ssh_node = build_tree_ssh(sftp, str(tmp_path)) + finally: + sftp.close() + + local_node = build_tree(tmp_path) + + assert ssh_node.size == local_node.size + assert ssh_node.name == local_node.name + assert {c.name for c in ssh_node.children} == {c.name for c in local_node.children} + + +def test_ssh_localhost_file_attributes(tmp_path: Path) -> None: + """SSH scan returns correct sizes and extensions for each file.""" + sftp = _localhost_sftp() + if sftp is None: + pytest.skip("SSH to localhost not available") + + (tmp_path / "hello.py").write_bytes(b"x" * 123) + (tmp_path / "Makefile").write_bytes(b"x" * 45) + + try: + node = build_tree_ssh(sftp, str(tmp_path)) + finally: + sftp.close() + + py = next(c for c in node.children if c.name == "hello.py") + mk = next(c for c in node.children if c.name == "Makefile") + + assert py.size == 123 + assert py.extension == ".py" + assert mk.size == 45 + assert mk.extension == "(no ext)" + + +def test_ssh_localhost_exclude(tmp_path: Path) -> None: + """Excluded paths are omitted from the SSH scan.""" + sftp = _localhost_sftp() + if sftp is None: + pytest.skip("SSH to localhost not available") + + (tmp_path / "keep.py").write_bytes(b"x" * 10) + (tmp_path / "skip.py").write_bytes(b"x" * 20) + + try: + node = build_tree_ssh( + sftp, + str(tmp_path), + exclude=frozenset({"skip.py"}), + ) + finally: + sftp.close() + + names = {c.name for c in node.children} + assert "keep.py" in names + assert "skip.py" not in names diff --git a/tests/test_svg_render.py b/tests/test_svg_render.py new file mode 100644 index 0000000..aaf66fc --- /dev/null +++ b/tests/test_svg_render.py @@ -0,0 +1,451 @@ +"""Tests for SVG treemap rendering.""" + +import io +from pathlib import Path + +import pytest + +from dirplot.scanner import build_tree +from dirplot.svg_render import ( + _hex, + _label_color, + _make_cushion_gradient, + _truncate, + _wrap, + create_treemap_svg, +) + +# --------------------------------------------------------------------------- +# Unit tests for helpers +# --------------------------------------------------------------------------- + + +def test_hex_white() -> None: + assert _hex((1.0, 1.0, 1.0, 1.0)) == "#ffffff" + + +def test_hex_black() -> None: + assert _hex((0.0, 0.0, 0.0, 1.0)) == "#000000" + + +def test_hex_color() -> None: + assert _hex((1.0, 0.0, 0.0, 1.0)) == "#ff0000" + + +def test_label_color_light_background() -> None: + assert _label_color((1.0, 1.0, 1.0, 1.0)) == "#000000" + assert _label_color((0.8, 0.8, 0.8, 1.0)) == "#000000" + + +def test_label_color_dark_background() -> None: + assert _label_color((0.0, 0.0, 0.0, 1.0)) == "#ffffff" + assert _label_color((0.2, 0.2, 0.2, 1.0)) == "#ffffff" + + +def test_wrap_short_name() -> None: + """A name that fits in one line is returned as a single-element list.""" + result = _wrap("short.py", font_size=12, max_w=200) + assert result == ["short.py"] + + +def test_wrap_long_name() -> None: + """A name that doesn't fit is split into multiple lines.""" + result = _wrap("very_long_filename_that_needs_wrapping.py", font_size=12, max_w=80) + assert len(result) > 1 + # All characters appear in the output + assert "".join(result) == "very_long_filename_that_needs_wrapping.py" + + +def test_wrap_splits_at_delimiter() -> None: + """Wrap should prefer splitting at delimiter characters.""" + result = _wrap("some.long.name", font_size=12, max_w=50) + # Should split somewhere at a dot + joined = "".join(result) + assert joined == "some.long.name" + + +def test_truncate_short() -> None: + assert _truncate("hi.py", font_size=12, max_w=200) == "hi.py" + + +def test_truncate_long() -> None: + result = _truncate("very_long_filename.py", font_size=12, max_w=50) + assert result.endswith("\u2026") + assert len(result) < len("very_long_filename.py") + + +# --------------------------------------------------------------------------- +# Integration tests for create_treemap_svg +# --------------------------------------------------------------------------- + + +def test_create_treemap_svg_returns_bytesio(sample_tree: Path) -> None: + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + assert isinstance(buf, io.BytesIO) + + +def test_create_treemap_svg_valid_xml(sample_tree: Path) -> None: + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert content.startswith("<?xml") + assert "<svg" in content + assert "</svg>" in content + + +def test_create_treemap_svg_dimensions(sample_tree: Path) -> None: + """The SVG viewBox and width/height must match the requested dimensions.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert 'width="400"' in content + assert 'height="300"' in content + + +def test_create_treemap_svg_background(sample_tree: Path) -> None: + """The SVG should contain the dark background color.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert "#1a1a2e" in content + + +def test_create_treemap_svg_empty_dir(tmp_path: Path) -> None: + """An empty directory should produce valid SVG without raising.""" + root = build_tree(tmp_path) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert "<svg" in content + + +def test_create_treemap_svg_custom_colormap(sample_tree: Path) -> None: + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240, colormap="viridis") + content = buf.read().decode("utf-8") + assert "<svg" in content + + +def test_create_treemap_svg_legend(sample_tree: Path) -> None: + """legend=True should produce SVG that includes extension labels.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=400, height_px=300, legend=True) + content = buf.read().decode("utf-8") + assert "<svg" in content + # Legend should contain some extension text + assert ".md" in content or ".py" in content + + +def test_create_treemap_svg_contains_rects(sample_tree: Path) -> None: + """Each file should be represented as a <rect> element.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert content.count("<rect") >= 3 # at least the bg + a few tiles + + +def test_create_treemap_svg_seeked_to_zero(sample_tree: Path) -> None: + """The returned buffer must be seeked to position 0.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + assert buf.tell() == 0 + + +def test_create_treemap_svg_file_extension_colors(tmp_path: Path) -> None: + """Known extensions should use Linguist palette colors in the SVG.""" + (tmp_path / "a.js").write_bytes(b"x" * 1000) + (tmp_path / "b.py").write_bytes(b"x" * 1000) + + root = build_tree(tmp_path) + buf = create_treemap_svg(root, width_px=400, height_px=200) + content = buf.read().decode("utf-8") + + # JavaScript is #f1e05a in Linguist palette; Python is #3572A5 + assert "#f1e05a" in content.lower() or "f1e05a" in content.lower() + assert "#3572a5" in content.lower() or "3572a5" in content.lower() + + +def test_create_treemap_svg_save_to_file(sample_tree: Path, tmp_path: Path) -> None: + """The SVG buffer can be written to a file and read back.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + out = tmp_path / "treemap.svg" + out.write_bytes(buf.read()) + content = out.read_text() + assert "<svg" in content + + +# --------------------------------------------------------------------------- +# Interactive effects tests +# --------------------------------------------------------------------------- + + +def test_no_native_title_tooltips(tmp_path: Path) -> None: + """Native <title> elements must not be present (replaced by JS tooltip).""" + (tmp_path / "app.py").write_bytes(b"x" * 500) + root = build_tree(tmp_path) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert "<title>" not in content + + +def test_tooltip_background_is_semitransparent(sample_tree: Path) -> None: + """The tooltip background rect must use fill-opacity < 1 so the treemap shows through.""" + import re + + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + bg_pos = content.find('id="_dp_tip_bg"') + assert bg_pos != -1, "_dp_tip_bg not found" + tag_start = content.rfind("<rect", 0, bg_pos) + tag_end = content.index("/>", tag_start) + 2 + tag = content[tag_start:tag_end] + mo = re.search(r'fill-opacity="([0-9.]+)"', tag) + assert mo, "fill-opacity attribute missing on tooltip background" + assert float(mo.group(1)) < 1.0 + + +def test_css_hover_class_on_file_tiles(tmp_path: Path) -> None: + """File tile <rect> elements must carry class='tile'.""" + (tmp_path / "main.py").write_bytes(b"x" * 800) + root = build_tree(tmp_path) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert 'class="tile"' in content + + +def test_css_hover_class_on_dir_tiles(sample_tree: Path) -> None: + """Directory header <rect> elements must carry class='dir-tile'.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert 'class="dir-tile"' in content + + +def test_css_style_block_present(sample_tree: Path) -> None: + """The SVG must contain a <style> block with .tile and .dir-tile rules.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert "<style>" in content + assert ".tile" in content + assert ".dir-tile" in content + assert "brightness" in content # hover filter + + +def test_data_attributes_on_file_tile(tmp_path: Path) -> None: + """File tiles must carry data-name, data-size, data-ext, data-is-dir attributes.""" + (tmp_path / "readme.md").write_bytes(b"x" * 250) + root = build_tree(tmp_path) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert 'data-name="readme.md"' in content + assert 'data-size="250"' in content + assert 'data-ext=".md"' in content + assert 'data-is-dir="0"' in content + + +def test_log_mode_tooltip_shows_original_size(tmp_path: Path) -> None: + """With --log, data-size must still show the original byte count, not the log value.""" + from dirplot.scanner import apply_log_sizes + + size = 100_000 + (tmp_path / "big.py").write_bytes(b"x" * size) + root = build_tree(tmp_path) + apply_log_sizes(root) + # After log transform, node.size is around 1000; original_size stays 100_000 + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert f'data-size="{size}"' in content, "tooltip must show original bytes, not log value" + + +def test_log_mode_dir_tooltip_shows_original_size(sample_tree: Path) -> None: + """With --log, directory header data-size must show the real total, not the log sum.""" + from dirplot.scanner import apply_log_sizes + + root = build_tree(sample_tree) + real_total = root.size # capture before log transform + apply_log_sizes(root) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert f'data-size="{real_total}"' in content, "dir tooltip must show original total bytes" + + +def test_data_attributes_on_dir_tile(sample_tree: Path) -> None: + """Directory header tiles must carry data-is-dir='1' and data-count.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=400, height_px=300) + content = buf.read().decode("utf-8") + assert 'data-is-dir="1"' in content + assert "data-count=" in content + + +def test_floating_tooltip_element_present(sample_tree: Path) -> None: + """The SVG must contain the JS tooltip group with its three text lines.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert 'id="_dp_tip"' in content + assert 'id="_dp_tip_l0"' in content + assert 'id="_dp_tip_l1"' in content + assert 'id="_dp_tip_l2"' in content + + +def test_floating_tooltip_hidden_by_default(sample_tree: Path) -> None: + """The tooltip group must start with visibility='hidden'.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert 'id="_dp_tip"' in content + tip_tag_start = content.rfind("<g", 0, content.index('id="_dp_tip"')) + tip_tag = content[tip_tag_start : content.index(">", tip_tag_start) + 1] + assert "hidden" in tip_tag + + +def test_javascript_block_present(sample_tree: Path) -> None: + """The SVG must contain an embedded <script> with the tooltip JS.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + assert "<script>" in content + assert "humanSize" in content + assert "_dp_tip" in content + assert "mouseenter" in content + + +def test_tooltip_element_is_last_visible_group(sample_tree: Path) -> None: + """The tooltip group must appear after all tile rects so it renders on top.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240) + content = buf.read().decode("utf-8") + # Find the start of the tooltip group; all tile rects should precede it + tip_pos = content.find('id="_dp_tip"') + # The last tile rect (carries class="tile" or class="dir-tile") must be before the tooltip + last_tile_pos = max(content.rfind('class="tile"'), content.rfind('class="dir-tile"')) + assert tip_pos > last_tile_pos, "Tooltip group must come after the last tile rect" + + +@pytest.mark.parametrize("w,h", [(200, 150), (320, 240), (101, 73)]) +def test_create_treemap_svg_various_sizes(sample_tree: Path, w: int, h: int) -> None: + """SVG rendering should succeed for various canvas sizes.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=w, height_px=h) + content = buf.read().decode("utf-8") + assert f'width="{w}"' in content + assert f'height="{h}"' in content + + +# --------------------------------------------------------------------------- +# Cushion gradient tests +# --------------------------------------------------------------------------- + + +def test_cushion_gradient_structure() -> None: + """Cushion gradient must be diagonal, objectBoundingBox, with 3 stops.""" + import drawsvg + + grad = _make_cushion_gradient() + assert isinstance(grad, drawsvg.LinearGradient) + d = drawsvg.Drawing(10, 10) + d.append(grad) + svg = d.as_svg() + assert 'gradientUnits="objectBoundingBox"' in svg + assert svg.count("<stop") == 3 + + +def test_cushion_gradient_stops() -> None: + """First stop is white highlight, last stop is black shadow.""" + import drawsvg + + grad = _make_cushion_gradient() + d = drawsvg.Drawing(10, 10) + d.append(grad) + svg = d.as_svg() + assert "stop-color" in svg + assert 'stop-color="white"' in svg + assert 'stop-color="black"' in svg + + +def test_cushion_on_produces_linearGradient(sample_tree: Path) -> None: + """With cushion=True (default) the SVG must contain a linearGradient.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240, cushion=True) + content = buf.read().decode("utf-8") + assert "linearGradient" in content + + +def test_cushion_off_omits_linearGradient(sample_tree: Path) -> None: + """With cushion=False the SVG must not contain a linearGradient.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=320, height_px=240, cushion=False) + content = buf.read().decode("utf-8") + assert "linearGradient" not in content + + +def test_cushion_gradient_is_defined_once(sample_tree: Path) -> None: + """Two gradients (file + dir) each defined once in <defs> regardless of tile count.""" + root = build_tree(sample_tree) + buf = create_treemap_svg(root, width_px=400, height_px=300, cushion=True) + content = buf.read().decode("utf-8") + assert content.count("linearGradient") == 4 # two gradients × (open + close tag) + + +def test_cushion_no_cushion_same_structure(sample_tree: Path) -> None: + """Cushion on/off should produce the same number of <rect> elements (gradient is extra).""" + root = build_tree(sample_tree) + buf_on = create_treemap_svg(root, width_px=320, height_px=240, cushion=True) + buf_off = create_treemap_svg(root, width_px=320, height_px=240, cushion=False) + # With cushion, each file tile gets an extra overlay rect → more rects overall + count_on = buf_on.read().decode().count("<rect") + count_off = buf_off.read().decode().count("<rect") + assert count_on > count_off + + +# --------------------------------------------------------------------------- +# Metadata tests +# --------------------------------------------------------------------------- + +EXPECTED_METADATA_KEYS = {"Date", "Software", "URL", "Python", "OS", "Command"} + + +def test_svg_has_metadata_element(sample_tree: Path) -> None: + """SVG output contains a <metadata> block.""" + root = build_tree(sample_tree) + svg = create_treemap_svg(root, width_px=400, height_px=300).read().decode() + assert "<metadata>" in svg + assert "</metadata>" in svg + + +def test_svg_metadata_has_all_keys(sample_tree: Path) -> None: + """SVG metadata block contains all expected dirplot: fields.""" + root = build_tree(sample_tree) + svg = create_treemap_svg(root, width_px=400, height_px=300).read().decode() + for key in EXPECTED_METADATA_KEYS: + assert f"<dirplot:{key}>" in svg, f"SVG missing metadata field {key!r}" + + +def test_svg_metadata_software_value(sample_tree: Path) -> None: + """SVG Software metadata starts with 'dirplot'.""" + import re + + root = build_tree(sample_tree) + svg = create_treemap_svg(root, width_px=400, height_px=300).read().decode() + match = re.search(r"<dirplot:Software>([^<]+)</dirplot:Software>", svg) + assert match and match.group(1).startswith("dirplot ") + + +def test_svg_metadata_url(sample_tree: Path) -> None: + """SVG URL metadata points to the dirplot GitHub repo.""" + root = build_tree(sample_tree) + svg = create_treemap_svg(root, width_px=400, height_px=300).read().decode() + assert "<dirplot:URL>https://github.com/deeplook/dirplot</dirplot:URL>" in svg + + +def test_svg_metadata_well_formed(sample_tree: Path) -> None: + """SVG output with metadata is well-formed XML.""" + import xml.etree.ElementTree as ET + + root = build_tree(sample_tree) + svg = create_treemap_svg(root, width_px=400, height_px=300).read().decode() + ET.fromstring(svg) # raises if not well-formed diff --git a/tests/test_terminal.py b/tests/test_terminal.py index f004e8c..553aef5 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -1,15 +1,23 @@ """Tests for terminal size detection.""" import struct +import sys from unittest.mock import MagicMock, patch +import pytest + from dirplot.terminal import get_terminal_pixel_size +_skip_no_fcntl = pytest.mark.skipif( + sys.platform == "win32", reason="fcntl not available on Windows" +) + def _make_winsz(rows: int, cols: int, width_px: int, height_px: int) -> bytes: return struct.pack("HHHH", rows, cols, width_px, height_px) +@_skip_no_fcntl def test_get_terminal_pixel_size_from_ioctl() -> None: with patch("fcntl.ioctl", return_value=_make_winsz(50, 200, 1600, 800)): w, h, row = get_terminal_pixel_size() @@ -18,6 +26,7 @@ def test_get_terminal_pixel_size_from_ioctl() -> None: assert row == 16 # 800 // 50 +@_skip_no_fcntl def test_get_terminal_pixel_size_ioctl_zero_falls_back() -> None: """ioctl returning zero dimensions triggers the os.get_terminal_size fallback.""" with ( @@ -30,6 +39,7 @@ def test_get_terminal_pixel_size_ioctl_zero_falls_back() -> None: assert row == 16 +@_skip_no_fcntl def test_get_terminal_pixel_size_ioctl_raises_falls_back() -> None: """ioctl exception triggers the os.get_terminal_size fallback.""" with ( @@ -42,6 +52,7 @@ def test_get_terminal_pixel_size_ioctl_raises_falls_back() -> None: assert row == 16 +@_skip_no_fcntl def test_get_terminal_pixel_size_both_fail() -> None: """Both ioctl and get_terminal_size failing returns the hardcoded fallback.""" with ( diff --git a/tests/test_watch.py b/tests/test_watch.py new file mode 100644 index 0000000..0e535a0 --- /dev/null +++ b/tests/test_watch.py @@ -0,0 +1,296 @@ +"""Tests for the filesystem watcher (TreemapEventHandler).""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from dirplot.watch import TreemapEventHandler + +try: + from watchdog.events import ( + FileCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, + ) + + _watchdog_available = True +except ImportError: + _watchdog_available = False + + +# --------------------------------------------------------------------------- +# Snapshot output (--snapshot) +# --------------------------------------------------------------------------- + + +def test_watch_snapshot_written(tmp_path: Path) -> None: + """_regenerate writes a PNG to output when snapshot path is given.""" + out = tmp_path / "treemap.png" + (tmp_path / "a.py").write_bytes(b"x" * 2_000) + + handler = TreemapEventHandler( + [tmp_path], + output=out, + width_px=200, + height_px=150, + font_size=12, + colormap="tab20", + cushion=False, + ) + handler._regenerate() + + assert out.exists() + assert out.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" + + +def test_watch_no_snapshot_no_write(tmp_path: Path) -> None: + """_regenerate does not crash when output is None (no snapshot).""" + (tmp_path / "a.py").write_bytes(b"x" * 1_000) + + handler = TreemapEventHandler( + [tmp_path], + output=None, + width_px=200, + height_px=150, + font_size=12, + colormap="tab20", + cushion=False, + ) + # Must not raise + handler._regenerate() + + +# --------------------------------------------------------------------------- +# SVG output path +# --------------------------------------------------------------------------- + + +def test_watch_svg_output(tmp_path: Path) -> None: + """_regenerate writes an SVG file when output has .svg extension.""" + out = tmp_path / "treemap.svg" + (tmp_path / "a.py").write_bytes(b"x" * 1_000) + + handler = TreemapEventHandler( + [tmp_path], out, width_px=200, height_px=150, font_size=12, colormap="tab20", cushion=False + ) + handler._regenerate() + + assert out.exists() + content = out.read_text() + assert "<svg" in content + + +# --------------------------------------------------------------------------- +# log-scale path +# --------------------------------------------------------------------------- + + +def test_watch_log_scale(tmp_path: Path) -> None: + """_regenerate with logscale > 1 does not crash and produces a PNG.""" + out = tmp_path / "treemap.png" + (tmp_path / "big.py").write_bytes(b"x" * 100_000) + (tmp_path / "tiny.py").write_bytes(b"x" * 1) + + handler = TreemapEventHandler( + [tmp_path], + out, + width_px=200, + height_px=150, + font_size=12, + colormap="tab20", + cushion=False, + logscale=4.0, + ) + handler._regenerate() + + assert out.exists() + assert out.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n" + + +# --------------------------------------------------------------------------- +# Exception during rendering is caught +# --------------------------------------------------------------------------- + + +def test_watch_regenerate_exception_is_caught( + tmp_path: Path, capsys: pytest.CaptureFixture +) -> None: + """An exception inside _regenerate is printed to stderr and does not propagate.""" + from unittest.mock import patch + + out = tmp_path / "treemap.png" + handler = TreemapEventHandler( + [tmp_path], out, width_px=200, height_px=150, font_size=12, colormap="tab20", cushion=False + ) + + with patch("dirplot.watch.build_tree_multi", side_effect=RuntimeError("boom")): + handler._regenerate() # must not raise + + captured = capsys.readouterr() + assert "boom" in captured.err + + +# --------------------------------------------------------------------------- +# _record_event with bytes paths +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_record_event_bytes_path(tmp_path: Path) -> None: + """_record_event decodes bytes src_path correctly.""" + out = tmp_path / "t.png" + handler = TreemapEventHandler( + [tmp_path], out, width_px=100, height_px=100, font_size=12, colormap="tab20", cushion=False + ) + event = MagicMock() + event.src_path = b"/some/bytes/path.py" + event.dest_path = None + handler._record_event("created", event) + assert handler._events[-1]["path"] == "/some/bytes/path.py" + + +# --------------------------------------------------------------------------- +# debounce=0 path +# --------------------------------------------------------------------------- + + +def test_schedule_regenerate_no_debounce(tmp_path: Path) -> None: + """With debounce=0, _schedule_regenerate calls _regenerate immediately.""" + out = tmp_path / "treemap.png" + (tmp_path / "f.py").write_bytes(b"x" * 500) + + handler = TreemapEventHandler( + [tmp_path], + out, + width_px=100, + height_px=100, + font_size=12, + colormap="tab20", + cushion=False, + debounce=0, + ) + handler._schedule_regenerate() + assert out.exists() + + +# --------------------------------------------------------------------------- +# flush joins a running render thread +# --------------------------------------------------------------------------- + + +def test_flush_joins_render_thread(tmp_path: Path) -> None: + """flush() calls render_thread.join() when a render thread is active.""" + out = tmp_path / "t.png" + handler = TreemapEventHandler( + [tmp_path], out, width_px=100, height_px=100, font_size=12, colormap="tab20", cushion=False + ) + mock_thread = MagicMock() + handler._render_thread = mock_thread + handler.flush() + mock_thread.join.assert_called_once() + + +# --------------------------------------------------------------------------- +# Event handlers: on_created, on_deleted, on_modified, on_moved +# --------------------------------------------------------------------------- + + +def _handler(tmp_path: Path, out: Path, **kw: object) -> TreemapEventHandler: + """Helper: create a handler with a large debounce so the timer never fires in tests.""" + return TreemapEventHandler( + [tmp_path], + out, + width_px=100, + height_px=100, + font_size=12, + colormap="tab20", + cushion=False, + debounce=100.0, + **kw, + ) + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_on_created_file(tmp_path: Path) -> None: + out = tmp_path / "t.png" + handler = _handler(tmp_path, out) + (tmp_path / "new.py").write_bytes(b"x" * 10) + event = FileCreatedEvent(str(tmp_path / "new.py")) + handler.on_created(event) + handler._timer.cancel() + assert handler._pending_highlights.get(str(tmp_path / "new.py")) == "created" + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_on_created_directory_ignored(tmp_path: Path) -> None: + """Directory creation events are ignored.""" + out = tmp_path / "t.png" + handler = _handler(tmp_path, out) + event = MagicMock() + event.is_directory = True + event.src_path = str(tmp_path / "newdir") + handler.on_created(event) + assert not handler._pending_highlights + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_on_deleted_file(tmp_path: Path) -> None: + out = tmp_path / "t.png" + handler = _handler(tmp_path, out) + event = FileDeletedEvent(str(tmp_path / "gone.py")) + handler.on_deleted(event) + handler._timer.cancel() + assert handler._pending_highlights.get(str(tmp_path / "gone.py")) == "deleted" + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_on_modified_file(tmp_path: Path) -> None: + out = tmp_path / "t.png" + f = tmp_path / "mod.py" + f.write_bytes(b"x" * 20) + handler = _handler(tmp_path, out) + event = FileModifiedEvent(str(f)) + handler.on_modified(event) + handler._timer.cancel() + assert handler._pending_highlights.get(str(f)) == "modified" + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_on_moved_file(tmp_path: Path) -> None: + out = tmp_path / "t.png" + src = tmp_path / "old.py" + dst = tmp_path / "new.py" + handler = _handler(tmp_path, out) + event = FileMovedEvent(str(src), str(dst)) + handler.on_moved(event) + handler._timer.cancel() + assert handler._pending_highlights.get(str(src)) == "deleted" + assert handler._pending_highlights.get(str(dst)) == "created" + + +# --------------------------------------------------------------------------- +# event_log written by flush() +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _watchdog_available, reason="watchdog not installed") +def test_flush_writes_event_log(tmp_path: Path) -> None: + """flush() writes recorded events as JSONL to event_log path.""" + import json + + out = tmp_path / "t.png" + log = tmp_path / "events.jsonl" + handler = _handler(tmp_path, out, event_log=log) + + event = FileCreatedEvent(str(tmp_path / "x.py")) + handler.on_created(event) + handler._timer.cancel() + handler.flush() + + assert log.exists() + lines = log.read_text().strip().splitlines() + assert len(lines) >= 1 + rec = json.loads(lines[0]) + assert rec["type"] == "created" diff --git a/uv.lock b/uv.lock index fd78658..3426f26 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", "python_full_version < '3.11'", ] @@ -15,6 +16,374 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "backports-zstd" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/70/766f6ebbb9db2ed75951f0a671ee15931dc69278c84d9f09b08dd6b67c3e/backports_zstd-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a2db17a6d9bf6b4dc223b3f6414aa9db6d1afe9de9bff61d582c2934ca456a0", size = 435664, upload-time = "2025-12-29T17:25:29.201Z" }, + { url = "https://files.pythonhosted.org/packages/55/f8/7b3fad9c6ee5ff3bcd7c941586675007330197ff4a388f01c73198ecc8bb/backports_zstd-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7f16b98ba81780a9517ce6c493e1aea9b7d72de2b1efa08375136c270e1ecba", size = 362060, upload-time = "2025-12-29T17:25:30.94Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/cad0f508ed7c3fbd07398f22b5bf25aa0523fcf56c84c3def642909e80ae/backports_zstd-1.3.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:1124a169a647671ccb4654a0ef1d0b42d6735c45ce3d0adf609df22fb1f099db", size = 505958, upload-time = "2025-12-29T17:25:32.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/96dc55c043b0d86e53ae9608b496196936244c1ecf7e95cdf66d0dbc0f23/backports_zstd-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8410fda08b36202d01ab4503f6787c763898888cb1a48c19fce94711563d3ee3", size = 475571, upload-time = "2025-12-29T17:25:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/48/d9c8c8c2a5ac57fc5697f1945254af31407b0c5f80335a175a7c215b4118/backports_zstd-1.3.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab139d1fc0e91a697e82fa834e6404098802f11b6035607174776173ded9a2cc", size = 581199, upload-time = "2025-12-29T17:25:35.566Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/7fe70d2d39ed39e26a6c6f6c1dd229f1ab889500d5c90b17527702b1a21e/backports_zstd-1.3.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f3115d203f387f77c23b5461fb6678d282d4f276f9f39298ad242b00120afc7", size = 640846, upload-time = "2025-12-29T17:25:36.86Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d8/5b8580469e70b72402212885bf19b9d31eaf23549b602e0c294edf380e25/backports_zstd-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:116f65cce84e215dfac0414924b051faf8d29dc7188cf3944dd1e5be8dd15a32", size = 491061, upload-time = "2025-12-29T17:25:38.721Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/17a752263fccd1ba24184b7e89c14cd31553d512e2e5b065f38e63a0ba86/backports_zstd-1.3.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04def169e4a9ae291298124da4e097c6d6545d0e93164f934b716da04d24630a", size = 565071, upload-time = "2025-12-29T17:25:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/df23d3fe664b2497ab2ec01dc012cb9304e7d568c67f50b1b324fb2d8cbb/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:481b586291ef02a250f03d4c31a37c9881e5e93556568abbd20ca1ad720d443f", size = 481518, upload-time = "2025-12-29T17:25:41.925Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cd/e50dd85fde890c5d79e1ed5dc241f1c45f87b6c12571fdb60add57f2ee66/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0290979eea67f7275fa42d5859cc5bea94f2c08cca6bc36396673476773d2bad", size = 509464, upload-time = "2025-12-29T17:25:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bb/e429156e4b834837fe78b4f32ed512491aea39415444420c79ccd3aa0526/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01c699d8c803dc9f9c9d6ede21b75ec99f45c3b411821011692befca538928cb", size = 585563, upload-time = "2025-12-29T17:25:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/95/c0/1a0d245325827242aefe76f4f3477ec183b996b8db5105698564f8303481/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2c662912cfc1a5ebd1d2162ac651549d58bd3c97a8096130ec13c703fca355f2", size = 562889, upload-time = "2025-12-29T17:25:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/93/42/126b2bc7540a15452c3ebdf190ebfea8a8644e29b22f4e10e2a6aa2389e4/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3180c8eb085396928e9946167e610aa625922b82c3e2263c5f17000556370168", size = 631423, upload-time = "2025-12-29T17:25:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/dc/32/018e49657411582569032b7d1bb5d62e514aad8b44952de740ec6250588d/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9a8c75a294e7ffa18fc8425a763facc366435a8b442e4dffdc19fa9499a22c", size = 495122, upload-time = "2025-12-29T17:25:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/cdd1d2e1d3612bb90d9cf9b23bea06f2155cdafccd8b6f28a1c4d7750004/backports_zstd-1.3.0-cp310-cp310-win32.whl", hash = "sha256:845defdb172385f17123d92a00d2e952d341e9ae310bfa2410c292bf03846034", size = 288573, upload-time = "2025-12-29T17:25:51.167Z" }, + { url = "https://files.pythonhosted.org/packages/55/7c/2e9c80f08375bd14262cefa69297a926134f517c9955c0795eec5e1d470e/backports_zstd-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a9fea6299c801da85221e387b32d90a9ad7c62aa2a34edf525359ce5ad8f3a", size = 313506, upload-time = "2025-12-29T17:25:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5d/fa67e8174f54db44eb33498abb7f98bea4f2329e873b225391bda0113a5e/backports_zstd-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:df8473cb117e1316e6c6101f2724e025bd8f50af2dc009d0001c0aabfb5eb57c", size = 288688, upload-time = "2025-12-29T17:25:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660, upload-time = "2025-12-29T17:25:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056, upload-time = "2025-12-29T17:25:56.729Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957, upload-time = "2025-12-29T17:25:57.941Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569, upload-time = "2025-12-29T17:25:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196, upload-time = "2025-12-29T17:26:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888, upload-time = "2025-12-29T17:26:02.134Z" }, + { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100, upload-time = "2025-12-29T17:26:03.418Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071, upload-time = "2025-12-29T17:26:05.022Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519, upload-time = "2025-12-29T17:26:06.255Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465, upload-time = "2025-12-29T17:26:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552, upload-time = "2025-12-29T17:26:08.911Z" }, + { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893, upload-time = "2025-12-29T17:26:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462, upload-time = "2025-12-29T17:26:11.99Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125, upload-time = "2025-12-29T17:26:13.218Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664, upload-time = "2025-12-29T17:26:14.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633, upload-time = "2025-12-29T17:26:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814, upload-time = "2025-12-29T17:26:17.571Z" }, + { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" }, + { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" }, + { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" }, + { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" }, + { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" }, + { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" }, + { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" }, + { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" }, + { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/e843d32122f25d9568e75d1e7a29c00eae5e5728015604f3f6d02259b3a5/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ab0d5632b84eff4355c42a04668cfe6466f7d390890f718978582bd1ff36949", size = 409771, upload-time = "2025-12-29T17:27:48.869Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a5/d6a897d4b91732f54b4506858f1da65d7a5b2dc0dbe36a23992a64f09f5a/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b97cea95dbb1a97c02afd718155fad93f747815069722107a429804c355e206", size = 339289, upload-time = "2025-12-29T17:27:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b0/f0ce566ec221b284508eebbf574a779ba4a8932830db6ea03b6176f336a2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:477895f2642f9397aeba69618df2c91d7f336e02df83d1e623ac37c5d3a5115e", size = 420335, upload-time = "2025-12-29T17:27:51.455Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/bf55652c84c79b2565d3087265bcb097719540a313dee16359a54d83ab4e/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:330172aaf5fd3bfa53f49318abc6d1d4238cb043c384cf71f7b8f0fe2fb7ce31", size = 393880, upload-time = "2025-12-29T17:27:52.869Z" }, + { url = "https://files.pythonhosted.org/packages/be/e0/d1feebb70ffeb150e2891c6f09700079f4a60085ebc67529eb1ca72fb5c2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32974e71eff15897ed3f8b7766a753d9f3197ea4f1c9025d80f8de099a691b99", size = 413840, upload-time = "2025-12-29T17:27:54.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/28/3b7be27ae51e418d3a724bbc4cb7fea77b6bd38b5007e333a56b0cb165c8/backports_zstd-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:993e3a34eaba5928a2065545e34bf75c65b9c34ecb67e43d5ef49b16cc182077", size = 299685, upload-time = "2025-12-29T17:27:56.149Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666, upload-time = "2025-12-29T17:27:57.37Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199, upload-time = "2025-12-29T17:27:58.542Z" }, + { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332, upload-time = "2025-12-29T17:28:00.332Z" }, + { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879, upload-time = "2025-12-29T17:28:01.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842, upload-time = "2025-12-29T17:28:03.113Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.63" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/2a/33d5d4b16fd97dfd629421ebed2456392eae1553cc401d9f86010c18065e/boto3-1.42.63.tar.gz", hash = "sha256:cd008cfd0d7ea30f1c5e22daf0998c55b7c6c68cb68eea05110e33fe641173d5", size = 112778, upload-time = "2026-03-06T22:47:55.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/19/f1d8d2b24871d3d0ccb2cbd0b0cb64a3396d439384bd9643d2c25c641b84/boto3-1.42.63-py3-none-any.whl", hash = "sha256:d502a89a0acc701692ae020d15981f2a82e9eb3485acc651cfd0cf1a3afe79ee", size = 140554, upload-time = "2026-03-06T22:47:53.463Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.63" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/eb/a1c042f6638ada552399a9977335a6de2668a85bf80bece193c953531236/botocore-1.42.63.tar.gz", hash = "sha256:1fdfc33cff58d21e8622cf620ba2bba3cff324557932aaf935b5374e4610f059", size = 14965362, upload-time = "2026-03-06T22:47:44.158Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/60/17a2d3b94658bb999c6aee7bba6c76b271905debf0c8c8e6ac63ca8491bc/botocore-1.42.63-py3-none-any.whl", hash = "sha256:83f39d04f2b316bdfc59a3cac2d12238bde7126ac99d9a57d910dbd86d58c528", size = 14639889, upload-time = "2026-03-06T22:47:39.347Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -37,167 +406,25 @@ wheels = [ ] [[package]] -name = "colorama" -version = "0.4.6" +name = "cmap" +version = "0.7.2" 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" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/85/5c31c565c68807e525cb268d783e62b1f4a46b97d301d991f6b4ffbd52d6/cmap-0.7.2.tar.gz", hash = "sha256:9501cec4d5c2b7a821479aec3282b3d8b42fda983bad055e0f9dbc19cf7bc5b1", size = 949039, upload-time = "2026-02-24T13:18:33.729Z" } 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://files.pythonhosted.org/packages/28/b6/0f760b625233ae39ed7df1069e11edd8c2f8807acac75e40f6228507238c/cmap-0.7.2-py3-none-any.whl", hash = "sha256:ad85bcc2327351bb72ff41516d4116d74b0af89258b35a323fcccb655a64f1f2", size = 995915, upload-time = "2026-02-24T13:18:31.346Z" }, ] [[package]] -name = "contourpy" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, - { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, - { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, - { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, - { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, - { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, - { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, - { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, - { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, - { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, - { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, - { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, - { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, - { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.3" +name = "colorama" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -319,23 +546,78 @@ toml = [ ] [[package]] -name = "cycler" -version = "0.12.1" +name = "cryptography" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] name = "dirplot" -version = "0.1.0" +version = "0.4.4" source = { editable = "." } dependencies = [ - { name = "matplotlib" }, + { name = "cmap" }, + { name = "drawsvg" }, { name = "pillow" }, + { name = "py7zr" }, + { name = "rarfile" }, { name = "squarify" }, { name = "typer" }, + { name = "watchdog" }, ] [package.optional-dependencies] @@ -346,20 +628,36 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, ] +libarchive = [ + { name = "libarchive-c" }, +] +s3 = [ + { name = "boto3" }, +] +ssh = [ + { name = "paramiko" }, +] [package.metadata] requires-dist = [ - { name = "matplotlib", specifier = ">=3.7" }, + { name = "boto3", marker = "extra == 's3'", specifier = ">=1.26" }, + { name = "cmap", specifier = ">=0.4" }, + { name = "drawsvg", specifier = ">=2.4.1" }, + { name = "libarchive-c", marker = "extra == 'libarchive'", specifier = ">=5.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "paramiko", marker = "extra == 'ssh'", specifier = ">=3.0" }, { name = "pillow", specifier = ">=9.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "py7zr", specifier = ">=0.20" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "rarfile", specifier = ">=4.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, { name = "squarify", specifier = ">=0.4" }, { name = "typer", specifier = ">=0.9" }, + { name = "watchdog", specifier = ">=6.0.0" }, ] -provides-extras = ["dev"] +provides-extras = ["ssh", "s3", "dev", "libarchive"] [[package]] name = "distlib" @@ -370,6 +668,14 @@ 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" }, ] +[[package]] +name = "drawsvg" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/07/a2c3db84e6af6fa761de905b39109fe24eff2c8d52653c1bff968b6b965d/drawsvg-2.4.1-py3-none-any.whl", hash = "sha256:241ff024968e03542bc8685b41a285427303c17f81eae1933229d26bb65b7fda", size = 44067, upload-time = "2026-01-04T00:06:09.817Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -391,63 +697,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] -[[package]] -name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" }, - { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" }, - { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" }, - { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" }, - { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, -] - [[package]] name = "identify" version = "2.6.17" @@ -457,6 +706,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, ] +[[package]] +name = "inflate64" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/f3/41bb2901543abe7aad0b0b0284ae5854bb75f848cf406bf8a046bf525f67/inflate64-1.0.4.tar.gz", hash = "sha256:b398c686960c029777afc0ed281a86f66adb956cfc3fbf6667cc6453f7b407ce", size = 902542, upload-time = "2025-11-28T10:55:52.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/d5/5c13cfc7954ed716ae0e5e64c4f54be43f8c145b546472b67803feaa18a4/inflate64-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f1a47837d4322e0684824f91eb635aa6fd1967584140c478b0a1aca7b11740d6", size = 58602, upload-time = "2025-11-28T10:54:21.346Z" }, + { url = "https://files.pythonhosted.org/packages/33/57/4d740b677cda81ec6f47c05b502ed15103c8a7d9c3e91ee93352d46fe69c/inflate64-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8600478542e2354d1ee7b5c57c957006cabacd8b787b4046951f487a2216e5c0", size = 35856, upload-time = "2025-11-28T10:54:22.629Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/ec3c058283706a43ab790e8d611a3a787a4f4cc4ae3faeafba6e2e216e36/inflate64-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fb2b5a62579d074f38352a3494c3c6ac1a90516b75c5793c39303547f1fea925", size = 36007, upload-time = "2025-11-28T10:54:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/90f7086f8078057a090db43459e478dc45e2d5ce2509f9c6a6a08100efa0/inflate64-1.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dcfafc572a642215894af1ec8d05949fa35eb7cb36d053aa97b11eccf1ae579e", size = 95055, upload-time = "2025-11-28T10:54:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/4f760f095ce8a9494d441b96a8735346141dd24f52fa573c971c0da1c958/inflate64-1.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb93159cb60aee8cab62541aa70e4c460f13359660a27a1a486518bba0153535", size = 96645, upload-time = "2025-11-28T10:54:26.308Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2a/78ab2fcb02c13e3c8c93c2d82bf5eec1862b428bc6177dcc76ac4044408d/inflate64-1.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:89126ceb4d96e76842f4697017a9a3e750c34e029ddb360b3d8ca79a648d47f6", size = 92933, upload-time = "2025-11-28T10:54:27.563Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1b/c9a2d84fc117dddee0749dc1b3ab9ed725bf92e866ef0ede0945a5128ef0/inflate64-1.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f70e6692617ec82500b203eefac8765302298ce7e73584fcf995bb9e23184530", size = 95898, upload-time = "2025-11-28T10:54:28.91Z" }, + { url = "https://files.pythonhosted.org/packages/bd/85/5879fa47122c7d5b563c6dacbd4a782bd9464405f69d46af01e02d4a3907/inflate64-1.0.4-cp310-cp310-win32.whl", hash = "sha256:d08cdda33341b4f992af60c12dc60e370e9993b80a936c17244a602711eeb727", size = 32940, upload-time = "2025-11-28T10:54:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/a3/59/cef1b3505dc33d8cb9d115481923dec1de1372d29ac278622feecf9c03a1/inflate64-1.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:950dd7fe53474df5f4699b8f099980027e812d55fd82d8e167d599822c3d27d6", size = 35541, upload-time = "2025-11-28T10:54:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/d964fbfceeee6752c36c45645e5e9a9ef0dd70d4ce64e5e7316822e43382/inflate64-1.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:bad20de249d6336793f6267880668dbb286ca5c6e6991795aa6344c817588068", size = 33460, upload-time = "2025-11-28T10:54:32.954Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/2fc07d71cf863ed4167e7d3eb7f89de0341ffe3ed3a62ff6cc4123bdbda6/inflate64-1.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bccda9815b27623e805a34ee3ee4f46c93f0cc7ac621f9834d75f033fd79c27a", size = 58595, upload-time = "2025-11-28T10:54:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/53/77/1119bb53e8f4c9c77f2a5e3ab7d8c3e905fcfc9912073962b9b4cbf72118/inflate64-1.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c11e2a3cb9d9b49620c9b0c806dd0c55daec3b6bb665299b770a68f01bfc5432", size = 35854, upload-time = "2025-11-28T10:54:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/8b028a731f6fabbb49069a58f1aa5c3332688b57dcb8726f9e596661ce5b/inflate64-1.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e42def03ace8c58fd50b0df4f40241c45a2314c3876d020cce24acf958323c98", size = 36011, upload-time = "2025-11-28T10:54:37.208Z" }, + { url = "https://files.pythonhosted.org/packages/86/a3/5b67ef7fd5e7546d4c2be8a9a869c70d9fd525de1cfb2b4dc4b0855eeee8/inflate64-1.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7912927a509ca58d1a445ce4ff6e6e9f276dc1d72687386cdf7103bf590e785c", size = 98112, upload-time = "2025-11-28T10:54:38.616Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6e/6443ea6c42f6b244c4e59cb43f16e6b903669f718a96e3f1985acf473dea/inflate64-1.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec40c0383cbd84d845dcb785a48ae76eef43246c923f84fda380fdd5ea653d3c", size = 99549, upload-time = "2025-11-28T10:54:40.167Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ce/e98577e0771b857b491a64a3f7495eb507e0752b80093068b69813088df1/inflate64-1.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b01539fea372c6078b9707d9121c12cb321e587e193f50e257ce06cf5b15e41", size = 95946, upload-time = "2025-11-28T10:54:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3e/8165ff4051dff08b2cc8448dafd15a697564e84cf40f3ee0dc0df16eed16/inflate64-1.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf4e34e32a37a42e9cf8bd9681f89e3e37b218f97d8b8cc95bd065419bc8db13", size = 98766, upload-time = "2025-11-28T10:54:43.366Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/20cad309ded1d97195c5ad50e341b38ddc2e82f5c44ee7d000c4372c8b56/inflate64-1.0.4-cp311-cp311-win32.whl", hash = "sha256:2725ccc14b138f0ad622d0322b769f177f9edfe016ee9ed3404102935d39e7de", size = 32939, upload-time = "2025-11-28T10:54:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/be5cac018960157d33d61278377d590d5cc34922222cb0c4dc3284ce6eeb/inflate64-1.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:7056148548c1f25dcb38251f88c19b4635a5f32af4c7bad00621c85509e3d8c0", size = 35535, upload-time = "2025-11-28T10:54:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/36/9b130e45299d587f306178d65e950e1c8f60a09db8bb55c7cdce8fdda3b6/inflate64-1.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:2ea7bdcad65e255b4596f84880f6e0c1756d6336d620e302653257defa407742", size = 33457, upload-time = "2025-11-28T10:54:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/ed/33/5cfa7468960de1be0833e7e41adf5b7804a0aef2fb46f3679df3876bf3ab/inflate64-1.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8009e4a4918ee6c8cbc49e58fe159464895064cfdf0565fed3f49ca81e45272", size = 58619, upload-time = "2025-11-28T10:54:48.315Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0a/583c7c2832da36e986c5758d0afb6f5944599e55c5b798b066a9ef63e581/inflate64-1.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d173a7a0e865bb7d19685c5b1ad2994712b8361b24136d7e94abeff58505647", size = 35865, upload-time = "2025-11-28T10:54:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/9c6acfbe900e5c8698132244c68036b0455bd2169f46e356c83dc0366f11/inflate64-1.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8bad992f2d034f5f7e36208e54502d1b0829ce772c898e5dc59109833420148a", size = 36019, upload-time = "2025-11-28T10:54:50.813Z" }, + { url = "https://files.pythonhosted.org/packages/7f/66/c0c3d3b4b863aab2c2ce631d219a8eb3b95b78acd5f40d3212f071e693db/inflate64-1.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6bfcf806912ced77a21394f7363805ecacd626b79f93cba87d505a48e88ede78", size = 98765, upload-time = "2025-11-28T10:54:52.273Z" }, + { url = "https://files.pythonhosted.org/packages/37/00/1a2351a85d36b26c5b2b8cfbb37ad86084c98f592dd7590f8577d8b33993/inflate64-1.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62d1aac3aba094ae42e27ce7581b414c90f218248be0953b6aeb11a127225e5d", size = 100594, upload-time = "2025-11-28T10:54:53.684Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3e/5d18ff5b86aaaf54117e1bb6ce15cb17163f56035f9c480e609d35f258ab/inflate64-1.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8065166f355122484f004225b379d403346bdae69ec624786a9334f025580675", size = 96745, upload-time = "2025-11-28T10:54:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/306d5d6aca1e04e596d2a504a59ff9a900623a6ec852f38aab99f384562d/inflate64-1.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94a95f32087d223d2e119ff5c7c264109e8d4cb7e421e7a688a899a6fe021b38", size = 99795, upload-time = "2025-11-28T10:54:56.485Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ec/d4caa4bf3c9e520c15e900fee5a00fc523953843e14aa378ca1abfb2b4ea/inflate64-1.0.4-cp312-cp312-win32.whl", hash = "sha256:ad4fa490bb7dc2a4640a3adaa2d5950f4a465ba034bbcf184c2103646e58ad97", size = 32956, upload-time = "2025-11-28T10:54:57.642Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/c0de4e9bdf12e449360b710e9ab5b5248804610f382b538773cbd07b72bb/inflate64-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:2c6befdf83d088a6e0d10d0873a9d4bfde2ce00ad7a52c8189cf303306f98030", size = 35577, upload-time = "2025-11-28T10:54:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/03a5ef74ad3869b3c5af3b09216321f5a1a5a45265f7bd6d5abc669c7622/inflate64-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:2b263c619469f90a75f29c421c53d31b208ad494a078235a8f6db2bc96583fdc", size = 33465, upload-time = "2025-11-28T10:55:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/c2/55/b7de7ae318a4f233f892c4f7c8b7e0e8643abe3fdcc53ed35020a9fe3f47/inflate64-1.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3f37540d0e64884a935fd62a7d17e40ab69f05ec63e815483b6513675d01bef", size = 58633, upload-time = "2025-11-28T10:55:01.931Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5f/6f89c8524503fd7a9ca2bd91fe60d7291b3f684e9d41edb38ef49e10fc3d/inflate64-1.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d24112180c95d12f279cade9a1e21f8be7f4790c4109c293292edf87d061992", size = 35864, upload-time = "2025-11-28T10:55:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/7d/14/9eadd59244b38cf85ccd0ca43d1296c50b3a33aa37be4fb68a1928efa58c/inflate64-1.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5c098dab17821f466fc6e6a3d78fc6e0295bb51458015f03416b1d58d6a8df4f", size = 36019, upload-time = "2025-11-28T10:55:04.126Z" }, + { url = "https://files.pythonhosted.org/packages/ca/04/399e82d8f5003dd92c8a0c5c1a9a8ce0919114710a496cbe88848bff3a72/inflate64-1.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a984b9287ff0fb596eb058d66a9e94530556afd2b7c054b44f2e0aeeff894e8f", size = 98973, upload-time = "2025-11-28T10:55:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/038dc2593bbc4272d87eac8c9f75692267d47f834ced888f6d81995df606/inflate64-1.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f62a13d0327631778fa2a47c308ae2b07b2659b7bb8564783259ac65949f8c0c", size = 100777, upload-time = "2025-11-28T10:55:06.41Z" }, + { url = "https://files.pythonhosted.org/packages/74/4a/f6d3031dd3578510894a41bfe1ac149228970ce1629a43f20e0c5abbe8d1/inflate64-1.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:513201336fb3b0b7e2aee5dbbbe30a9f1b23291738b5ceb80076fc285f2ec2f1", size = 96967, upload-time = "2025-11-28T10:55:07.594Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/7ab3bde4e176609ae0e607a8a9bb38d201885275664a6d574299f5bf7850/inflate64-1.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84ce3a97272ba745fce52b38363855c7201968f6402a794bbade774e64c657b9", size = 99997, upload-time = "2025-11-28T10:55:08.85Z" }, + { url = "https://files.pythonhosted.org/packages/39/59/8256b3e802e203c8645e0b32b25e6bd94508ad572593f0cdf8234db3879a/inflate64-1.0.4-cp313-cp313-win32.whl", hash = "sha256:332051a9d7e50579b90a3f555d68f53414b06f636c9ffe82e97c0baae3c8fbcc", size = 32958, upload-time = "2025-11-28T10:55:10.343Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4f/5784ee1eb8260f2310e24ef2883f1f494f9332bcfde4ed14ee780372149e/inflate64-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:3983f53b590ff7d0ba243f664ce852aca882482f30f7a8eab33e10d769336d0c", size = 35578, upload-time = "2025-11-28T10:55:11.389Z" }, + { url = "https://files.pythonhosted.org/packages/39/e8/8d927770ce25dc9764c8104207a80653d65471d0a6a8f9ead350016e4586/inflate64-1.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:118d8286f085e99a14341c76ef9fbffd56619ccc80318a9a204aea3dbfa71470", size = 33465, upload-time = "2025-11-28T10:55:12.824Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fb/ec9d10f44f2fc7666ad5d70acae2b8a1941e8e08ccae1fad0820f7796be3/inflate64-1.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4f61925b2d4248eac2ebb15350a80aaa0d1f7f1dc770bd5ebbbb3b0db4a6a416", size = 58727, upload-time = "2025-11-28T10:55:13.834Z" }, + { url = "https://files.pythonhosted.org/packages/81/80/24ba0d2ee14e07e275e9c5b058e59a8a58f8ef42dd51a78ebbfd7c857ac4/inflate64-1.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c1acf18b08b32981a4a11ec5a112b8ad5d7c7a5b15cb5bdbdb5b19201e9aa180", size = 35945, upload-time = "2025-11-28T10:55:15.225Z" }, + { url = "https://files.pythonhosted.org/packages/70/b8/073a79716e093db973b8823bdfb02e10fbdf65642dbe1fa3cda24832aeb2/inflate64-1.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abddae8920b2eaef824254e14b8d4ff54afbe6194a1bbe9816584859f0c1244d", size = 36060, upload-time = "2025-11-28T10:55:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f0/87d3c317ed0acd94f5e9b3b1b9e9000228ea2af0cb4618c62cbfc816da34/inflate64-1.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b303132cc562a906543a56f35c4e164e3880da6ff041cb4a7b1df9f9d2b4bb69", size = 98995, upload-time = "2025-11-28T10:55:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/0b6035302e9c33f004240a50cb6e2e1fc7bb1f2b415b02d939c551bdd06b/inflate64-1.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f0993214dea0738c557fa56c13cd9083aef0097a201d726c21984ad7f577514", size = 100781, upload-time = "2025-11-28T10:55:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/2b/05/5f383c615ec0f01bcbbc699a71da167623e494083ab7ed0df86b4bddf125/inflate64-1.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a6baedc3288d7a4ff588951d3a9a97a5391dceed6255ff5b16e42cae7274bfa9", size = 97038, upload-time = "2025-11-28T10:55:20.156Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9a/c1482718c717c49c67490c42b4fdced9476e894eaebd52193e488c12e188/inflate64-1.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a846ce1f38845b20bef2625af1b512be83416d97824539524c5a34e7a729aec7", size = 100016, upload-time = "2025-11-28T10:55:21.337Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7f/700ede7474e72a1c0e2e8fe36624cd0225ca8b2875eca33d64aa2de75f4d/inflate64-1.0.4-cp314-cp314-win32.whl", hash = "sha256:eef87908c780439393d577a155868317f0a275b47b417db9f47d8633ec791745", size = 33691, upload-time = "2025-11-28T10:55:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/f2972df8cceecc9bf3afa3353d517ffc7125285198c844588e9aaf98f5d0/inflate64-1.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fb2fdd63ef3933b67af98b3f2ee2f57e7787278041d7ba4821382fedd729b68a", size = 36304, upload-time = "2025-11-28T10:55:24.005Z" }, + { url = "https://files.pythonhosted.org/packages/29/77/16200aced67215119fb30ec9a5889b48289ee2fa5ce5137623a9ad41b2c4/inflate64-1.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:2e129669a0243ac7816fd526946ee01c25688fe81623a6d6bc95b3156d80f4fb", size = 34491, upload-time = "2025-11-28T10:55:25.068Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/b466ec7666c40912ea81a305a8a2b75f5998e6cec1d3d75e067d76203731/inflate64-1.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:b17bf665d948dc4edeea0cd17752415d0cd7240c882b9c7e136ad4cc4321e9d4", size = 59300, upload-time = "2025-11-28T10:55:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/16e1168ac80f39894e6cb18b439eec63fec42cbced239aebfe12081b6ec7/inflate64-1.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6751758301936fbb38fa38eb5312e14e27b6a1abf568f83c17557fab2694373d", size = 36258, upload-time = "2025-11-28T10:55:27.295Z" }, + { url = "https://files.pythonhosted.org/packages/25/42/c463b42fd8a7947b4445fbaf57c265bb7f3114362fb7aee6884ffd8b5341/inflate64-1.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6a4136752aa2a544301059d8f13780aeb88c34d60770258436a87dacd3fc304", size = 36296, upload-time = "2025-11-28T10:55:28.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/f1/cf6121926e405020e9e7bccb78ec7781fbc87500ec67368e7d9e866758be/inflate64-1.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:938ebc6b28578bfd365d1a9fdb18b7faab08321babeb2198e8025d07d8dc7fb5", size = 106788, upload-time = "2025-11-28T10:55:29.797Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dd/b653f9962497cf4d3520d69272894c37fd76f86a0e04bb3bd9f32827dc2f/inflate64-1.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61f51f80fa6f367288343c1a2cd20a42af454883087064e9274fd2a8c3a5a200", size = 107959, upload-time = "2025-11-28T10:55:31.306Z" }, + { url = "https://files.pythonhosted.org/packages/60/ae/65d88109b63611c9b6c29008201107127cf2603d186ab99beec39d85f38e/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:172b51da7bbfa66b33f0a5405e944807b9949e92cf4cd9f983c07af8152766df", size = 104213, upload-time = "2025-11-28T10:55:32.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/94/c17de2f55b9fb1269bca4657f9089efe4ba0f3d4b652f07b34dfc69f69a2/inflate64-1.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8ca9a2985afd5a14fb48cd126a67e5944ccb7a0a6bdec58c4f796c8c88a84539", size = 106629, upload-time = "2025-11-28T10:55:33.735Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/8bbc587bb2ad09ab7edf1f9215b2c5faf4fa7ee7c071daefe9ed55e28814/inflate64-1.0.4-cp314-cp314t-win32.whl", hash = "sha256:f8964ceaabea294bc20abc9ef408c6aae978a75c25c83168a76cd87a37c38938", size = 33865, upload-time = "2025-11-28T10:55:35.335Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/8bf6f412f93c8b6dc14866a021b83321331fbdd17f6ab902a24dbf88773d/inflate64-1.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:7d13b04cba65c12d21e65eaa77da9484e265e8e821b26e0761d1455ad3a878d9", size = 36943, upload-time = "2025-11-28T10:55:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/33447bb3c4e3c0ae7b1fde3aadc52a18b2b0193cfcf4f585977e924c6463/inflate64-1.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9ae3ee727235a06dc3cd353ee5761fdd8e3b56ad119c711f61680528972a6ced", size = 34846, upload-time = "2025-11-28T10:55:38.433Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -467,111 +784,30 @@ wheels = [ ] [[package]] -name = "kiwisolver" -version = "1.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, - { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, - { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, - { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, - { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, - { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, - { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, - { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, - { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +name = "invoke" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "libarchive-c" +version = "5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/23/e72434d5457c24113e0c22605cbf7dd806a2561294a335047f5aa8ddc1ca/libarchive_c-5.3.tar.gz", hash = "sha256:5ddb42f1a245c927e7686545da77159859d5d4c6d00163c59daff4df314dae82", size = 54349, upload-time = "2025-05-22T08:08:04.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/3f/ff00c588ebd7eae46a9d6223389f5ae28a3af4b6d975c0f2a6d86b1342b9/libarchive_c-5.3-py3-none-any.whl", hash = "sha256:651550a6ec39266b78f81414140a1e04776c935e72dfc70f1d7c8e0a3672ffba", size = 17035, upload-time = "2025-05-22T08:08:03.045Z" }, ] [[package]] @@ -671,81 +907,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[[package]] -name = "matplotlib" -version = "3.10.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, - { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -755,6 +916,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "multivolumefile" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/f0/a7786212b5a4cb9ba05ae84a2bbd11d1d0279523aea0424b6d981d652a14/multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6", size = 77984, upload-time = "2021-04-29T12:18:39.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/31/ec5f46fd4c83185b806aa9c736e228cb780f13990a9cf4da0beb70025fcc/multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678", size = 17037, upload-time = "2021-04-29T12:18:38.886Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -889,7 +1059,8 @@ name = "numpy" version = "2.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", ] sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } wheels = [ @@ -975,6 +1146,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -1084,11 +1270,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.2" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { 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" }, ] [[package]] @@ -1116,6 +1302,161 @@ 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" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "py7zr" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" }, + { name = "inflate64" }, + { name = "multivolumefile" }, + { name = "psutil", marker = "sys_platform != 'cygwin'" }, + { name = "pybcj" }, + { name = "pycryptodomex" }, + { name = "pyppmd" }, + { name = "texttable" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/e6/01fb15361ca75ee5d01df6361825a49816a836c99980c5481da0e40c6877/py7zr-1.1.0.tar.gz", hash = "sha256:087b1a94861ad9eb4d21604f6aaa0a8986a7e00580abd79fedd6f82fecf0592c", size = 70855, upload-time = "2025-12-21T03:27:44.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/9c/762284710ead9076eeecd55fb60509c19cd1f4bea811df5f3603725b44cb/py7zr-1.1.0-py3-none-any.whl", hash = "sha256:5921bc30fb72b5453aafe3b2183664c08ef508cde2655988d5e9bd6078353ef7", size = 71257, upload-time = "2025-12-21T03:27:42.881Z" }, +] + +[[package]] +name = "pybcj" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/2670b672655b18454841b8e88f024b9159d637a4c07f6ce6db85accf8467/pybcj-1.0.7.tar.gz", hash = "sha256:72d64574069ffb0a800020668376b7ebd7adea159adbf4d35f8effc62f0daa67", size = 31282, upload-time = "2025-11-29T00:53:29.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/8b/8dad8e96268349363184a77ca7a0f9ab3941d0e84e41d75fd4be8ac25494/pybcj-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:618ec7345775c306d83527750e2d0ab3f42ffdc5ad6282f62f88cb53c9b2b679", size = 31780, upload-time = "2025-11-29T00:52:22.317Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/dc0cc7b4f876c733519956a764a64b4fa46b0da353a578f5eacdc5a24897/pybcj-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7e7faa1b0f7d894685e4567dd41268b93df89cff347ebfdfdc48b4bc0d68cb2", size = 23690, upload-time = "2025-11-29T00:52:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bf/102510783410dde5aadbc4caa98a1d45dba2c9c304ff02320c0297456a6a/pybcj-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3cd4b2d05272df605d5bdb54b3386985a2b074b4d97072da944736abd639fdee", size = 24072, upload-time = "2025-11-29T00:52:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/24/da/1fe5aa16188260408e21e850268a758863cf9683049f29b03336e46585dc/pybcj-1.0.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8eb5cd6f52df8857a8d9de594ca28a71683169b9de5af7e727c0e510aedb4550", size = 50614, upload-time = "2025-11-29T00:52:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/5bfc159a996a8c009cc3ac2dbb1d2603f3afefbf03f674ec7f784448fb57/pybcj-1.0.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9d10760356b7d254b7b04ff38e052d5229c5f5a69a5514c9c31cb1dbb7d7f82", size = 49451, upload-time = "2025-11-29T00:52:28.189Z" }, + { url = "https://files.pythonhosted.org/packages/89/85/050a9dd0aaacce88fb223a9842fb8f58674e0f485c3ccf65aa08bfef07ff/pybcj-1.0.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1696d9b50971e317f72802bebd18f9b53684892ad3c43e0258f34e0a01738484", size = 49738, upload-time = "2025-11-29T00:52:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/73/c9/c6b06dd5845ca1c00e7b2ea2ba5bddfd660a0c7fcf7168b67af1b9d2ba76/pybcj-1.0.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e3d91b5dfdb0a200545b68145d81dae4edd2c385d89643dc45d6d01291f5c04", size = 49112, upload-time = "2025-11-29T00:52:30.787Z" }, + { url = "https://files.pythonhosted.org/packages/32/80/bd8c01fe6804319a19bcdacf0c725b898508696705b0e17cdaf1abee59f3/pybcj-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:74df8f4c897f937105e8cd830df3b4ddf65ab5b5ba3e63cd6e3aeb3f4ecb0864", size = 24900, upload-time = "2025-11-29T00:52:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/95/b9/5d70116ea216474077fe6e9c8b326a576d367bcb3c1ec03255ccab33a600/pybcj-1.0.7-cp310-cp310-win_arm64.whl", hash = "sha256:dc121ecb26fdc1a4173a20b3c7cca5d8cc81494b485d4b44a62ed8448f8c796e", size = 23396, upload-time = "2025-11-29T00:52:33.216Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c7/93567f1a9624b41e7755978243f7abbf198e153242ff7737862376edf468/pybcj-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:906ee707e89302253813a123f90a36d94d1f3c8785a4a1b853b31ac67296857a", size = 31780, upload-time = "2025-11-29T00:52:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/c96a25b4ded3eabe58b07e05528cca0c0ff5b5142a67dc117979d43ae2c2/pybcj-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93da8503161fd51e01843aca031444fd46dce83e8a8bb4972f0256d6b3d280d3", size = 23696, upload-time = "2025-11-29T00:52:35.99Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/893d290def15f687c35806ffb2fd45c3f34b07c61bd0348220cb602c8c38/pybcj-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb4a52cd573f4359a89fd3a4a1d82c914f8b758a5c9f16cd5dd13fb8aa24436", size = 24067, upload-time = "2025-11-29T00:52:37.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/51/19a45c2cb92c722a214b8cfd9a39d1e144b5d1f76c7d92c128b479f4e405/pybcj-1.0.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6d9a26fa9e627eb2fbba0f5b376ab42246bebdaf38cf437e384a6b7e3d78e23", size = 51675, upload-time = "2025-11-29T00:52:38.18Z" }, + { url = "https://files.pythonhosted.org/packages/55/fc/3c9e2323b97bbbe295d4b0bb1eb24f0ddb81e2fe67181b9668ab502ccefe/pybcj-1.0.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47207b69997fdc39e91a66812477506267964284b7d45fed68876dd74323d44f", size = 50539, upload-time = "2025-11-29T00:52:39.469Z" }, + { url = "https://files.pythonhosted.org/packages/f6/87/625dbdbb575cbee728b736bffc8f14bb0a8ee6cc23874d5cb598c28a67a5/pybcj-1.0.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b33d6ef1de94720f4856e198bd2b8eca978015ed685aef4138755ba3910eb963", size = 49720, upload-time = "2025-11-29T00:52:40.822Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/61dab2d5078ce5a36402fee31ba9ee637755b1700aeec3c4fe39ebb11c39/pybcj-1.0.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:053c7cda499a8934151d0c915b6efce8e53fa6b47d162434a5b24afef7af5d17", size = 49213, upload-time = "2025-11-29T00:52:41.951Z" }, + { url = "https://files.pythonhosted.org/packages/9c/19/4b756dc90d51492c1c13439b3de5da00137bd068001d4d6293e20686ff60/pybcj-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:555e90270d665d94cd34d2e50b096f68dba6baf7035ae11ac65c2bc126f8cef7", size = 24900, upload-time = "2025-11-29T00:52:43.484Z" }, + { url = "https://files.pythonhosted.org/packages/e9/50/426be36573eeeab9e771aa41ab9824edbb285bc5035c226ba766deec0ef9/pybcj-1.0.7-cp311-cp311-win_arm64.whl", hash = "sha256:22bdb390da9a4e38b2191070a62b88ad52edc3f6e12fe7eea278217ccfdbc02c", size = 23394, upload-time = "2025-11-29T00:52:44.528Z" }, + { url = "https://files.pythonhosted.org/packages/f5/60/39b51114e3e740b61844448c3b61be146781a5c0ffabcd473a17ba7f4336/pybcj-1.0.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d39787b85678d2ab1c67e2f21dd2e71be851f08e5c9fe619c605877b57dd529d", size = 31858, upload-time = "2025-11-29T00:52:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/ee/15/df4cd94bdf6a73c2b6ecf5e99dd9dcfe654215992a0114860ff1c94752b5/pybcj-1.0.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8cd5dd166093a1fb146fb78859aac0f00b45db6c11074705517bc72a940a1c8e", size = 23746, upload-time = "2025-11-29T00:52:46.947Z" }, + { url = "https://files.pythonhosted.org/packages/15/1a/f8bbe5f9ad95a0c2d1853006a93021aa1c2851b25a6bccc0894b1d72c0f4/pybcj-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82152e8641f5ce68638f3504227065f27b6b1efe96479ffbf20d81530c220062", size = 24088, upload-time = "2025-11-29T00:52:47.962Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/53675b56f9dbcb7a4dd681af8c05b1abf95fea3cdf7bf64872b9e0fdc8c8/pybcj-1.0.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2095b45d05f8d19430167b7df52ebd920df854ab8d064bae879df0a4611374b3", size = 52407, upload-time = "2025-11-29T00:52:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/3008e748c7d35c407db97b77af52ade07756033250d0e208a6af231131ca/pybcj-1.0.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b400c9f48faed01edb7f0df54b4354270325c886e785f31c866c581a46023b", size = 51462, upload-time = "2025-11-29T00:52:51.016Z" }, + { url = "https://files.pythonhosted.org/packages/83/f4/8e8b079af7ac6a51b2edcb8bed6040a9748542cb1daf55387f769f9571d0/pybcj-1.0.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8210e51a2d4e5ccb4fdb75a1e692dd8c121858b589026bb28988ed7ffdb7ed00", size = 50320, upload-time = "2025-11-29T00:52:52.835Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/c2277eeb083029313f5822a491c39d7af91ebd1e717f42c772d56b8c3c4e/pybcj-1.0.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6c3fe420083186ae2e5f75c23aa6563dcb030b8fc188d00778ce374d1df1984", size = 49987, upload-time = "2025-11-29T00:52:53.904Z" }, + { url = "https://files.pythonhosted.org/packages/a0/52/711a94d5ae634ff3dd51324a40885158f819ba660b4601653bd78bbd33cd/pybcj-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:c435062d66364f85674a639541980000e37657b98367a2ce2699514e44b8ab05", size = 24940, upload-time = "2025-11-29T00:52:54.973Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d4/02e3ca25deca3359e63b70e5804bdbae9f400d03f93d0c66341e0471bb39/pybcj-1.0.7-cp312-cp312-win_arm64.whl", hash = "sha256:3f74fd70b08092e58b1ee13c67fbf9de63d73eb1c61ab06670a0d7161efeb252", size = 23410, upload-time = "2025-11-29T00:52:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/b08d5be15209b584858981b44a447ae7a6d8c591487e502e212b5420f94e/pybcj-1.0.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d5e0feeeee3a659b30d7afbd89bf41da84e8c8fe13e5b997457e799a70fa550", size = 31869, upload-time = "2025-11-29T00:52:56.973Z" }, + { url = "https://files.pythonhosted.org/packages/47/e5/68bffbc87581ea96bb4aea623d8cd085786f36d5b912ed8d9bade3265110/pybcj-1.0.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:60baaf9f0da31438515a401145f920f75f2ec7d511165bbf57475467af72a3e6", size = 23750, upload-time = "2025-11-29T00:52:57.986Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/718d6daa0d5e15e2131301220eaefbf6bc8cd0c90791c5ac18c893be111a/pybcj-1.0.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b9c6e726618c3d43c730df5a4067fc19653b360f89c2f72f4323dae10d324552", size = 24089, upload-time = "2025-11-29T00:52:59.457Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a6/7df6b55b64b370e2d42aa51b88402336d7f17c97eda284755f404d8d6047/pybcj-1.0.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a5fcd40a4ce8f0c5428032ec5db9f03abb42214b993886cdf558e5644de636e", size = 52454, upload-time = "2025-11-29T00:53:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/2ce282501a39f32f27cef2a3ee11c621f9d5348da4441d2326f2fcc9b17e/pybcj-1.0.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:029112255c22de66e0117bec932c8be341ed20c56dcf6a961c14689f7f0ce772", size = 51522, upload-time = "2025-11-29T00:53:01.714Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2a/f75ea5f09c91e01456a13759a325e007663855fa16af28830ce7d44d5427/pybcj-1.0.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6492bcef5cb6883506b9dce5e48cb81217407305957b0e602c6c689c60097c5e", size = 50398, upload-time = "2025-11-29T00:53:02.839Z" }, + { url = "https://files.pythonhosted.org/packages/5b/14/6d49e0c62a0ab68aa3325e6f141c33f37e5bc9d61cc9a1186c0a2d324fbe/pybcj-1.0.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22a7f4a51d36a1abb67a61e93248f997eb2be278f788d681096f5044ae18b4f9", size = 50087, upload-time = "2025-11-29T00:53:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/a0d58633236783028c08e585b3c4f4715a4286970b4ec4a25acfe23794f1/pybcj-1.0.7-cp313-cp313-win_amd64.whl", hash = "sha256:ebcce9b419fe5d3109150a1fab0fc93a64d5cd812ca44c5ddb7d4f7128ea369f", size = 24939, upload-time = "2025-11-29T00:53:05.363Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/8856cd8bc07e66322a55750ab87a264829cdfbc6cd85c5844340cf06bc53/pybcj-1.0.7-cp313-cp313-win_arm64.whl", hash = "sha256:bc6acf0320976b4e31bdc0e59b16689083d5c346a6c62ac4f799685d1cc5cf27", size = 23409, upload-time = "2025-11-29T00:53:06.749Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9e/eb50f11ea7fb6342167bb8353d48966b490afa8ae47c98917e9acd045b71/pybcj-1.0.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:293f951eb3877840acab79f0c4dcfc06eab03e087cb9e4c004ec058e093acb1d", size = 32409, upload-time = "2025-11-29T00:53:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f6/b55f4a5faf9bd162426f49de84873fbf813698d1b018234c3c99816a9662/pybcj-1.0.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3ae64960904f362d33ffca10715803afd9f9a6a2a592f871dcb335acf82edf29", size = 23818, upload-time = "2025-11-29T00:53:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/4c/24/78cf0973b3deded1e072479ac35d387083a626b3dc0b29e2e099cf73e082/pybcj-1.0.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:70aa4476910f982025f878e598c136559a6d78b59fc20ba8b4b592306cde6051", size = 24115, upload-time = "2025-11-29T00:53:10.241Z" }, + { url = "https://files.pythonhosted.org/packages/e6/69/e044d7127018158c67b119e6ed26d1a4b34b1e1defd9b0d3de029a782b9f/pybcj-1.0.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79ce3ce9b380b1b75c5e490abc3888ee3b5b2d28c22b59618674bf410b9cee16", size = 52484, upload-time = "2025-11-29T00:53:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/55337b0a9807887967b2cf49dd6f3bf47c7745786b0bda51a5cbfda66c78/pybcj-1.0.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc73ee1bc064d6f97dfd66051d3859b32e1b6a4cf89b077f5c8ef6c2dccb71af", size = 51471, upload-time = "2025-11-29T00:53:12.86Z" }, + { url = "https://files.pythonhosted.org/packages/59/09/6c57cbbf4931ac304f822ef78f478dd6b34b00b08e9530d308737959f064/pybcj-1.0.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5b0d13f41a9f85b3f95dd5dc7bfaa9539e80f8ae60a96db7f34c07ed732e4a82", size = 50435, upload-time = "2025-11-29T00:53:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/26415ca1b96456e27550dae67092023af3e8454621b1efa701079d8acb64/pybcj-1.0.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:597d7e9a8cbb30a6ed54d552fd3436edb32bbb821a7ac2fa8e5c7ebd1f7e0e93", size = 50010, upload-time = "2025-11-29T00:53:15.396Z" }, + { url = "https://files.pythonhosted.org/packages/91/f7/d4e55aede85143adff3ded89a1ae87bbf29060ddc2249fb861d78b103d4a/pybcj-1.0.7-cp314-cp314-win_amd64.whl", hash = "sha256:4603cc41ceb1236abe9169e2ead344140be5d2c3ac01bbc5e44cb1b13078a009", size = 25283, upload-time = "2025-11-29T00:53:16.472Z" }, + { url = "https://files.pythonhosted.org/packages/f6/19/9939ba437140d7375aecf8063d8695dfeea56ca40f57ddaa6ca3b828c131/pybcj-1.0.7-cp314-cp314-win_arm64.whl", hash = "sha256:adf985e816ddd59f3bf6d1066b7fa89de7424a4f19f3725f9976284cabe54e28", size = 23622, upload-time = "2025-11-29T00:53:17.994Z" }, + { url = "https://files.pythonhosted.org/packages/62/6e/5e14c70f3ddc268f28e7ac912510c8bc2b5430f18bb7f326bbb9d1878955/pybcj-1.0.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bbd835873de147481d62c11ba91a75d26a72df1142de3516b384b04e5a1db6d", size = 33016, upload-time = "2025-11-29T00:53:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/49/c1/25a1a8d5811c5d2a2ca3439c548abbba882fa72eba9a6eb41040206fdc26/pybcj-1.0.7-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b7576b25d7b01a953e2f987e77cef93c001db7b95924a5541d5a55f9195a7e89", size = 24114, upload-time = "2025-11-29T00:53:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/35/66/09567564cdc6cc2713d729bd29794367ebb67fa9ea5c10313c49608c08fa/pybcj-1.0.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57b36920498f82ca6a325a98b13e0fbff8fc29bade7aaaddc7d284640bffd87d", size = 24428, upload-time = "2025-11-29T00:53:21.522Z" }, + { url = "https://files.pythonhosted.org/packages/c5/49/4f6793624eb418cc2536ef39e67c8783d7ca542f6139a051e9d2d76fdc80/pybcj-1.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aac2a46faf41e373939f6d3e6a5aa2121bf09e2446972c14a8e5d1ca3b0f8130", size = 58806, upload-time = "2025-11-29T00:53:22.568Z" }, + { url = "https://files.pythonhosted.org/packages/be/1c/8604f7fe360a9340bbf798b826a88f8e9d186fc031eb531bcd11ac9e684b/pybcj-1.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c7d6156ef2b4e8ecd450b62dc4cc3a89e8dda307cb26288b670952ef0df3a37", size = 56975, upload-time = "2025-11-29T00:53:23.691Z" }, + { url = "https://files.pythonhosted.org/packages/83/71/d706b4220f60fa4e6a5756dc0051fb76c32a5615958fc12f0dcacef3f86b/pybcj-1.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0fe306213de1e764abae63c06ae5a4e9a83632f62612805f1f883b8d74431901", size = 56023, upload-time = "2025-11-29T00:53:25.135Z" }, + { url = "https://files.pythonhosted.org/packages/3e/28/ad306f5acb1226241960bb86e9021b4e32bb7da426ccdc824d9d36d61261/pybcj-1.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:00448182d535cca37e8f24d892d480fa86f80ff20c79385f6eca75f118efcbb4", size = 55194, upload-time = "2025-11-29T00:53:26.209Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d8/70c5e5701926fc67dae02101f0298d21c0b89ff83fa705712c3b4da252e5/pybcj-1.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:7e94aa712d0fa5fda9875828441755ece7121fc3f8c5cc3bc8ee92d05b853590", size = 25826, upload-time = "2025-11-29T00:53:27.655Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/7a87ba32c0c0756f36000fafe642fa4609be2c26a50a7913a057a47eabdf/pybcj-1.0.7-cp314-cp314t-win_arm64.whl", hash = "sha256:16fd4e51a5556d1f38d7ba5d1fab588bfb60ae23d2299b5179779bf9900adf71", size = 24049, upload-time = "2025-11-29T00:53:28.679Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1126,12 +1467,106 @@ wheels = [ ] [[package]] -name = "pyparsing" -version = "3.3.2" +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyppmd" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d7/803232913cab9163a1a97ecf2236cd7135903c46ac8d49613448d88e8759/pyppmd-1.3.1.tar.gz", hash = "sha256:ced527f08ade4408c1bfc5264e9f97ffac8d221c9d13eca4f35ec1ec0c7b6b2e", size = 1351815, upload-time = "2025-11-27T22:08:44.175Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f2/175dbdc56178d5d00caa73df79f98f72fab80f5c01a7467aff3ed245f739/pyppmd-1.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:041f46fbeb0a59888c0a94d6b9a557c652935633a104be1c31c12de491b5f448", size = 77545, upload-time = "2025-11-27T22:07:22.938Z" }, + { url = "https://files.pythonhosted.org/packages/61/a0/729139273084dd574b3b529cd4b006ca9d2dc42d8bd3b65adf66ae8dffd8/pyppmd-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9512a8b39740923559c26eb16266bf8b70d4eab6ad27a9b39cd2465e60e0acfa", size = 47997, upload-time = "2025-11-27T22:07:24.437Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/eab338c9fa00ddeb3f54552360949ac503f3cd77debc71857c07263eba20/pyppmd-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8966f26b91ba7cdff3cfec5512d39d1f8bf4a8dbb75c44085e33b564566fea66", size = 48437, upload-time = "2025-11-27T22:07:25.558Z" }, + { url = "https://files.pythonhosted.org/packages/96/5b/f098d68ce887d25a47e95a1aaa6443436cb21bb4bcc3cd73f332135a214d/pyppmd-1.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d1cff657e85655c67426c29c90c78a6210148b207993e643fc351c72c60d188", size = 139198, upload-time = "2025-11-27T22:07:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1e/59757d7e3aba883b2e446baee07055a069c9e17143fd4bd297fe590dd461/pyppmd-1.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9de2cdcc3932e7c23a54beb48dfe1b5ab7b4aedd5ffaae1e4871bd213d630cb3", size = 140959, upload-time = "2025-11-27T22:07:28.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/4d1aeea9bcb8293dada21b51e2d20eff49d24ddf512ec611b47d2440846a/pyppmd-1.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e1985461219c30d4576070b7e2de718dbb6f32637d1e658d25f838dfda2a4bb", size = 136301, upload-time = "2025-11-27T22:07:30.523Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/f6a46a60b092b5fb9a9631be408e447ea87cce4d656197736586ba082975/pyppmd-1.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20d9d1aa4d0f32118c8094c212c66b7af50e55f47e7c6dffa5f35a8ac391faca", size = 139126, upload-time = "2025-11-27T22:07:32.274Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/cf1f4ac5248ddcb19cf18ef8acb256d909912a5c5684fcfe400fb42d778d/pyppmd-1.3.1-cp310-cp310-win32.whl", hash = "sha256:44d25e7dede2abb614bc023fe87835365fdd5865981c2273b70bfad71b84db29", size = 42019, upload-time = "2025-11-27T22:07:33.442Z" }, + { url = "https://files.pythonhosted.org/packages/1b/93/1e035bd1ef405ca024be1fc4d9eb7d603eb03960d7010515ddf155dcccd2/pyppmd-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:1e503a28c9a275d31f24af9b735d2cca543b62f438b064e2833e9833e758bdbc", size = 46860, upload-time = "2025-11-27T22:07:34.873Z" }, + { url = "https://files.pythonhosted.org/packages/23/2f/236f7bf2f8b8ccfd93f0505692b9ad10a249fa00ce4671c50049c69ea414/pyppmd-1.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:3fb3708d7b2b38e2999385a2f02c8e68e0f5a364d94f94e475e2e8b09e9338fc", size = 45253, upload-time = "2025-11-27T22:07:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/04/40/580b99818c9db3a09cbae8e3740f9b5127ef7fd7314daf009c487ab02eb6/pyppmd-1.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdf55aa6ee7aef492f6896464e7a5a528f8615bb9e435f55bc8dff226fcc8292", size = 77550, upload-time = "2025-11-27T22:07:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a3/f7b4c49ef920f5b2c9812f8f54f48e01435433cb6b46ec82b1b4e740714d/pyppmd-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa820aac385ac4ee57160b26d92862c69d31c08f92272dbef05fe8e619cea8d1", size = 48000, upload-time = "2025-11-27T22:07:38.983Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c3/30de6f7892e77b83350e55d54a6913420591cc2c5c4d2394c219a4c461ac/pyppmd-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e16593ba4ca0a85821ae698ef06847a52937662f5ce1b130c39cca2979a4e8cd", size = 48439, upload-time = "2025-11-27T22:07:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/39/22/33889cbe22a617c42df2199be54e9e1bf88393fd8643fe911eb9f982849c/pyppmd-1.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:486dde2294ff9b30465ab5bb0f213b20bd5ac0e4adf21be801a1ceb29aa75d9d", size = 141737, upload-time = "2025-11-27T22:07:41.641Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/940ab2fd78caaef3a9e9833adb36e17b9d03016d8b45ae110cbb4bfc83d1/pyppmd-1.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89730cf026416ae2546c92738966ecf117c8176d52c229ad621a61c34643818b", size = 143643, upload-time = "2025-11-27T22:07:42.892Z" }, + { url = "https://files.pythonhosted.org/packages/75/51/129ce02d2b28a4f6788bccfe6d0ae1defc45db10eb1c4e4389cde8994ec7/pyppmd-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e77f5a6770950d464b50da760d53e67bce308a3abc8e3bd51db620b3f8cf1fa8", size = 138625, upload-time = "2025-11-27T22:07:44.54Z" }, + { url = "https://files.pythonhosted.org/packages/3a/89/f8390d1a21951d3d4619c854a839f7b1431746997cd98b475f614a658305/pyppmd-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8e3bf8deef44f8e03612689a6067a4a3dd7e50d2ef00af4cf987c59b62ff3006", size = 141663, upload-time = "2025-11-27T22:07:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3f/b58dd8875ebbb78436f87567547967bebdf3a5586f5f243c735fc4460843/pyppmd-1.3.1-cp311-cp311-win32.whl", hash = "sha256:a3509b3f881409ebc5522942438108c48a78f8df88bcf3f9d907b74131b9431c", size = 42014, upload-time = "2025-11-27T22:07:47.058Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/5fe05bdccdd7af04461c25a3dc34799f48ad3e9a2bd591aaec9afbc1d10a/pyppmd-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:23b83799f33f9a24577f22e092b0feecda8cd1ea33871ad8610a58629874f7bc", size = 46862, upload-time = "2025-11-27T22:07:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ba/6414cbe8407c23bacce906dad0599f155b2baf6082da104e64d7f1573717/pyppmd-1.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:234489036a1758670655d1ceafd4caeb93b858bd4c0ca39686837d38aef044c0", size = 45249, upload-time = "2025-11-27T22:07:49.261Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/696046e53c7aea98bb563aed3f15c3e2fa20c33e3d6c9de3c20992c586cc/pyppmd-1.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3faa58ab2ebe3b13ec23b1904639d687fb727270d2962fd2d239ca00fd6eb865", size = 77796, upload-time = "2025-11-27T22:07:50.362Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/a708cdb58e76889bc65201eda12211486548ffe00ecddc5dfbdce6d4d252/pyppmd-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27703f041ee96912a5410fd3ce31c5cde32f9323bd67f72f100bd960ee67bf13", size = 48185, upload-time = "2025-11-27T22:07:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/f299535c18009addb866d89a7044f1086e4eecf50542e57ff5bb15840195/pyppmd-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e773d8353b36f7e7973a43526993fb276b98a97839cb5dc8f4e6465ad873f41a", size = 48496, upload-time = "2025-11-27T22:07:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/4a59b73ea8e305f9192ee26ceb7c3d57e17ccb9bcca0e99ef335db29fcf7/pyppmd-1.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37b1883accf840cb0b711785d353f8548853a1401d381da007c0aec362f3ffac", size = 142620, upload-time = "2025-11-27T22:07:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/88/d7/fe32c2a4f8539365e6292aed25545830a5e718a510cdb4caddd6fd8d8056/pyppmd-1.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bd6d179ad39b6191ca0cbe62fb9592f33f49277b4384ad7bc5eb0e6ca27ebee", size = 144306, upload-time = "2025-11-27T22:07:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/e3907cf263b58f4ed4c5315bc1ac91721c85332fd0d73a89e3e2752904ef/pyppmd-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:806cf8d33606e44bf5ff5786c57891f57993f1eef1c763da3c58ea97de3a13c8", size = 139522, upload-time = "2025-11-27T22:07:57.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/22d5f93251e481f23bf741dde7f59cfc3e315f60b32b63f215a2d7bb8944/pyppmd-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1826cbce9a2c944aa08df79310a7e6d4a61fd20636b6dff64a77ea4bc43da30f", size = 142340, upload-time = "2025-11-27T22:07:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/38d36c1394d5e331db9708dae08863c87c059bd5b6e43b20234be02a0503/pyppmd-1.3.1-cp312-cp312-win32.whl", hash = "sha256:d3ff96671319318d941dd34300d641745048e8a3251b077bddf98652d6ddc513", size = 42102, upload-time = "2025-11-27T22:08:00.124Z" }, + { url = "https://files.pythonhosted.org/packages/ee/29/1398c6f67dfb66367babeed2980caee837d0a488705e3dff7ff159e017e7/pyppmd-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:c8c1ad39e7ebde71bf5a54cf61f489bf4790f1dd0beb70dc2e8f5ad3329d7ca7", size = 46957, upload-time = "2025-11-27T22:08:01.587Z" }, + { url = "https://files.pythonhosted.org/packages/d2/41/ee3193c16472a5a1c5f1965abb8fa87a7ad455c39688e9a0c2d87d6a7027/pyppmd-1.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:391b2bf76d7dc45b343781754d0b734dcbf539b92667986a343f5488c4bf9ca0", size = 45214, upload-time = "2025-11-27T22:08:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/f0/01/7cc3854b0e1304b90d2435e946db5e1c42e103adf840a68400002fc838b4/pyppmd-1.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4b4edb3e9619fd0bc39c1a07eb03e8731db833a93b23134f36c7ef581a94b37a", size = 77800, upload-time = "2025-11-27T22:08:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/caf6305224b9432ea7373670d101392f620c1ce741d6f95458b1fb9a16a4/pyppmd-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8b5c813e462c91048b88e2adfbcc0c69f2c905f70097001d32066f86f675bd4", size = 48195, upload-time = "2025-11-27T22:08:05.323Z" }, + { url = "https://files.pythonhosted.org/packages/45/82/8c5d3f0f738a3cf5c209d1471efda105a16417c0aaa91a2ad39efb3d4efb/pyppmd-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e8d372d9fac382183e0371cf0c2d736b494b1857a1befe98d563342b1205265b", size = 48482, upload-time = "2025-11-27T22:08:06.441Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/09ab8b4f00473f210284dacf837cae8355ad900f3fd8fd98b2bba12de4fa/pyppmd-1.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b765ae21f7ed2f4ea8f32bfd9e3a4a8d738e73fc8f8dcddec9cbe2c898d60be", size = 142828, upload-time = "2025-11-27T22:08:07.611Z" }, + { url = "https://files.pythonhosted.org/packages/64/fd/673d429df719affa1445611288aab6111c0bc87e51eb4677fe2421a1dd64/pyppmd-1.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd00522ddfcc292304577386b6c217758c0c10e1fb9ce7877ad7d3b7b821a808", size = 144502, upload-time = "2025-11-27T22:08:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0f/64dad213f56eaf23210ac7d3a5dedb19ad8f186f8b8682f65e7d51d9fa4a/pyppmd-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3de62099ff2ca876c2d39bc547bcba6f7b878988663abd782a5bad4edac3bb44", size = 139718, upload-time = "2025-11-27T22:08:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/bd5dbe8d7374748687d48f0bc44e6b930470e09552b1bbc65ef899ab673e/pyppmd-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:011f845de195d60fe973a635a1f4be981b7d80f357a8acb1b2d83bdf5087c808", size = 142578, upload-time = "2025-11-27T22:08:11.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/ae/0af00a9b1cd59b6821eb182413033d5ddbe05dfcde7511e3a6b1a5b83f6d/pyppmd-1.3.1-cp313-cp313-win32.whl", hash = "sha256:7d61bd01f25289b6ae54832db4254602fb0c6d105f6e6bf0aee39b803b698b98", size = 42102, upload-time = "2025-11-27T22:08:13.297Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/311d8faca142f39b4d158c94ed7eb237a197d41dd7eb67f53442a7904e04/pyppmd-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:df8d84ab72381058a964ba66e5e81ed52dbd0b5ad734a5ef8353452983506098", size = 46962, upload-time = "2025-11-27T22:08:14.759Z" }, + { url = "https://files.pythonhosted.org/packages/bb/42/24b59b6bb73a3fec96c7f81521d146c2ac89ffcb89cf9e848f62e0660381/pyppmd-1.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:124a04aab6936ba011f9ad57067798c7f052fdb1848b0cc4318606eea55475e6", size = 45209, upload-time = "2025-11-27T22:08:15.933Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/ac2a011af1c7c40b6482e60d85d267ac5923fb8794b51bfddbb56b04d1ec/pyppmd-1.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ea53f71ac16e113599b8441a9d8b6dcd71cfdf15cdb33ba5151810b8e656c5ec", size = 77897, upload-time = "2025-11-27T22:08:17.104Z" }, + { url = "https://files.pythonhosted.org/packages/20/13/737f4dac06685865d23f51b7061dbe9373c7bcc60b5b6456cd08301bc807/pyppmd-1.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:985c8703b53e5f68fe17f653e96748d60b1f855676c852a6e67cd472eb853671", size = 48268, upload-time = "2025-11-27T22:08:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d3/c50ebaee8b8a064fe9a534e98df5051e309efb705728aadff4e6893cd87f/pyppmd-1.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33daa996ad5203c665c0b55aff6329817b2cb7fa95f2c33a2e83ed0121b400cb", size = 48521, upload-time = "2025-11-27T22:08:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/80/6c/4a97c0a0ab7640aeddde4843cb6381b80ecb71fe2c6c48b9032d60586b11/pyppmd-1.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b49870c6d7194f6eb80f30335ca03596d153e02fcde2c222e4f1202ac25f7fcf", size = 142892, upload-time = "2025-11-27T22:08:20.972Z" }, + { url = "https://files.pythonhosted.org/packages/8f/22/744c32c7da3de171d9e62a1b2cb4104544ac778358565a3dbc06a2253324/pyppmd-1.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:385e92c97c42e8a6f0bfc0e4acfc6c074cb1ba3a2f650f292696dd9f19e2e603", size = 144550, upload-time = "2025-11-27T22:08:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/84af6808e0754923062122d3ee9f0532050f1612069d558bb5d53978e67a/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017a1e2903f1c3147a1046db486990d401e8a25eb52c320b1fc2fb3e7b83cbeb", size = 139850, upload-time = "2025-11-27T22:08:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/20/5e/dc6166ea7929d442625301289d99313a5b358e3cb2e927a6bd913047e8ee/pyppmd-1.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f2770a4b777c0c5236b3d9294b7bf4bc15538c95d45b2079eb8ebc1298e62e37", size = 142589, upload-time = "2025-11-27T22:08:25.228Z" }, + { url = "https://files.pythonhosted.org/packages/77/92/adc6b12ddf11173a02fd631010f88ad4fe4c532363959638d5d53583a3d9/pyppmd-1.3.1-cp314-cp314-win32.whl", hash = "sha256:b9d54cd59ce97f2ba57be1da91b3d874d129faca21c9565d7afec111f942e6a1", size = 42761, upload-time = "2025-11-27T22:08:26.917Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/5003a413d9f2743298a070df6713a75dedccc470e7adeeced5b43cad7418/pyppmd-1.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0e64247618cb150d2909beb0137da3084fef1d3479b4cc73b5b47fda7611abf9", size = 47806, upload-time = "2025-11-27T22:08:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/07/b7/db927e1df7fc3132cb57960f4ea23bf174c7e86ccec22857e6a35cccdae7/pyppmd-1.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:d354d6e551d2630b0ac98f27e3ad63e86cdcac9ce2115b5dfe46e2c9d3f4e82c", size = 46122, upload-time = "2025-11-27T22:08:29.191Z" }, + { url = "https://files.pythonhosted.org/packages/a7/5f/3150227624f374b06e331a7f67ae3383e796dd90973ee17ec3e35d30524c/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2d77ed79662c32e2551748d59763cfe3dcd10855bf3495937e3d5e5917507818", size = 78876, upload-time = "2025-11-27T22:08:30.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/11a3bba62d1d92050f4dd86db810062b506ec76d20b6e00b4ab567ff15e4/pyppmd-1.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6a1f92b94635c23d85270bb26db25cc0db544e436af86efc1cf58302d71d5af1", size = 48830, upload-time = "2025-11-27T22:08:32.087Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/e8f50cf89c689543d5a16c7be3df3c4afd43cbeb1a019b84ff9e82f13415/pyppmd-1.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:610f214f2405e27eb5a3dad7fa15f385cfc42141a01cda71995d9c1e0b09fab9", size = 48956, upload-time = "2025-11-27T22:08:33.567Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0f/944b30679a8dea3ea95a66531ee30cb6feb5d011ec7fd6832069d9130bf3/pyppmd-1.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae81a14895498d9a23429d92114c98da478b74b8e33251527d7cff3e01c09de0", size = 152872, upload-time = "2025-11-27T22:08:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/87/1e48ea92994f4c72dc9b5520a6a386b21c524d3d2969c2c2a5c325929ad7/pyppmd-1.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1683e6d1ba09e377e0ae02de3a518191a3d63ccdb0b6037c74e6ddf577b5644", size = 154210, upload-time = "2025-11-27T22:08:36.368Z" }, + { url = "https://files.pythonhosted.org/packages/3f/21/650911f98c4cb360442724bbdade27cb3679b0587ea77e73512693d2918d/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4d74fa0f3531e9dadc56e0ace41bce82d3c0babed47b3f224101dc0dbde7287", size = 147636, upload-time = "2025-11-27T22:08:37.657Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/4f8cfc1dd67b04ced8c10669fa14c4e35a42cf4a84e1101793735a82d5f0/pyppmd-1.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f8d6375b18b9c79127fee0885cfd52e2e983edb67041464309426571d38dcd4", size = 150998, upload-time = "2025-11-27T22:08:38.951Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/dc9e5e10bc56056bd8c33e533517cb327752cbaf172688bba3c23f6b2318/pyppmd-1.3.1-cp314-cp314t-win32.whl", hash = "sha256:de87f7acd575fb07a4ff42d41bcc071570fe759a36f345f1f54f574ecfccfc5b", size = 43016, upload-time = "2025-11-27T22:08:40.234Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a0/46dc15549ad2c0427a9825730e6bdf342045ad410543368afd4389a16f36/pyppmd-1.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:76e4800aa67292b4cc80058fd29b39e02a5dded721af9fe5654f356ef24307f4", size = 48636, upload-time = "2025-11-27T22:08:41.45Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7b/7e9a83848de928c26f0ab3ee105c0da63a932a0804a94807205e6313e73a/pyppmd-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e066cbf1d335fe20480cd8ecc10848ba78d99fe6d1e44ea00def48feaf46afdf", size = 46402, upload-time = "2025-11-27T22:08:42.804Z" }, ] [[package]] @@ -1255,6 +1690,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rarfile" +version = "4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/3f/3118a797444e7e30e784921c4bfafb6500fb288a0c84cb8c32ed15853c16/rarfile-4.2.tar.gz", hash = "sha256:8e1c8e72d0845ad2b32a47ab11a719bc2e41165ec101fd4d3fe9e92aa3f469ef", size = 153476, upload-time = "2024-04-03T17:10:53.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/fc/ab37559419ca36dd8dd317c3a98395ed4dcee2beeb28bf6059b972906727/rarfile-4.2-py3-none-any.whl", hash = "sha256:8757e1e3757e32962e229cab2432efc1f15f210823cc96ccba0f6a39d17370c9", size = 29052, upload-time = "2024-04-03T17:10:52.632Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -1270,27 +1714,39 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, - { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, - { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] [[package]] @@ -1320,6 +1776,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/3c/eedbe9fb07cc20fd9a8423da14b03bc270d0570b3ba9174a4497156a2152/squarify-0.4.4-py3-none-any.whl", hash = "sha256:d7597724e29d48aa14fd2f551060d6b09e1f0a67e4cd3ea329fe03b4c9a56f11", size = 4082, upload-time = "2024-07-19T18:57:40.338Z" }, ] +[[package]] +name = "texttable" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -1398,6 +1863,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "virtualenv" version = "21.1.0" @@ -1413,3 +1887,35 @@ sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d609184 wheels = [ { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]