Skip to content

Commit 8130abd

Browse files
1183 respect requires python bounds in get supported major python versions for none backend (#1273)
* Add function for getting the required minor Python versions * Warn when current Python interpreter is outside the `requires-python` bounds * Pass `basedpyright` by fixing override signatures for `get_bitbucket_steps` * Revert signature changes to avail of new `warn_print` caching * Shore up test behaviour * Fix missing monkeypatch * Fix missing monkey patch
1 parent 09e0bff commit 8130abd

9 files changed

Lines changed: 593 additions & 15 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,9 @@ layers = [
319319
"ci | pre_commit",
320320
"environ",
321321
"backend | mkdocs | pytest | pydantic | sonarqube",
322-
"project | python",
322+
"project",
323323
"file",
324+
"python",
324325
]
325326
containers = [ "usethis._integrations" ]
326327
exhaustive = true

src/usethis/_integrations/environ/python.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,61 @@
22

33
from typing_extensions import assert_never
44

5+
from usethis._console import warn_print
56
from usethis._integrations.backend.dispatch import get_backend
67
from usethis._integrations.backend.uv.python import (
78
get_supported_uv_minor_python_versions,
89
)
10+
from usethis._integrations.file.pyproject_toml.errors import PyprojectTOMLNotFoundError
11+
from usethis._integrations.file.pyproject_toml.requires_python import (
12+
MissingRequiresPythonError,
13+
get_required_minor_python_versions,
14+
get_requires_python,
15+
)
916
from usethis._integrations.python.version import PythonVersion
1017
from usethis._types.backend import BackendEnum
1118

1219

1320
def get_supported_minor_python_versions() -> list[PythonVersion]:
21+
"""Get supported Python versions for the current backend.
22+
23+
For the uv backend, queries available Python versions from uv. Otherwise, without a
24+
backend, uses 'requires-python' from 'pyproject.toml' if available, otherwise falls
25+
back to current interpreter.
26+
27+
Returns:
28+
Supported Python versions within the requires-python bounds, sorted from lowest
29+
to highest.
30+
"""
1431
backend = get_backend()
1532

1633
if backend is BackendEnum.uv:
1734
versions = get_supported_uv_minor_python_versions()
1835
elif backend is BackendEnum.none:
19-
versions = [PythonVersion.from_interpreter()]
36+
# When no build backend is available, we can't query for available Python versions.
37+
# Instead, we use requires-python if available.
38+
try:
39+
versions = get_required_minor_python_versions()
40+
except (MissingRequiresPythonError, PyprojectTOMLNotFoundError):
41+
# No requires-python specified, use current interpreter
42+
return [PythonVersion.from_interpreter()]
43+
44+
# If no versions match, fall back to current interpreter
45+
if not versions:
46+
return [PythonVersion.from_interpreter()]
47+
48+
# Check if current interpreter is within bounds and warn if not
49+
try:
50+
requires_python = get_requires_python()
51+
current_version = PythonVersion.from_interpreter()
52+
if not requires_python.contains(current_version.to_short_string()):
53+
warn_print(
54+
f"Current Python interpreter ({current_version.to_short_string()}) "
55+
f"is outside requires-python bounds ({requires_python}). "
56+
f"Using lowest supported version ({versions[0].to_short_string()})."
57+
)
58+
except (MissingRequiresPythonError, PyprojectTOMLNotFoundError):
59+
pass
2060
else:
2161
assert_never(backend)
2262

src/usethis/_integrations/file/pyproject_toml/requires_python.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pydantic import TypeAdapter
55

66
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
7+
from usethis._integrations.python.version import PythonVersion
78

89

910
class 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

src/usethis/_tool/base.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,9 @@ def get_bitbucket_steps(
607607
matrix_python: Whether to use a Python version matrix. When False,
608608
only the current development version is used.
609609
"""
610+
# N.B. the default implementation doesn't need matrix_python,
611+
# but it's included in the signature to allow for it to be used, e.g. for pytest
612+
610613
try:
611614
cmd = self.default_command()
612615
except NoDefaultToolCommand:
@@ -670,14 +673,15 @@ def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None:
670673
return
671674

672675
# Add the new steps
673-
for step in self.get_bitbucket_steps(matrix_python=matrix_python):
676+
steps = self.get_bitbucket_steps(matrix_python=matrix_python)
677+
for step in steps:
674678
add_bitbucket_step_in_default(step)
675679

676680
# Remove any old steps that are not active managed by this tool
681+
managed_names = self.get_managed_bitbucket_step_names()
677682
for step in get_steps_in_default():
678-
if step.name in self.get_managed_bitbucket_step_names() and not any(
679-
bitbucket_steps_are_equivalent(step, step_)
680-
for step_ in self.get_bitbucket_steps(matrix_python=matrix_python)
683+
if step.name in managed_names and not any(
684+
bitbucket_steps_are_equivalent(step, step_) for step_ in steps
681685
):
682686
remove_bitbucket_step_from_default(step)
683687

src/usethis/_tool/impl/pre_commit.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4+
from typing import TYPE_CHECKING
45

56
from typing_extensions import assert_never
67

@@ -18,6 +19,9 @@
1819
from usethis._types.backend import BackendEnum
1920
from usethis._types.deps import Dependency
2021

22+
if TYPE_CHECKING:
23+
from usethis._integrations.python.version import PythonVersion
24+
2125
_SYNC_WITH_UV_VERSION = "v0.5.0" # Manually bump this version when necessary
2226

2327

@@ -69,7 +73,10 @@ def get_managed_files(self) -> list[Path]:
6973
return [Path(".pre-commit-config.yaml")]
7074

7175
def get_bitbucket_steps(
72-
self, *, matrix_python: bool = True
76+
self,
77+
*,
78+
matrix_python: bool = True,
79+
versions: list[PythonVersion] | None = None,
7380
) -> list[bitbucket_schema.Step]:
7481
backend = get_backend()
7582

0 commit comments

Comments
 (0)