Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,9 @@ layers = [
"ci | pre_commit",
"environ",
"backend | mkdocs | pytest | pydantic | sonarqube",
"project | python",
"project",
"file",
"python",
]
containers = [ "usethis._integrations" ]
exhaustive = true
Expand Down
42 changes: 41 additions & 1 deletion src/usethis/_integrations/environ/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,61 @@

from typing_extensions import assert_never

from usethis._console import warn_print
from usethis._integrations.backend.dispatch import get_backend
from usethis._integrations.backend.uv.python import (
get_supported_uv_minor_python_versions,
)
from usethis._integrations.file.pyproject_toml.errors import PyprojectTOMLNotFoundError
from usethis._integrations.file.pyproject_toml.requires_python import (
MissingRequiresPythonError,
get_required_minor_python_versions,
get_requires_python,
)
from usethis._integrations.python.version import PythonVersion
from usethis._types.backend import BackendEnum


def get_supported_minor_python_versions() -> list[PythonVersion]:
"""Get supported Python versions for the current backend.

For the uv backend, queries available Python versions from uv. Otherwise, without a
backend, uses 'requires-python' from 'pyproject.toml' if available, otherwise falls
back to current interpreter.

Returns:
Supported Python versions within the requires-python bounds, sorted from lowest
to highest.
"""
backend = get_backend()

if backend is BackendEnum.uv:
versions = get_supported_uv_minor_python_versions()
elif backend is BackendEnum.none:
versions = [PythonVersion.from_interpreter()]
# When no build backend is available, we can't query for available Python versions.
# Instead, we use requires-python if available.
try:
versions = get_required_minor_python_versions()
except (MissingRequiresPythonError, PyprojectTOMLNotFoundError):
# No requires-python specified, use current interpreter
return [PythonVersion.from_interpreter()]

# If no versions match, fall back to current interpreter
if not versions:
return [PythonVersion.from_interpreter()]

# Check if current interpreter is within bounds and warn if not
try:
requires_python = get_requires_python()
current_version = PythonVersion.from_interpreter()
if not requires_python.contains(current_version.to_short_string()):
warn_print(
f"Current Python interpreter ({current_version.to_short_string()}) "
f"is outside requires-python bounds ({requires_python}). "
f"Using lowest supported version ({versions[0].to_short_string()})."
)
except (MissingRequiresPythonError, PyprojectTOMLNotFoundError):
pass
else:
assert_never(backend)

Expand Down
165 changes: 165 additions & 0 deletions src/usethis/_integrations/file/pyproject_toml/requires_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import TypeAdapter

from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
from usethis._integrations.python.version import PythonVersion


class MissingRequiresPythonError(Exception):
Expand All @@ -22,3 +23,167 @@ def get_requires_python() -> SpecifierSet:
raise MissingRequiresPythonError(msg) from None

return SpecifierSet(requires_python)


def get_required_minor_python_versions() -> list[PythonVersion]:
"""Get Python minor versions that match the project's requires-python constraint.

Returns:
List of Python versions within the requires-python bounds,
sorted from lowest to highest. Empty list if no versions match.

Raises:
MissingRequiresPythonError: If requires-python is not specified.
PyprojectTOMLNotFoundError: If pyproject.toml doesn't exist.
"""
requires_python = get_requires_python()

# Extract all versions mentioned in the specifier, grouped by (major, minor)
versions_by_minor: dict[tuple[int, int], set[int]] = {}
for spec in requires_python:
parsed = PythonVersion.from_string(spec.version)
major_minor = (int(parsed.major), int(parsed.minor))
patch = int(parsed.patch) if parsed.patch else 0
versions_by_minor.setdefault(major_minor, set()).add(patch)

# Get overall bounds from what's explicitly in the specifier
min_version = _get_minimum_minor_python_version_tuple(
requires_python, versions_by_minor
)
max_version = _get_maximum_minor_python_version_tuple(
requires_python, versions_by_minor
)

# If max_version is in a higher major version than min_version,
# extend the previous major version to its hard-coded limit
# E.g., >=3.6,<4.0 should include up to 3.15
major_version_limits: dict[int, int] = {}
if max_version[0] > min_version[0]:
# We'll handle this by tracking which major versions need limits
for major in range(min_version[0], max_version[0]):
major_version_limits[major] = _get_maximum_python_minor_version(major)

# Get minor version bounds from what's actually in the spec
all_major_minors = list(versions_by_minor.keys())
all_minors = [minor for _, minor in all_major_minors]
min_minor_in_spec = min(all_minors)
max_minor_in_spec = max(all_minors)

supported_versions = []
# Generate all major.minor combinations in range
for major in range(min_version[0], max_version[0] + 1):
min_minor = min_version[1] if major == min_version[0] else min_minor_in_spec
# Apply hard-coded limit if this major version has one
if major in major_version_limits:
max_minor = major_version_limits[major]
else:
max_minor = max_version[1] if major == max_version[0] else max_minor_in_spec

for minor in range(min_minor, max_minor + 1):
version = PythonVersion(major=str(major), minor=str(minor), patch=None)
version_str = version.to_short_string()

