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 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.
@@ -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,5 @@
1
+ #
2
+ #
3
+ #
4
+
5
+ __version__ = '0.0.1'
@@ -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