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
23 changes: 16 additions & 7 deletions src/usethis/_integrations/backend/uv/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@
MissingRequiresPythonError,
get_requires_python,
)
from usethis._integrations.python.version import (
extract_major_version,
get_python_version,
)
from usethis._integrations.python.version import PythonVersion


def get_available_uv_python_versions() -> set[str]:
Expand All @@ -23,19 +20,31 @@ def get_available_uv_python_versions() -> set[str]:
}


def get_supported_uv_major_python_versions() -> list[int]:
def get_supported_uv_minor_python_versions() -> list[PythonVersion]:
try:
requires_python = get_requires_python()
except (MissingRequiresPythonError, PyprojectTOMLNotFoundError):
return [extract_major_version(get_python_version())]
return [PythonVersion.from_interpreter()]

versions = set()
for version in get_available_uv_python_versions():
# N.B. a standard range won't include alpha versions.
if requires_python.contains(version):
versions.add(version)

return sorted({extract_major_version(version) for version in versions})
# Extract unique minor versions and create PythonVersion objects with patch=None
# Use (major, minor) tuple as key to avoid assuming major will always be 3
version_objs = {PythonVersion.from_string(version) for version in versions}
minor_versions: dict[tuple[str, str], PythonVersion] = {}
for v in version_objs:
key = (v.major, v.minor)
if key not in minor_versions:
# Create a new PythonVersion with just major.minor (patch=None)
minor_versions[key] = PythonVersion(
major=v.major, minor=v.minor, patch=None
)

return sorted(minor_versions.values(), key=lambda v: (int(v.major), int(v.minor)))


def _parse_python_version_from_uv_output(version: str) -> str:
Expand Down
6 changes: 3 additions & 3 deletions src/usethis/_integrations/ci/bitbucket/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
StepItem,
)
from usethis._integrations.ci.bitbucket.schema_utils import step1tostep
from usethis._integrations.environ.python import get_supported_major_python_versions
from usethis._integrations.environ.python import get_supported_minor_python_versions
from usethis._integrations.file.yaml.update import update_ruamel_yaml_map
from usethis._types.backend import BackendEnum

