Skip to content
Closed
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
140 changes: 137 additions & 3 deletions pyroma/projectdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions pyroma/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,63 @@ 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"

data = projectdata.get_data(directory)
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
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ install_requires =
pygments
requests
setuptools>=61
tomli
trove-classifiers>=2022.6.26

[options.packages.find]
Expand Down
Loading