Skip to content

Adapt .python-version requirements to connect accepted ones #662

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 29, 2025
Merged
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: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -24,4 +24,7 @@
"build/**": true,
"venv/**": true,
},
"python.analysis.exclude": [
"tests"
],
}
59 changes: 55 additions & 4 deletions rsconnect/pyproject.py
Original file line number Diff line number Diff line change
@@ -5,16 +5,23 @@
but not from setup.py due to its dynamic nature.
"""

import configparser
import pathlib
import re
import typing
import configparser

try:
import tomllib
except ImportError:
# Python 3.11+ has tomllib in the standard library
import toml as tomllib # type: ignore[no-redef]

from .log import logger


PEP440_OPERATORS_REGEX = r"(===|==|!=|<=|>=|<|>|~=)"
VALID_VERSION_REQ_REGEX = rf"^({PEP440_OPERATORS_REGEX}?\d+(\.[\d\*]+)*)+$"


def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]) -> typing.Optional[str]:
"""Detect the python version requirement for a project.
@@ -26,7 +33,12 @@ def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]
"""
for _, metadata_file in lookup_metadata_file(directory):
parser = get_python_version_requirement_parser(metadata_file)
version_constraint = parser(metadata_file)
try:
version_constraint = parser(metadata_file)
except InvalidVersionConstraintError as err:
logger.error(f"Invalid python version constraint in {metadata_file}, ignoring it: {err}")
continue

if version_constraint:
return version_constraint

@@ -103,5 +115,44 @@ def parse_pyversion_python_requires(pyversion_file: pathlib.Path) -> typing.Opti

Returns None if the field is not found.
"""
content = pyversion_file.read_text()
return content.strip()
return adapt_python_requires(pyversion_file.read_text().strip())


def adapt_python_requires(
python_requires: str,
) -> str:
"""Convert a literal python version to a PEP440 constraint.

Connect expects a PEP440 format, but the .python-version file can contain
plain version numbers and other formats.

We should convert them to the constraints that connect expects.
"""
current_contraints = python_requires.split(",")

def _adapt_contraint(constraints: typing.List[str]) -> typing.Generator[str, None, None]:
for constraint in constraints:
constraint = constraint.strip()
if "@" in constraint or "-" in constraint or "/" in constraint:
raise InvalidVersionConstraintError(f"python specific implementations are not supported: {constraint}")

if "b" in constraint or "rc" in constraint or "a" in constraint:
raise InvalidVersionConstraintError(f"pre-release versions are not supported: {constraint}")

if re.match(VALID_VERSION_REQ_REGEX, constraint) is None:
raise InvalidVersionConstraintError(f"Invalid python version: {constraint}")

if re.search(PEP440_OPERATORS_REGEX, constraint):
yield constraint
else:
# Convert to PEP440 format
if "*" in constraint:
yield f"=={constraint}"
else:
yield f"~={constraint.rstrip('0').rstrip('.')}" # Remove trailing zeros and dots

return ",".join(_adapt_contraint(current_contraints))


class InvalidVersionConstraintError(ValueError):
pass
4 changes: 2 additions & 2 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
@@ -143,12 +143,12 @@ def test_pyproject_toml(self):
def test_python_version(self):
env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "using_pyversion"))
assert env.python_interpreter == sys.executable
assert env.python_version_requirement == ">=3.8, <3.12"
assert env.python_version_requirement == ">=3.8,<3.12"

def test_all_of_them(self):
env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "allofthem"))
assert env.python_interpreter == sys.executable
assert env.python_version_requirement == ">=3.8, <3.12"
assert env.python_version_requirement == ">=3.8,<3.12"

def test_missing(self):
env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "empty"))
70 changes: 63 additions & 7 deletions tests/test_pyproject.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import os
import pathlib
import tempfile

import pytest

from rsconnect.pyproject import (
detect_python_version_requirement,
get_python_version_requirement_parser,
lookup_metadata_file,
parse_pyproject_python_requires,
parse_setupcfg_python_requires,
parse_pyversion_python_requires,
get_python_version_requirement_parser,
detect_python_version_requirement,
parse_setupcfg_python_requires,
InvalidVersionConstraintError,
)

import pytest

HERE = os.path.dirname(__file__)
PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project"))

@@ -117,7 +119,7 @@ def test_setupcfg_python_requires(project_dir, expected):
@pytest.mark.parametrize(
"project_dir, expected",
[
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8, <3.12"),
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8,<3.12"),
],
ids=["option-exists"],
)
@@ -139,6 +141,60 @@ def test_detect_python_version_requirement():
version requirement is used.
"""
project_dir = os.path.join(PROJECTS_DIRECTORY, "allofthem")
assert detect_python_version_requirement(project_dir) == ">=3.8, <3.12"
assert detect_python_version_requirement(project_dir) == ">=3.8,<3.12"

assert detect_python_version_requirement(os.path.join(PROJECTS_DIRECTORY, "empty")) is None


@pytest.mark.parametrize( # type: ignore
["content", "expected"],
[
("3.8", "~=3.8"),
("3.8.0", "~=3.8"),
("3.8.0b1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0b1")),
("3.8.0rc1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0rc1")),
("3.8.0a1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0a1")),
("3.8.*", "==3.8.*"),
("3.*", "==3.*"),
("*", InvalidVersionConstraintError("Invalid python version: *")),
# This is not perfect, but the added regex complexity doesn't seem worth it.
("invalid", InvalidVersionConstraintError("pre-release versions are not supported: invalid")),
("[email protected]", InvalidVersionConstraintError("python specific implementations are not supported: [email protected]")),
(
"cpython-3.12.3-macos-aarch64-none",
InvalidVersionConstraintError(
"python specific implementations are not supported: cpython-3.12.3-macos-aarch64-none"
),
),
(
"/usr/bin/python3.8",
InvalidVersionConstraintError("python specific implementations are not supported: /usr/bin/python3.8"),
),
(">=3.8,<3.10", ">=3.8,<3.10"),
(">=3.8, <*", ValueError("Invalid python version: <*")),
],
)
def test_python_version_file_adapt(content, expected):
"""Test that the python version is correctly converted to a PEP440 format.

Connect expects a PEP440 format, but the .python-version file can contain
plain version numbers and other formats.

We should convert them to the constraints that connect expects.
"""
with tempfile.TemporaryDirectory() as tmpdir:
versionfile = pathlib.Path(tmpdir) / ".python-version"
with open(versionfile, "w") as tmpfile:
tmpfile.write(content)

try:
if isinstance(expected, Exception):
with pytest.raises(expected.__class__) as excinfo:
parse_pyversion_python_requires(versionfile)
assert str(excinfo.value) == expected.args[0]
assert detect_python_version_requirement(tmpdir) is None
else:
assert parse_pyversion_python_requires(versionfile) == expected
assert detect_python_version_requirement(tmpdir) == expected
finally:
os.remove(tmpfile.name)