diff --git a/pyroma/projectdata.py b/pyroma/projectdata.py index d78eb42..e58d202 100644 --- a/pyroma/projectdata.py +++ b/pyroma/projectdata.py @@ -4,8 +4,9 @@ import os import pathlib import re +import tomli as tomllib -from setuptools.config.setupcfg import read_configuration +from setuptools.config import pyprojecttoml, setupcfg from distutils.errors import DistutilsFileError # MAP from old setup.py type keys to Core Metadata keys @@ -24,6 +25,128 @@ def normalize(name): return re.sub(r"[-_.]+", "-", name).lower() +def _read_pyproject(path): + pyproject = pathlib.Path(path) / "pyproject.toml" + if not pyproject.exists(): + return None + + with open(pyproject, "rb") as f: + return tomllib.load(f) + + +def _guess_readme_content_type(filename): + suffix = pathlib.Path(filename).suffix.lower() + if suffix in (".md", ".markdown"): + return "text/markdown" + if suffix == ".rst": + return "text/x-rst" + return "text/plain" + + +def _read_project_readme(path, readme): + if isinstance(readme, str): + readme_path = pathlib.Path(path) / readme + return readme_path.read_text(encoding="UTF-8"), _guess_readme_content_type(readme) + + if isinstance(readme, dict): + if "text" in readme: + content_type = readme.get("content-type", "text/plain") + return readme["text"], content_type + if "file" in readme: + content_type = readme.get("content-type", _guess_readme_content_type(readme["file"])) + readme_path = pathlib.Path(path) / readme["file"] + return readme_path.read_text(encoding="UTF-8"), content_type + + return None, None + + +def get_pyproject_data(path): + pyproject = _read_pyproject(path) + if pyproject is None: + return None + + project = dict(pyproject.get("project", {})) + build_system = pyproject.get("build-system", {}) + backend = build_system.get("build-backend", "") + + if backend.startswith("setuptools.build_meta"): + data = get_setuptools_data(path, project) + else: + data = _pep621_to_metadata(path, project) + + if backend: + data["_build_backend"] = backend + return data + + +def get_setuptools_data(path, project): + pyproject_path = pathlib.Path(path) / "pyproject.toml" + config = pyprojecttoml.read_configuration(str(pyproject_path), expand=True, ignore_option_errors=False) + expanded = dict(config.get("project", {})) + expanded["_build_backend"] = project.get("_build_backend") + return _pep621_to_metadata(path, expanded) + + +def _pep621_to_metadata(path, project): + if not project: + return {} + + if "description" in project: + project["summary"] = project.pop("description") + + if "classifiers" in project: + project["classifier"] = project.pop("classifiers") + + if "keywords" in project and isinstance(project["keywords"], list): + project["keywords"] = ",".join(project["keywords"]) + + if "dependencies" in project: + project["requires-dist"] = project.pop("dependencies") + + authors = project.pop("authors", None) + if authors: + first = authors[0] + if "name" in first: + project["author"] = first["name"] + if "email" in first: + project["author-email"] = first["email"] + + if "license" in project: + license_data = project["license"] + if isinstance(license_data, dict): + if "text" in license_data: + project["license"] = license_data["text"] + elif "file" in license_data: + license_path = pathlib.Path(path) / license_data["file"] + project["license"] = license_path.read_text(encoding="UTF-8") + + urls = project.pop("urls", None) + if urls: + project_urls = [] + for title, url in urls.items(): + project_urls.append(f"{title}, {url}") + if normalize(title) == "homepage": + project["home-page"] = url + if project_urls: + project["project-url"] = project_urls + + readme = project.pop("readme", None) + if readme is not None: + description, content_type = _read_project_readme(path, readme) + if description: + # Keep newline behavior from built metadata for consistent validation. + project["description"] = description if description.endswith("\n") else description + "\n" + if content_type: + project["description-content-type"] = content_type + + return project + + +def _has_required_metadata(data): + # A package must have these to avoid needing backend-generated metadata. + return bool(data.get("name") and data.get("version")) + + def wheel_metadata(path, isolated=None): # If explictly specified whether to use isolation, pass it directly if isolated is not None: @@ -81,7 +204,7 @@ def get_build_data(path, isolated=None): def get_setupcfg_data(path): - data = read_configuration(str(pathlib.Path(path) / "setup.cfg")) + data = setupcfg.read_configuration(str(pathlib.Path(path) / "setup.cfg")) metadata = {} # Python requires is under "options" in setup.cfg (and so are other @@ -107,14 +230,25 @@ def get_data(path): def _get_data(path): + pyproject = get_pyproject_data(path) + + if pyproject is not None and _has_required_metadata(pyproject): + return pyproject + try: - return get_build_data(path) + metadata = get_build_data(path) + if pyproject: + # Prefer explicit pyproject metadata; use wheel metadata as a fallback source. + metadata.update(pyproject) + return metadata except build.BuildException as e: if "no pyproject.toml or setup.py" in e.args[0]: # It couldn't build the package, because there is no setup.py or pyproject.toml. # Let's see if there is a setup.cfg: try: metadata = get_setupcfg_data(path) + if pyproject: + metadata.update(pyproject) # Yes, there's a setup.cfg. Pyroma accepted this earlier, because it worked, # and at some point the idea was that that setup.cfg should replace setup.py. # But that never happened, and instead pyproject.toml arrived. diff --git a/pyroma/tests.py b/pyroma/tests.py index 4f12141..d83dddb 100644 --- a/pyroma/tests.py +++ b/pyroma/tests.py @@ -393,6 +393,23 @@ def test_run_forwards_custom_index_url(self, datamock, ratemock): class ProjectDataTest(unittest.TestCase): maxDiff = None + class _FakeMetadata: + def __init__(self): + self._data = { + "Name": ["pep517"], + "Version": ["1.0"], + "Summary": ["This is a test package for pyroma"], + } + + def keys(self): + return self._data.keys() + + def get_all(self, key): + return self._data[key] + + def get_payload(self): + return "" + def test_complete(self): directory = TESTDATA_DIR / "complete" @@ -400,6 +417,39 @@ def test_complete(self): del data["_path"] # This changes, so I just ignore it self.assertEqual(data, COMPLETE) + @unittest.mock.patch("pyroma.projectdata.wheel_metadata", side_effect=AssertionError("wheel build not expected")) + def test_pep621_prefers_pyproject_data(self, wheelmock): + directory = TESTDATA_DIR / "pep621" + + data = projectdata.get_data(directory) + + self.assertEqual(data["name"], "pyroma_pep621_test_pkg") + self.assertEqual(data["version"], "1.0") + self.assertIn("description", data) + wheelmock.assert_not_called() + + @unittest.mock.patch("pyroma.projectdata.wheel_metadata") + def test_pep517_falls_back_to_wheel_metadata(self, wheelmock): + directory = TESTDATA_DIR / "pep517" + wheelmock.return_value = self._FakeMetadata() + + data = projectdata.get_data(directory) + + self.assertEqual(data["name"], "pep517") + self.assertEqual(data["version"], "1.0") + wheelmock.assert_called_once() + + def test_get_pyproject_data_includes_build_backend(self): + data = projectdata.get_pyproject_data(TESTDATA_DIR / "pep621") + + self.assertEqual(data["_build_backend"], "flit_core.buildapi") + self.assertEqual(data["name"], "pyroma_pep621_test_pkg") + + def test_get_pyproject_data_backend_without_project_table(self): + data = projectdata.get_pyproject_data(TESTDATA_DIR / "pep517") + + self.assertEqual(data["_build_backend"], "setuptools.build_meta") + class DistroDataTest(unittest.TestCase): maxDiff = None diff --git a/setup.cfg b/setup.cfg index 57e454e..c0660a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,6 +40,7 @@ install_requires = pygments requests setuptools>=61 + tomli trove-classifiers>=2022.6.26 [options.packages.find]