Skip to content

Commit 6101942

Browse files
committed
Foundation for detecting python version requirements from pyproject.toml
1 parent 7fab75d commit 6101942

File tree

15 files changed

+174
-0
lines changed

15 files changed

+174
-0
lines changed

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ dependencies = [
1313
"semver>=2.0.0,<4.0.0",
1414
"pyjwt>=2.4.0",
1515
"click>=8.0.0",
16+
"rsconnect-python[test]",
17+
"toml>=0.10; python_version < '3.11'"
1618
]
1719

1820
dynamic = ["version"]
@@ -82,9 +84,18 @@ rsconnect = ["py.typed"]
8284

8385
[tool.pytest.ini_options]
8486
markers = ["vetiver: tests for vetiver"]
87+
addopts = """
88+
--ignore=tests/testdata
89+
"""
8590

8691
[tool.pyright]
8792
typeCheckingMode = "strict"
8893
reportPrivateUsage = "none"
8994
reportUnnecessaryIsInstance = "none"
9095
reportUnnecessaryComparison = "none"
96+
97+
[tool.uv.sources]
98+
rsconnect-python = { workspace = true }
99+
100+
[tool.uv.workspace]
101+
members = ["tests/testdata/python-project"]

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
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+
with pyproject_file.open("rb") as f:
43+
pyproject = tomllib.load(f)
44+
45+
return pyproject.get("project", {}).get("requires-python", None)

tests/test_pyproject.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
12+
@pytest.mark.parametrize(
13+
"project_dir, expected",
14+
[
15+
(os.path.join(PROJECTS_DIRECTORY, "using_pyproject"), ("pyproject.toml",)),
16+
(os.path.join(PROJECTS_DIRECTORY, "using_setupcfg"), ("setup.cfg",)),
17+
(
18+
os.path.join(PROJECTS_DIRECTORY, "using_pyversion"),
19+
(
20+
"pyproject.toml",
21+
".python-version",
22+
),
23+
),
24+
(os.path.join(PROJECTS_DIRECTORY, "allofthem"), ("pyproject.toml", "setup.cfg", ".python-version")),
25+
],
26+
ids=["pyproject.toml", "setup.cfg", ".python-version", "allofthem"],
27+
)
28+
def test_python_project_metadata_detect(project_dir, expected):
29+
expectation = [(f, pathlib.Path(project_dir) / f) for f in expected]
30+
assert lookup_metadata_file(project_dir) == expectation
31+
32+
33+
@pytest.mark.parametrize(
34+
"project_dir",
35+
[
36+
os.path.join(PROJECTS_DIRECTORY, "empty"),
37+
os.path.join(PROJECTS_DIRECTORY, "missing"),
38+
],
39+
ids=["empty", "missing"],
40+
)
41+
def test_python_project_metadata_missing(project_dir):
42+
assert lookup_metadata_file(project_dir) == []
43+
44+
45+
@pytest.mark.parametrize(
46+
"project_dir, expected",
47+
[
48+
(os.path.join(PROJECTS_DIRECTORY, "using_pyproject"), ">=3.8"),
49+
(os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), None),
50+
],
51+
ids=["option-exists", "option-missing"],
52+
)
53+
def test_pyprojecttoml_python_requires(project_dir, expected):
54+
pyproject_file = pathlib.Path(project_dir) / "pyproject.toml"
55+
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)