44from pydantic import TypeAdapter
55
66from usethis ._integrations .file .pyproject_toml .io_ import PyprojectTOMLManager
7+ from usethis ._integrations .python .version import PythonVersion
78
89
910class MissingRequiresPythonError (Exception ):
@@ -22,3 +23,167 @@ def get_requires_python() -> SpecifierSet:
2223 raise MissingRequiresPythonError (msg ) from None
2324
2425 return SpecifierSet (requires_python )
26+
27+
28+ def get_required_minor_python_versions () -> list [PythonVersion ]:
29+ """Get Python minor versions that match the project's requires-python constraint.
30+
31+ Returns:
32+ List of Python versions within the requires-python bounds,
33+ sorted from lowest to highest. Empty list if no versions match.
34+
35+ Raises:
36+ MissingRequiresPythonError: If requires-python is not specified.
37+ PyprojectTOMLNotFoundError: If pyproject.toml doesn't exist.
38+ """
39+ requires_python = get_requires_python ()
40+
41+ # Extract all versions mentioned in the specifier, grouped by (major, minor)
42+ versions_by_minor : dict [tuple [int , int ], set [int ]] = {}
43+ for spec in requires_python :
44+ parsed = PythonVersion .from_string (spec .version )
45+ major_minor = (int (parsed .major ), int (parsed .minor ))
46+ patch = int (parsed .patch ) if parsed .patch else 0
47+ versions_by_minor .setdefault (major_minor , set ()).add (patch )
48+
49+ # Get overall bounds from what's explicitly in the specifier
50+ min_version = _get_minimum_minor_python_version_tuple (
51+ requires_python , versions_by_minor
52+ )
53+ max_version = _get_maximum_minor_python_version_tuple (
54+ requires_python , versions_by_minor
55+ )
56+
57+ # If max_version is in a higher major version than min_version,
58+ # extend the previous major version to its hard-coded limit
59+ # E.g., >=3.6,<4.0 should include up to 3.15
60+ major_version_limits : dict [int , int ] = {}
61+ if max_version [0 ] > min_version [0 ]:
62+ # We'll handle this by tracking which major versions need limits
63+ for major in range (min_version [0 ], max_version [0 ]):
64+ major_version_limits [major ] = _get_maximum_python_minor_version (major )
65+
66+ # Get minor version bounds from what's actually in the spec
67+ all_major_minors = list (versions_by_minor .keys ())
68+ all_minors = [minor for _ , minor in all_major_minors ]
69+ min_minor_in_spec = min (all_minors )
70+ max_minor_in_spec = max (all_minors )
71+
72+ supported_versions = []
73+ # Generate all major.minor combinations in range
74+ for major in range (min_version [0 ], max_version [0 ] + 1 ):
75+ min_minor = min_version [1 ] if major == min_version [0 ] else min_minor_in_spec
76+ # Apply hard-coded limit if this major version has one
77+ if major in major_version_limits :
78+ max_minor = major_version_limits [major ]
79+ else :
80+ max_minor = max_version [1 ] if major == max_version [0 ] else max_minor_in_spec
81+
82+ for minor in range (min_minor , max_minor + 1 ):
83+ version = PythonVersion (major = str (major ), minor = str (minor ), patch = None )
84+ version_str = version .to_short_string ()
85+
86+ # Get patch versions mentioned for this major.minor in the specifier
87+ # The extremes will lie +/- 1 from any named patch version
88+ patches_to_check = set ()
89+ major_minor_key = (major , minor )
90+ if major_minor_key in versions_by_minor :
91+ for patch in versions_by_minor [major_minor_key ]:
92+ patches_to_check .add (max (0 , patch - 1 ))
93+ patches_to_check .add (patch )
94+ patches_to_check .add (patch + 1 )
95+ else :
96+ # No patch specified for this minor, default to checking .0
97+ patches_to_check .add (0 )
98+
99+ # Check if any of these patch versions satisfy the specifier
100+ is_valid = any (
101+ requires_python .contains (f"{ version_str } .{ patch } " )
102+ for patch in patches_to_check
103+ )
104+ if is_valid :
105+ supported_versions .append (version )
106+
107+ return supported_versions
108+
109+
110+ def _get_minimum_minor_python_version_tuple (
111+ requires_python : SpecifierSet , versions_by_minor : dict [tuple [int , int ], set [int ]]
112+ ) -> tuple [int , int ]:
113+ """Get the minimum (major, minor) Python version from requires-python specifier.
114+
115+ Handles unbounded downward cases by applying hard-coded limits.
116+
117+ Args:
118+ requires_python: The requires-python specifier set.
119+ versions_by_minor: Dict mapping (major, minor) to set of patch versions.
120+
121+ Returns:
122+ Tuple of (major, minor) representing the minimum version.
123+ """
124+ all_major_minors = list (versions_by_minor .keys ())
125+ min_version = min (all_major_minors )
126+
127+ # Check if specifier is unbounded downward by testing min_version - 1 minor
128+ # Only test if min_minor > 0 (can't go below .0)
129+ is_unbounded_downward = min_version [1 ] > 0 and requires_python .contains (
130+ f"{ min_version [0 ]} .{ min_version [1 ] - 1 } .0"
131+ )
132+
133+ if is_unbounded_downward :
134+ if min_version [0 ] == 2 :
135+ min_version = (2 , 0 )
136+ elif min_version [0 ] == 3 :
137+ min_version = (3 , 0 )
138+
139+ return min_version
140+
141+
142+ def _get_maximum_minor_python_version_tuple (
143+ requires_python : SpecifierSet , versions_by_minor : dict [tuple [int , int ], set [int ]]
144+ ) -> tuple [int , int ]:
145+ """Get the maximum (major, minor) Python version from requires-python specifier.
146+
147+ Handles unbounded upward cases by applying hard-coded limits.
148+
149+ Args:
150+ requires_python: The requires-python specifier set.
151+ versions_by_minor: Dict mapping (major, minor) to set of patch versions.
152+
153+ Returns:
154+ Tuple of (major, minor) representing the maximum version.
155+ """
156+ all_major_minors = list (versions_by_minor .keys ())
157+ max_version = max (all_major_minors )
158+
159+ # Check if specifier is unbounded upward by testing max_version + 1 minor
160+ is_unbounded_upward = requires_python .contains (
161+ f"{ max_version [0 ]} .{ max_version [1 ] + 1 } .0"
162+ )
163+
164+ # Apply hard-coded limits for unbounded cases
165+ if is_unbounded_upward :
166+ max_version = (
167+ max_version [0 ],
168+ _get_maximum_python_minor_version (max_version [0 ]),
169+ )
170+
171+ return max_version
172+
173+
174+ def _get_maximum_python_minor_version (major : int ) -> int :
175+ """Get the hard-coded maximum minor version for a given Python major version.
176+
177+ Args:
178+ major: The Python major version (e.g., 2, 3). Usually will be 3.
179+
180+ Returns:
181+ The maximum minor version for that major version.
182+ """
183+ if major == 2 :
184+ return 7
185+ elif major == 3 :
186+ # N.B. needs maintenance as new versions are released
187+ return 15
188+ else :
189+ raise NotImplementedError
0 commit comments