pin-versions 0.0.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: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+
7
+ jobs:
8
+ pypi:
9
+ name: Publish to PyPI
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v5
16
+
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v7
19
+ with:
20
+ python-version: 3.14
21
+
22
+ - name: Build
23
+ run: uv build
24
+
25
+ - name: Publish package to PyPI
26
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,216 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
@@ -0,0 +1,7 @@
1
+ - id: pin-versions
2
+ name: pin-versions
3
+ description: Check that all dependencies in pyproject.toml are pinned to a version
4
+ entry: uv run pin_versions.py --dry-run
5
+ language: system
6
+ files: pyproject\.toml$
7
+ pass_filenames: false
@@ -0,0 +1,21 @@
1
+ MIT License Copyright (c) 2026 Jay Miller
2
+
3
+ Permission is hereby granted, free of
4
+ charge, to any person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use, copy, modify, merge,
7
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ (including the next paragraph) shall be included in all copies or substantial
13
+ portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: pin-versions
3
+ Version: 0.0.0
4
+ Summary: Pin all dependencies in pyproject.toml to their currently installed versions
5
+ Author: Jay Miller
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: tomlkit
11
+ Requires-Dist: click
12
+ Requires-Dist: httpx
13
+ Dynamic: license-file
14
+
15
+ # pin-versions
16
+
17
+ A CLI tool and pre-commit hook that pins all unpinned dependencies in `pyproject.toml` to their currently installed versions.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install pin-versions
23
+ ```
24
+
25
+ Or with [uv](https://docs.astral.sh/uv/):
26
+
27
+ ```bash
28
+ uv add pin-versions
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run in a project directory with a `pyproject.toml` and a virtual environment:
34
+
35
+ ```bash
36
+ pin-versions
37
+ ```
38
+
39
+ This pins dependencies in `[project].dependencies`, `[project.optional-dependencies]`, and `[dependency-groups]`.
40
+
41
+ ### Options
42
+
43
+ | Flag | Description |
44
+ |---|---|
45
+ | `--operator`, `-o` | Version pin operator (default: `==`). Supports `>=`, `~=`, etc. |
46
+ | `--pyproject`, `-p` | Path to `pyproject.toml` (default: `./pyproject.toml`) |
47
+ | `--venv` | Path to the virtual environment (default: `.venv`) |
48
+ | `--pin-latest` | Pin uninstalled packages to their latest version on PyPI |
49
+ | `--dry-run` | Preview changes without modifying the file |
50
+
51
+ ### Pre-commit hook
52
+
53
+ Add to your `.pre-commit-config.yaml`:
54
+
55
+ ```yaml
56
+ repos:
57
+ - repo: https://github.com/kjaymiller/pin-versions
58
+ rev: v0.1.0
59
+ hooks:
60
+ - id: pin-versions
61
+ ```
62
+
63
+ ## Contributing
64
+
65
+ 1. Fork the repo and clone it locally.
66
+ 2. Create a virtual environment and install the project in editable mode:
67
+ ```bash
68
+ uv venv && uv pip install -e ".[dev]"
69
+ ```
70
+ 3. Create a branch for your changes:
71
+ ```bash
72
+ git checkout -b my-feature
73
+ ```
74
+ 4. Make your changes and ensure they work by running:
75
+ ```bash
76
+ pin-versions --dry-run
77
+ ```
78
+ 5. Open a pull request against `main`.
@@ -0,0 +1,64 @@
1
+ # pin-versions
2
+
3
+ A CLI tool and pre-commit hook that pins all unpinned dependencies in `pyproject.toml` to their currently installed versions.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pin-versions
9
+ ```
10
+
11
+ Or with [uv](https://docs.astral.sh/uv/):
12
+
13
+ ```bash
14
+ uv add pin-versions
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Run in a project directory with a `pyproject.toml` and a virtual environment:
20
+
21
+ ```bash
22
+ pin-versions
23
+ ```
24
+
25
+ This pins dependencies in `[project].dependencies`, `[project.optional-dependencies]`, and `[dependency-groups]`.
26
+
27
+ ### Options
28
+
29
+ | Flag | Description |
30
+ |---|---|
31
+ | `--operator`, `-o` | Version pin operator (default: `==`). Supports `>=`, `~=`, etc. |
32
+ | `--pyproject`, `-p` | Path to `pyproject.toml` (default: `./pyproject.toml`) |
33
+ | `--venv` | Path to the virtual environment (default: `.venv`) |
34
+ | `--pin-latest` | Pin uninstalled packages to their latest version on PyPI |
35
+ | `--dry-run` | Preview changes without modifying the file |
36
+
37
+ ### Pre-commit hook
38
+
39
+ Add to your `.pre-commit-config.yaml`:
40
+
41
+ ```yaml
42
+ repos:
43
+ - repo: https://github.com/kjaymiller/pin-versions
44
+ rev: v0.1.0
45
+ hooks:
46
+ - id: pin-versions
47
+ ```
48
+
49
+ ## Contributing
50
+
51
+ 1. Fork the repo and clone it locally.
52
+ 2. Create a virtual environment and install the project in editable mode:
53
+ ```bash
54
+ uv venv && uv pip install -e ".[dev]"
55
+ ```
56
+ 3. Create a branch for your changes:
57
+ ```bash
58
+ git checkout -b my-feature
59
+ ```
60
+ 4. Make your changes and ensure they work by running:
61
+ ```bash
62
+ pin-versions --dry-run
63
+ ```
64
+ 5. Open a pull request against `main`.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: pin-versions
3
+ Version: 0.0.0
4
+ Summary: Pin all dependencies in pyproject.toml to their currently installed versions
5
+ Author: Jay Miller
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: tomlkit
11
+ Requires-Dist: click
12
+ Requires-Dist: httpx
13
+ Dynamic: license-file
14
+
15
+ # pin-versions
16
+
17
+ A CLI tool and pre-commit hook that pins all unpinned dependencies in `pyproject.toml` to their currently installed versions.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install pin-versions
23
+ ```
24
+
25
+ Or with [uv](https://docs.astral.sh/uv/):
26
+
27
+ ```bash
28
+ uv add pin-versions
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run in a project directory with a `pyproject.toml` and a virtual environment:
34
+
35
+ ```bash
36
+ pin-versions
37
+ ```
38
+
39
+ This pins dependencies in `[project].dependencies`, `[project.optional-dependencies]`, and `[dependency-groups]`.
40
+
41
+ ### Options
42
+
43
+ | Flag | Description |
44
+ |---|---|
45
+ | `--operator`, `-o` | Version pin operator (default: `==`). Supports `>=`, `~=`, etc. |
46
+ | `--pyproject`, `-p` | Path to `pyproject.toml` (default: `./pyproject.toml`) |
47
+ | `--venv` | Path to the virtual environment (default: `.venv`) |
48
+ | `--pin-latest` | Pin uninstalled packages to their latest version on PyPI |
49
+ | `--dry-run` | Preview changes without modifying the file |
50
+
51
+ ### Pre-commit hook
52
+
53
+ Add to your `.pre-commit-config.yaml`:
54
+
55
+ ```yaml
56
+ repos:
57
+ - repo: https://github.com/kjaymiller/pin-versions
58
+ rev: v0.1.0
59
+ hooks:
60
+ - id: pin-versions
61
+ ```
62
+
63
+ ## Contributing
64
+
65
+ 1. Fork the repo and clone it locally.
66
+ 2. Create a virtual environment and install the project in editable mode:
67
+ ```bash
68
+ uv venv && uv pip install -e ".[dev]"
69
+ ```
70
+ 3. Create a branch for your changes:
71
+ ```bash
72
+ git checkout -b my-feature
73
+ ```
74
+ 4. Make your changes and ensure they work by running:
75
+ ```bash
76
+ pin-versions --dry-run
77
+ ```
78
+ 5. Open a pull request against `main`.
@@ -0,0 +1,15 @@
1
+ .gitignore
2
+ .pre-commit-hooks.yaml
3
+ LICENSE
4
+ README.md
5
+ pyproject.toml
6
+ test.py
7
+ uv.lock
8
+ .github/workflows/publish.yml
9
+ pin_versions.egg-info/PKG-INFO
10
+ pin_versions.egg-info/SOURCES.txt
11
+ pin_versions.egg-info/dependency_links.txt
12
+ pin_versions.egg-info/entry_points.txt
13
+ pin_versions.egg-info/requires.txt
14
+ pin_versions.egg-info/top_level.txt
15
+ pyncushion/pin_versions.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pin-versions = pin_versions:main
@@ -0,0 +1,3 @@
1
+ tomlkit
2
+ click
3
+ httpx
@@ -0,0 +1 @@
1
+ pyncushion
@@ -0,0 +1,166 @@
1
+ """Pin all dependencies in pyproject.toml to their currently installed versions.
2
+
3
+ Usage:
4
+ uv run pin_versions.py [OPTIONS]
5
+ """
6
+
7
+ # /// script
8
+ # requires-python = ">=3.10"
9
+ # dependencies = ["tomlkit", "click", "httpx"]
10
+ # ///
11
+
12
+ import asyncio
13
+ import json
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ import click
18
+ import httpx
19
+ import tomlkit
20
+
21
+
22
+ def get_installed_versions(venv: Path) -> dict[str, str]:
23
+ """Get a mapping of package name -> installed version."""
24
+ cmd = ["uv", "pip", "list", "--format=json"]
25
+ if venv.exists():
26
+ cmd += ["--python", str(venv / "bin" / "python")]
27
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
28
+ packages = json.loads(result.stdout)
29
+ return {pkg["name"].lower(): pkg["version"] for pkg in packages}
30
+
31
+
32
+ async def get_latest_version(client: httpx.AsyncClient, package_name: str) -> str:
33
+ """Get the latest version of a package from PyPI."""
34
+ response = await client.get(f"https://pypi.org/pypi/{package_name}/json")
35
+ response.raise_for_status()
36
+ return response.json()["info"]["version"]
37
+
38
+
39
+ def extract_package_name(dep: str) -> str:
40
+ """Extract the package name from a dependency string."""
41
+ return dep.split("[")[0].split(">")[0].split("<")[0].split("=")[0].split("!")[0].split("~")[0].strip()
42
+
43
+
44
+ def has_version_constraint(dep: str) -> bool:
45
+ """Check if a dependency string already has a version constraint."""
46
+ return any(op in dep for op in [">=", "<=", "==", "!=", "~=", ">"])
47
+
48
+
49
+ async def resolve_missing_versions(
50
+ client: httpx.AsyncClient,
51
+ missing: list[str],
52
+ ) -> dict[str, str]:
53
+ """Fetch latest versions for all missing packages concurrently."""
54
+ tasks = {name: get_latest_version(client, name) for name in missing}
55
+ results = {}
56
+ for name, coro in tasks.items():
57
+ results[name] = await coro
58
+ return results
59
+
60
+
61
+ def collect_unpinned_deps(data: dict) -> list[str]:
62
+ """Collect all unpinned dependency names that aren't in the installed versions."""
63
+ deps = []
64
+
65
+ if "project" in data:
66
+ if "dependencies" in data["project"]:
67
+ deps.extend(data["project"]["dependencies"])
68
+ if "optional-dependencies" in data["project"]:
69
+ for group_deps in data["project"]["optional-dependencies"].values():
70
+ deps.extend(group_deps)
71
+
72
+ if "dependency-groups" in data:
73
+ for group_deps in data["dependency-groups"].values():
74
+ deps.extend(group_deps)
75
+
76
+ return [
77
+ extract_package_name(dep).lower().replace("_", "-")
78
+ for dep in deps
79
+ if not has_version_constraint(dep)
80
+ ]
81
+
82
+
83
+ def pin_dependency(dep: str, versions: dict[str, str], operator: str, failed: list[str]) -> str:
84
+ """Add version pin to a dependency string if it doesn't already have one."""
85
+ if has_version_constraint(dep):
86
+ return dep
87
+
88
+ name = extract_package_name(dep)
89
+ normalized = name.lower().replace("_", "-")
90
+
91
+ version = versions.get(normalized)
92
+ if version:
93
+ return f"{dep}{operator}{version}"
94
+
95
+ click.echo(f" WARNING: no version found for '{name}', leaving unpinned")
96
+ failed.append(name)
97
+ return dep
98
+
99
+
100
+ def pin_list(deps, versions: dict[str, str], operator: str, failed: list[str]) -> None:
101
+ """Pin all dependencies in a tomlkit array in place."""
102
+ for i, dep in enumerate(deps):
103
+ deps[i] = pin_dependency(dep, versions, operator, failed)
104
+
105
+
106
+ async def async_main(operator: str, pyproject: str, venv: str, pin_latest: bool, dry_run: bool):
107
+ pyproject_path = Path(pyproject)
108
+ data = tomlkit.loads(pyproject_path.read_text())
109
+ versions = get_installed_versions(Path(venv))
110
+ failed: list[str] = []
111
+
112
+ if pin_latest:
113
+ unpinned = collect_unpinned_deps(data)
114
+ missing = [name for name in unpinned if name not in versions]
115
+ if missing:
116
+ click.echo(f"Looking up latest versions for {len(missing)} uninstalled packages...")
117
+ async with httpx.AsyncClient() as client:
118
+ latest = await resolve_missing_versions(client, missing)
119
+ versions.update(latest)
120
+
121
+ # Pin [project].dependencies
122
+ if "project" in data and "dependencies" in data["project"]:
123
+ click.echo("Pinning [project].dependencies:")
124
+ pin_list(data["project"]["dependencies"], versions, operator, failed)
125
+ for dep in data["project"]["dependencies"]:
126
+ click.echo(f" {dep}")
127
+
128
+ # Pin [project.optional-dependencies]
129
+ if "project" in data and "optional-dependencies" in data["project"]:
130
+ for group, deps in data["project"]["optional-dependencies"].items():
131
+ click.echo(f"\nPinning [project.optional-dependencies].{group}:")
132
+ pin_list(deps, versions, operator, failed)
133
+ for dep in deps:
134
+ click.echo(f" {dep}")
135
+
136
+ # Pin [dependency-groups]
137
+ if "dependency-groups" in data:
138
+ for group, deps in data["dependency-groups"].items():
139
+ click.echo(f"\nPinning [dependency-groups].{group}:")
140
+ pin_list(deps, versions, operator, failed)
141
+ for dep in deps:
142
+ click.echo(f" {dep}")
143
+
144
+ if dry_run:
145
+ click.echo("\nDry run — no changes written.")
146
+ else:
147
+ pyproject_path.write_text(tomlkit.dumps(data))
148
+ click.echo(f"\nUpdated {pyproject_path}")
149
+
150
+ if failed:
151
+ raise SystemExit(1)
152
+
153
+
154
+ @click.command()
155
+ @click.option("--operator", "-o", default="==", help="Version pin operator (e.g. ==, >=, ~=)")
156
+ @click.option("--pyproject", "-p", default="pyproject.toml", type=click.Path(exists=True), help="Path to pyproject.toml")
157
+ @click.option("--venv", default=".venv", type=click.Path(), help="Path to the project virtualenv")
158
+ @click.option("--pin-latest", is_flag=True, default=False, help="Pin uninstalled packages to their latest PyPI version")
159
+ @click.option("--dry-run", is_flag=True, default=False, help="Show what would change without modifying pyproject.toml")
160
+ def main(operator: str, pyproject: str, venv: str, pin_latest: bool, dry_run: bool):
161
+ """Pin all unpinned dependencies in pyproject.toml to their installed versions."""
162
+ asyncio.run(async_main(operator, pyproject, venv, pin_latest, dry_run))
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pin-versions"
7
+ dynamic = ["version"]
8
+ description = "Pin all dependencies in pyproject.toml to their currently installed versions"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "Jay Miller"},
14
+ ]
15
+ dependencies = [
16
+ "tomlkit",
17
+ "click",
18
+ "httpx",
19
+ ]
20
+
21
+ [project.scripts]
22
+ pin-versions = "pin_versions:main"
23
+
24
+ [tool.setuptools-scm]
25
+ local_scheme = "no-local-version"
26
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,359 @@
1
+ """Tests for pin_versions — a tool that pins dependencies in pyproject.toml to installed versions.
2
+
3
+ Tests cover parsing dependency strings, detecting version constraints,
4
+ pinning individual and grouped dependencies, fetching versions from PyPI,
5
+ and the end-to-end workflow via async_main.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from unittest.mock import AsyncMock, patch
11
+
12
+ import httpx
13
+ import pytest
14
+ import tomlkit
15
+
16
+ from pin_versions import (
17
+ async_main,
18
+ collect_unpinned_deps,
19
+ extract_package_name,
20
+ get_installed_versions,
21
+ get_latest_version,
22
+ has_version_constraint,
23
+ pin_dependency,
24
+ pin_list,
25
+ resolve_missing_versions,
26
+ )
27
+
28
+
29
+ class TestExtractPackageName:
30
+ """Tests for extract_package_name(dep: str) -> str.
31
+
32
+ Given a dependency string that may contain extras (e.g. [argon2]) and/or
33
+ version specifiers (>=, ==, etc.), should return only the bare package name.
34
+ """
35
+
36
+ @pytest.mark.parametrize(
37
+ "dep, expected",
38
+ [
39
+ ("requests", "requests"),
40
+ ("requests>=2.0", "requests"),
41
+ ("requests==2.28.0", "requests"),
42
+ ("requests~=2.28", "requests"),
43
+ ("django[argon2]", "django"),
44
+ ("django[argon2]>=4.0", "django"),
45
+ ],
46
+ )
47
+ def test_extracts_name_from_various_formats(self, dep, expected):
48
+ """Should strip version specifiers and extras to return the bare name."""
49
+ assert extract_package_name(dep) == expected
50
+
51
+
52
+ class TestHasVersionConstraint:
53
+ """Tests for has_version_constraint(dep: str) -> bool.
54
+
55
+ Returns True when the dependency string contains any version operator
56
+ (>=, ==, <=, !=, ~=, >), False otherwise. Extras like [argon2] alone
57
+ should not be treated as version constraints.
58
+ """
59
+
60
+ @pytest.mark.parametrize(
61
+ "dep, expected",
62
+ [
63
+ ("requests", False),
64
+ ("django[argon2]", False),
65
+ ("requests>=2.0", True),
66
+ ("requests==2.28.0", True),
67
+ ("requests!=2.0", True),
68
+ ("requests~=2.28", True),
69
+ ],
70
+ )
71
+ def test_detects_constraints(self, dep, expected):
72
+ """Should return True only when a version operator is present."""
73
+ assert has_version_constraint(dep) == expected
74
+
75
+
76
+ class TestPinDependency:
77
+ """Tests for pin_dependency(dep, versions, operator, failed) -> str.
78
+
79
+ Given a dependency string, a {name: version} mapping, and an operator,
80
+ returns the dep string with the version appended. Skips already-pinned deps,
81
+ normalizes underscores to hyphens for lookup, and appends to the failed list
82
+ when no version is found.
83
+ """
84
+
85
+ def test_pins_with_installed_version(self):
86
+ """'requests' with versions={'requests': '2.28.0'} and operator '==' returns 'requests==2.28.0'."""
87
+ failed = []
88
+ assert pin_dependency("requests", {"requests": "2.28.0"}, "==", failed) == "requests==2.28.0"
89
+ assert failed == []
90
+
91
+ def test_preserves_extras(self):
92
+ """'django[argon2]' pins to 'django[argon2]==4.2.0', preserving the extras bracket."""
93
+ failed = []
94
+ assert pin_dependency("django[argon2]", {"django": "4.2.0"}, "==", failed) == "django[argon2]==4.2.0"
95
+
96
+ def test_skips_already_pinned(self):
97
+ """'requests>=2.0' is returned unchanged even when a newer version is available."""
98
+ failed = []
99
+ assert pin_dependency("requests>=2.0", {"requests": "2.28.0"}, "==", failed) == "requests>=2.0"
100
+
101
+ def test_records_missing_version(self):
102
+ """'unknown-pkg' with empty versions dict is left unpinned and added to the failed list."""
103
+ failed = []
104
+ assert pin_dependency("unknown-pkg", {}, "==", failed) == "unknown-pkg"
105
+ assert failed == ["unknown-pkg"]
106
+
107
+ def test_normalizes_underscores(self):
108
+ """'my_package' matches versions key 'my-package' via underscore-to-hyphen normalization."""
109
+ failed = []
110
+ assert pin_dependency("my_package", {"my-package": "1.0.0"}, "==", failed) == "my_package==1.0.0"
111
+
112
+
113
+ class TestPinList:
114
+ """Tests for pin_list(deps, versions, operator, failed) -> None.
115
+
116
+ Mutates a tomlkit array in place, pinning bare deps and leaving
117
+ already-constrained deps unchanged.
118
+ """
119
+
120
+ def test_pins_unpinned_and_skips_pinned(self):
121
+ """Should pin bare deps and leave already-constrained deps unchanged."""
122
+ deps = tomlkit.array()
123
+ deps.append("requests")
124
+ deps.append("flask>=2.0")
125
+ failed = []
126
+ pin_list(deps, {"requests": "2.28.0"}, "==", failed)
127
+ assert deps[0] == "requests==2.28.0"
128
+ assert deps[1] == "flask>=2.0"
129
+
130
+
131
+ class TestCollectUnpinnedDeps:
132
+ """Tests for collect_unpinned_deps(data: dict) -> list[str].
133
+
134
+ Scans project.dependencies, project.optional-dependencies, and
135
+ dependency-groups for entries without version constraints. Returns
136
+ normalized (lowercased, underscores replaced with hyphens) package names.
137
+ """
138
+
139
+ def test_from_all_sections(self):
140
+ """Should collect unpinned deps from dependencies, optional-dependencies, and dependency-groups."""
141
+ data = {
142
+ "project": {
143
+ "dependencies": ["requests", "flask>=2.0"],
144
+ "optional-dependencies": {"dev": ["pytest"]},
145
+ },
146
+ "dependency-groups": {"test": ["coverage>=7.0", "hypothesis"]},
147
+ }
148
+ result = collect_unpinned_deps(data)
149
+ assert set(result) == {"requests", "pytest", "hypothesis"}
150
+
151
+ def test_normalizes_names(self):
152
+ """Should normalize underscores to hyphens in collected names."""
153
+ data = {"project": {"dependencies": ["my_package"]}}
154
+ assert collect_unpinned_deps(data) == ["my-package"]
155
+
156
+ def test_empty_data(self):
157
+ """Should return an empty list when no dependency sections exist."""
158
+ assert collect_unpinned_deps({}) == []
159
+
160
+
161
+ class TestGetInstalledVersions:
162
+ """Tests for get_installed_versions(venv: Path) -> dict[str, str].
163
+
164
+ Runs `uv pip list --format=json` and returns a {lowered_name: version} dict.
165
+ When the venv path exists, passes --python to target that interpreter.
166
+ """
167
+
168
+ def test_with_existing_venv(self, tmp_path):
169
+ """With a valid .venv dir, passes --python <venv>/bin/python and lowercases package names."""
170
+ venv = tmp_path / ".venv"
171
+ venv.mkdir()
172
+ (venv / "bin").mkdir()
173
+ (venv / "bin" / "python").touch()
174
+
175
+ pip_output = json.dumps([
176
+ {"name": "requests", "version": "2.28.0"},
177
+ {"name": "Flask", "version": "2.3.0"},
178
+ ])
179
+
180
+ with patch("pin_versions.subprocess.run") as mock_run:
181
+ mock_run.return_value.stdout = pip_output
182
+ result = get_installed_versions(venv)
183
+
184
+ assert result == {"requests": "2.28.0", "flask": "2.3.0"}
185
+ assert "--python" in mock_run.call_args[0][0]
186
+
187
+ def test_without_venv(self, tmp_path):
188
+ """With a nonexistent venv path, omits --python and uses the default interpreter."""
189
+ pip_output = json.dumps([{"name": "requests", "version": "2.28.0"}])
190
+
191
+ with patch("pin_versions.subprocess.run") as mock_run:
192
+ mock_run.return_value.stdout = pip_output
193
+ get_installed_versions(tmp_path / "nonexistent")
194
+
195
+ assert "--python" not in mock_run.call_args[0][0]
196
+
197
+
198
+ class TestGetLatestVersion:
199
+ """Tests for get_latest_version(client, package_name) -> str | None.
200
+
201
+ Makes a GET to https://pypi.org/pypi/{name}/json. Returns the version
202
+ string on success (200) or raises httpx.HTTPStatusError on failure.
203
+ """
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_success(self):
207
+ """PyPI returns 200 with {"info": {"version": "3.0.0"}} -> returns '3.0.0'."""
208
+ mock_response = httpx.Response(
209
+ 200,
210
+ json={"info": {"version": "3.0.0"}},
211
+ request=httpx.Request("GET", "https://pypi.org/pypi/requests/json"),
212
+ )
213
+
214
+ client = AsyncMock(spec=httpx.AsyncClient)
215
+ client.get.return_value = mock_response
216
+
217
+ assert await get_latest_version(client, "requests") == "3.0.0"
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_not_found(self):
221
+ """PyPI returns 404 for an unknown package -> raises httpx.HTTPStatusError."""
222
+ mock_response = httpx.Response(404, request=httpx.Request("GET", "https://pypi.org/pypi/nonexistent/json"))
223
+
224
+ client = AsyncMock(spec=httpx.AsyncClient)
225
+ client.get.return_value = mock_response
226
+
227
+ with pytest.raises(httpx.HTTPStatusError):
228
+ await get_latest_version(client, "nonexistent")
229
+
230
+
231
+ class TestResolveMissingVersions:
232
+ """Tests for resolve_missing_versions(client, missing) -> dict[str, str].
233
+
234
+ Concurrently fetches latest versions for a list of package names.
235
+ Returns a dict of {name: version} on success; raises on any failed lookup.
236
+ """
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_resolves_all_available(self):
240
+ """['requests', 'flask'] -> {'requests': '2.31.0', 'flask': '3.0.0'}."""
241
+
242
+ async def mock_get(url):
243
+ version = "2.31.0" if "requests" in url else "3.0.0"
244
+ return httpx.Response(
245
+ 200,
246
+ json={"info": {"version": version}},
247
+ request=httpx.Request("GET", url),
248
+ )
249
+
250
+ client = AsyncMock(spec=httpx.AsyncClient)
251
+ client.get.side_effect = mock_get
252
+
253
+ result = await resolve_missing_versions(client, ["requests", "flask"])
254
+ assert result == {"requests": "2.31.0", "flask": "3.0.0"}
255
+
256
+ @pytest.mark.asyncio
257
+ async def test_raises_on_missing_package(self):
258
+ """A 404 from PyPI propagates as httpx.HTTPStatusError."""
259
+
260
+ async def mock_get(url):
261
+ return httpx.Response(404, request=httpx.Request("GET", url))
262
+
263
+ client = AsyncMock(spec=httpx.AsyncClient)
264
+ client.get.side_effect = mock_get
265
+
266
+ with pytest.raises(httpx.HTTPStatusError):
267
+ await resolve_missing_versions(client, ["nonexistent"])
268
+
269
+
270
+ class TestAsyncMain:
271
+ """Integration tests for async_main(operator, pyproject, venv, pin_latest, dry_run).
272
+
273
+ Exercises the full workflow: reading pyproject.toml, resolving versions,
274
+ pinning across all dependency sections, and writing (or skipping) the result.
275
+ Uses a temp-dir pyproject.toml and mocked version lookups.
276
+ """
277
+
278
+ @pytest.fixture
279
+ def sample_pyproject(self, tmp_path):
280
+ """Create a sample pyproject.toml with unpinned and pinned deps."""
281
+ content = tomlkit.dumps({
282
+ "project": {
283
+ "dependencies": ["requests", "flask>=2.0"],
284
+ "optional-dependencies": {"dev": ["pytest"]},
285
+ },
286
+ "dependency-groups": {"test": ["coverage"]},
287
+ })
288
+ path = tmp_path / "pyproject.toml"
289
+ path.write_text(content)
290
+ return path
291
+
292
+ @pytest.fixture
293
+ def mock_versions(self):
294
+ """Version mapping for test packages."""
295
+ return {"requests": "2.28.0", "pytest": "7.4.0", "coverage": "7.3.0"}
296
+
297
+ @pytest.mark.asyncio
298
+ async def test_pins_all_sections(self, sample_pyproject, mock_versions, tmp_path):
299
+ """Writes pinned versions to all three sections; leaves already-constrained deps untouched."""
300
+ with patch("pin_versions.get_installed_versions", return_value=mock_versions):
301
+ await async_main("==", str(sample_pyproject), str(tmp_path / ".venv"), False, False)
302
+
303
+ data = tomlkit.loads(sample_pyproject.read_text())
304
+ assert data["project"]["dependencies"][0] == "requests==2.28.0"
305
+ assert data["project"]["dependencies"][1] == "flask>=2.0"
306
+ assert data["project"]["optional-dependencies"]["dev"][0] == "pytest==7.4.0"
307
+ assert data["dependency-groups"]["test"][0] == "coverage==7.3.0"
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_dry_run_does_not_write(self, sample_pyproject, mock_versions, tmp_path):
311
+ """With dry_run=True, pyproject.toml content is identical before and after."""
312
+ original = sample_pyproject.read_text()
313
+
314
+ with patch("pin_versions.get_installed_versions", return_value=mock_versions):
315
+ await async_main("==", str(sample_pyproject), str(tmp_path / ".venv"), False, True)
316
+
317
+ assert sample_pyproject.read_text() == original
318
+
319
+ @pytest.mark.asyncio
320
+ async def test_custom_operator(self, sample_pyproject, mock_versions, tmp_path):
321
+ """With operator='>=' pins as 'requests>=2.28.0' instead of 'requests==2.28.0'."""
322
+ with patch("pin_versions.get_installed_versions", return_value=mock_versions):
323
+ await async_main(">=", str(sample_pyproject), str(tmp_path / ".venv"), False, False)
324
+
325
+ data = tomlkit.loads(sample_pyproject.read_text())
326
+ assert data["project"]["dependencies"][0] == "requests>=2.28.0"
327
+
328
+ @pytest.mark.asyncio
329
+ async def test_exits_on_missing_versions(self, sample_pyproject, tmp_path):
330
+ """With an empty versions dict, all deps fail to resolve and SystemExit(1) is raised."""
331
+ with patch("pin_versions.get_installed_versions", return_value={}):
332
+ with pytest.raises(SystemExit):
333
+ await async_main("==", str(sample_pyproject), str(tmp_path / ".venv"), False, False)
334
+
335
+ @pytest.mark.asyncio
336
+ async def test_pin_latest_fetches_from_pypi(self, sample_pyproject, tmp_path):
337
+ """With pin_latest=True, 'coverage' (not installed) is fetched from PyPI and pinned to '7.3.0'."""
338
+ installed = {"requests": "2.28.0", "pytest": "7.4.0"}
339
+
340
+ async def mock_get(url):
341
+ return httpx.Response(
342
+ 200,
343
+ json={"info": {"version": "7.3.0"}},
344
+ request=httpx.Request("GET", url),
345
+ )
346
+
347
+ with (
348
+ patch("pin_versions.get_installed_versions", return_value=installed),
349
+ patch("httpx.AsyncClient") as MockClient,
350
+ ):
351
+ mock_client = AsyncMock()
352
+ mock_client.get.side_effect = mock_get
353
+ MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client)
354
+ MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
355
+
356
+ await async_main("==", str(sample_pyproject), str(tmp_path / ".venv"), True, False)
357
+
358
+ data = tomlkit.loads(sample_pyproject.read_text())
359
+ assert data["dependency-groups"]["test"][0] == "coverage==7.3.0"
@@ -0,0 +1,139 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.12.1"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
11
+ { name = "idna" },
12
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
13
+ ]
14
+ sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
15
+ wheels = [
16
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
17
+ ]
18
+
19
+ [[package]]
20
+ name = "certifi"
21
+ version = "2026.2.25"
22
+ source = { registry = "https://pypi.org/simple" }
23
+ sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "click"
30
+ version = "8.3.1"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ dependencies = [
33
+ { name = "colorama", marker = "sys_platform == 'win32'" },
34
+ ]
35
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
36
+ wheels = [
37
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
38
+ ]
39
+
40
+ [[package]]
41
+ name = "colorama"
42
+ version = "0.4.6"
43
+ source = { registry = "https://pypi.org/simple" }
44
+ 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" }
45
+ wheels = [
46
+ { 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" },
47
+ ]
48
+
49
+ [[package]]
50
+ name = "exceptiongroup"
51
+ version = "1.3.1"
52
+ source = { registry = "https://pypi.org/simple" }
53
+ dependencies = [
54
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
55
+ ]
56
+ 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" }
57
+ wheels = [
58
+ { 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" },
59
+ ]
60
+
61
+ [[package]]
62
+ name = "h11"
63
+ version = "0.16.0"
64
+ source = { registry = "https://pypi.org/simple" }
65
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
66
+ wheels = [
67
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
68
+ ]
69
+
70
+ [[package]]
71
+ name = "httpcore"
72
+ version = "1.0.9"
73
+ source = { registry = "https://pypi.org/simple" }
74
+ dependencies = [
75
+ { name = "certifi" },
76
+ { name = "h11" },
77
+ ]
78
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
79
+ wheels = [
80
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
81
+ ]
82
+
83
+ [[package]]
84
+ name = "httpx"
85
+ version = "0.28.1"
86
+ source = { registry = "https://pypi.org/simple" }
87
+ dependencies = [
88
+ { name = "anyio" },
89
+ { name = "certifi" },
90
+ { name = "httpcore" },
91
+ { name = "idna" },
92
+ ]
93
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
94
+ wheels = [
95
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
96
+ ]
97
+
98
+ [[package]]
99
+ name = "idna"
100
+ version = "3.11"
101
+ source = { registry = "https://pypi.org/simple" }
102
+ sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
103
+ wheels = [
104
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
105
+ ]
106
+
107
+ [[package]]
108
+ name = "pin-versions"
109
+ source = { editable = "." }
110
+ dependencies = [
111
+ { name = "click" },
112
+ { name = "httpx" },
113
+ { name = "tomlkit" },
114
+ ]
115
+
116
+ [package.metadata]
117
+ requires-dist = [
118
+ { name = "click" },
119
+ { name = "httpx" },
120
+ { name = "tomlkit" },
121
+ ]
122
+
123
+ [[package]]
124
+ name = "tomlkit"
125
+ version = "0.14.0"
126
+ source = { registry = "https://pypi.org/simple" }
127
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
128
+ wheels = [
129
+ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "typing-extensions"
134
+ version = "4.15.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
137
+ wheels = [
138
+ { 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" },
139
+ ]