git-reclaim 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ name: ci
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+
11
+ - name: Install uv
12
+ uses: astral-sh/setup-uv@v5
13
+ with:
14
+ python-version: "3.12"
15
+
16
+ - name: Install dependencies
17
+ run: uv sync --group dev
18
+
19
+ - name: Ruff lint
20
+ run: uv run ruff check src tests
21
+
22
+ - name: Ruff format check
23
+ run: uv run ruff format --check src tests
24
+
25
+ - name: Pytest
26
+ run: uv run pytest
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ build/
8
+ dist/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # uv
15
+ .uv/
16
+
17
+ # Editors and OS
18
+ .DS_Store
19
+ ._*
20
+ *.swp
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Borck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.5
2
+ Name: git-reclaim
3
+ Version: 0.1.0
4
+ Summary: Reclaim disk space across a tree of git repositories
5
+ Project-URL: Homepage, https://github.com/michael-borck/git-reclaim
6
+ Project-URL: Repository, https://github.com/michael-borck/git-reclaim
7
+ Project-URL: Issues, https://github.com/michael-borck/git-reclaim/issues
8
+ Author-email: Michael Borck <michael@borck.dev>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cleanup,cli,disk-space,git,repository-management
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Version Control :: Git
24
+ Classifier: Topic :: System :: Filesystems
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ # git-reclaim
30
+
31
+ A small, dependency-free **disk-reclaim** helper for people who keep many git
32
+ clones side by side. It finds space you can safely get back across a whole tree
33
+ of repos, and flags clones that are safe to delete because the remote already
34
+ has everything.
35
+
36
+ `git-reclaim` is a [git custom subcommand](https://git-scm.com/book/en/v2/Git-Internals-Git-Internals):
37
+ an executable named `git-reclaim` on your `PATH` is runnable as `git reclaim`.
38
+
39
+ > **Scope:** `git-reclaim` does one thing: reclaim disk space across local
40
+ > clones. For multi-repo **status and sync** (branch, ahead/behind, batch
41
+ > `git` commands), use [gita](https://github.com/nosarthur/gita). For anything
42
+ > needing the **GitHub API** (bulk listing, health checks, topics, portfolio
43
+ > sites), use [gh-toolkit](https://github.com/michael-borck/gh-toolkit) or
44
+ > [`gh`](https://cli.github.com/). If you want one verb set across all of them,
45
+ > use [gx](https://github.com/michael-borck/gx).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ uv tool install git-reclaim
51
+ # or
52
+ pipx install git-reclaim
53
+ # or
54
+ pip install git-reclaim
55
+ ```
56
+
57
+ No dependencies beyond Python 3.10+ and `git`.
58
+
59
+ ## `git reclaim`
60
+
61
+ ```
62
+ git reclaim [-p] [-g] [-r] [-d DIR]
63
+
64
+ (default) dry run: report reclaimable cache space; deletes nothing
65
+ -p purge the cache directories (any dir git tracks files inside is
66
+ skipped, never deleted)
67
+ -g compact history: run `git gc` (and `git lfs prune` if installed)
68
+ in every repo and report the .git space reclaimed
69
+ -r also list repos that are safe to delete (size + last commit + URL)
70
+ -d DIR directory to scan (default: current directory)
71
+ ```
72
+
73
+ Three ways to free space across a tree of clones:
74
+
75
+ - **Purge regenerable caches.** It finds `node_modules`, `.venv`/`venv`,
76
+ `__pycache__`, `.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `.next`, and
77
+ `target`. These rebuild on demand. So `git reclaim -p` recovers space without
78
+ touching your source, history, or uncommitted work. If a directory with one
79
+ of these names turns out to be git-tracked (a committed `target/`, say), the
80
+ purge detects that and skips it. `dist/` and `build/` are deliberately
81
+ excluded because they are occasionally committed. Re-run `npm install` or
82
+ `uv sync` next time you open a project.
83
+
84
+ - **Compact `.git` itself.** `git reclaim -g` runs `git gc` (plus
85
+ `git lfs prune` when git-lfs is installed) in every repo and reports the
86
+ space recovered per repo. This is standard git maintenance: nothing reachable
87
+ is deleted, and lfs prune always retains unpushed objects.
88
+
89
+ - **Find repos that are safe to delete.** `git reclaim -r` lists repos where
90
+ the remote already has everything: clean working tree, has a remote, no
91
+ unpushed commits on any local branch, no stashes, and no tags. Rows are sorted
92
+ by size, with each repo's last-commit date and re-clone URL. Anything not
93
+ fully synced is counted as "keep" and never listed as safe. Tags are treated
94
+ conservatively because a local tag ref can be lost even when its commit is on
95
+ the remote.
96
+
97
+ ### How discovery works
98
+
99
+ It walks the tree but **prunes at repo boundaries**. The moment a directory
100
+ contains a `.git` entry it is recorded as a repo and not descended into. So
101
+ workspace folders are descended into to find their child repos, while a repo's
102
+ own working tree (and its `node_modules`, `vendor`, submodules, etc.) is not
103
+ re-scanned. `.git` is detected as a **file or directory**, so submodule and
104
+ worktree clones are recognised too.
105
+
106
+ ### Examples
107
+
108
+ ```bash
109
+ git reclaim # what could I free from caches? (dry run)
110
+ git reclaim -p # actually purge the caches
111
+ git reclaim -g # repack every repo's .git (git gc)
112
+ git reclaim -r # caches report + list of repos safe to delete
113
+ git reclaim -r -d ~/work # scan a different tree
114
+ ```
115
+
116
+ > `git reclaim` only ever deletes **caches**, and only with `-p`; `-g` just
117
+ > repacks history in place. It never deletes a repo. It just reports which are
118
+ > safe, so you stay in control of removing whole clones. Pair it with the
119
+ > re-clone URLs it prints.
120
+
121
+ ## Using it from `gita`
122
+
123
+ If you manage your clones with `gita`, you can make `git-reclaim` feel native.
124
+ gita loads a user command file from `~/.config/gita/cmds.json` and lets custom
125
+ commands shadow the defaults. Add:
126
+
127
+ ```json
128
+ {
129
+ "reclaim": {
130
+ "cmd": "git-reclaim -d .",
131
+ "disable_async": true,
132
+ "help": "reclaim caches / flag safe-to-delete clones"
133
+ }
134
+ }
135
+ ```
136
+
137
+ Now `gita reclaim` (or `gita reclaim <group>`) runs `git-reclaim` in each
138
+ registered repo. No-config alternative:
139
+
140
+ ```bash
141
+ gita shell 'git-reclaim -d .'
142
+ ```
143
+
144
+ Note this runs it **per repo**, which suits the cache purge (`-p`) and
145
+ `git gc` (`-g`) modes. For the cross-tree "what's safe to delete, biggest
146
+ first" view (`-r`), run `git-reclaim` standalone against the parent folder
147
+ (`git reclaim -r -d ~/projects`) so you get the single aggregate, size-sorted
148
+ report.
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ uv sync --group dev
154
+ uv run pytest
155
+ uv run ruff check src tests
156
+ uv run ruff format --check src tests
157
+ ```
158
+
159
+ CI runs the same checks on every push and pull request.
160
+
161
+ ## License
162
+
163
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,135 @@
1
+ # git-reclaim
2
+
3
+ A small, dependency-free **disk-reclaim** helper for people who keep many git
4
+ clones side by side. It finds space you can safely get back across a whole tree
5
+ of repos, and flags clones that are safe to delete because the remote already
6
+ has everything.
7
+
8
+ `git-reclaim` is a [git custom subcommand](https://git-scm.com/book/en/v2/Git-Internals-Git-Internals):
9
+ an executable named `git-reclaim` on your `PATH` is runnable as `git reclaim`.
10
+
11
+ > **Scope:** `git-reclaim` does one thing: reclaim disk space across local
12
+ > clones. For multi-repo **status and sync** (branch, ahead/behind, batch
13
+ > `git` commands), use [gita](https://github.com/nosarthur/gita). For anything
14
+ > needing the **GitHub API** (bulk listing, health checks, topics, portfolio
15
+ > sites), use [gh-toolkit](https://github.com/michael-borck/gh-toolkit) or
16
+ > [`gh`](https://cli.github.com/). If you want one verb set across all of them,
17
+ > use [gx](https://github.com/michael-borck/gx).
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ uv tool install git-reclaim
23
+ # or
24
+ pipx install git-reclaim
25
+ # or
26
+ pip install git-reclaim
27
+ ```
28
+
29
+ No dependencies beyond Python 3.10+ and `git`.
30
+
31
+ ## `git reclaim`
32
+
33
+ ```
34
+ git reclaim [-p] [-g] [-r] [-d DIR]
35
+
36
+ (default) dry run: report reclaimable cache space; deletes nothing
37
+ -p purge the cache directories (any dir git tracks files inside is
38
+ skipped, never deleted)
39
+ -g compact history: run `git gc` (and `git lfs prune` if installed)
40
+ in every repo and report the .git space reclaimed
41
+ -r also list repos that are safe to delete (size + last commit + URL)
42
+ -d DIR directory to scan (default: current directory)
43
+ ```
44
+
45
+ Three ways to free space across a tree of clones:
46
+
47
+ - **Purge regenerable caches.** It finds `node_modules`, `.venv`/`venv`,
48
+ `__pycache__`, `.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `.next`, and
49
+ `target`. These rebuild on demand. So `git reclaim -p` recovers space without
50
+ touching your source, history, or uncommitted work. If a directory with one
51
+ of these names turns out to be git-tracked (a committed `target/`, say), the
52
+ purge detects that and skips it. `dist/` and `build/` are deliberately
53
+ excluded because they are occasionally committed. Re-run `npm install` or
54
+ `uv sync` next time you open a project.
55
+
56
+ - **Compact `.git` itself.** `git reclaim -g` runs `git gc` (plus
57
+ `git lfs prune` when git-lfs is installed) in every repo and reports the
58
+ space recovered per repo. This is standard git maintenance: nothing reachable
59
+ is deleted, and lfs prune always retains unpushed objects.
60
+
61
+ - **Find repos that are safe to delete.** `git reclaim -r` lists repos where
62
+ the remote already has everything: clean working tree, has a remote, no
63
+ unpushed commits on any local branch, no stashes, and no tags. Rows are sorted
64
+ by size, with each repo's last-commit date and re-clone URL. Anything not
65
+ fully synced is counted as "keep" and never listed as safe. Tags are treated
66
+ conservatively because a local tag ref can be lost even when its commit is on
67
+ the remote.
68
+
69
+ ### How discovery works
70
+
71
+ It walks the tree but **prunes at repo boundaries**. The moment a directory
72
+ contains a `.git` entry it is recorded as a repo and not descended into. So
73
+ workspace folders are descended into to find their child repos, while a repo's
74
+ own working tree (and its `node_modules`, `vendor`, submodules, etc.) is not
75
+ re-scanned. `.git` is detected as a **file or directory**, so submodule and
76
+ worktree clones are recognised too.
77
+
78
+ ### Examples
79
+
80
+ ```bash
81
+ git reclaim # what could I free from caches? (dry run)
82
+ git reclaim -p # actually purge the caches
83
+ git reclaim -g # repack every repo's .git (git gc)
84
+ git reclaim -r # caches report + list of repos safe to delete
85
+ git reclaim -r -d ~/work # scan a different tree
86
+ ```
87
+
88
+ > `git reclaim` only ever deletes **caches**, and only with `-p`; `-g` just
89
+ > repacks history in place. It never deletes a repo. It just reports which are
90
+ > safe, so you stay in control of removing whole clones. Pair it with the
91
+ > re-clone URLs it prints.
92
+
93
+ ## Using it from `gita`
94
+
95
+ If you manage your clones with `gita`, you can make `git-reclaim` feel native.
96
+ gita loads a user command file from `~/.config/gita/cmds.json` and lets custom
97
+ commands shadow the defaults. Add:
98
+
99
+ ```json
100
+ {
101
+ "reclaim": {
102
+ "cmd": "git-reclaim -d .",
103
+ "disable_async": true,
104
+ "help": "reclaim caches / flag safe-to-delete clones"
105
+ }
106
+ }
107
+ ```
108
+
109
+ Now `gita reclaim` (or `gita reclaim <group>`) runs `git-reclaim` in each
110
+ registered repo. No-config alternative:
111
+
112
+ ```bash
113
+ gita shell 'git-reclaim -d .'
114
+ ```
115
+
116
+ Note this runs it **per repo**, which suits the cache purge (`-p`) and
117
+ `git gc` (`-g`) modes. For the cross-tree "what's safe to delete, biggest
118
+ first" view (`-r`), run `git-reclaim` standalone against the parent folder
119
+ (`git reclaim -r -d ~/projects`) so you get the single aggregate, size-sorted
120
+ report.
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ uv sync --group dev
126
+ uv run pytest
127
+ uv run ruff check src tests
128
+ uv run ruff format --check src tests
129
+ ```
130
+
131
+ CI runs the same checks on every push and pull request.
132
+
133
+ ## License
134
+
135
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "git-reclaim"
3
+ version = "0.1.0"
4
+ description = "Reclaim disk space across a tree of git repositories"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Michael Borck", email = "michael@borck.dev" },
10
+ ]
11
+ keywords = ["git", "disk-space", "cleanup", "repository-management", "cli"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Version Control :: Git",
25
+ "Topic :: System :: Filesystems",
26
+ "Topic :: Utilities",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/michael-borck/git-reclaim"
32
+ Repository = "https://github.com/michael-borck/git-reclaim"
33
+ Issues = "https://github.com/michael-borck/git-reclaim/issues"
34
+
35
+ [project.scripts]
36
+ git-reclaim = "git_reclaim.cli:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/git_reclaim"]
44
+
45
+ [tool.ruff]
46
+ target-version = "py310"
47
+ line-length = 88
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
51
+ ignore = ["E501", "B008"]
52
+
53
+ [tool.ruff.format]
54
+ quote-style = "double"
55
+ indent-style = "space"
56
+ skip-magic-trailing-comma = false
57
+ line-ending = "auto"
58
+
59
+ [tool.pytest.ini_options]
60
+ minversion = "8.0"
61
+ addopts = "-ra -q"
62
+ testpaths = ["tests"]
63
+
64
+ [dependency-groups]
65
+ dev = [
66
+ "pytest>=8.0.0",
67
+ "ruff>=0.4.0",
68
+ ]
@@ -0,0 +1,3 @@
1
+ """Reclaim disk space across a tree of git repositories."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,353 @@
1
+ """Command-line entry point for git-reclaim.
2
+
3
+ The tool is installed as ``git-reclaim``, so git runs it as ``git reclaim``.
4
+ It has three independent jobs:
5
+
6
+ * report and optionally purge regenerable cache directories
7
+ * compact every repository's ``.git`` with ``git gc``
8
+ * list clones that are safe to delete because the remote has everything
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from git_reclaim import __version__
22
+
23
+ # Directory names that are regenerable and never source. dist/ and build/ are
24
+ # deliberately excluded because they are occasionally committed.
25
+ CACHE_NAMES = frozenset(
26
+ {
27
+ "node_modules",
28
+ ".venv",
29
+ "venv",
30
+ "__pycache__",
31
+ ".pytest_cache",
32
+ ".ruff_cache",
33
+ ".mypy_cache",
34
+ ".next",
35
+ "target",
36
+ }
37
+ )
38
+
39
+ # Directories not worth descending into while looking for repos. A directory
40
+ # that is itself a repo is detected before this list is applied.
41
+ PRUNE_NAMES = CACHE_NAMES | {".tox", "vendor"}
42
+
43
+ GIT_TIMEOUT = 60
44
+ GC_TIMEOUT = 600
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Style:
49
+ """ANSI colours, disabled when output is not a terminal."""
50
+
51
+ red: str = ""
52
+ green: str = ""
53
+ yellow: str = ""
54
+ grey: str = ""
55
+ bold: str = ""
56
+ reset: str = ""
57
+
58
+ @classmethod
59
+ def auto(cls) -> Style:
60
+ if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
61
+ return cls()
62
+ return cls(
63
+ red="\033[31m",
64
+ green="\033[32m",
65
+ yellow="\033[33m",
66
+ grey="\033[90m",
67
+ bold="\033[1m",
68
+ reset="\033[0m",
69
+ )
70
+
71
+
72
+ def human(kb: int) -> str:
73
+ """Format a size in KiB the way the old shell version did."""
74
+ if kb >= 1024 * 1024:
75
+ return f"{kb / 1024 / 1024:.1f}G"
76
+ if kb >= 1024:
77
+ return f"{kb // 1024}M"
78
+ return f"{kb}K"
79
+
80
+
81
+ def du_kb(path: Path) -> int:
82
+ """Return disk usage for a path in KiB, without following symlinks."""
83
+ total = 0
84
+ for root, dirs, files in os.walk(path, followlinks=False):
85
+ for name in [*dirs, *files]:
86
+ entry = Path(root) / name
87
+ if entry.is_symlink():
88
+ continue
89
+ try:
90
+ stat = entry.lstat()
91
+ except OSError:
92
+ continue
93
+ blocks = getattr(stat, "st_blocks", 0)
94
+ total += blocks * 512 if blocks else stat.st_size
95
+ return total // 1024
96
+
97
+
98
+ def find_cache_dirs(root: Path) -> list[Path]:
99
+ """Find cache directories under root, without descending into them."""
100
+ found: list[Path] = []
101
+ for dirpath, dirnames, _ in os.walk(root, topdown=True, followlinks=False):
102
+ current = Path(dirpath)
103
+ keep: list[str] = []
104
+ for name in dirnames:
105
+ entry = current / name
106
+ if name == ".git" or entry.is_symlink():
107
+ continue
108
+ if name in CACHE_NAMES:
109
+ found.append(entry)
110
+ continue
111
+ keep.append(name)
112
+ dirnames[:] = keep
113
+ return found
114
+
115
+
116
+ def find_repos(root: Path) -> list[Path]:
117
+ """Find git repositories under root, stopping at each repo boundary."""
118
+ repos: list[Path] = []
119
+ for dirpath, dirnames, _ in os.walk(root, topdown=True, followlinks=False):
120
+ current = Path(dirpath)
121
+ if (current / ".git").exists():
122
+ repos.append(current)
123
+ dirnames[:] = []
124
+ continue
125
+
126
+ keep: list[str] = []
127
+ for name in dirnames:
128
+ entry = current / name
129
+ if name in PRUNE_NAMES or entry.is_symlink():
130
+ continue
131
+ keep.append(name)
132
+ dirnames[:] = keep
133
+ return repos
134
+
135
+
136
+ def git(
137
+ repo: Path, *args: str, timeout: int = GIT_TIMEOUT
138
+ ) -> subprocess.CompletedProcess[str]:
139
+ """Run git in a repository, capturing output."""
140
+ return subprocess.run(
141
+ ["git", "-C", str(repo), *args],
142
+ capture_output=True,
143
+ text=True,
144
+ timeout=timeout,
145
+ check=False,
146
+ )
147
+
148
+
149
+ def git_tracks_files(path: Path) -> bool:
150
+ """True when git tracks at least one file inside path."""
151
+ result = git(path, "ls-files")
152
+ return result.returncode == 0 and bool(result.stdout.strip())
153
+
154
+
155
+ def report_caches(root: Path, style: Style, purge: bool) -> None:
156
+ """Report caches and optionally delete them."""
157
+ caches = find_cache_dirs(root)
158
+ sizes = sorted(((du_kb(path), path) for path in caches), reverse=True)
159
+
160
+ print(f"{style.bold}Regenerable caches under {root}{style.reset}")
161
+ if not sizes:
162
+ print(f" {style.grey}none found{style.reset}")
163
+ return
164
+
165
+ for kb, path in sizes[:12]:
166
+ print(f" {human(kb):<8} {style.grey}{path.relative_to(root)}{style.reset}")
167
+ if len(sizes) > 12:
168
+ print(f" {style.grey}... and {len(sizes) - 12} more{style.reset}")
169
+
170
+ total = sum(kb for kb, _ in sizes)
171
+ print(
172
+ f" {style.bold}{len(sizes)} cache dirs, {human(total)} reclaimable{style.reset}"
173
+ )
174
+
175
+ if not purge:
176
+ print(f" {style.yellow}(dry run, pass -p to delete these){style.reset}")
177
+ return
178
+
179
+ before = shutil.disk_usage(root).free // 1024
180
+ purged = skipped = failed = 0
181
+ for _, path in sizes:
182
+ if git_tracks_files(path):
183
+ print(
184
+ f" {style.yellow}skipped {path.relative_to(root)} "
185
+ f"(git tracks files inside it){style.reset}"
186
+ )
187
+ skipped += 1
188
+ continue
189
+ try:
190
+ shutil.rmtree(path)
191
+ except OSError:
192
+ failed += 1
193
+ else:
194
+ purged += 1
195
+
196
+ after = shutil.disk_usage(root).free // 1024
197
+ freed = max(0, after - before)
198
+ print(
199
+ f" {style.green}-> purged {purged} of {len(sizes)} dirs, "
200
+ f"freed {human(freed)} (free: {human(before)} -> {human(after)}){style.reset}"
201
+ )
202
+ if failed:
203
+ print(f" {style.red}-> {failed} dir(s) could not be removed{style.reset}")
204
+
205
+
206
+ def report_gc(root: Path, style: Style) -> None:
207
+ """Run git gc, and git lfs prune when available, in every repo."""
208
+ print()
209
+ print(f"{style.bold}Compacting repo history (git gc){style.reset}")
210
+
211
+ have_lfs = shutil.which("git-lfs") is not None
212
+ count = before = after = 0
213
+
214
+ for repo in find_repos(root):
215
+ rev = git(repo, "rev-parse", "--absolute-git-dir")
216
+ if rev.returncode != 0:
217
+ continue
218
+ gitdir = Path(rev.stdout.strip())
219
+ size_before = du_kb(gitdir)
220
+ git(repo, "gc", "--quiet", timeout=GC_TIMEOUT)
221
+ if have_lfs:
222
+ git(repo, "lfs", "prune", timeout=GC_TIMEOUT)
223
+ size_after = du_kb(gitdir)
224
+
225
+ count += 1
226
+ before += size_before
227
+ after += size_after
228
+ if size_before > size_after:
229
+ print(
230
+ f" -{human(size_before - size_after):<7} "
231
+ f"{style.grey}{repo.relative_to(root)}{style.reset}"
232
+ )
233
+
234
+ saved = max(0, before - after)
235
+ print(
236
+ f" {style.bold}gc'd {count} repo(s): .git {human(before)} -> "
237
+ f"{human(after)} (saved {human(saved)}){style.reset}"
238
+ )
239
+
240
+
241
+ def report_safe_repos(root: Path, style: Style) -> None:
242
+ """List clones where the remote already has everything."""
243
+ print()
244
+ print(
245
+ f"{style.bold}Repos safe to delete (remote has everything), "
246
+ f"biggest first{style.reset}"
247
+ )
248
+
249
+ rows: list[tuple[int, str, Path, str]] = []
250
+ keep = 0
251
+
252
+ for repo in find_repos(root):
253
+ remotes = git(repo, "remote").stdout.strip()
254
+ dirty = git(repo, "status", "--porcelain").stdout.strip()
255
+ unpushed = git(
256
+ repo, "log", "--branches", "--not", "--remotes", "--oneline"
257
+ ).stdout.strip()
258
+ stash = git(repo, "stash", "list").stdout.strip()
259
+ tags = git(repo, "tag").stdout.strip()
260
+
261
+ if not remotes or dirty or unpushed or stash or tags:
262
+ keep += 1
263
+ continue
264
+
265
+ size = du_kb(repo)
266
+ last = git(repo, "log", "-1", "--format=%cs").stdout.strip()
267
+ url_result = git(repo, "remote", "get-url", "origin")
268
+ url = url_result.stdout.strip() if url_result.returncode == 0 else "?"
269
+ rows.append((size, last, repo, url))
270
+
271
+ rows.sort(key=lambda row: row[0], reverse=True)
272
+ for size, last, repo, url in rows:
273
+ print(
274
+ f" {human(size):<8} last:{last:<10} "
275
+ f"{str(repo.relative_to(root)):<44} {style.grey}{url}{style.reset}"
276
+ )
277
+
278
+ total = sum(size for size, _, _, _ in rows)
279
+ print(
280
+ f"{style.bold}{len(rows)} safe ({human(total)}) / {keep} keep{style.reset} "
281
+ f"{style.grey}(keep = dirty, unpushed, stashed, tagged, or no remote)"
282
+ f"{style.reset}"
283
+ )
284
+
285
+
286
+ def build_parser() -> argparse.ArgumentParser:
287
+ parser = argparse.ArgumentParser(
288
+ prog="git reclaim",
289
+ description="Reclaim disk space across a tree of git repositories.",
290
+ formatter_class=argparse.RawDescriptionHelpFormatter,
291
+ epilog=(
292
+ "(default) dry run: report reclaimable cache space; deletes nothing\n"
293
+ "-p purge the cache directories (git-tracked dirs are skipped)\n"
294
+ "-g run git gc (and git lfs prune) in every repo\n"
295
+ "-r list repos that are safe to delete (size, last commit, URL)\n"
296
+ "-d DIR directory to scan (default: current directory)"
297
+ ),
298
+ )
299
+ parser.add_argument(
300
+ "-p",
301
+ "--purge",
302
+ action="store_true",
303
+ help="delete the cache directories",
304
+ )
305
+ parser.add_argument(
306
+ "-g",
307
+ "--gc",
308
+ action="store_true",
309
+ help="compact every repository with git gc",
310
+ )
311
+ parser.add_argument(
312
+ "-r",
313
+ "--repos",
314
+ action="store_true",
315
+ help="list repositories that are safe to delete",
316
+ )
317
+ parser.add_argument(
318
+ "-d",
319
+ "--dir",
320
+ default=".",
321
+ metavar="DIR",
322
+ help="directory to scan (default: current directory)",
323
+ )
324
+ parser.add_argument(
325
+ "-V",
326
+ "--version",
327
+ action="version",
328
+ version=f"%(prog)s {__version__}",
329
+ )
330
+ return parser
331
+
332
+
333
+ def main(argv: list[str] | None = None) -> int:
334
+ args = build_parser().parse_args(argv)
335
+ root = Path(args.dir).expanduser()
336
+
337
+ if not root.is_dir():
338
+ print(f"Cannot cd into {root}", file=sys.stderr)
339
+ return 1
340
+
341
+ root = root.resolve()
342
+ style = Style.auto()
343
+
344
+ report_caches(root, style, args.purge)
345
+ if args.gc:
346
+ report_gc(root, style)
347
+ if args.repos:
348
+ report_safe_repos(root, style)
349
+ return 0
350
+
351
+
352
+ if __name__ == "__main__":
353
+ raise SystemExit(main())
@@ -0,0 +1,224 @@
1
+ """Tests for git-reclaim."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from git_reclaim.cli import (
11
+ du_kb,
12
+ find_cache_dirs,
13
+ find_repos,
14
+ main,
15
+ )
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def git_env(monkeypatch: pytest.MonkeyPatch) -> None:
20
+ """Keep git hermetic and set an identity for test commits."""
21
+ monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/dev/null")
22
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null")
23
+ monkeypatch.setenv("GIT_AUTHOR_NAME", "test")
24
+ monkeypatch.setenv("GIT_AUTHOR_EMAIL", "test@example.com")
25
+ monkeypatch.setenv("GIT_COMMITTER_NAME", "test")
26
+ monkeypatch.setenv("GIT_COMMITTER_EMAIL", "test@example.com")
27
+
28
+
29
+ def run_git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
30
+ return subprocess.run(
31
+ ["git", "-C", str(path), *args],
32
+ capture_output=True,
33
+ text=True,
34
+ check=True,
35
+ )
36
+
37
+
38
+ def make_repo(path: Path) -> Path:
39
+ path.mkdir(parents=True, exist_ok=True)
40
+ run_git(path, "init", "-q", "-b", "main")
41
+ run_git(path, "commit", "-q", "--allow-empty", "-m", "init")
42
+ return path
43
+
44
+
45
+ def test_default_dry_run_lists_caches(
46
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
47
+ ) -> None:
48
+ repo = make_repo(tmp_path / "a")
49
+ (repo / "node_modules").mkdir()
50
+ (repo / "node_modules" / "f").write_text("x")
51
+
52
+ assert main(["-d", str(tmp_path)]) == 0
53
+
54
+ out = capsys.readouterr().out
55
+ assert "node_modules" in out
56
+ assert "dry run" in out
57
+ assert (repo / "node_modules").is_dir()
58
+
59
+
60
+ def test_purge_removes_untracked_caches(
61
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
62
+ ) -> None:
63
+ repo = make_repo(tmp_path / "a")
64
+ (repo / "node_modules").mkdir()
65
+ (repo / "sub" / "target").mkdir(parents=True)
66
+
67
+ assert main(["-p", "-d", str(tmp_path)]) == 0
68
+
69
+ out = capsys.readouterr().out
70
+ assert not (repo / "node_modules").exists()
71
+ assert not (repo / "sub" / "target").exists()
72
+ assert "purged 2 of 2" in out
73
+
74
+
75
+ def test_purge_skips_a_dir_git_tracks(
76
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
77
+ ) -> None:
78
+ repo = make_repo(tmp_path / "a")
79
+ (repo / "target").mkdir()
80
+ (repo / "target" / "tracked.txt").write_text("hello")
81
+ run_git(repo, "add", "target")
82
+ run_git(repo, "commit", "-q", "-m", "commit target dir")
83
+ (repo / "node_modules").mkdir()
84
+
85
+ assert main(["-p", "-d", str(tmp_path)]) == 0
86
+
87
+ out = capsys.readouterr().out
88
+ assert (repo / "target").is_dir()
89
+ assert (repo / "target" / "tracked.txt").is_file()
90
+ assert not (repo / "node_modules").exists()
91
+ assert "skipped" in out
92
+ assert "purged 1 of 2" in out
93
+
94
+
95
+ def test_purge_never_touches_refs_inside_git(tmp_path: Path) -> None:
96
+ repo = make_repo(tmp_path / "a")
97
+ run_git(repo, "branch", "target/v2")
98
+ run_git(repo, "branch", "venv/x")
99
+ (repo / "node_modules").mkdir()
100
+
101
+ assert main(["-p", "-d", str(tmp_path)]) == 0
102
+
103
+ run_git(repo, "rev-parse", "--verify", "-q", "refs/heads/target/v2")
104
+ run_git(repo, "rev-parse", "--verify", "-q", "refs/heads/venv/x")
105
+ assert not (repo / "node_modules").exists()
106
+
107
+
108
+ def test_purge_does_not_follow_symlinks(tmp_path: Path) -> None:
109
+ repo = make_repo(tmp_path / "a")
110
+ outside = tmp_path / "outside"
111
+ outside.mkdir()
112
+ (outside / "keep.txt").write_text("keep")
113
+ (repo / "node_modules").symlink_to(outside, target_is_directory=True)
114
+
115
+ assert main(["-p", "-d", str(tmp_path)]) == 0
116
+
117
+ assert (outside / "keep.txt").exists()
118
+
119
+
120
+ def test_repos_lists_clean_pushed_clone(
121
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
122
+ ) -> None:
123
+ origin = make_repo(tmp_path / "origin")
124
+ subprocess.run(
125
+ ["git", "clone", "-q", str(origin), str(tmp_path / "pushed")], check=True
126
+ )
127
+ subprocess.run(
128
+ ["git", "clone", "-q", str(origin), str(tmp_path / "messy")], check=True
129
+ )
130
+ (tmp_path / "messy" / "uncommitted.txt").write_text("x")
131
+
132
+ assert main(["-r", "-d", str(tmp_path)]) == 0
133
+
134
+ out = capsys.readouterr().out
135
+ assert "pushed" in out
136
+ assert "messy" not in out
137
+ assert "1 safe" in out
138
+ assert "2 keep" in out
139
+
140
+
141
+ def test_repos_counts_unpushed_commits_as_keep(
142
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
143
+ ) -> None:
144
+ origin = make_repo(tmp_path / "origin")
145
+ clone = tmp_path / "clone"
146
+ subprocess.run(["git", "clone", "-q", str(origin), str(clone)], check=True)
147
+ run_git(clone, "commit", "-q", "--allow-empty", "-m", "unpushed")
148
+
149
+ assert main(["-r", "-d", str(tmp_path)]) == 0
150
+
151
+ out = capsys.readouterr().out
152
+ assert "clone" not in out
153
+ assert "0 safe" in out
154
+
155
+
156
+ def test_repos_keeps_tagged_clone(
157
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
158
+ ) -> None:
159
+ origin = make_repo(tmp_path / "origin")
160
+ tagged = tmp_path / "zeta"
161
+ subprocess.run(["git", "clone", "-q", str(origin), str(tagged)], check=True)
162
+ run_git(tagged, "tag", "local-only")
163
+
164
+ assert main(["-r", "-d", str(tmp_path)]) == 0
165
+
166
+ out = capsys.readouterr().out
167
+ assert "zeta" not in out
168
+ assert "0 safe" in out
169
+
170
+
171
+ def test_gc_runs_across_repos(
172
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
173
+ ) -> None:
174
+ repo = make_repo(tmp_path / "a")
175
+ (repo / "big.txt").write_text("\n".join(str(i) for i in range(5000)))
176
+ run_git(repo, "add", "big.txt")
177
+ run_git(repo, "commit", "-q", "-m", "big")
178
+
179
+ assert main(["-g", "-d", str(tmp_path)]) == 0
180
+
181
+ out = capsys.readouterr().out
182
+ assert "gc'd 1 repo(s)" in out
183
+
184
+
185
+ def test_find_repos_stops_at_repo_boundary(tmp_path: Path) -> None:
186
+ outer = make_repo(tmp_path / "outer")
187
+ make_repo(outer / "inner")
188
+
189
+ repos = find_repos(tmp_path)
190
+
191
+ assert repos == [outer]
192
+
193
+
194
+ def test_find_cache_dirs_ignores_git_refs(tmp_path: Path) -> None:
195
+ repo = make_repo(tmp_path / "a")
196
+ run_git(repo, "branch", "target/v2")
197
+
198
+ caches = find_cache_dirs(tmp_path)
199
+
200
+ assert all(".git" not in path.parts for path in caches)
201
+
202
+
203
+ def test_du_kb_counts_file_data(tmp_path: Path) -> None:
204
+ data = tmp_path / "data"
205
+ data.write_bytes(b"x" * (1024 * 1024))
206
+
207
+ assert du_kb(tmp_path) >= 1000
208
+
209
+
210
+ def test_help_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
211
+ with pytest.raises(SystemExit) as exc:
212
+ main(["-h"])
213
+
214
+ assert exc.value.code == 0
215
+ assert "usage: git reclaim" in capsys.readouterr().out
216
+
217
+
218
+ def test_missing_dir_returns_error(
219
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
220
+ ) -> None:
221
+ missing = tmp_path / "missing"
222
+
223
+ assert main(["-d", str(missing)]) == 1
224
+ assert "Cannot cd into" in capsys.readouterr().err
@@ -0,0 +1,203 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ 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" }
10
+ wheels = [
11
+ { 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" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "exceptiongroup"
16
+ version = "1.3.1"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ dependencies = [
19
+ { name = "typing-extensions" },
20
+ ]
21
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
22
+ wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
24
+ ]
25
+
26
+ [[package]]
27
+ name = "git-reclaim"
28
+ version = "0.1.0"
29
+ source = { editable = "." }
30
+
31
+ [package.dev-dependencies]
32
+ dev = [
33
+ { name = "pytest" },
34
+ { name = "ruff" },
35
+ ]
36
+
37
+ [package.metadata]
38
+
39
+ [package.metadata.requires-dev]
40
+ dev = [
41
+ { name = "pytest", specifier = ">=8.0.0" },
42
+ { name = "ruff", specifier = ">=0.4.0" },
43
+ ]
44
+
45
+ [[package]]
46
+ name = "iniconfig"
47
+ version = "2.3.1"
48
+ source = { registry = "https://pypi.org/simple" }
49
+ sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
50
+ wheels = [
51
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
52
+ ]
53
+
54
+ [[package]]
55
+ name = "packaging"
56
+ version = "26.3"
57
+ source = { registry = "https://pypi.org/simple" }
58
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
59
+ wheels = [
60
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
61
+ ]
62
+
63
+ [[package]]
64
+ name = "pluggy"
65
+ version = "1.7.0"
66
+ source = { registry = "https://pypi.org/simple" }
67
+ sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
68
+ wheels = [
69
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
70
+ ]
71
+
72
+ [[package]]
73
+ name = "pygments"
74
+ version = "2.21.0"
75
+ source = { registry = "https://pypi.org/simple" }
76
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
77
+ wheels = [
78
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
79
+ ]
80
+
81
+ [[package]]
82
+ name = "pytest"
83
+ version = "9.2.0"
84
+ source = { registry = "https://pypi.org/simple" }
85
+ dependencies = [
86
+ { name = "colorama", marker = "sys_platform == 'win32'" },
87
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
88
+ { name = "iniconfig" },
89
+ { name = "packaging" },
90
+ { name = "pluggy" },
91
+ { name = "pygments" },
92
+ { name = "tomli", marker = "python_full_version < '3.11'" },
93
+ ]
94
+ sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
95
+ wheels = [
96
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
97
+ ]
98
+
99
+ [[package]]
100
+ name = "ruff"
101
+ version = "0.17.3"
102
+ source = { registry = "https://pypi.org/simple" }
103
+ sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
104
+ wheels = [
105
+ { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
106
+ { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
107
+ { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
108
+ { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
109
+ { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
110
+ { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
111
+ { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
112
+ { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
113
+ { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
114
+ { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
115
+ { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
116
+ { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
117
+ { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
118
+ { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
119
+ { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
120
+ { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
121
+ { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
122
+ ]
123
+
124
+ [[package]]
125
+ name = "tomli"
126
+ version = "2.5.0"
127
+ source = { registry = "https://pypi.org/simple" }
128
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
129
+ wheels = [
130
+ { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" },
131
+ { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" },
132
+ { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" },
133
+ { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" },
134
+ { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" },
135
+ { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" },
136
+ { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" },
137
+ { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" },
138
+ { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" },
139
+ { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
140
+ { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
141
+ { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
142
+ { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
143
+ { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
144
+ { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
145
+ { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
146
+ { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
147
+ { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
148
+ { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
149
+ { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
150
+ { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
151
+ { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
152
+ { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
153
+ { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
154
+ { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
155
+ { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
156
+ { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
157
+ { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
158
+ { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
159
+ { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
160
+ { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
161
+ { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
162
+ { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
163
+ { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
164
+ { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
165
+ { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
166
+ { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
167
+ { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
168
+ { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
169
+ { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
170
+ { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
171
+ { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
172
+ { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
173
+ { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
174
+ { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
175
+ { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
176
+ { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
177
+ { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
178
+ { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
179
+ { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
180
+ { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
181
+ { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
182
+ { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
183
+ { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
184
+ { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
185
+ { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
186
+ { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
187
+ { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
188
+ { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
189
+ { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
190
+ { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
191
+ { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
192
+ { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
193
+ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
194
+ ]
195
+
196
+ [[package]]
197
+ name = "typing-extensions"
198
+ version = "4.16.0"
199
+ source = { registry = "https://pypi.org/simple" }
200
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
201
+ wheels = [
202
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
203
+ ]