Skip to content

Commit 6e222bc

Browse files
committed
Adapt python-requires versions
1 parent d544fc1 commit 6e222bc

File tree

3 files changed

+102
-8
lines changed

3 files changed

+102
-8
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: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@
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+
PEP440_OPERATORS_REGEX = r"(===|==|!=|<=|>=|<|>|~=)"
20+
VALID_VERSION_REQ_REGEX = rf"^({PEP440_OPERATORS_REGEX}?\d+(\.[\d\*]+)*)+$"
21+
1822

1923
def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]) -> typing.Optional[str]:
2024
"""Detect the python version requirement for a project.
@@ -103,5 +107,42 @@ def parse_pyversion_python_requires(pyversion_file: pathlib.Path) -> typing.Opti
103107
104108
Returns None if the field is not found.
105109
"""
106-
content = pyversion_file.read_text()
107-
return content.strip()
110+
return adapt_python_requires(pyversion_file.read_text().strip())
111+
112+
113+
def adapt_python_requires(
114+
python_requires: str,
115+
) -> str:
116+
"""Convert a literal python version to a PEP440 constraint.
117+
118+
Connect expects a PEP440 format, but the .python-version file can contain
119+
plain version numbers and other formats.
120+
121+
We should convert them to the constraints that connect expects.
122+
"""
123+
current_contraints = python_requires.split(",")
124+
125+
def _adapt_contraint(constraints: list[str]) -> typing.Generator[str, None, None]:
126+
for constraint in constraints:
127+
constraint = constraint.strip()
128+
if "@" in constraint or "-" in constraint or "/" in constraint:
129+
raise ValueError(
130+
f"Invalid python version, python specific implementations are not supported: {constraint}"
131+
)
132+
133+
if "b" in constraint or "rc" in constraint or "a" in constraint:
134+
raise ValueError(f"Invalid python version, pre-release versions are not supported: {constraint}")
135+
136+
if re.match(VALID_VERSION_REQ_REGEX, constraint) is None:
137+
raise ValueError(f"Invalid python version: {constraint}")
138+
139+
if re.search(PEP440_OPERATORS_REGEX, constraint):
140+
yield constraint
141+
else:
142+
# Convert to PEP440 format
143+
if "*" in constraint:
144+
yield f"=={constraint}"
145+
else:
146+
yield f"~={constraint.rstrip('0').rstrip('.')}" # Remove trailing zeros and dots
147+
148+
return ",".join(_adapt_contraint(current_contraints))

tests/test_pyproject.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
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,
1114
)
1215

13-
import pytest
14-
1516
HERE = os.path.dirname(__file__)
1617
PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project"))
1718

@@ -142,3 +143,52 @@ def test_detect_python_version_requirement():
142143
assert detect_python_version_requirement(project_dir) == ">=3.8, <3.12"
143144

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

0 commit comments

Comments
 (0)