Skip to content

Commit e991f3d

Browse files
authored
Parser for interpreter version requirements from setup.cfg .python-version (#645)
* Support for parsing setup.cfg and python-version, detection of parser
1 parent 0c170fb commit e991f3d

File tree

3 files changed

+113
-6
lines changed

3 files changed

+113
-6
lines changed

rsconnect/pyproject.py

+46-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import pathlib
99
import typing
10+
import configparser
1011

1112
try:
1213
import tomllib
@@ -20,18 +21,38 @@ def lookup_metadata_file(directory: typing.Union[str, pathlib.Path]) -> typing.L
2021
2122
The returned value is either a list of tuples [(filename, path)] or
2223
an empty list [] if no metadata file was found.
24+
25+
The metadata files are returned in the priority they should be processed
26+
to determine the python version requirements.
2327
"""
2428
directory = pathlib.Path(directory)
2529

2630
def _generate():
27-
for filename in ("pyproject.toml", "setup.cfg", ".python-version"):
31+
for filename in (".python-version", "pyproject.toml", "setup.cfg"):
2832
path = directory / filename
2933
if path.is_file():
3034
yield (filename, path)
3135

3236
return list(_generate())
3337

3438

39+
def get_python_version_requirement_parser(
40+
metadata_file: pathlib.Path,
41+
) -> typing.Optional[typing.Callable[[pathlib.Path], typing.Optional[str]]]:
42+
"""Given the metadata file, return the appropriate parser function.
43+
44+
The returned function takes a pathlib.Path and returns the parsed value.
45+
"""
46+
if metadata_file.name == "pyproject.toml":
47+
return parse_pyproject_python_requires
48+
elif metadata_file.name == "setup.cfg":
49+
return parse_setupcfg_python_requires
50+
elif metadata_file.name == ".python-version":
51+
return parse_pyversion_python_requires
52+
else:
53+
return None
54+
55+
3556
def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Optional[str]:
3657
"""Parse the project.requires-python field from a pyproject.toml file.
3758
@@ -43,3 +64,27 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti
4364
pyproject = tomllib.loads(content)
4465

4566
return pyproject.get("project", {}).get("requires-python", None)
67+
68+
69+
def parse_setupcfg_python_requires(setupcfg_file: pathlib.Path) -> typing.Optional[str]:
70+
"""Parse the options.python_requires field from a setup.cfg file.
71+
72+
Assumes that the setup.cfg file exists, is accessible and well formatted.
73+
74+
Returns None if the field is not found.
75+
"""
76+
config = configparser.ConfigParser()
77+
config.read(setupcfg_file)
78+
79+
return config.get("options", "python_requires", fallback=None)
80+
81+
82+
def parse_pyversion_python_requires(pyversion_file: pathlib.Path) -> typing.Optional[str]:
83+
"""Parse the python version from a .python-version file.
84+
85+
Assumes that the .python-version file exists, is accessible and well formatted.
86+
87+
Returns None if the field is not found.
88+
"""
89+
content = pyversion_file.read_text()
90+
return content.strip()

tests/test_pyproject.py

+63-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import os
22
import pathlib
33

4-
from rsconnect.pyproject import lookup_metadata_file, parse_pyproject_python_requires
4+
from rsconnect.pyproject import (
5+
lookup_metadata_file,
6+
parse_pyproject_python_requires,
7+
parse_setupcfg_python_requires,
8+
parse_pyversion_python_requires,
9+
get_python_version_requirement_parser,
10+
)
511

612
import pytest
713

@@ -11,7 +17,7 @@
1117
# Most of this tests, verify against three fixture projects that are located in PROJECTS_DIRECTORY
1218
# - using_pyproject: contains a pyproject.toml file with a project.requires-python field
1319
# - using_setupcfg: contains a setup.cfg file with a options.python_requires field
14-
# - using_pyversion: contains a .python-version file and a pyproject.toml file without any version constraint.
20+
# - using_pyversion: contains a .python-version file and pyproject.toml, setup.cfg without any version constraint.
1521
# - allofthem: contains all metadata files all with different version constraints.
1622

1723

@@ -23,11 +29,12 @@
2329
(
2430
os.path.join(PROJECTS_DIRECTORY, "using_pyversion"),
2531
(
26-
"pyproject.toml",
2732
".python-version",
33+
"pyproject.toml",
34+
"setup.cfg",
2835
),
2936
),
30-
(os.path.join(PROJECTS_DIRECTORY, "allofthem"), ("pyproject.toml", "setup.cfg", ".python-version")),
37+
(os.path.join(PROJECTS_DIRECTORY, "allofthem"), (".python-version", "pyproject.toml", "setup.cfg")),
3138
],
3239
ids=["pyproject.toml", "setup.cfg", ".python-version", "allofthem"],
3340
)
@@ -37,6 +44,23 @@ def test_python_project_metadata_detect(project_dir, expected):
3744
assert lookup_metadata_file(project_dir) == expectation
3845

