Skip to content

Commit 4299657

Browse files
authored
Adapt .python-version requirements to connect accepted ones (#662)
1 parent d544fc1 commit 4299657

File tree

4 files changed

+123
-13
lines changed

4 files changed

+123
-13
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@
2424
"build/**": true,
2525
"venv/**": true,
2626
},
27+
"python.analysis.exclude": [
28+
"tests"
29+
],
2730
}

rsconnect/pyproject.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@
55
but not from setup.py due to its dynamic nature.
66
"""
77

8+
import configparser
89
import pathlib
10+
import re
911
import typing
10-
import configparser
1112

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

19+
from .log import logger
20+
21+
22+
PEP440_OPERATORS_REGEX = r"(===|==|!=|<=|>=|<|>|~=)"
23+
VALID_VERSION_REQ_REGEX = rf"^({PEP440_OPERATORS_REGEX}?\d+(\.[\d\*]+)*)+$"
24+
1825

1926
def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]) -> typing.Optional[str]:
2027
"""Detect the python version requirement for a project.
@@ -26,7 +33,12 @@ def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]
2633
"""
2734
for _, metadata_file in lookup_metadata_file(directory):
2835
parser = get_python_version_requirement_parser(metadata_file)
29-
version_constraint = parser(metadata_file)
36+
try:
37+
version_constraint = parser(metadata_file)
38+
except InvalidVersionConstraintError as err:
39+
logger.error(f"Invalid python version constraint in {metadata_file}, ignoring it: {err}")
40+
continue
41+
3042
if version_constraint:
3143
return version_constraint
3244

@@ -103,5 +115,44 @@ def parse_pyversion_python_requires(pyversion_file: pathlib.Path) -> typing.Opti
103115
104116
Returns None if the field is not found.
105117
"""
106-
content = pyversion_file.read_text()
107-
return content.strip()
118+
return adapt_python_requires(pyversion_file.read_text().strip())
119+
120+
121+
def adapt_python_requires(
122+
python_requires: str,
123+
) -> str:
124+
"""Convert a literal python version to a PEP440 constraint.
125+
126+
Connect expects a PEP440 format, but the .python-version file can contain
127+
plain version numbers and other formats.
128+
129+
We should convert them to the constraints that connect expects.
130+
"""
131+
current_contraints = python_requires.split(",")
132+
133+
def _adapt_contraint(constraints: typing.List[str]) -> typing.Generator[str, None, None]:
134+
for constraint in constraints:
135+
constraint = constraint.strip()
136+
if "@" in constraint or "-" in constraint or "/" in constraint:
137+
raise InvalidVersionConstraintError(f"python specific implementations are not supported: {constraint}")
138+
139+
if "b" in constraint or "rc" in constraint or "a" in constraint:
140+
raise InvalidVersionConstraintError(f"pre-release versions are not supported: {constraint}")
141+
142+
if re.match(VALID_VERSION_REQ_REGEX, constraint) is None:
143+
raise InvalidVersionConstraintError(f"Invalid python version: {constraint}")
144+
145+
if re.search(PEP440_OPERATORS_REGEX, constraint):
146+
yield constraint
147+
else:
148+
# Convert to PEP440 format
149+
if "*" in constraint:
150+
yield f"=={constraint}"
151+
else:
152+
yield f"~={constraint.rstrip('0').rstrip('.')}" # Remove trailing zeros and dots
153+
154+
return ",".join(_adapt_contraint(current_contraints))
155+
156+
157+
class InvalidVersionConstraintError(ValueError):
158+
pass

tests/test_environment.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,12 @@ def test_pyproject_toml(self):
143143
def test_python_version(self):
144144
env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "using_pyversion"))
145145
assert env.python_interpreter == sys.executable
146-
assert env.python_version_requirement == ">=3.8, <3.12"
146+
assert env.python_version_requirement == ">=3.8,<3.12"
147147

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

153153
def test_missing(self):
154154
env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "empty"))

