diff --git a/pyproject.toml b/pyproject.toml index cb66f042..45ef703f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", + "toml>=0.10; python_version < '3.11'" ] dynamic = ["version"] @@ -82,6 +83,9 @@ rsconnect = ["py.typed"] [tool.pytest.ini_options] markers = ["vetiver: tests for vetiver"] +addopts = """ + --ignore=tests/testdata +""" [tool.pyright] typeCheckingMode = "strict" diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py new file mode 100644 index 00000000..e1bc3ae0 --- /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 # type: ignore[no-redef] + + +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. + """ + content = pyproject_file.read_text() + pyproject = tomllib.loads(content) + + 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..fb2a9830 --- /dev/null +++ b/tests/test_pyproject.py @@ -0,0 +1,67 @@ +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")) + +# 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", + [ + (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): + """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 + + +@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): + """Test that lookup_metadata_file is able to deal with missing or empty directories.""" + 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): + """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 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