Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -82,6 +83,9 @@ rsconnect = ["py.typed"]

[tool.pytest.ini_options]
markers = ["vetiver: tests for vetiver"]
addopts = """
--ignore=tests/testdata
"""

[tool.pyright]
typeCheckingMode = "strict"
Expand Down
45 changes: 45 additions & 0 deletions rsconnect/pyproject.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions tests/test_pyproject.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/testdata/python-project/allofthem/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
>=3.8, <3.12
6 changes: 6 additions & 0 deletions tests/testdata/python-project/allofthem/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from python-project!")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions tests/testdata/python-project/allofthem/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[project]
name = "python-project"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.8"
dependencies = []
7 changes: 7 additions & 0 deletions tests/testdata/python-project/allofthem/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[metadata]
name = python-project
version = 0.1.0
description = Add your description here

[options]
python_requires = >=3.8
6 changes: 6 additions & 0 deletions tests/testdata/python-project/empty/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from python-project!")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions tests/testdata/python-project/using_pyproject/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from python-project!")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions tests/testdata/python-project/using_pyproject/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[project]
name = "python-project"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.8"
dependencies = []
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
>=3.8, <3.12
6 changes: 6 additions & 0 deletions tests/testdata/python-project/using_pyversion/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from python-project!")


if __name__ == "__main__":
main()
5 changes: 5 additions & 0 deletions tests/testdata/python-project/using_pyversion/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "python-project"
version = "0.1.0"
description = "Add your description here"
dependencies = []
6 changes: 6 additions & 0 deletions tests/testdata/python-project/using_setupcfg/hello.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def main():
print("Hello from python-project!")


if __name__ == "__main__":
main()
7 changes: 7 additions & 0 deletions tests/testdata/python-project/using_setupcfg/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[metadata]
name = python-project
version = 0.1.0
description = Add your description here

[options]
python_requires = >=3.8
Loading