Expand Down Expand Up @@ -171,7 +171,7 @@ def _add_step_in_default_via_doc(
# N.B. Currently, we are not accounting for parallelism, whereas all these steps
# could be parallel potentially.
# See https://github.com/usethis-python/usethis-python/issues/149
maj_versions = get_supported_major_python_versions()
minor_versions = get_supported_minor_python_versions()
step_order = [
"Run pre-commit",
# For these tools, sync them with the pre-commit removal logic
Expand All @@ -181,7 +181,7 @@ def _add_step_in_default_via_doc(
"Run deptry",
"Run Import Linter",
"Run Codespell",
*[f"Test on 3.{maj_version}" for maj_version in maj_versions],
*[f"Test on {version.to_short_string()}" for version in minor_versions],
]
for step_name in step_order:
if step_name == step.name:
Expand Down
10 changes: 5 additions & 5 deletions src/usethis/_integrations/environ/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@

from usethis._integrations.backend.dispatch import get_backend
from usethis._integrations.backend.uv.python import (
get_supported_uv_major_python_versions,
get_supported_uv_minor_python_versions,
)
from usethis._integrations.python.version import get_python_major_version
from usethis._integrations.python.version import PythonVersion
from usethis._types.backend import BackendEnum


def get_supported_major_python_versions() -> list[int]:
def get_supported_minor_python_versions() -> list[PythonVersion]:
backend = get_backend()

if backend is BackendEnum.uv:
versions = get_supported_uv_major_python_versions()
versions = get_supported_uv_minor_python_versions()
elif backend is BackendEnum.none:
versions = [get_python_major_version()]
versions = [PythonVersion.from_interpreter()]
else:
assert_never(backend)

Expand Down
54 changes: 45 additions & 9 deletions src/usethis/_integrations/python/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,55 @@

from __future__ import annotations

import re
from dataclasses import dataclass
from sysconfig import get_python_version as _get_python_version


def get_python_version() -> str:
"""Get the Python version."""
return _get_python_version()
class PythonVersionParseError(ValueError):
"""Raised when a Python version string cannot be parsed."""


def get_python_major_version() -> int:
"""Get the major version of Python."""
return extract_major_version(get_python_version())
@dataclass(frozen=True)
class PythonVersion:
"""Represents a Python version with major.minor.patch components.

All components are stored as strings to handle alpha versions like 3.14.0a3.

def extract_major_version(version: str) -> int:
"""Extract the major version from a version string."""
return int(version.split(".")[1])
Examples:
3.10.5 → major="3", minor="10", patch="5"
3.13 → major="3", minor="13", patch=None
3.14.0a3 → major="3", minor="14", patch="0a3"
"""

major: str
minor: str
patch: str | None = None

@classmethod
def from_string(cls, version: str) -> PythonVersion:
"""Parse version string like '3.10.5' or '3.13' or '3.14.0a3'."""
match = re.match(r"^(\d+)\.(\d+)(?:\.(\S+))?", version)
if match is None:
msg = f"Could not parse Python version from '{version}'."
raise PythonVersionParseError(msg)

major = match.group(1)
minor = match.group(2)
patch = match.group(3) if match.group(3) else None
return cls(major=major, minor=minor, patch=patch)

def to_short_string(self) -> str:
"""Return X.Y format (e.g., '3.10')."""
return f"{self.major}.{self.minor}"

def __str__(self) -> str:
"""Return full version string."""
if self.patch is None:
return self.to_short_string()
return f"{self.major}.{self.minor}.{self.patch}"

@classmethod
def from_interpreter(cls) -> PythonVersion:
"""Get the Python version from the current interpreter."""
return cls.from_string(_get_python_version())
23 changes: 5 additions & 18 deletions src/usethis/_integrations/sonarqube/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,14 @@
from usethis._config import usethis_config
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
from usethis._integrations.project.layout import get_source_dir_str
from usethis._integrations.python.version import get_python_version
from usethis._integrations.python.version import PythonVersion, PythonVersionParseError
from usethis._integrations.sonarqube.errors import (
CoverageReportConfigNotFoundError,
InvalidSonarQubeProjectKeyError,
MissingProjectKeyError,
)


class _NonstandardPythonVersionError(Exception):
"""Raised when a non-standard Python version is detected."""


def get_sonar_project_properties() -> str:
"""Get contents for (or from) the sonar-project.properties file."""
path = usethis_config.cpd() / "sonar-project.properties"
Expand All @@ -27,11 +23,11 @@ def get_sonar_project_properties() -> str:

# Get Python version
try:
python_version = _get_short_version(
python_version = PythonVersion.from_string(
(usethis_config.cpd() / ".python-version").read_text().strip()
)
except (FileNotFoundError, _NonstandardPythonVersionError):
python_version = get_python_version()
).to_short_string()
except (FileNotFoundError, PythonVersionParseError):
python_version = PythonVersion.from_interpreter().to_short_string()

project_key = _get_sonarqube_project_key()
verbose = _is_sonarqube_verbose()
Expand Down Expand Up @@ -71,15 +67,6 @@ def get_sonar_project_properties() -> str:
return text


def _get_short_version(version: str) -> str:
match = re.match(r"^(\d{1,2}\.\d{1,2})", version)
if match is None:
msg = f"Could not parse Python version from '{version}'."
raise _NonstandardPythonVersionError(msg)

return match.group(1)


def _get_sonarqube_project_key() -> str:
try:
project_key = PyprojectTOMLManager()[
Expand Down
16 changes: 8 additions & 8 deletions src/usethis/_tool/impl/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
from usethis._integrations.ci.bitbucket.schema import Step as BitbucketStep
from usethis._integrations.ci.bitbucket.steps import get_steps_in_default
from usethis._integrations.ci.bitbucket.used import is_bitbucket_used
from usethis._integrations.environ.python import get_supported_major_python_versions
from usethis._integrations.environ.python import get_supported_minor_python_versions
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
from usethis._integrations.file.setup_cfg.io_ import SetupCFGManager
from usethis._integrations.project.build import has_pyproject_toml_declared_build_system
from usethis._integrations.project.layout import get_source_dir_str
from usethis._integrations.python.version import get_python_major_version
from usethis._integrations.python.version import PythonVersion
from usethis._tool.base import Tool
from usethis._tool.config import ConfigEntry, ConfigItem, ConfigSpec
from usethis._tool.rule import RuleConfig
Expand Down Expand Up @@ -225,29 +225,29 @@ def get_active_config_file_managers(self) -> set[KeyValueFileManager]:

def get_bitbucket_steps(self, *, matrix_python: bool = True) -> list[BitbucketStep]:
if matrix_python:
versions = get_supported_major_python_versions()
versions = get_supported_minor_python_versions()
else:
versions = [get_python_major_version()]
versions = [PythonVersion.from_interpreter()]

backend = get_backend()

steps = []
for version in versions:
if backend is BackendEnum.uv:
step = BitbucketStep(
name=f"Test on 3.{version}",
name=f"Test on {version.to_short_string()}",
caches=["uv"],
script=BitbucketScript(
[
BitbucketScriptItemAnchor(name="install-uv"),
f"uv run --python 3.{version} pytest -x --junitxml=test-reports/report.xml",
f"uv run --python {version.to_short_string()} pytest -x --junitxml=test-reports/report.xml",
]
),
)
elif backend is BackendEnum.none:
step = BitbucketStep(
name=f"Test on 3.{version}",
image=Image(ImageName(f"python:3.{version}")),
name=f"Test on {version.to_short_string()}",
image=Image(ImageName(f"python:{version.to_short_string()}")),
script=BitbucketScript(
[
BitbucketScriptItemAnchor(name="ensure-venv"),
Expand Down
2 changes: 1 addition & 1 deletion src/usethis/_tool/impl/ruff.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from usethis._io import KeyValueFileManager
from usethis._tool.rule import Rule, RuleConfig

_RUFF_VERSION = "v0.14.9" # Manually bump this version when necessary
_RUFF_VERSION = "v0.14.10" # Manually bump this version when necessary


class RuffTool(Tool):
Expand Down
4 changes: 2 additions & 2 deletions tests/usethis/_core/test_core_ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ def test_matrix_disabled_creates_single_step(
# Arrange
monkeypatch.setattr(
usethis._integrations.python.version,
"get_python_version",
"_get_python_version",
lambda: "3.10.0",
)
(uv_init_dir / "tests").mkdir()
Expand Down Expand Up @@ -641,7 +641,7 @@ def test_matrix_disabled_with_none_backend(
# Arrange
monkeypatch.setattr(
usethis._integrations.python.version,
"get_python_version",
"_get_python_version",
lambda: "3.11.0",
)
(bare_dir / "tests").mkdir()
Expand Down
10 changes: 6 additions & 4 deletions tests/usethis/_core/test_core_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from usethis._integrations.backend.uv.toml import UVTOMLManager
from usethis._integrations.file.pyproject_toml.io_ import PyprojectTOMLManager
from usethis._integrations.pre_commit.hooks import _HOOK_ORDER, get_hook_ids
from usethis._integrations.python.version import get_python_version
from usethis._integrations.python.version import PythonVersion
from usethis._test import change_cwd
from usethis._tool.all_ import ALL_TOOLS
from usethis._tool.impl.pre_commit import _SYNC_WITH_UV_VERSION
Expand Down Expand Up @@ -372,7 +372,9 @@ def test_no_pyproject_toml(
):
# Arrange
# Set python version
(tmp_path / ".python-version").write_text(get_python_version())
(tmp_path / ".python-version").write_text(
str(PythonVersion.from_interpreter())
)

with (
change_cwd(tmp_path),
Expand Down Expand Up @@ -2738,7 +2740,7 @@ def test_no_backend(
# Set the Python version to 3.10
monkeypatch.setattr(
usethis._integrations.python.version,
"get_python_version",
"_get_python_version",
lambda: "3.10.0",
)

Expand Down Expand Up @@ -2950,7 +2952,7 @@ def test_remove_no_backend(
# Set the Python version to 3.10
monkeypatch.setattr(
usethis._integrations.python.version,
"get_python_version",
"_get_python_version",
lambda: "3.10.0",
)

Expand Down
Loading