# Get patch versions mentioned for this major.minor in the specifier
# The extremes will lie +/- 1 from any named patch version
patches_to_check = set()
major_minor_key = (major, minor)
if major_minor_key in versions_by_minor:
for patch in versions_by_minor[major_minor_key]:
patches_to_check.add(max(0, patch - 1))
patches_to_check.add(patch)
patches_to_check.add(patch + 1)
else:
# No patch specified for this minor, default to checking .0
patches_to_check.add(0)

# Check if any of these patch versions satisfy the specifier
is_valid = any(
requires_python.contains(f"{version_str}.{patch}")
for patch in patches_to_check
)
if is_valid:
supported_versions.append(version)

return supported_versions


def _get_minimum_minor_python_version_tuple(
requires_python: SpecifierSet, versions_by_minor: dict[tuple[int, int], set[int]]
) -> tuple[int, int]:
"""Get the minimum (major, minor) Python version from requires-python specifier.

Handles unbounded downward cases by applying hard-coded limits.

Args:
requires_python: The requires-python specifier set.
versions_by_minor: Dict mapping (major, minor) to set of patch versions.

Returns:
Tuple of (major, minor) representing the minimum version.
"""
all_major_minors = list(versions_by_minor.keys())
min_version = min(all_major_minors)

# Check if specifier is unbounded downward by testing min_version - 1 minor
# Only test if min_minor > 0 (can't go below .0)
is_unbounded_downward = min_version[1] > 0 and requires_python.contains(
f"{min_version[0]}.{min_version[1] - 1}.0"
)

if is_unbounded_downward:
if min_version[0] == 2:
min_version = (2, 0)
elif min_version[0] == 3:
min_version = (3, 0)

return min_version


def _get_maximum_minor_python_version_tuple(
requires_python: SpecifierSet, versions_by_minor: dict[tuple[int, int], set[int]]
) -> tuple[int, int]:
"""Get the maximum (major, minor) Python version from requires-python specifier.

Handles unbounded upward cases by applying hard-coded limits.

Args:
requires_python: The requires-python specifier set.
versions_by_minor: Dict mapping (major, minor) to set of patch versions.

Returns:
Tuple of (major, minor) representing the maximum version.
"""
all_major_minors = list(versions_by_minor.keys())
max_version = max(all_major_minors)

# Check if specifier is unbounded upward by testing max_version + 1 minor
is_unbounded_upward = requires_python.contains(
f"{max_version[0]}.{max_version[1] + 1}.0"
)

# Apply hard-coded limits for unbounded cases
if is_unbounded_upward:
max_version = (
max_version[0],
_get_maximum_python_minor_version(max_version[0]),
)

return max_version


def _get_maximum_python_minor_version(major: int) -> int:
"""Get the hard-coded maximum minor version for a given Python major version.

Args:
major: The Python major version (e.g., 2, 3). Usually will be 3.

Returns:
The maximum minor version for that major version.
"""
if major == 2:
return 7
elif major == 3:
# N.B. needs maintenance as new versions are released
return 15
else:
raise NotImplementedError
12 changes: 8 additions & 4 deletions src/usethis/_tool/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,9 @@ def get_bitbucket_steps(
matrix_python: Whether to use a Python version matrix. When False,
only the current development version is used.
"""
# N.B. the default implementation doesn't need matrix_python,
# but it's included in the signature to allow for it to be used, e.g. for pytest

try:
cmd = self.default_command()
except NoDefaultToolCommand:
Expand Down Expand Up @@ -670,14 +673,15 @@ def update_bitbucket_steps(self, *, matrix_python: bool = True) -> None:
return

# Add the new steps
for step in self.get_bitbucket_steps(matrix_python=matrix_python):
steps = self.get_bitbucket_steps(matrix_python=matrix_python)
for step in steps:
add_bitbucket_step_in_default(step)

# Remove any old steps that are not active managed by this tool
managed_names = self.get_managed_bitbucket_step_names()
for step in get_steps_in_default():
if step.name in self.get_managed_bitbucket_step_names() and not any(
bitbucket_steps_are_equivalent(step, step_)
for step_ in self.get_bitbucket_steps(matrix_python=matrix_python)
if step.name in managed_names and not any(
bitbucket_steps_are_equivalent(step, step_) for step_ in steps
):
remove_bitbucket_step_from_default(step)

Expand Down
9 changes: 8 additions & 1 deletion src/usethis/_tool/impl/pre_commit.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from typing_extensions import assert_never

Expand All @@ -18,6 +19,9 @@
from usethis._types.backend import BackendEnum
from usethis._types.deps import Dependency

if TYPE_CHECKING:
from usethis._integrations.python.version import PythonVersion

_SYNC_WITH_UV_VERSION = "v0.5.0" # Manually bump this version when necessary


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

def get_bitbucket_steps(
self, *, matrix_python: bool = True
self,
*,
matrix_python: bool = True,
versions: list[PythonVersion] | None = None,
) -> list[bitbucket_schema.Step]:
backend = get_backend()

Expand Down
Loading