From 187c1d3ae5a2dde6fb0d1bba9b9cbdbedbcd47d7 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 16:56:05 +0100 Subject: [PATCH 1/5] Foundation for detecting python version requirements from pyproject.toml --- pyproject.toml | 11 ++++ rsconnect/pyproject.py | 45 +++++++++++++++ tests/test_pyproject.py | 55 +++++++++++++++++++ .../python-project/allofthem/.python-version | 1 + .../python-project/allofthem/hello.py | 6 ++ .../python-project/allofthem/pyproject.toml | 6 ++ .../python-project/allofthem/setup.cfg | 7 +++ tests/testdata/python-project/empty/hello.py | 6 ++ .../python-project/using_pyproject/hello.py | 6 ++ .../using_pyproject/pyproject.toml | 6 ++ .../using_pyversion/.python-version | 1 + .../python-project/using_pyversion/hello.py | 6 ++ .../using_pyversion/pyproject.toml | 5 ++ .../python-project/using_setupcfg/hello.py | 6 ++ .../python-project/using_setupcfg/setup.cfg | 7 +++ 15 files changed, 174 insertions(+) create mode 100644 rsconnect/pyproject.py create mode 100644 tests/test_pyproject.py create mode 100644 tests/testdata/python-project/allofthem/.python-version create mode 100644 tests/testdata/python-project/allofthem/hello.py create mode 100644 tests/testdata/python-project/allofthem/pyproject.toml create mode 100644 tests/testdata/python-project/allofthem/setup.cfg create mode 100644 tests/testdata/python-project/empty/hello.py create mode 100644 tests/testdata/python-project/using_pyproject/hello.py create mode 100644 tests/testdata/python-project/using_pyproject/pyproject.toml create mode 100644 tests/testdata/python-project/using_pyversion/.python-version create mode 100644 tests/testdata/python-project/using_pyversion/hello.py create mode 100644 tests/testdata/python-project/using_pyversion/pyproject.toml create mode 100644 tests/testdata/python-project/using_setupcfg/hello.py create mode 100644 tests/testdata/python-project/using_setupcfg/setup.cfg diff --git a/pyproject.toml b/pyproject.toml index cb66f042..add3e3a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", + "rsconnect-python[test]", + "toml>=0.10; python_version < '3.11'" ] dynamic = ["version"] @@ -82,9 +84,18 @@ rsconnect = ["py.typed"] [tool.pytest.ini_options] markers = ["vetiver: tests for vetiver"] +addopts = """ + --ignore=tests/testdata +""" [tool.pyright] typeCheckingMode = "strict" reportPrivateUsage = "none" reportUnnecessaryIsInstance = "none" reportUnnecessaryComparison = "none" + +[tool.uv.sources] +rsconnect-python = { workspace = true } + +[tool.uv.workspace] +members = ["tests/testdata/python-project"] diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py new file mode 100644 index 00000000..7a66bda6 --- /dev/null +++ b/rsconnect/pyproject.py @@ -0,0 +1,45 @@ +""" +Support for detecting various information from python projects metadata. + +Metadata can only be loaded from static files (e.g. pyproject.toml, setup.cfg, etc.) +but not from setup.py due to its dynamic nature. +""" + +import pathlib +import typing + +try: + import tomllib +except ImportError: + # Python 3.11+ has tomllib in the standard library + import toml as tomllib + + +def lookup_metadata_file(directory: typing.Union[str, pathlib.Path]) -> typing.List[typing.Tuple[str, pathlib.Path]]: + """Given the directory of a project return the path of a usable metadata file. + + The returned value is either a list of tuples [(filename, path)] or + an empty list [] if no metadata file was found. + """ + directory = pathlib.Path(directory) + + def _generate(): + for filename in ("pyproject.toml", "setup.cfg", ".python-version"): + path = directory / filename + if path.is_file(): + yield (filename, path) + + return list(_generate()) + + +def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Optional[str]: + """Parse the project.requires-python field from a pyproject.toml file. + + Assumes that the pyproject.toml file exists, is accessible and well formatted. + + Returns None if the field is not found. + """ + with pyproject_file.open("rb") as f: + pyproject = tomllib.load(f) + + return pyproject.get("project", {}).get("requires-python", None) diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py new file mode 100644 index 00000000..f32213b3 --- /dev/null +++ b/tests/test_pyproject.py @@ -0,0 +1,55 @@ +import os +import pathlib + +from rsconnect.pyproject import lookup_metadata_file, parse_pyproject_python_requires + +import pytest + +HERE = os.path.dirname(__file__) +PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project")) + + +@pytest.mark.parametrize( + "project_dir, expected", + [ + (os.path.join(PROJECTS_DIRECTORY, "using_pyproject"), ("pyproject.toml",)), + (os.path.join(PROJECTS_DIRECTORY, "using_setupcfg"), ("setup.cfg",)), + ( + os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), + ( + "pyproject.toml", + ".python-version", + ), + ), + (os.path.join(PROJECTS_DIRECTORY, "allofthem"), ("pyproject.toml", "setup.cfg", ".python-version")), + ], + ids=["pyproject.toml", "setup.cfg", ".python-version", "allofthem"], +) +def test_python_project_metadata_detect(project_dir, expected): + expectation = [(f, pathlib.Path(project_dir) / f) for f in expected] + assert lookup_metadata_file(project_dir) == expectation + + +@pytest.mark.parametrize( + "project_dir", + [ + os.path.join(PROJECTS_DIRECTORY, "empty"), + os.path.join(PROJECTS_DIRECTORY, "missing"), + ], + ids=["empty", "missing"], +) +def test_python_project_metadata_missing(project_dir): + assert lookup_metadata_file(project_dir) == [] + + +@pytest.mark.parametrize( + "project_dir, expected", + [ + (os.path.join(PROJECTS_DIRECTORY, "using_pyproject"), ">=3.8"), + (os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), None), + ], + ids=["option-exists", "option-missing"], +) +def test_pyprojecttoml_python_requires(project_dir, expected): + pyproject_file = pathlib.Path(project_dir) / "pyproject.toml" + assert parse_pyproject_python_requires(pyproject_file) == expected diff --git a/tests/testdata/python-project/allofthem/.python-version b/tests/testdata/python-project/allofthem/.python-version new file mode 100644 index 00000000..853c7b3e --- /dev/null +++ b/tests/testdata/python-project/allofthem/.python-version @@ -0,0 +1 @@ +>=3.8, <3.12 diff --git a/tests/testdata/python-project/allofthem/hello.py b/tests/testdata/python-project/allofthem/hello.py new file mode 100644 index 00000000..83bd65e2 --- /dev/null +++ b/tests/testdata/python-project/allofthem/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from python-project!") + + +if __name__ == "__main__": + main() diff --git a/tests/testdata/python-project/allofthem/pyproject.toml b/tests/testdata/python-project/allofthem/pyproject.toml new file mode 100644 index 00000000..bcf53f26 --- /dev/null +++ b/tests/testdata/python-project/allofthem/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "python-project" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.8" +dependencies = [] diff --git a/tests/testdata/python-project/allofthem/setup.cfg b/tests/testdata/python-project/allofthem/setup.cfg new file mode 100644 index 00000000..6681256a --- /dev/null +++ b/tests/testdata/python-project/allofthem/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = python-project +version = 0.1.0 +description = Add your description here + +[options] +python_requires = >=3.8 diff --git a/tests/testdata/python-project/empty/hello.py b/tests/testdata/python-project/empty/hello.py new file mode 100644 index 00000000..83bd65e2 --- /dev/null +++ b/tests/testdata/python-project/empty/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from python-project!") + + +if __name__ == "__main__": + main() diff --git a/tests/testdata/python-project/using_pyproject/hello.py b/tests/testdata/python-project/using_pyproject/hello.py new file mode 100644 index 00000000..83bd65e2 --- /dev/null +++ b/tests/testdata/python-project/using_pyproject/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from python-project!") + + +if __name__ == "__main__": + main() diff --git a/tests/testdata/python-project/using_pyproject/pyproject.toml b/tests/testdata/python-project/using_pyproject/pyproject.toml new file mode 100644 index 00000000..bcf53f26 --- /dev/null +++ b/tests/testdata/python-project/using_pyproject/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "python-project" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.8" +dependencies = [] diff --git a/tests/testdata/python-project/using_pyversion/.python-version b/tests/testdata/python-project/using_pyversion/.python-version new file mode 100644 index 00000000..853c7b3e --- /dev/null +++ b/tests/testdata/python-project/using_pyversion/.python-version @@ -0,0 +1 @@ +>=3.8, <3.12 diff --git a/tests/testdata/python-project/using_pyversion/hello.py b/tests/testdata/python-project/using_pyversion/hello.py new file mode 100644 index 00000000..83bd65e2 --- /dev/null +++ b/tests/testdata/python-project/using_pyversion/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from python-project!") + + +if __name__ == "__main__": + main() diff --git a/tests/testdata/python-project/using_pyversion/pyproject.toml b/tests/testdata/python-project/using_pyversion/pyproject.toml new file mode 100644 index 00000000..ba35d8f4 --- /dev/null +++ b/tests/testdata/python-project/using_pyversion/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "python-project" +version = "0.1.0" +description = "Add your description here" +dependencies = [] diff --git a/tests/testdata/python-project/using_setupcfg/hello.py b/tests/testdata/python-project/using_setupcfg/hello.py new file mode 100644 index 00000000..83bd65e2 --- /dev/null +++ b/tests/testdata/python-project/using_setupcfg/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from python-project!") + + +if __name__ == "__main__": + main() diff --git a/tests/testdata/python-project/using_setupcfg/setup.cfg b/tests/testdata/python-project/using_setupcfg/setup.cfg new file mode 100644 index 00000000..6681256a --- /dev/null +++ b/tests/testdata/python-project/using_setupcfg/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = python-project +version = 0.1.0 +description = Add your description here + +[options] +python_requires = >=3.8 From 274fefc2248508dbbd22637462c27f97095f1000 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 17:03:11 +0100 Subject: [PATCH 2/5] Remove accidental uv changes --- pyproject.toml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index add3e3a5..45ef703f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", - "rsconnect-python[test]", "toml>=0.10; python_version < '3.11'" ] @@ -93,9 +92,3 @@ typeCheckingMode = "strict" reportPrivateUsage = "none" reportUnnecessaryIsInstance = "none" reportUnnecessaryComparison = "none" - -[tool.uv.sources] -rsconnect-python = { workspace = true } - -[tool.uv.workspace] -members = ["tests/testdata/python-project"] From 41863fd4bdd0b47ab257b1ae6aef54ede93bfe6a Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 17:08:02 +0100 Subject: [PATCH 3/5] Avoid complains from type checker --- rsconnect/pyproject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 7a66bda6..69b730cb 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -12,7 +12,7 @@ import tomllib except ImportError: # Python 3.11+ has tomllib in the standard library - import toml as tomllib + import toml as tomllib # type: ignore[no-redef] def lookup_metadata_file(directory: typing.Union[str, pathlib.Path]) -> typing.List[typing.Tuple[str, pathlib.Path]]: From 887fbad29ecf62b1a1b6e03ac12f539c55f42c24 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 17:12:15 +0100 Subject: [PATCH 4/5] Fix, toml wants a string, while tomllib wants bytes --- rsconnect/pyproject.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 69b730cb..e1bc3ae0 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -39,7 +39,7 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti Returns None if the field is not found. """ - with pyproject_file.open("rb") as f: - pyproject = tomllib.load(f) + content = pyproject_file.read_text() + pyproject = tomllib.loads(content) return pyproject.get("project", {}).get("requires-python", None) From 5ed8e3ce58193a7774ccb32584144470f8da5b04 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 12 Mar 2025 17:37:26 +0100 Subject: [PATCH 5/5] Add more explicit comments to pyptoject tests --- tests/test_pyproject.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index f32213b3..fb2a9830 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -8,6 +8,12 @@ HERE = os.path.dirname(__file__) PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project")) +# Most of this tests, verify against three fixture projects that are located in PROJECTS_DIRECTORY +# - using_pyproject: contains a pyproject.toml file with a project.requires-python field +# - using_setupcfg: contains a setup.cfg file with a options.python_requires field +# - using_pyversion: contains a .python-version file and a pyproject.toml file without any version constraint. +# - allofthem: contains all metadata files all with different version constraints. + @pytest.mark.parametrize( "project_dir, expected", @@ -26,6 +32,7 @@ ids=["pyproject.toml", "setup.cfg", ".python-version", "allofthem"], ) def test_python_project_metadata_detect(project_dir, expected): + """Test that the metadata files are detected when they exist.""" expectation = [(f, pathlib.Path(project_dir) / f) for f in expected] assert lookup_metadata_file(project_dir) == expectation @@ -39,6 +46,7 @@ def test_python_project_metadata_detect(project_dir, expected): ids=["empty", "missing"], ) def test_python_project_metadata_missing(project_dir): + """Test that lookup_metadata_file is able to deal with missing or empty directories.""" assert lookup_metadata_file(project_dir) == [] @@ -51,5 +59,9 @@ def test_python_project_metadata_missing(project_dir): ids=["option-exists", "option-missing"], ) def test_pyprojecttoml_python_requires(project_dir, expected): + """Test that the python_requires field is correctly parsed from pyproject.toml. + + Both when the option exists or when it missing in the pyproject.toml file. + """ pyproject_file = pathlib.Path(project_dir) / "pyproject.toml" assert parse_pyproject_python_requires(pyproject_file) == expected