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.
- pin_versions-0.0.0/.github/workflows/publish.yml +26 -0
- pin_versions-0.0.0/.gitignore +216 -0
- pin_versions-0.0.0/.pre-commit-hooks.yaml +7 -0
- pin_versions-0.0.0/LICENSE +21 -0
- pin_versions-0.0.0/PKG-INFO +78 -0
- pin_versions-0.0.0/README.md +64 -0
- pin_versions-0.0.0/pin_versions.egg-info/PKG-INFO +78 -0
- pin_versions-0.0.0/pin_versions.egg-info/SOURCES.txt +15 -0
- pin_versions-0.0.0/pin_versions.egg-info/dependency_links.txt +1 -0
- pin_versions-0.0.0/pin_versions.egg-info/entry_points.txt +2 -0
- pin_versions-0.0.0/pin_versions.egg-info/requires.txt +3 -0
- pin_versions-0.0.0/pin_versions.egg-info/top_level.txt +1 -0
- pin_versions-0.0.0/pyncushion/pin_versions.py +166 -0
- pin_versions-0.0.0/pyproject.toml +26 -0
- pin_versions-0.0.0/setup.cfg +4 -0
- pin_versions-0.0.0/test.py +359 -0
- pin_versions-0.0.0/uv.lock +139 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|
+
]
|