proviso 0.0.1__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.
- proviso-0.0.1/LICENSE +25 -0
- proviso-0.0.1/PKG-INFO +147 -0
- proviso-0.0.1/README.md +106 -0
- proviso-0.0.1/proviso/__init__.py +5 -0
- proviso-0.0.1/proviso/builder.py +29 -0
- proviso-0.0.1/proviso/main.py +271 -0
- proviso-0.0.1/proviso/python.py +52 -0
- proviso-0.0.1/proviso/resolver.py +248 -0
- proviso-0.0.1/proviso/utils.py +70 -0
- proviso-0.0.1/proviso.egg-info/PKG-INFO +147 -0
- proviso-0.0.1/proviso.egg-info/SOURCES.txt +21 -0
- proviso-0.0.1/proviso.egg-info/dependency_links.txt +1 -0
- proviso-0.0.1/proviso.egg-info/entry_points.txt +2 -0
- proviso-0.0.1/proviso.egg-info/requires.txt +20 -0
- proviso-0.0.1/proviso.egg-info/top_level.txt +1 -0
- proviso-0.0.1/pyproject.toml +19 -0
- proviso-0.0.1/setup.cfg +4 -0
- proviso-0.0.1/setup.py +60 -0
- proviso-0.0.1/tests/test_builder.py +280 -0
- proviso-0.0.1/tests/test_main.py +561 -0
- proviso-0.0.1/tests/test_python.py +305 -0
- proviso-0.0.1/tests/test_resolver.py +764 -0
- proviso-0.0.1/tests/test_utils.py +126 -0
proviso-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Ross McFarland & the octoDNS Maintainers
|
|
4
|
+
Copyright (c) 2017 GitHub, Inc.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person
|
|
7
|
+
obtaining a copy of this software and associated documentation
|
|
8
|
+
files (the "Software"), to deal in the Software without
|
|
9
|
+
restriction, including without limitation the rights to use,
|
|
10
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the
|
|
12
|
+
Software is furnished to do so, subject to the following
|
|
13
|
+
conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be
|
|
16
|
+
included in all copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
19
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
20
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
21
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
22
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
23
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
24
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
25
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
proviso-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: proviso
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Proviso requirements.txt management
|
|
5
|
+
Home-page: https://github.com/octodns/proviso
|
|
6
|
+
Author: Ross McFarland
|
|
7
|
+
Author-email: rwmcfa1@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: build>=0.7.0
|
|
13
|
+
Requires-Dist: resolvelib>=1.0.0
|
|
14
|
+
Requires-Dist: setuptools>=40.8.0
|
|
15
|
+
Requires-Dist: unearth>=0.17.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-network; extra == "dev"
|
|
20
|
+
Requires-Dist: black<26.0.0,>=25.0.0; extra == "dev"
|
|
21
|
+
Requires-Dist: changelet; extra == "dev"
|
|
22
|
+
Requires-Dist: isort>=5.11.5; extra == "dev"
|
|
23
|
+
Requires-Dist: pyflakes>=2.2.0; extra == "dev"
|
|
24
|
+
Requires-Dist: readme_renderer[md]>=26.0; extra == "dev"
|
|
25
|
+
Requires-Dist: twine>=3.4.2; extra == "dev"
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
29
|
+
Requires-Dist: pytest-network; extra == "test"
|
|
30
|
+
Dynamic: author
|
|
31
|
+
Dynamic: author-email
|
|
32
|
+
Dynamic: description
|
|
33
|
+
Dynamic: description-content-type
|
|
34
|
+
Dynamic: home-page
|
|
35
|
+
Dynamic: license
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
Dynamic: provides-extra
|
|
38
|
+
Dynamic: requires-dist
|
|
39
|
+
Dynamic: requires-python
|
|
40
|
+
Dynamic: summary
|
|
41
|
+
|
|
42
|
+
## Proviso requirements.txt management
|
|
43
|
+
|
|
44
|
+
A tool for managing requirements.txt version pinning and updating. Proviso extracts dependencies from your project's setup.py or pyproject.toml, resolves all transitive dependencies using PyPI, and generates a fully pinned requirements.txt file that works across multiple Python versions.
|
|
45
|
+
|
|
46
|
+
### Installation
|
|
47
|
+
|
|
48
|
+
#### Command line
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
pip install proviso
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### requirements.txt/setup.py
|
|
55
|
+
|
|
56
|
+
Pinning specific versions or SHAs is recommended to avoid unplanned upgrades.
|
|
57
|
+
|
|
58
|
+
##### Versions
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
# Start with the latest versions and don't just copy what's here
|
|
62
|
+
proviso==0.0.1
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
##### SHAs
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
# Start with the latest/specific versions and don't just copy what's here
|
|
69
|
+
-e git+https://git@github.com/octodns/proviso.git@ec9661f8b335241ae4746eea467a8509205e6a30#egg=proviso
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Usage
|
|
73
|
+
|
|
74
|
+
#### Basic usage
|
|
75
|
+
|
|
76
|
+
Generate a requirements.txt for all currently active Python versions:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
proviso
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This will:
|
|
83
|
+
1. Read dependencies from your project's setup.py or pyproject.toml
|
|
84
|
+
2. Resolve all transitive dependencies from PyPI
|
|
85
|
+
3. Generate a requirements.txt with pinned versions that work across all currently active Python versions (queried from endoflife.date)
|
|
86
|
+
|
|
87
|
+
#### Common options
|
|
88
|
+
|
|
89
|
+
Generate requirements for specific Python versions:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
proviso --python-versions 3.10,3.11,3.12
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Include specific extras (e.g., dev dependencies):
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
proviso --extras dev,test
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Specify a custom output file:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
proviso --filename requirements-dev.txt
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Customize the header comment:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
proviso --header "# Generated requirements - do not edit"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### Full command-line reference
|
|
114
|
+
|
|
115
|
+
```console
|
|
116
|
+
usage: proviso [-h] [--directory DIRECTORY] [--extras EXTRAS] [--python-versions PYTHON_VERSIONS] [--filename FILENAME]
|
|
117
|
+
[--level {DEBUG,INFO,WARNING,ERROR}] [--header HEADER]
|
|
118
|
+
|
|
119
|
+
Extract project requirements, resolve dependencies, and manage requirements.txt
|
|
120
|
+
|
|
121
|
+
options:
|
|
122
|
+
-h, --help show this help message and exit
|
|
123
|
+
--directory DIRECTORY
|
|
124
|
+
Project root directory (default: current directory)
|
|
125
|
+
--extras EXTRAS Comma-separated list of extras to include (e.g., "dev,test"). Defaults to all defined extras.
|
|
126
|
+
--python-versions PYTHON_VERSIONS
|
|
127
|
+
Comma-separated list of Python versions (e.g., "3.9,3.10,3.11"). Defaults to currently active versions
|
|
128
|
+
per endoflife.date.
|
|
129
|
+
--filename FILENAME Output filename or path for requirements (default: requirements.txt). If just a filename, will be placed
|
|
130
|
+
in --directory; if a path, will be used as-is.
|
|
131
|
+
--level {DEBUG,INFO,WARNING,ERROR}
|
|
132
|
+
Logging level (default: WARNING)
|
|
133
|
+
--header HEADER Header comment to place at the top of the requirements file. Use the string "[command line]" to insert the
|
|
134
|
+
actual command that was run. (default: "# DO NOT EDIT THIS FILE DIRECTLY - use [command line]")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Features
|
|
138
|
+
|
|
139
|
+
- **Multi-version support**: Generates a single requirements.txt that works across multiple Python versions, using environment markers (e.g., `package==1.0.0; python_version=='3.10'`) when different versions are needed
|
|
140
|
+
- **Complete dependency resolution**: Recursively resolves all transitive dependencies from PyPI
|
|
141
|
+
- **Automatic Python version detection**: Uses endoflife.date API to determine currently active Python versions
|
|
142
|
+
- **Extras support**: Include optional dependency groups (dev, test, etc.) from your setup.py or pyproject.toml
|
|
143
|
+
- **Flexible output**: Generate requirements files with custom names and locations
|
|
144
|
+
|
|
145
|
+
### Development
|
|
146
|
+
|
|
147
|
+
See the [/script/](/script/) directory for some tools to help with the development process. They generally follow the [Script to rule them all](https://github.com/github/scripts-to-rule-them-all) pattern. Most useful is `./script/bootstrap` which will create a venv and install both the runtime and development related requirements. It will also hook up a pre-commit hook that covers most of what's run by CI.
|
proviso-0.0.1/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
## Proviso requirements.txt management
|
|
2
|
+
|
|
3
|
+
A tool for managing requirements.txt version pinning and updating. Proviso extracts dependencies from your project's setup.py or pyproject.toml, resolves all transitive dependencies using PyPI, and generates a fully pinned requirements.txt file that works across multiple Python versions.
|
|
4
|
+
|
|
5
|
+
### Installation
|
|
6
|
+
|
|
7
|
+
#### Command line
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install proviso
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
#### requirements.txt/setup.py
|
|
14
|
+
|
|
15
|
+
Pinning specific versions or SHAs is recommended to avoid unplanned upgrades.
|
|
16
|
+
|
|
17
|
+
##### Versions
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
# Start with the latest versions and don't just copy what's here
|
|
21
|
+
proviso==0.0.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
##### SHAs
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
# Start with the latest/specific versions and don't just copy what's here
|
|
28
|
+
-e git+https://git@github.com/octodns/proviso.git@ec9661f8b335241ae4746eea467a8509205e6a30#egg=proviso
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Usage
|
|
32
|
+
|
|
33
|
+
#### Basic usage
|
|
34
|
+
|
|
35
|
+
Generate a requirements.txt for all currently active Python versions:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
proviso
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This will:
|
|
42
|
+
1. Read dependencies from your project's setup.py or pyproject.toml
|
|
43
|
+
2. Resolve all transitive dependencies from PyPI
|
|
44
|
+
3. Generate a requirements.txt with pinned versions that work across all currently active Python versions (queried from endoflife.date)
|
|
45
|
+
|
|
46
|
+
#### Common options
|
|
47
|
+
|
|
48
|
+
Generate requirements for specific Python versions:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
proviso --python-versions 3.10,3.11,3.12
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Include specific extras (e.g., dev dependencies):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
proviso --extras dev,test
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Specify a custom output file:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
proviso --filename requirements-dev.txt
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Customize the header comment:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
proviso --header "# Generated requirements - do not edit"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
#### Full command-line reference
|
|
73
|
+
|
|
74
|
+
```console
|
|
75
|
+
usage: proviso [-h] [--directory DIRECTORY] [--extras EXTRAS] [--python-versions PYTHON_VERSIONS] [--filename FILENAME]
|
|
76
|
+
[--level {DEBUG,INFO,WARNING,ERROR}] [--header HEADER]
|
|
77
|
+
|
|
78
|
+
Extract project requirements, resolve dependencies, and manage requirements.txt
|
|
79
|
+
|
|
80
|
+
options:
|
|
81
|
+
-h, --help show this help message and exit
|
|
82
|
+
--directory DIRECTORY
|
|
83
|
+
Project root directory (default: current directory)
|
|
84
|
+
--extras EXTRAS Comma-separated list of extras to include (e.g., "dev,test"). Defaults to all defined extras.
|
|
85
|
+
--python-versions PYTHON_VERSIONS
|
|
86
|
+
Comma-separated list of Python versions (e.g., "3.9,3.10,3.11"). Defaults to currently active versions
|
|
87
|
+
per endoflife.date.
|
|
88
|
+
--filename FILENAME Output filename or path for requirements (default: requirements.txt). If just a filename, will be placed
|
|
89
|
+
in --directory; if a path, will be used as-is.
|
|
90
|
+
--level {DEBUG,INFO,WARNING,ERROR}
|
|
91
|
+
Logging level (default: WARNING)
|
|
92
|
+
--header HEADER Header comment to place at the top of the requirements file. Use the string "[command line]" to insert the
|
|
93
|
+
actual command that was run. (default: "# DO NOT EDIT THIS FILE DIRECTLY - use [command line]")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### Features
|
|
97
|
+
|
|
98
|
+
- **Multi-version support**: Generates a single requirements.txt that works across multiple Python versions, using environment markers (e.g., `package==1.0.0; python_version=='3.10'`) when different versions are needed
|
|
99
|
+
- **Complete dependency resolution**: Recursively resolves all transitive dependencies from PyPI
|
|
100
|
+
- **Automatic Python version detection**: Uses endoflife.date API to determine currently active Python versions
|
|
101
|
+
- **Extras support**: Include optional dependency groups (dev, test, etc.) from your setup.py or pyproject.toml
|
|
102
|
+
- **Flexible output**: Generate requirements files with custom names and locations
|
|
103
|
+
|
|
104
|
+
### Development
|
|
105
|
+
|
|
106
|
+
See the [/script/](/script/) directory for some tools to help with the development process. They generally follow the [Script to rule them all](https://github.com/github/scripts-to-rule-them-all) pattern. Most useful is `./script/bootstrap` which will create a venv and install both the runtime and development related requirements. It will also hook up a pre-commit hook that covers most of what's run by CI.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
from os import environ
|
|
3
|
+
from os.path import join
|
|
4
|
+
from subprocess import run
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
|
|
7
|
+
from build import ProjectBuilder
|
|
8
|
+
from packaging.metadata import Metadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _runner(cmd, cwd=None, extra_environ=None):
|
|
12
|
+
env = environ.copy()
|
|
13
|
+
if extra_environ is not None:
|
|
14
|
+
env.update(extra_environ)
|
|
15
|
+
|
|
16
|
+
run(cmd, cwd=cwd, env=env, capture_output=True, check=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Builder:
|
|
20
|
+
def __init__(self, directory):
|
|
21
|
+
self.directory = directory
|
|
22
|
+
|
|
23
|
+
@cached_property
|
|
24
|
+
def metadata(self):
|
|
25
|
+
builder = ProjectBuilder(self.directory, runner=_runner)
|
|
26
|
+
with TemporaryDirectory() as tmpdir:
|
|
27
|
+
metadata_path = builder.metadata_path(tmpdir)
|
|
28
|
+
with open(join(metadata_path, 'METADATA')) as f:
|
|
29
|
+
return Metadata.from_email(f.read())
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from argparse import ArgumentParser
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from logging import DEBUG, ERROR, INFO, WARNING, basicConfig, getLogger
|
|
4
|
+
from os import getcwd
|
|
5
|
+
from os.path import dirname, expanduser, join
|
|
6
|
+
from sys import argv, exit
|
|
7
|
+
|
|
8
|
+
from build import BuildBackendException
|
|
9
|
+
|
|
10
|
+
from proviso.builder import Builder
|
|
11
|
+
from proviso.python import Python
|
|
12
|
+
from proviso.resolver import Resolver
|
|
13
|
+
|
|
14
|
+
log = getLogger('proviso')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_project_metadata(directory):
|
|
18
|
+
"""Build project metadata and handle exceptions."""
|
|
19
|
+
builder = Builder(directory)
|
|
20
|
+
try:
|
|
21
|
+
return builder.metadata
|
|
22
|
+
except BuildBackendException as e:
|
|
23
|
+
cpe = e.exception
|
|
24
|
+
log.error(
|
|
25
|
+
f'''Failed to build project.
|
|
26
|
+
|
|
27
|
+
captured stdout:
|
|
28
|
+
--------------------------------------------------------------------------------
|
|
29
|
+
{cpe.output.decode('utf-8')}
|
|
30
|
+
--------------------------------------------------------------------------------
|
|
31
|
+
captured stderr:
|
|
32
|
+
--------------------------------------------------------------------------------
|
|
33
|
+
{cpe.stderr.decode('utf-8')}
|
|
34
|
+
--------------------------------------------------------------------------------
|
|
35
|
+
'''
|
|
36
|
+
)
|
|
37
|
+
exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_and_print_metadata(metadata, extras, python_versions):
|
|
41
|
+
"""Format and print project metadata."""
|
|
42
|
+
log.info(
|
|
43
|
+
f'Project: {metadata.name} {metadata.version}, '
|
|
44
|
+
f'extras: {", ".join(extras)}, '
|
|
45
|
+
f'python_versions: {", ".join(python_versions)}'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_requirements_with_extras(metadata, extras):
|
|
50
|
+
"""Get requirements including extras."""
|
|
51
|
+
requires_dist = metadata.requires_dist or []
|
|
52
|
+
|
|
53
|
+
# base runtime direct requirements
|
|
54
|
+
requirements = [r for r in requires_dist if r.marker is None]
|
|
55
|
+
for extra in extras:
|
|
56
|
+
requirements.extend(
|
|
57
|
+
r
|
|
58
|
+
for r in requires_dist
|
|
59
|
+
if r.marker and r.marker.evaluate({'extra': extra})
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return requirements
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_requirements(requirements, python_versions):
|
|
66
|
+
|
|
67
|
+
if not requirements:
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
log.info(f'Requirements: {", ".join([str(r) for r in requirements])}')
|
|
71
|
+
|
|
72
|
+
# Create one resolver instance (shared cache across all Python versions)
|
|
73
|
+
resolver = Resolver()
|
|
74
|
+
|
|
75
|
+
# Collect versions: versions[package][version] = [python_versions]
|
|
76
|
+
versions = defaultdict(lambda: defaultdict(list))
|
|
77
|
+
|
|
78
|
+
# Resolve for each Python version
|
|
79
|
+
for python_version in python_versions:
|
|
80
|
+
log.info(f'Resolving dependencies for Python {python_version}')
|
|
81
|
+
|
|
82
|
+
resolved = resolver.resolve(requirements, python_version=python_version)
|
|
83
|
+
|
|
84
|
+
log.info(
|
|
85
|
+
f'Resolved {len(resolved)} dependencies for Python {python_version}'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Accumulate into versions dict
|
|
89
|
+
for name, info in resolved.items():
|
|
90
|
+
versions[name][info['version']].append(python_version)
|
|
91
|
+
|
|
92
|
+
return versions
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_requirements_to_file(
|
|
96
|
+
versions, python_versions, filename, header=None
|
|
97
|
+
):
|
|
98
|
+
"""Write resolved requirements to file."""
|
|
99
|
+
with open(filename, 'w') as fh: # Changed to 'w' mode for writing
|
|
100
|
+
if header:
|
|
101
|
+
fh.write(header)
|
|
102
|
+
if not header.endswith('\n'):
|
|
103
|
+
fh.write('\n')
|
|
104
|
+
for pkg, vers in sorted(versions.items()):
|
|
105
|
+
for ver, pythons in vers.items():
|
|
106
|
+
# same pkg ver for all pythons
|
|
107
|
+
fh.write(pkg)
|
|
108
|
+
fh.write('==')
|
|
109
|
+
fh.write(ver)
|
|
110
|
+
if pythons != python_versions:
|
|
111
|
+
fh.write('; ')
|
|
112
|
+
for i, python in enumerate(pythons):
|
|
113
|
+
if i:
|
|
114
|
+
fh.write(' or ')
|
|
115
|
+
fh.write("python_version=='")
|
|
116
|
+
fh.write(python)
|
|
117
|
+
fh.write("'")
|
|
118
|
+
fh.write('\n')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_and_validate_args(metadata, args):
|
|
122
|
+
"""Parse and validate command line arguments.
|
|
123
|
+
|
|
124
|
+
Returns a dict with parsed values:
|
|
125
|
+
- directory: expanded directory path
|
|
126
|
+
- extras: set of extras to include
|
|
127
|
+
- python_versions: list of Python versions
|
|
128
|
+
- output_path: full path for output file
|
|
129
|
+
- header: header string to write to file
|
|
130
|
+
"""
|
|
131
|
+
# Expand user path (e.g., ~ -> /home/username)
|
|
132
|
+
directory = expanduser(args.directory)
|
|
133
|
+
|
|
134
|
+
# Parse extras from command line
|
|
135
|
+
if args.extras is None:
|
|
136
|
+
extras = metadata.provides_extra
|
|
137
|
+
else:
|
|
138
|
+
extras = set(e.strip() for e in args.extras.split(',') if e.strip())
|
|
139
|
+
# Validate that requested extras exist
|
|
140
|
+
available_extras = set(metadata.provides_extra)
|
|
141
|
+
invalid_extras = extras - available_extras
|
|
142
|
+
if invalid_extras:
|
|
143
|
+
log.error(
|
|
144
|
+
f"Invalid extras requested: {', '.join(sorted(invalid_extras))}. "
|
|
145
|
+
f"Available extras: {', '.join(sorted(available_extras)) if available_extras else 'none'}"
|
|
146
|
+
)
|
|
147
|
+
exit(1)
|
|
148
|
+
|
|
149
|
+
if args.python_versions is None:
|
|
150
|
+
# If no python versions specified, use active versions from endoflife.date
|
|
151
|
+
python = Python()
|
|
152
|
+
python_versions = [release['cycle'] for release in python.active]
|
|
153
|
+
else:
|
|
154
|
+
python_versions = [
|
|
155
|
+
v.strip() for v in args.python_versions.split(',') if v.strip()
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
# Determine output path: if filename has a directory component, use as-is;
|
|
159
|
+
# otherwise place it in the project directory
|
|
160
|
+
filename = args.filename
|
|
161
|
+
if dirname(filename):
|
|
162
|
+
output_path = expanduser(filename)
|
|
163
|
+
else:
|
|
164
|
+
output_path = join(directory, filename)
|
|
165
|
+
|
|
166
|
+
# Build the header
|
|
167
|
+
if args.header is None:
|
|
168
|
+
# Default header
|
|
169
|
+
command_line = ' '.join(argv)
|
|
170
|
+
header = f'# DO NOT EDIT THIS FILE DIRECTLY - use {command_line}\n'
|
|
171
|
+
else:
|
|
172
|
+
# User-provided header, replace [command line] placeholder
|
|
173
|
+
command_line = ' '.join(argv)
|
|
174
|
+
header = args.header.replace('[command line]', command_line)
|
|
175
|
+
if header and not header.endswith('\n'):
|
|
176
|
+
header += '\n'
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
'directory': directory,
|
|
180
|
+
'extras': extras,
|
|
181
|
+
'python_versions': python_versions,
|
|
182
|
+
'output_path': output_path,
|
|
183
|
+
'header': header,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(): # pragma: no cover
|
|
188
|
+
parser = ArgumentParser(
|
|
189
|
+
description='Extract project requirements, resolve dependencies, and manage requirements.txt'
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
'--directory',
|
|
193
|
+
default=getcwd(),
|
|
194
|
+
help='Project root directory (default: current directory)',
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
'--extras',
|
|
198
|
+
default=None,
|
|
199
|
+
help='Comma-separated list of extras to include (e.g., "dev,test"). Defaults to all defined extras.',
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
'--python-versions',
|
|
203
|
+
default=None,
|
|
204
|
+
help='Comma-separated list of Python versions (e.g., "3.9,3.10,3.11"). Defaults to currently active versions per endoflife.date.',
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
'--filename',
|
|
208
|
+
default='requirements.txt',
|
|
209
|
+
help='Output filename or path for requirements (default: requirements.txt). If just a filename, will be placed in --directory; if a path, will be used as-is.',
|
|
210
|
+
)
|
|
211
|
+
level_map = {
|
|
212
|
+
'DEBUG': DEBUG,
|
|
213
|
+
'INFO': INFO,
|
|
214
|
+
'WARNING': WARNING,
|
|
215
|
+
'ERROR': ERROR,
|
|
216
|
+
'debug': DEBUG,
|
|
217
|
+
'info': INFO,
|
|
218
|
+
'warning': WARNING,
|
|
219
|
+
'error': ERROR,
|
|
220
|
+
}
|
|
221
|
+
parser.add_argument(
|
|
222
|
+
'--level',
|
|
223
|
+
default='WARNING',
|
|
224
|
+
choices=sorted(level_map.keys()),
|
|
225
|
+
help='Logging level (default: WARNING)',
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
'--header',
|
|
229
|
+
default=None,
|
|
230
|
+
help='Header comment to place at the top of the requirements file. Use the string "[command line]" to insert the actual command that was run. (default: "# DO NOT EDIT THIS FILE DIRECTLY - use [command line]")',
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
args = parser.parse_args()
|
|
234
|
+
|
|
235
|
+
# Configure logging
|
|
236
|
+
basicConfig(
|
|
237
|
+
level=level_map.get(args.level, WARNING),
|
|
238
|
+
format='%(asctime)s [%(levelname)-8s] %(message)s',
|
|
239
|
+
datefmt='%Y-%m-%dT%H:%M:%S',
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Suppress verbose httpx logging unless we're in DEBUG mode
|
|
243
|
+
if args.level != 'DEBUG':
|
|
244
|
+
getLogger('httpx').setLevel(WARNING)
|
|
245
|
+
|
|
246
|
+
# Build project metadata
|
|
247
|
+
metadata = build_project_metadata(args.directory)
|
|
248
|
+
|
|
249
|
+
# Parse and validate arguments
|
|
250
|
+
parsed = parse_and_validate_args(metadata, args)
|
|
251
|
+
|
|
252
|
+
format_and_print_metadata(
|
|
253
|
+
metadata, parsed['extras'], parsed['python_versions']
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
requirements = get_requirements_with_extras(metadata, parsed['extras'])
|
|
257
|
+
|
|
258
|
+
versions = find_requirements(
|
|
259
|
+
requirements, python_versions=parsed['python_versions']
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
write_requirements_to_file(
|
|
263
|
+
versions,
|
|
264
|
+
parsed['python_versions'],
|
|
265
|
+
parsed['output_path'],
|
|
266
|
+
header=parsed['header'],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == '__main__': # pragma: no cover
|
|
271
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Python:
|
|
8
|
+
"""Fetches and filters active Python releases from endoflife.date."""
|
|
9
|
+
|
|
10
|
+
API_URL = 'https://endoflife.date/api/python.json'
|
|
11
|
+
|
|
12
|
+
@cached_property
|
|
13
|
+
def releases(self):
|
|
14
|
+
"""Fetch all Python release data from endoflife.date API.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
List of dicts containing release information with fields:
|
|
18
|
+
- cycle: version string (e.g., "3.12")
|
|
19
|
+
- releaseDate: ISO date string
|
|
20
|
+
- eol: end-of-life ISO date string
|
|
21
|
+
- latest: latest patch version
|
|
22
|
+
- and other metadata
|
|
23
|
+
"""
|
|
24
|
+
response = httpx.get(self.API_URL)
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
return response.json()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def active(self):
|
|
30
|
+
"""Return list of active Python releases.
|
|
31
|
+
|
|
32
|
+
A release is active if: releaseDate <= today <= eol
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of release dicts sorted by version (oldest to newest)
|
|
36
|
+
"""
|
|
37
|
+
today = date.today()
|
|
38
|
+
active_releases = []
|
|
39
|
+
|
|
40
|
+
for release in self.releases:
|
|
41
|
+
release_date = date.fromisoformat(release['releaseDate'])
|
|
42
|
+
eol_date = date.fromisoformat(release['eol'])
|
|
43
|
+
|
|
44
|
+
if release_date <= today <= eol_date:
|
|
45
|
+
active_releases.append(release)
|
|
46
|
+
|
|
47
|
+
# Sort by version number (oldest to newest)
|
|
48
|
+
active_releases.sort(
|
|
49
|
+
key=lambda r: tuple(map(int, r['cycle'].split('.')))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return active_releases
|