3946

47+
@pytest.mark.parametrize(
48+
"filename, expected_parser",
49+
[
50+
("pyproject.toml", parse_pyproject_python_requires),
51+
("setup.cfg", parse_setupcfg_python_requires),
52+
(".python-version", parse_pyversion_python_requires),
53+
("invalid.txt", None),
54+
],
55+
ids=["pyproject.toml", "setup.cfg", ".python-version", "invalid"],
56+
)
57+
def test_get_python_version_requirement_parser(filename, expected_parser):
58+
"""Test that given a metadata file name, the correct parser is returned."""
59+
metadata_file = pathlib.Path(PROJECTS_DIRECTORY) / filename
60+
parser = get_python_version_requirement_parser(metadata_file)
61+
assert parser == expected_parser
62+
63+
4064
@pytest.mark.parametrize(
4165
"project_dir",
4266
[
@@ -59,9 +83,43 @@ def test_python_project_metadata_missing(project_dir):
5983
ids=["option-exists", "option-missing"],
6084
)
6185
def test_pyprojecttoml_python_requires(project_dir, expected):
62-
"""Test that the python_requires field is correctly parsed from pyproject.toml.
86+
"""Test that the requires-python field is correctly parsed from pyproject.toml.
6387
6488
Both when the option exists or when it missing in the pyproject.toml file.
6589
"""
6690
pyproject_file = pathlib.Path(project_dir) / "pyproject.toml"
6791
assert parse_pyproject_python_requires(pyproject_file) == expected
92+
93+
94+
@pytest.mark.parametrize(
95+
"project_dir, expected",
96+
[
97+
(os.path.join(PROJECTS_DIRECTORY, "using_setupcfg"), ">=3.8"),
98+
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), None),
99+
],
100+
ids=["option-exists", "option-missing"],
101+
)
102+
def test_setupcfg_python_requires(tmp_path, project_dir, expected):
103+
"""Test that the python_requires field is correctly parsed from setup.cfg.
104+
105+
Both when the option exists or when it missing in the file.
106+
"""
107+
setupcfg_file = pathlib.Path(project_dir) / "setup.cfg"
108+
assert parse_setupcfg_python_requires(setupcfg_file) == expected
109+
110+
111+
@pytest.mark.parametrize(
112+
"project_dir, expected",
113+
[
114+
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8, <3.12"),
115+
],
116+
ids=["option-exists"],
117+
)
118+
def test_pyversion_python_requires(tmp_path, project_dir, expected):
119+
"""Test that the python version is correctly parsed from .python-version.
120+
121+
We do not test the case where the option is missing, as an empty .python-version file
122+
is not a valid case for a python project.
123+
"""
124+
versionfile = pathlib.Path(project_dir) / ".python-version"
125+
assert parse_pyversion_python_requires(versionfile) == expected
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[metadata]
2+
name = python-project
3+
version = 0.1.0
4+
description = Add your description here

0 commit comments

Comments
 (0)