Skip to content

Commit 0c170fb

Browse files
authored
Foundation for detecting python version requirements from pyproject.toml (#643)
1 parent 25d735a commit 0c170fb

File tree

15 files changed

+179
-0
lines changed

15 files changed

+179
-0
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"semver>=2.0.0,<4.0.0",
1414
"pyjwt>=2.4.0",
1515
"click>=8.0.0",
16+
"toml>=0.10; python_version < '3.11'"
1617
]
1718

1819
dynamic = ["version"]
@@ -82,6 +83,9 @@ rsconnect = ["py.typed"]
8283

8384
[tool.pytest.ini_options]
8485
markers = ["vetiver: tests for vetiver"]
86+
addopts = """
87+
--ignore=tests/testdata
88+
"""
8589

8690
[tool.pyright]
8791
typeCheckingMode = "strict"

rsconnect/pyproject.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Support for detecting various information from python projects metadata.
3+
4+
Metadata can only be loaded from static files (e.g. pyproject.toml, setup.cfg, etc.)
5+
but not from setup.py due to its dynamic nature.
6+
"""
7+
8+
import pathlib
9+
import typing
10+
11+
try:
12+
import tomllib
13+
except ImportError:
14+
# Python 3.11+ has tomllib in the standard library
15+
import toml as tomllib # type: ignore[no-redef]
16+
17+
18+
def lookup_metadata_file(directory: typing.Union[str, pathlib.Path]) -> typing.List[typing.Tuple[str, pathlib.Path]]:
19+
"""Given the directory of a project return the path of a usable metadata file.
20+
21+
The returned value is either a list of tuples [(filename, path)] or
22+
an empty list [] if no metadata file was found.
23+
"""
24+
directory = pathlib.Path(directory)
25+
26+
def _generate():
27+
for filename in ("pyproject.toml", "setup.cfg", ".python-version"):
28+
path = directory / filename
29+
if path.is_file():
30+
yield (filename, path)
31+
32+
return list(_generate())
33+
34+
35+
def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Optional[str]:
36+
"""Parse the project.requires-python field from a pyproject.toml file.
37+
38+
Assumes that the pyproject.toml file exists, is accessible and well formatted.
39+
40+
Returns None if the field is not found.
41+
"""
42+
content = pyproject_file.read_text()
43+
pyproject = tomllib.loads(content)
44+
45+
return pyproject.get("project", {}).get("requires-python", None)

tests/test_pyproject.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
import pathlib
3+
4+
from rsconnect.pyproject import lookup_metadata_file, parse_pyproject_python_requires
5+
6+
import pytest
7+
8+
HERE = os.path.dirname(__file__)
9+
PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project"))
10+
11+
# Most of this tests, verify against three fixture projects that are located in PROJECTS_DIRECTORY
12+
# - using_pyproject: contains a pyproject.toml file with a project.requires-python field
13+
# - 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.
15+
# - allofthem: contains all metadata files all with different version constraints.
16+
17+
18+
@pytest.mark.parametrize(
19+
"project_dir, expected",
20+
[
21+
(os.path.join(PROJECTS_DIRECTORY, "using_pyproject"), ("pyproject.toml",)),
22+
(os.path.join(PROJECTS_DIRECTORY, "using_setupcfg"), ("setup.cfg",)),
23+
(
24+
os.path.join(PROJECTS_DIRECTORY, "using_pyversion"),
25+
(
26+
"pyproject.toml",
27+
".python-version",
28+
),
29+
),
30+
(os.path.join(PROJECTS_DIRECTORY, "allofthem"), ("pyproject.toml", "setup.cfg", ".python-version")),
31+
],
32+
ids=["pyproject.toml", "setup.cfg", ".python-version", "allofthem"],
33+
)
34+
def test_python_project_metadata_detect(project_dir, expected):
35+
"""Test that the metadata files are detected when they exist."""
36+
expectation = [(f, pathlib.Path(project_dir) / f) for f in expected]
37+
assert lookup_metadata_file(project_dir) == expectation
38+
39+
40+
@pytest.mark.parametrize(
41+
"project_dir",
42+
[
43+
os.path.join(PROJECTS_DIRECTORY, "empty"),
44+
os.path.join(PROJECTS_DIRECTORY, "missing"),
45+
],
46+
ids=["empty", "missing"],
47+
)
48+
def test_python_project_metadata_missing(project_dir):
49+
"""Test that lookup_metadata_file is able to deal with missing or empty directories."""
50+
assert lookup_metadata_file(project_dir) == []
51+
52+
53+
@pytest.mark.parametrize(
54+
"project_dir, expected",
55+
[
56+
(os.path.join(PROJECTS_DIRECTORY, "using_pyproject"), ">=3.8"),
57+
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), None),
58+
],
59+
ids=["option-exists", "option-missing"],
60+
)
61+
def test_pyprojecttoml_python_requires(project_dir, expected):
62+
"""Test that the python_requires field is correctly parsed from pyproject.toml.
63+
64+
Both when the option exists or when it missing in the pyproject.toml file.
65+
"""
66+
pyproject_file = pathlib.Path(project_dir) / "pyproject.toml"
67+
assert parse_pyproject_python_requires(pyproject_file) == expected
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
>=3.8, <3.12
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def main():
2+
print("Hello from python-project!")
3+
4+
5+
if __name__ == "__main__":
6+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "python-project"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
requires-python = ">=3.8"
6+
dependencies = []
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[metadata]
2+
name = python-project
3+
version = 0.1.0
4+
description = Add your description here
5+
6+
[options]
7+
python_requires = >=3.8
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def main():
2+
print("Hello from python-project!")
3+
4+
5+
if __name__ == "__main__":
6+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def main():
2+
print("Hello from python-project!")
3+
4+
5+
if __name__ == "__main__":
6+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[project]
2+
name = "python-project"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
requires-python = ">=3.8"
6+
dependencies = []

0 commit comments

Comments
 (0)