proviso 0.0.1__py3-none-any.whl
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/__init__.py +5 -0
- proviso/builder.py +29 -0
- proviso/main.py +271 -0
- proviso/python.py +52 -0
- proviso/resolver.py +248 -0
- proviso/utils.py +70 -0
- proviso-0.0.1.dist-info/METADATA +147 -0
- proviso-0.0.1.dist-info/RECORD +12 -0
- proviso-0.0.1.dist-info/WHEEL +5 -0
- proviso-0.0.1.dist-info/entry_points.txt +2 -0
- proviso-0.0.1.dist-info/licenses/LICENSE +25 -0
- proviso-0.0.1.dist-info/top_level.txt +1 -0
proviso/builder.py
ADDED
|
@@ -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())
|
proviso/main.py
ADDED
|
@@ -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()
|
proviso/python.py
ADDED
|
@@ -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
|
proviso/resolver.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from operator import attrgetter
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from packaging.markers import default_environment
|
|
6
|
+
from packaging.metadata import Metadata
|
|
7
|
+
from packaging.requirements import Requirement
|
|
8
|
+
from packaging.utils import canonicalize_name
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
from resolvelib import AbstractProvider, BaseReporter
|
|
11
|
+
from resolvelib import Resolver as ResolveLibResolver
|
|
12
|
+
from unearth import PackageFinder, TargetPython
|
|
13
|
+
from unearth.auth import MultiDomainBasicAuth
|
|
14
|
+
|
|
15
|
+
from .utils import CachingClient, format_python_version_for_markers
|
|
16
|
+
|
|
17
|
+
log = getLogger('proviso.resolver')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Candidate:
|
|
21
|
+
"""Represents a concrete package version."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name, version, extras=None, dependencies=None):
|
|
24
|
+
self.name = canonicalize_name(name)
|
|
25
|
+
self.version = version
|
|
26
|
+
self.extras = extras or frozenset()
|
|
27
|
+
self._dependencies = dependencies
|
|
28
|
+
|
|
29
|
+
def __repr__(self):
|
|
30
|
+
return f"Candidate({self.name!r}, {self.version!r})"
|
|
31
|
+
|
|
32
|
+
def __eq__(self, other):
|
|
33
|
+
if not isinstance(other, Candidate):
|
|
34
|
+
return NotImplemented
|
|
35
|
+
return (
|
|
36
|
+
self.name == other.name
|
|
37
|
+
and self.version == other.version
|
|
38
|
+
and self.extras == other.extras
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __hash__(self):
|
|
42
|
+
return hash((self.name, self.version, self.extras))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PyPIProvider(AbstractProvider):
|
|
46
|
+
"""Provider that queries PyPI for packages and their dependencies."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, session, index_urls, python_version=None):
|
|
49
|
+
self.session = session
|
|
50
|
+
self._dependencies_cache = {}
|
|
51
|
+
|
|
52
|
+
# Create TargetPython if a specific version is requested
|
|
53
|
+
target_python = None
|
|
54
|
+
if python_version:
|
|
55
|
+
target_python = TargetPython(py_ver=Version(python_version).release)
|
|
56
|
+
|
|
57
|
+
# Create PackageFinder with the target Python version
|
|
58
|
+
self.finder = PackageFinder(
|
|
59
|
+
session=session, index_urls=index_urls, target_python=target_python
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Build environment for marker evaluation
|
|
63
|
+
if python_version:
|
|
64
|
+
# Start with default environment and override Python version
|
|
65
|
+
version_info_dict = format_python_version_for_markers(
|
|
66
|
+
python_version
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.environment = default_environment()
|
|
70
|
+
self.environment.update(version_info_dict)
|
|
71
|
+
else:
|
|
72
|
+
# Use current environment
|
|
73
|
+
self.environment = None
|
|
74
|
+
|
|
75
|
+
def identify(self, requirement_or_candidate):
|
|
76
|
+
"""Return the package name as the identifier."""
|
|
77
|
+
if isinstance(requirement_or_candidate, Requirement):
|
|
78
|
+
return canonicalize_name(requirement_or_candidate.name)
|
|
79
|
+
return requirement_or_candidate.name
|
|
80
|
+
|
|
81
|
+
def get_preference(
|
|
82
|
+
self, identifier, resolutions, candidates, information, backtrack_causes
|
|
83
|
+
):
|
|
84
|
+
"""Return preference for resolving this requirement."""
|
|
85
|
+
# Simpler preference: prefer already resolved packages, then by number of candidates
|
|
86
|
+
return (
|
|
87
|
+
identifier not in resolutions,
|
|
88
|
+
len(list(candidates.get(identifier, []))),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def find_matches(self, identifier, requirements, incompatibilities):
|
|
92
|
+
"""Find all candidates that match the given requirements."""
|
|
93
|
+
# Get all requirements for this identifier
|
|
94
|
+
reqs = list(requirements.get(identifier, []))
|
|
95
|
+
if not reqs:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
# Use the first requirement to search (they should all have the same name)
|
|
99
|
+
req = reqs[0]
|
|
100
|
+
|
|
101
|
+
log.debug(f'Finding matches for {identifier}: {req}')
|
|
102
|
+
|
|
103
|
+
# Find best match using unearth
|
|
104
|
+
result = self.finder.find_matches(str(req))
|
|
105
|
+
|
|
106
|
+
# Get all applicable versions
|
|
107
|
+
candidates = []
|
|
108
|
+
for package in result:
|
|
109
|
+
version = Version(package.version)
|
|
110
|
+
|
|
111
|
+
# Check if this version satisfies all requirements
|
|
112
|
+
if all(version in r.specifier for r in reqs):
|
|
113
|
+
# Check if it's not in incompatibilities
|
|
114
|
+
if version not in [
|
|
115
|
+
c.version for c in incompatibilities.get(identifier, [])
|
|
116
|
+
]:
|
|
117
|
+
candidates.append(Candidate(identifier, version))
|
|
118
|
+
|
|
119
|
+
# Return candidates sorted by version (newest first)
|
|
120
|
+
log.debug(f'Found {len(candidates)} candidates for {identifier}')
|
|
121
|
+
return sorted(candidates, key=attrgetter('version'), reverse=True)
|
|
122
|
+
|
|
123
|
+
def is_satisfied_by(self, requirement, candidate):
|
|
124
|
+
"""Check if the candidate satisfies the requirement."""
|
|
125
|
+
if canonicalize_name(requirement.name) != candidate.name:
|
|
126
|
+
return False
|
|
127
|
+
return candidate.version in requirement.specifier
|
|
128
|
+
|
|
129
|
+
def get_dependencies(self, candidate):
|
|
130
|
+
"""Get dependencies for a candidate."""
|
|
131
|
+
cache_key = (candidate.name, candidate.version)
|
|
132
|
+
|
|
133
|
+
if cache_key in self._dependencies_cache:
|
|
134
|
+
log.debug(
|
|
135
|
+
f'Dependencies cache hit for {candidate.name}=={candidate.version}'
|
|
136
|
+
)
|
|
137
|
+
return self._dependencies_cache[cache_key]
|
|
138
|
+
|
|
139
|
+
log.debug(
|
|
140
|
+
f'Getting dependencies for {candidate.name}=={candidate.version}'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Find the package to get its metadata
|
|
144
|
+
result = self.finder.find_best_match(
|
|
145
|
+
f"{candidate.name}=={candidate.version}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if not result.best:
|
|
149
|
+
log.warning(
|
|
150
|
+
f'No matching package found for {candidate.name}=={candidate.version}'
|
|
151
|
+
)
|
|
152
|
+
self._dependencies_cache[cache_key] = []
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
package = result.best
|
|
156
|
+
|
|
157
|
+
# Fetch metadata from dist_info_link if available
|
|
158
|
+
if package.link.dist_info_link:
|
|
159
|
+
url = package.link.dist_info_link.url
|
|
160
|
+
|
|
161
|
+
# Fetch (session caches if it's a CachingClient)
|
|
162
|
+
if self.session:
|
|
163
|
+
response = self.session.get(url)
|
|
164
|
+
else:
|
|
165
|
+
response = httpx.get(url)
|
|
166
|
+
|
|
167
|
+
# Disable validation to handle metadata version mismatches
|
|
168
|
+
metadata = Metadata.from_email(response.text, validate=False)
|
|
169
|
+
else:
|
|
170
|
+
# Fallback: no metadata available
|
|
171
|
+
self._dependencies_cache[cache_key] = []
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
# Extract dependencies from metadata
|
|
175
|
+
dependencies = []
|
|
176
|
+
for req in metadata.requires_dist or []:
|
|
177
|
+
# Evaluate markers for target environment
|
|
178
|
+
if req.marker is None:
|
|
179
|
+
# No marker means always included
|
|
180
|
+
dependencies.append(req)
|
|
181
|
+
elif self.environment is None:
|
|
182
|
+
# No custom environment, use default
|
|
183
|
+
if req.marker.evaluate():
|
|
184
|
+
dependencies.append(req)
|
|
185
|
+
else:
|
|
186
|
+
# Use custom environment for evaluation
|
|
187
|
+
if req.marker.evaluate(environment=self.environment):
|
|
188
|
+
dependencies.append(req)
|
|
189
|
+
|
|
190
|
+
log.debug(
|
|
191
|
+
f'Found {len(dependencies)} dependencies for {candidate.name}=={candidate.version}'
|
|
192
|
+
)
|
|
193
|
+
self._dependencies_cache[cache_key] = dependencies
|
|
194
|
+
return dependencies
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Resolver:
|
|
198
|
+
"""Resolves package dependencies using PyPI."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, index_urls=None):
|
|
201
|
+
"""Initialize the resolver.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
index_urls: List of package index URLs. Defaults to PyPI.
|
|
205
|
+
"""
|
|
206
|
+
if index_urls is None:
|
|
207
|
+
index_urls = ['https://pypi.org/simple/']
|
|
208
|
+
|
|
209
|
+
self.index_urls = index_urls
|
|
210
|
+
|
|
211
|
+
# Create cached HTTP client shared across all resolutions
|
|
212
|
+
self._session = CachingClient()
|
|
213
|
+
self._session.auth = MultiDomainBasicAuth(index_urls=index_urls)
|
|
214
|
+
|
|
215
|
+
def resolve(self, requirements, python_version=None):
|
|
216
|
+
"""Resolve dependencies for the given requirements.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
requirements: List of packaging.requirements.Requirement objects
|
|
220
|
+
python_version: Target Python version string (e.g., "3.9", "3.10.5").
|
|
221
|
+
Defaults to current Python version.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Dict mapping package names to metadata dicts with 'version' and 'extras'
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
resolvelib.ResolutionImpossible: If resolution fails
|
|
228
|
+
"""
|
|
229
|
+
provider = PyPIProvider(
|
|
230
|
+
session=self._session,
|
|
231
|
+
index_urls=self.index_urls,
|
|
232
|
+
python_version=python_version,
|
|
233
|
+
)
|
|
234
|
+
reporter = BaseReporter()
|
|
235
|
+
resolver = ResolveLibResolver(provider, reporter)
|
|
236
|
+
|
|
237
|
+
# Resolve
|
|
238
|
+
result = resolver.resolve(requirements)
|
|
239
|
+
|
|
240
|
+
# Convert result to our format
|
|
241
|
+
resolved = {}
|
|
242
|
+
for identifier, candidate in result.mapping.items():
|
|
243
|
+
resolved[identifier] = {
|
|
244
|
+
'version': str(candidate.version),
|
|
245
|
+
'extras': list(candidate.extras),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return resolved
|
proviso/utils.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#
|
|
2
|
+
#
|
|
3
|
+
#
|
|
4
|
+
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from packaging.version import Version
|
|
9
|
+
from unearth.fetchers import DEFAULT_SECURE_ORIGINS
|
|
10
|
+
|
|
11
|
+
log = getLogger('proviso.utils')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CachingClient(httpx.Client):
|
|
15
|
+
"""httpx.Client wrapper that implements the Fetcher protocol for unearth with caching."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args, **kwargs):
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
self._cache = {}
|
|
20
|
+
|
|
21
|
+
def get(self, url, **kwargs):
|
|
22
|
+
"""Override get() to add simple caching."""
|
|
23
|
+
|
|
24
|
+
# Create cache key from URL, lots of assumptions here, e.g. headers
|
|
25
|
+
# don't matter that should hold for our use cases
|
|
26
|
+
cache_key = url
|
|
27
|
+
|
|
28
|
+
# Check memory cache first
|
|
29
|
+
if cache_key in self._cache:
|
|
30
|
+
log.debug(f'Cache hit: {url}')
|
|
31
|
+
return self._cache[cache_key]
|
|
32
|
+
|
|
33
|
+
# Not in cache, fetch from network
|
|
34
|
+
log.debug(f'Cache miss, fetching: {url}')
|
|
35
|
+
response = super().get(url, **kwargs)
|
|
36
|
+
|
|
37
|
+
self._cache[cache_key] = response
|
|
38
|
+
|
|
39
|
+
return response
|
|
40
|
+
|
|
41
|
+
def get_stream(self, url, *, headers=None):
|
|
42
|
+
"""Required by Fetcher protocol."""
|
|
43
|
+
return self.stream('GET', url, headers=headers)
|
|
44
|
+
|
|
45
|
+
def iter_secure_origins(self):
|
|
46
|
+
"""Required by Fetcher protocol."""
|
|
47
|
+
yield from DEFAULT_SECURE_ORIGINS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_python_version_for_markers(version_str):
|
|
51
|
+
"""Format a Python version string for use in marker evaluation.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
version_str: Version string like "3.9", "3.10.5", "3.11.0"
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dict with 'python_version' and 'python_full_version' keys
|
|
58
|
+
"""
|
|
59
|
+
v = Version(version_str)
|
|
60
|
+
|
|
61
|
+
# python_version is major.minor (e.g., "3.9")
|
|
62
|
+
python_version = f"{v.major}.{v.minor}"
|
|
63
|
+
|
|
64
|
+
# python_full_version includes patch
|
|
65
|
+
python_full_version = f"{v.major}.{v.minor}.{v.micro}"
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
'python_version': python_version,
|
|
69
|
+
'python_full_version': python_full_version,
|
|
70
|
+
}
|
|
@@ -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.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
proviso/__init__.py,sha256=L-G2-VrPD38RTsYd_nrRZgpBYoInjQDdcoFPGjBTgMw,29
|
|
2
|
+
proviso/builder.py,sha256=mVBRjqsPtN4g_Y4ToyaluBIImZhpT3cPYEgW-ADe06U,850
|
|
3
|
+
proviso/main.py,sha256=Owsdsylcqk9_EpI7Qy3e4Yr_UhKepqlamOU0BbOWy4I,8813
|
|
4
|
+
proviso/python.py,sha256=mBGWBj9VuiZUiyMKPfIK8ddbHm2kvMDleuR4pRzGXU0,1526
|
|
5
|
+
proviso/resolver.py,sha256=4GgXO4lpLUH1qiAJtOqtx9hcr9hJ9Y7PWdXfS3OQ-VA,8812
|
|
6
|
+
proviso/utils.py,sha256=SF1fg__c0Z625FZgZiNB_CY-0n7083TXbrqoBhgDEV8,1925
|
|
7
|
+
proviso-0.0.1.dist-info/licenses/LICENSE,sha256=vWCiWlB3SMjfbIdbGGhJu62IvKEEexHokAa8wFcqJr4,1129
|
|
8
|
+
proviso-0.0.1.dist-info/METADATA,sha256=I7WutP407eMD_Am6bcDbw1nUwoxKWUya94FU4h6cN54,5269
|
|
9
|
+
proviso-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
10
|
+
proviso-0.0.1.dist-info/entry_points.txt,sha256=vBNf22Y0c7ilXxM19a1jyO-48_Hj3Ro0g4bP5UHqIps,46
|
|
11
|
+
proviso-0.0.1.dist-info/top_level.txt,sha256=TsNb9ftylxJVEPQwv7r2qoxmodkY7ejcx7XX5-PHvKQ,8
|
|
12
|
+
proviso-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
proviso
|