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 ADDED
@@ -0,0 +1,5 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ __version__ = '0.0.1'
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proviso = proviso.main:main
@@ -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