tests/test_pyproject.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import os
22
import pathlib
3+
import tempfile
4+
5+
import pytest
36

47
from rsconnect.pyproject import (
8+
detect_python_version_requirement,
9+
get_python_version_requirement_parser,
510
lookup_metadata_file,
611
parse_pyproject_python_requires,
7-
parse_setupcfg_python_requires,
812
parse_pyversion_python_requires,
9-
get_python_version_requirement_parser,
10-
detect_python_version_requirement,
13+
parse_setupcfg_python_requires,
14+
InvalidVersionConstraintError,
1115
)
1216

13-
import pytest
14-
1517
HERE = os.path.dirname(__file__)
1618
PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project"))
1719

@@ -117,7 +119,7 @@ def test_setupcfg_python_requires(project_dir, expected):
117119
@pytest.mark.parametrize(
118120
"project_dir, expected",
119121
[
120-
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8, <3.12"),
122+
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8,<3.12"),
121123
],
122124
ids=["option-exists"],
123125
)
@@ -139,6 +141,60 @@ def test_detect_python_version_requirement():
139141
version requirement is used.
140142
"""
141143
project_dir = os.path.join(PROJECTS_DIRECTORY, "allofthem")
142-
assert detect_python_version_requirement(project_dir) == ">=3.8, <3.12"
144+
assert detect_python_version_requirement(project_dir) == ">=3.8,<3.12"
143145

144146
assert detect_python_version_requirement(os.path.join(PROJECTS_DIRECTORY, "empty")) is None
147+
148+
149+
@pytest.mark.parametrize( # type: ignore
150+
["content", "expected"],
151+
[
152+
("3.8", "~=3.8"),
153+
("3.8.0", "~=3.8"),
154+
("3.8.0b1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0b1")),
155+
("3.8.0rc1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0rc1")),
156+
("3.8.0a1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0a1")),
157+
("3.8.*", "==3.8.*"),
158+
("3.*", "==3.*"),
159+
("*", InvalidVersionConstraintError("Invalid python version: *")),
160+
# This is not perfect, but the added regex complexity doesn't seem worth it.
161+
("invalid", InvalidVersionConstraintError("pre-release versions are not supported: invalid")),
162+
("[email protected]", InvalidVersionConstraintError("python specific implementations are not supported: [email protected]")),
163+
(
164+
"cpython-3.12.3-macos-aarch64-none",
165+
InvalidVersionConstraintError(
166+
"python specific implementations are not supported: cpython-3.12.3-macos-aarch64-none"
167+
),
168+
),
169+
(
170+
"/usr/bin/python3.8",
171+
InvalidVersionConstraintError("python specific implementations are not supported: /usr/bin/python3.8"),
172+
),
173+
(">=3.8,<3.10", ">=3.8,<3.10"),
174+
(">=3.8, <*", ValueError("Invalid python version: <*")),
175+
],
176+
)
177+
def test_python_version_file_adapt(content, expected):
178+
"""Test that the python version is correctly converted to a PEP440 format.
179+
180+
Connect expects a PEP440 format, but the .python-version file can contain
181+
plain version numbers and other formats.
182+
183+
We should convert them to the constraints that connect expects.
184+
"""
185+
with tempfile.TemporaryDirectory() as tmpdir:
186+
versionfile = pathlib.Path(tmpdir) / ".python-version"
187+
with open(versionfile, "w") as tmpfile:
188+
tmpfile.write(content)
189+
190+
try:
191+
if isinstance(expected, Exception):
192+
with pytest.raises(expected.__class__) as excinfo:
193+
parse_pyversion_python_requires(versionfile)
194+
assert str(excinfo.value) == expected.args[0]
195+
assert detect_python_version_requirement(tmpdir) is None
196+
else:
197+
assert parse_pyversion_python_requires(versionfile) == expected
198+
assert detect_python_version_requirement(tmpdir) == expected
199+
finally:
200+
os.remove(tmpfile.name)

0 commit comments

Comments
 (0)