From 81fa0825878aa693684d4c1543cf7a4f092322f5 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 16:56:05 +0100 Subject: [PATCH 1/7] Foundation for detecting python version requirements from pyproject.toml --- pyproject.toml | 6 ++++++ rsconnect/pyproject.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45ef703f..acdacf7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,3 +92,9 @@ 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 index e1bc3ae0..2c8ace9e 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -41,5 +41,4 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti """ content = pyproject_file.read_text() pyproject = tomllib.loads(content) - return pyproject.get("project", {}).get("requires-python", None) From 657af225251a3f86dfa9791c47f25e014957d66f Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 17:03:11 +0100 Subject: [PATCH 2/7] Remove accidental uv changes --- pyproject.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index acdacf7b..45ef703f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,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 6f5c02ec560a89019aa9613c6851c5fe4331c44a Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 11 Mar 2025 17:12:15 +0100 Subject: [PATCH 3/7] Fix, toml wants a string, while tomllib wants bytes --- rsconnect/pyproject.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 2c8ace9e..e1bc3ae0 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -41,4 +41,5 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti """ content = pyproject_file.read_text() pyproject = tomllib.loads(content) + return pyproject.get("project", {}).get("requires-python", None) From 1d59d6dbf962879a45617958a3be03b81b1267f6 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 12 Mar 2025 14:17:49 +0100 Subject: [PATCH 4/7] Support for parsin setup.cfg and python-version, detection of parser --- rsconnect/pyproject.py | 44 +++++++++++++- tests/test_pyproject.py | 57 ++++++++++++++++++- .../python-project/using_pyversion/setup.cfg | 4 ++ 3 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 tests/testdata/python-project/using_pyversion/setup.cfg diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index e1bc3ae0..7f67e5e7 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -7,6 +7,7 @@ import pathlib import typing +import configparser try: import tomllib @@ -24,7 +25,7 @@ def lookup_metadata_file(directory: typing.Union[str, pathlib.Path]) -> typing.L directory = pathlib.Path(directory) def _generate(): - for filename in ("pyproject.toml", "setup.cfg", ".python-version"): + for filename in (".python-version", "pyproject.toml", "setup.cfg"): path = directory / filename if path.is_file(): yield (filename, path) @@ -32,6 +33,23 @@ def _generate(): return list(_generate()) +def get_python_requires_parser( + metadata_file: pathlib.Path, +) -> typing.Optional[typing.Callable[[pathlib.Path], typing.Optional[str]]]: + """Given the metadata file, return the appropriate parser function. + + The returned function takes a pathlib.Path and returns the parsed value. + """ + if metadata_file.name == "pyproject.toml": + return parse_pyproject_python_requires + elif metadata_file.name == "setup.cfg": + return parse_setupcfg_python_requires + elif metadata_file.name == ".python-version": + return parse_pyversion_python_requires + else: + return None + + def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Optional[str]: """Parse the project.requires-python field from a pyproject.toml file. @@ -43,3 +61,27 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti pyproject = tomllib.loads(content) return pyproject.get("project", {}).get("requires-python", None) + + +def parse_setupcfg_python_requires(setupcfg_file: pathlib.Path) -> typing.Optional[str]: + """Parse the python_requires field from a setup.cfg file. + + Assumes that the setup.cfg file exists, is accessible and well formatted. + + Returns None if the field is not found. + """ + config = configparser.ConfigParser() + config.read(setupcfg_file) + + return config.get("options", "python_requires", fallback=None) + + +def parse_pyversion_python_requires(pyversion_file: pathlib.Path) -> typing.Optional[str]: + """Parse the python version from a .python-version file. + + Assumes that the .python-version file exists, is accessible and well formatted. + + Returns None if the field is not found. + """ + content = pyversion_file.read_text() + return content.strip() diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index fb2a9830..16405fa3 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -1,7 +1,14 @@ import os import pathlib +import tempfile -from rsconnect.pyproject import lookup_metadata_file, parse_pyproject_python_requires +from rsconnect.pyproject import ( + lookup_metadata_file, + parse_pyproject_python_requires, + parse_setupcfg_python_requires, + parse_pyversion_python_requires, + get_python_requires_parser, +) import pytest @@ -23,11 +30,12 @@ ( os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ( - "pyproject.toml", ".python-version", + "pyproject.toml", + "setup.cfg", ), ), - (os.path.join(PROJECTS_DIRECTORY, "allofthem"), ("pyproject.toml", "setup.cfg", ".python-version")), + (os.path.join(PROJECTS_DIRECTORY, "allofthem"), (".python-version", "pyproject.toml", "setup.cfg")), ], ids=["pyproject.toml", "setup.cfg", ".python-version", "allofthem"], ) @@ -37,6 +45,22 @@ def test_python_project_metadata_detect(project_dir, expected): assert lookup_metadata_file(project_dir) == expectation +@pytest.mark.parametrize( + "filename, expected_parser", + [ + ("pyproject.toml", parse_pyproject_python_requires), + ("setup.cfg", parse_setupcfg_python_requires), + (".python-version", parse_pyversion_python_requires), + ("invalid.txt", None), + ], + ids=["pyproject.toml", "setup.cfg", ".python-version", "invalid"], +) +def test_get_python_requires_parser(filename, expected_parser): + metadata_file = pathlib.Path(PROJECTS_DIRECTORY) / filename + parser = get_python_requires_parser(metadata_file) + assert parser == expected_parser + + @pytest.mark.parametrize( "project_dir", [ @@ -65,3 +89,30 @@ def test_pyprojecttoml_python_requires(project_dir, expected): """ pyproject_file = pathlib.Path(project_dir) / "pyproject.toml" assert parse_pyproject_python_requires(pyproject_file) == expected + + +@pytest.mark.parametrize( + "project_dir, expected", + [ + (os.path.join(PROJECTS_DIRECTORY, "using_setupcfg"), ">=3.8"), + (os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), None), + ], + ids=["option-exists", "option-missing"], +) +def test_setupcfg_python_requires(tmp_path, project_dir, expected): + setupcfg_file = pathlib.Path(project_dir) / "setup.cfg" + assert parse_setupcfg_python_requires(setupcfg_file) == expected + + +@pytest.mark.parametrize( + "project_dir, expected", + [ + (os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8, <3.12"), + # There is no case (option-missing) where the .python-version file is empty, + # so we don't test that. + ], + ids=["option-exists"], +) +def test_pyversion_python_requires(tmp_path, project_dir, expected): + versionfile = pathlib.Path(project_dir) / ".python-version" + assert parse_pyversion_python_requires(versionfile) == expected diff --git a/tests/testdata/python-project/using_pyversion/setup.cfg b/tests/testdata/python-project/using_pyversion/setup.cfg new file mode 100644 index 00000000..6f7841d9 --- /dev/null +++ b/tests/testdata/python-project/using_pyversion/setup.cfg @@ -0,0 +1,4 @@ +[metadata] +name = python-project +version = 0.1.0 +description = Add your description here From 9e8a9300ab841d87a4fc6fcfddfbf08730cb46e2 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 12 Mar 2025 14:23:57 +0100 Subject: [PATCH 5/7] Remove unused import --- tests/test_pyproject.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 16405fa3..f8430ec1 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -1,6 +1,5 @@ import os import pathlib -import tempfile from rsconnect.pyproject import ( lookup_metadata_file, From 0024fdc03f3fd5c74f1cd25772b119fce0726e7a Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 13 Mar 2025 14:58:15 +0100 Subject: [PATCH 6/7] Improve comments --- rsconnect/pyproject.py | 5 ++++- tests/test_pyproject.py | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 7f67e5e7..94dcf258 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -21,6 +21,9 @@ def lookup_metadata_file(directory: typing.Union[str, pathlib.Path]) -> typing.L The returned value is either a list of tuples [(filename, path)] or an empty list [] if no metadata file was found. + + The metadata files are returned in the priority they should be processed + to determine the python version requirements. """ directory = pathlib.Path(directory) @@ -64,7 +67,7 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti def parse_setupcfg_python_requires(setupcfg_file: pathlib.Path) -> typing.Optional[str]: - """Parse the python_requires field from a setup.cfg file. + """Parse the options.python_requires field from a setup.cfg file. Assumes that the setup.cfg file exists, is accessible and well formatted. diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index f8430ec1..89573323 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -17,7 +17,7 @@ # 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. +# - using_pyversion: contains a .python-version file and pyproject.toml, setup.cfg without any version constraint. # - allofthem: contains all metadata files all with different version constraints. @@ -55,6 +55,7 @@ def test_python_project_metadata_detect(project_dir, expected): ids=["pyproject.toml", "setup.cfg", ".python-version", "invalid"], ) def test_get_python_requires_parser(filename, expected_parser): + """Test that given a metadata file name, the correct parser is returned.""" metadata_file = pathlib.Path(PROJECTS_DIRECTORY) / filename parser = get_python_requires_parser(metadata_file) assert parser == expected_parser @@ -82,7 +83,7 @@ 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. + """Test that the requires-python field is correctly parsed from pyproject.toml. Both when the option exists or when it missing in the pyproject.toml file. """ @@ -99,6 +100,10 @@ def test_pyprojecttoml_python_requires(project_dir, expected): ids=["option-exists", "option-missing"], ) def test_setupcfg_python_requires(tmp_path, project_dir, expected): + """Test that the python_requires field is correctly parsed from setup.cfg. + + Both when the option exists or when it missing in the file. + """ setupcfg_file = pathlib.Path(project_dir) / "setup.cfg" assert parse_setupcfg_python_requires(setupcfg_file) == expected @@ -107,11 +112,14 @@ def test_setupcfg_python_requires(tmp_path, project_dir, expected): "project_dir, expected", [ (os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8, <3.12"), - # There is no case (option-missing) where the .python-version file is empty, - # so we don't test that. ], ids=["option-exists"], ) def test_pyversion_python_requires(tmp_path, project_dir, expected): + """Test that the python version is correctly parsed from .python-version. + + We do not test the case where the option is missing, as an empty .python-version file + is not a valid case for a python project. + """ versionfile = pathlib.Path(project_dir) / ".python-version" assert parse_pyversion_python_requires(versionfile) == expected From 576c9d49e2e4116e7bc367283c866ec2ff1ceadf Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 14 Mar 2025 17:42:01 +0100 Subject: [PATCH 7/7] more explicit function name --- rsconnect/pyproject.py | 2 +- tests/test_pyproject.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 94dcf258..553ece9c 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -36,7 +36,7 @@ def _generate(): return list(_generate()) -def get_python_requires_parser( +def get_python_version_requirement_parser( metadata_file: pathlib.Path, ) -> typing.Optional[typing.Callable[[pathlib.Path], typing.Optional[str]]]: """Given the metadata file, return the appropriate parser function. diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 89573323..a245988e 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -6,7 +6,7 @@ parse_pyproject_python_requires, parse_setupcfg_python_requires, parse_pyversion_python_requires, - get_python_requires_parser, + get_python_version_requirement_parser, ) import pytest @@ -54,10 +54,10 @@ def test_python_project_metadata_detect(project_dir, expected): ], ids=["pyproject.toml", "setup.cfg", ".python-version", "invalid"], ) -def test_get_python_requires_parser(filename, expected_parser): +def test_get_python_version_requirement_parser(filename, expected_parser): """Test that given a metadata file name, the correct parser is returned.""" metadata_file = pathlib.Path(PROJECTS_DIRECTORY) / filename - parser = get_python_requires_parser(metadata_file) + parser = get_python_version_requirement_parser(metadata_file) assert parser == expected_parser