diff --git a/pyproject.toml b/pyproject.toml index 153ba4eb5..3f094f7eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ ignore-words-list = [ [tool.coverage.run] branch = true source_pkgs = ["packaging"] +omit = ["src/packaging/project_table.py"] [tool.coverage.report] show_missing = true @@ -131,3 +132,4 @@ flake8-unused-arguments.ignore-variadic-names = true "src/packaging/_musllinux.py" = ["T20"] "docs/conf.py" = ["INP001", "S", "A001"] "noxfile.py" = ["T20", "S"] +"tests/test_project*.py" = ["E501"] # temp diff --git a/src/packaging/_pyproject.py b/src/packaging/_pyproject.py new file mode 100644 index 000000000..6d51e5bff --- /dev/null +++ b/src/packaging/_pyproject.py @@ -0,0 +1,475 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import dataclasses +import re +import typing + +import packaging.requirements + +from .errors import ErrorCollector + +if typing.TYPE_CHECKING: # pragma: no cover + import pathlib + from collections.abc import Generator, Iterable, Sequence + + from .project_table import ContactTable, Dynamic, ProjectTable + from .requirements import Requirement + + +__all__ = [ + "License", + "Readme", +] + + +@dataclasses.dataclass(frozen=True) +class License: + """ + This represents a classic license, which contains text, and optionally a + file path. Modern licenses are just SPDX identifiers, which are strings. + """ + + text: str + file: pathlib.Path | None + + +@dataclasses.dataclass(frozen=True) +class Readme: + """ + This represents a readme, which contains text and a content type, and + optionally a file path. + """ + + text: str + file: pathlib.Path | None + content_type: str + + +T = typing.TypeVar("T") + + +@dataclasses.dataclass +class PyProjectReader(ErrorCollector): + """Class for reading pyproject.toml fields with error collection. + + Unrelated errors are collected and raised at once if the `collect_errors` + parameter is set to `True`. Some methods will return None if an error was + raised. Most of them expect a non-None value as input to enforce the caller + to handle missing vs. error correctly. The exact design is based on usage, + as this is an internal class. + """ + + def ensure_str(self, value: str, key: str) -> str | None: + """Ensure that a value is a string.""" + if isinstance(value, str): + return value + + msg = "Field {key} has an invalid type, expecting a string" + self.config_error(msg, key=key, got_type=type(value)) + return None + + def ensure_list(self, val: list[T] | None, key: str) -> list[T] | None: + """Ensure that a value is a list of strings.""" + if val is None: + return None + if not isinstance(val, list): + msg = "Field {key} has an invalid type, expecting a list of strings" + self.config_error(msg, key=key, got_type=type(val)) + return None + for item in val: + if not isinstance(item, str): + msg = "Field {key} contains item with invalid type, expecting a string" + self.config_error(msg, key=key, got_type=type(item)) + return None + + return val + + def ensure_dict(self, val: dict[str, str], key: str) -> dict[str, str] | None: + """Ensure that a value is a dictionary of strings.""" + if not isinstance(val, dict): + msg = "Field {key} has an invalid type, expecting a table of strings" + self.config_error(msg, key=key, got_type=type(val)) + return None + for subkey, item in val.items(): + if not isinstance(item, str): + msg = "Field {key} has an invalid type, expecting a string" + self.config_error(msg, key=f"{key}.{subkey}", got_type=type(item)) + return None + return val + + def ensure_people( + self, val: Sequence[ContactTable], key: str + ) -> list[tuple[str, str | None]]: + """ + Ensure that a value is a list of tables with optional "name" and + "email" keys. + """ + if not isinstance(val, list): + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + ) + self.config_error(msg, key=key, got_type=type(val)) + return [] + for each in val: + if not isinstance(each, dict): + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + " (got list with {type_name})" + ) + self.config_error(msg, key=key, type_name=type(each).__name__) + return [] + for value in each.values(): + if not isinstance(value, str): + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + " (got list with dict with {type_name})" + ) + self.config_error(msg, key=key, type_name=type(value).__name__) + return [] + extra_keys = set(each) - {"name", "email"} + if extra_keys: + msg = ( + "Field {key} has an invalid type, expecting a list of " + 'tables containing the "name" and/or "email" keys' + " (got list with dict with extra keys {extra_keys})" + ) + self.config_error( + msg, + key=key, + extra_keys=", ".join(sorted(f'"{k}"' for k in extra_keys)), + ) + return [] + return [(entry.get("name", "Unknown"), entry.get("email")) for entry in val] + + def get_license( + self, project: ProjectTable, project_dir: pathlib.Path + ) -> License | str | None: + """ + Get the license field from the project table. Handles PEP 639 style + license too. + + None is returned if the license field is not present or if an error occurred. + """ + val = project.get("license") + if val is None: + return None + if isinstance(val, str): + return val + + if isinstance(val, dict): + _license = self.ensure_dict(val, "project.license") # type: ignore[arg-type] + if _license is None: + return None + else: + msg = ( + "Field {key} has an invalid type, expecting" + " a string or table of strings" + ) + self.config_error(msg, key="project.license", got_type=type(val)) + return None + + for field in _license: + if field not in ("file", "text"): + msg = "Unexpected field {key}" + self.config_error(msg, key=f"project.license.{field}") + return None + + file: pathlib.Path | None = None + filename = _license.get("file") + text = _license.get("text") + + if (filename and text) or (not filename and not text): + msg = ( + 'Invalid {key} contents, expecting a string or one key "file" or "text"' + ) + self.config_error(msg, key="project.license", got=_license) + return None + + if filename: + file = project_dir.joinpath(filename) + if not file.is_file(): + msg = f"License file not found ({filename!r})" + self.config_error(msg, key="project.license.file") + return None + text = file.read_text(encoding="utf-8") + + assert text is not None + return License(text, file) + + def get_license_files( + self, project: ProjectTable, project_dir: pathlib.Path + ) -> list[pathlib.Path] | None: + """Get the license-files list of files from the project table. + + Returns None if an error occurred (including invalid globs, etc) or if + not present. + """ + license_files = project.get("license-files") + if license_files is None: + return None + if self.ensure_list(license_files, "project.license-files") is None: + return None + + return list(self._get_files_from_globs(project_dir, license_files)) + + def get_readme( + self, project: ProjectTable, project_dir: pathlib.Path + ) -> Readme | None: + """Get the text of the readme from the project table. + + Returns None if an error occurred or if the readme field is not present. + """ + if "readme" not in project: + return None + + filename: str | None = None + file: pathlib.Path | None = None + text: str | None = None + content_type: str | None = None + + readme = project["readme"] + if isinstance(readme, str): + # readme is a file + text = None + filename = readme + if filename.endswith(".md"): + content_type = "text/markdown" + elif filename.endswith(".rst"): + content_type = "text/x-rst" + else: + msg = "Could not infer content type for readme file {filename!r}" + self.config_error(msg, key="project.readme", filename=filename) + return None + elif isinstance(readme, dict): + # readme is a dict containing either 'file' or 'text', and content-type + for field in readme: + if field not in ("content-type", "file", "text"): + msg = "Unexpected field {key}" + self.config_error(msg, key=f"project.readme.{field}") + return None + + content_type_raw = readme.get("content-type") + if content_type_raw is not None: + content_type = self.ensure_str( + content_type_raw, "project.readme.content-type" + ) + if content_type is None: + return None + filename_raw = readme.get("file") + if filename_raw is not None: + filename = self.ensure_str(filename_raw, "project.readme.file") + if filename is None: + return None + + text_raw = readme.get("text") + if text_raw is not None: + text = self.ensure_str(text_raw, "project.readme.text") + if text is None: + return None + + if (filename and text) or (not filename and not text): + msg = 'Invalid {key} contents, expecting either "file" or "text"' + self.config_error(msg, key="project.readme", got=readme) + return None + if not content_type: + msg = "Field {key} missing" + self.config_error(msg, key="project.readme.content-type") + return None + else: + msg = ( + "Field {key} has an invalid type, expecting either" + " a string or table of strings" + ) + self.config_error(msg, key="project.readme", got_type=type(readme)) + return None + + if filename: + file = project_dir.joinpath(filename) + if not file.is_file(): + msg = "Readme file not found ({filename!r})" + self.config_error(msg, key="project.readme.file", filename=filename) + return None + text = file.read_text(encoding="utf-8") + + assert text is not None + return Readme(text, file, content_type) + + def get_dependencies(self, project: ProjectTable) -> list[Requirement]: + """Get the dependencies from the project table.""" + + requirement_strings: list[str] | None = None + requirement_strings_raw = project.get("dependencies") + if requirement_strings_raw is not None: + requirement_strings = self.ensure_list( + requirement_strings_raw, "project.dependencies" + ) + if requirement_strings is None: + return [] + + requirements: list[Requirement] = [] + try: + for req in requirement_strings: + requirements.append(packaging.requirements.Requirement(req)) + except packaging.requirements.InvalidRequirement as e: + msg = ( + "Field {key} contains an invalid PEP 508" + " requirement string {req!r} ({error!r})" + ) + self.config_error(msg, key="project.dependencies", req=req, error=e) + return [] + return requirements + + def get_optional_dependencies( + self, + project: ProjectTable, + ) -> dict[str, list[Requirement]]: + """Get the optional dependencies from the project table.""" + + val = project.get("optional-dependencies") + if not val: + return {} + + requirements_dict: dict[str, list[Requirement]] = {} + if not isinstance(val, dict): + msg = ( + "Field {key} has an invalid type, expecting" + " a table of PEP 508 requirement strings" + ) + self.config_error( + msg, key="project.optional-dependencies", got_type=type(val) + ) + return {} + for extra, requirements in val.copy().items(): + assert isinstance(extra, str) + if not isinstance(requirements, list): + msg = ( + "Field {key} has an invalid type, expecting" + " a table of PEP 508 requirement strings" + ) + self.config_error( + msg, + key=f"project.optional-dependencies.{extra}", + got_type=type(requirements), + ) + return {} + requirements_dict[extra] = [] + for req in requirements: + if not isinstance(req, str): + msg = ( + "Field {key} has an invalid type, expecting" + " a PEP 508 requirement string" + ) + self.config_error( + msg, + key=f"project.optional-dependencies.{extra}", + got_type=type(req), + ) + return {} + try: + requirements_dict[extra].append( + packaging.requirements.Requirement(req) + ) + except packaging.requirements.InvalidRequirement as e: + msg = ( + "Field {key} contains " + "an invalid PEP 508 requirement string {req!r} ({error!r})" + ) + self.config_error( + msg, + key=f"project.optional-dependencies.{extra}", + req=req, + error=e, + ) + return {} + return dict(requirements_dict) + + def get_entrypoints(self, project: ProjectTable) -> dict[str, dict[str, str]]: + """Get the entrypoints from the project table.""" + + val = project.get("entry-points", None) + if val is None: + return {} + if not isinstance(val, dict): + msg = ( + "Field {key} has an invalid type," + " expecting a table of entrypoint sections" + ) + self.config_error(msg, key="project.entry-points", got_type=type(val)) + return {} + for section, entrypoints in val.items(): + assert isinstance(section, str) + if not re.match(r"^\w+(\.\w+)*$", section): + msg = ( + "Field {key} has an invalid value, expecting a name " + "containing only alphanumeric, underscore, or dot characters" + ) + self.config_error(msg, key="project.entry-points", got=section) + return {} + if not isinstance(entrypoints, dict): + msg = ( + "Field {key} has an invalid type, expecting a table of entrypoints" + ) + self.config_error( + msg, + key=f"project.entry-points.{section}", + got_type=type(entrypoints), + ) + return {} + for name, entrypoint in entrypoints.items(): + assert isinstance(name, str) + if not isinstance(entrypoint, str): + msg = "Field {key} has an invalid type, expecting a string" + self.config_error( + msg, + key=f"project.entry-points.{section}.{name}", + got_type=type(entrypoint), + ) + return {} + return val + + def get_dynamic(self, project: ProjectTable) -> list[Dynamic]: + """Get the dynamic fields from the project table. + + Returns an empty list if the field is not present or if an error occurred. + """ + dynamic = project.get("dynamic", []) + + self.ensure_list(dynamic, "project.dynamic") + + if "name" in dynamic: + msg = "Unsupported field 'name' in {key}" + self.config_error(msg, key="project.dynamic") + return [] + + return dynamic + + def _get_files_from_globs( + self, project_dir: pathlib.Path, globs: Iterable[str] + ) -> Generator[pathlib.Path, None, None]: + """Given a list of globs, get files that match.""" + + for glob in globs: + if glob.startswith(("..", "/")): + msg = ( + "{glob!r} is an invalid {key} glob: the pattern must match " + "files within the project directory" + ) + self.config_error(msg, key="project.license-files", glob=glob) + break + files = [f for f in project_dir.glob(glob) if f.is_file()] + if not files: + msg = ( + "Every pattern in {key} must match at least one file:" + " {glob!r} did not match any" + ) + self.config_error(msg, key="project.license-files", glob=glob) + break + for f in files: + yield f.relative_to(project_dir) diff --git a/src/packaging/errors.py b/src/packaging/errors.py new file mode 100644 index 000000000..c2a8ae75b --- /dev/null +++ b/src/packaging/errors.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import contextlib +import dataclasses +import sys +import typing + +if typing.TYPE_CHECKING: + from collections.abc import Generator + from typing import Any + +__all__ = ["ConfigurationError", "ConfigurationWarning", "ExceptionGroup"] + + +if sys.version_info >= (3, 11): # pragma: no cover + from builtins import ExceptionGroup +else: # pragma: no cover + + class ExceptionGroup(Exception): + """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. + + If :external:exc:`ExceptionGroup` is already defined by Python itself, + that version is used instead. + """ + + message: str + exceptions: list[Exception] + + def __init__(self, message: str, exceptions: list[Exception]) -> None: + self.message = message + self.exceptions = exceptions + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" + + +class ConfigurationError(Exception): + """ + Error in the backend metadata. Has an optional key attribute, which will be + non-None if the error is related to a single key in the pyproject.toml + file. + """ + + def __init__(self, msg: str, *, key: str | None = None): + super().__init__(msg) + self._key = key + + @property + def key(self) -> str | None: # pragma: no cover + return self._key + + +class ConfigurationWarning(UserWarning): + """Warnings about backend metadata.""" + + +@dataclasses.dataclass +class ErrorCollector: + """ + Collect errors and raise them as a group at the end (if collect_errors is True), + otherwise raise them immediately. + """ + + errors: list[Exception] = dataclasses.field(default_factory=list) + + def config_error( + self, + msg: str, + *, + key: str | None = None, + got: Any = None, + got_type: type[Any] | None = None, + **kwargs: Any, + ) -> None: + """Raise a configuration error, or add it to the error list.""" + msg = msg.format(key=f'"{key}"', **kwargs) + if got is not None: + msg = f"{msg} (got {got!r})" + if got_type is not None: + msg = f"{msg} (got {got_type.__name__})" + + self.errors.append(ConfigurationError(msg, key=key)) + + def finalize(self, msg: str) -> None: + """Raise a group exception if there are any errors.""" + if self.errors: + raise ExceptionGroup(msg, self.errors) + + @contextlib.contextmanager + def collect(self) -> Generator[None, None, None]: + """Support nesting; add any grouped errors to the error list.""" + try: + yield + except ExceptionGroup as error: + self.errors.extend(error.exceptions) diff --git a/src/packaging/metadata.py b/src/packaging/metadata.py index dfe51df99..5fe20661b 100644 --- a/src/packaging/metadata.py +++ b/src/packaging/metadata.py @@ -7,7 +7,6 @@ import email.policy import keyword import pathlib -import sys import typing from typing import ( Any, @@ -20,6 +19,8 @@ from . import licenses, requirements, specifiers, utils from . import version as version_module +from .errors import ExceptionGroup +from .licenses import NormalizedLicenseExpression if typing.TYPE_CHECKING: from .licenses import NormalizedLicenseExpression @@ -27,28 +28,6 @@ T = typing.TypeVar("T") -if sys.version_info >= (3, 11): # pragma: no cover - ExceptionGroup = ExceptionGroup # noqa: F821 -else: # pragma: no cover - - class ExceptionGroup(Exception): - """A minimal implementation of :external:exc:`ExceptionGroup` from Python 3.11. - - If :external:exc:`ExceptionGroup` is already defined by Python itself, - that version is used instead. - """ - - message: str - exceptions: list[Exception] - - def __init__(self, message: str, exceptions: list[Exception]) -> None: - self.message = message - self.exceptions = exceptions - - def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.message!r}, {self.exceptions!r})" - - class InvalidMetadata(ValueError): """A metadata field contains invalid data.""" @@ -181,6 +160,7 @@ class RawMetadata(TypedDict, total=False): _DICT_FIELDS = { "project_urls", } +ALL_FIELDS = _STRING_FIELDS | _LIST_FIELDS | _DICT_FIELDS def _parse_keywords(data: str) -> list[str]: @@ -743,6 +723,8 @@ def _process_import_names(self, value: list[str]) -> list[str]: name, semicolon, private = import_name.partition(";") name = name.rstrip() for identifier in name.split("."): + if identifier == "": + continue if not identifier.isidentifier(): raise self._invalid_metadata( f"{name!r} is invalid for {{field}}; " diff --git a/src/packaging/project.py b/src/packaging/project.py new file mode 100644 index 000000000..7df19e2f7 --- /dev/null +++ b/src/packaging/project.py @@ -0,0 +1,613 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import copy +import dataclasses +import email.message +import email.policy +import email.utils +import itertools +import keyword +import os +import os.path +import pathlib +import typing +import warnings + +from . import markers, specifiers, utils +from . import metadata as packaging_metadata +from . import version as packaging_version +from ._pyproject import License, PyProjectReader, Readme +from .errors import ConfigurationError, ConfigurationWarning, ErrorCollector + +if typing.TYPE_CHECKING: # pragma: no cover + import sys + from collections.abc import Generator, Mapping, Sequence + from typing import Any + + from .requirements import Requirement + + if sys.version_info < (3, 11): + from typing_extensions import Self + else: + from typing import Self + + from .project_table import Dynamic, PyProjectTable + +__all__ = [ + "ConfigurationError", + "License", + "Readme", + "StandardMetadata", + "extras_build_system", + "extras_project", + "extras_top_level", +] + +KNOWN_TOPLEVEL_FIELDS = {"build-system", "project", "tool", "dependency-groups"} +KNOWN_BUILD_SYSTEM_FIELDS = {"backend-path", "build-backend", "requires"} +KNOWN_PROJECT_FIELDS = { + "authors", + "classifiers", + "dependencies", + "description", + "dynamic", + "entry-points", + "gui-scripts", + "import-names", + "import-namespaces", + "keywords", + "license", + "license-files", + "maintainers", + "name", + "optional-dependencies", + "readme", + "requires-python", + "scripts", + "urls", + "version", +} +PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} +PRE_2_5_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} + + +def extras_top_level(pyproject_table: Mapping[str, Any]) -> set[str]: + """ + Return any extra keys in the top-level of the pyproject table. + """ + return set(pyproject_table) - KNOWN_TOPLEVEL_FIELDS + + +def extras_build_system(pyproject_table: Mapping[str, Any]) -> set[str]: + """ + Return any extra keys in the build-system table. + """ + return set(pyproject_table.get("build-system", [])) - KNOWN_BUILD_SYSTEM_FIELDS + + +def extras_project(pyproject_table: Mapping[str, Any]) -> set[str]: + """ + Return any extra keys in the project table. + """ + return set(pyproject_table.get("project", [])) - KNOWN_PROJECT_FIELDS + + +def _validate_import_names( + names: list[str], key: str, *, errors: ErrorCollector +) -> Generator[str, None, None]: + """ + Returns normalized names for comparisons. + """ + for fullname in names: + name, simicolon, private = fullname.partition(";") + if simicolon and private.lstrip() != "private": + msg = "{key} contains an ending tag other than '; private', got {value!r}" + errors.config_error(msg, key=key, value=fullname) + name = name.rstrip() + + for ident in name.split("."): + if not ident.isidentifier(): + msg = "{key} contains {value!r}, which is not a valid identifier" + errors.config_error(msg, key=key, value=fullname) + + elif keyword.iskeyword(ident): + msg = ( + "{key} contains a Python keyword," + " which is not a valid import name, got {value!r}" + ) + errors.config_error(msg, key=key, value=fullname) + + yield name + + +def _validate_dotted_names(names: set[str], *, errors: ErrorCollector) -> None: + """ + Checks to make sure every name is accounted for. Takes the union of de-tagged names. + """ + + for name in names: + for parent in itertools.accumulate( + name.split(".")[:-1], lambda a, b: f"{a}.{b}" + ): + if parent not in names: + msg = "{key} is missing {value!r}, but submodules are present elsewhere" + errors.config_error(msg, key="project.import-namespaces", value=parent) + continue + + +@dataclasses.dataclass +class StandardMetadata: + """ + This class represents the standard metadata fields for a project. It can be + used to read metadata from a pyproject.toml table, validate it, and write it + to an RFC822 message. + """ + + name: str + version: packaging_version.Version | None = None + description: str | None = None + license: License | str | None = None + license_files: list[pathlib.Path] | None = None + readme: Readme | None = None + requires_python: specifiers.SpecifierSet | None = None + dependencies: list[Requirement] = dataclasses.field(default_factory=list) + optional_dependencies: dict[str, list[Requirement]] = dataclasses.field( + default_factory=dict + ) + entrypoints: dict[str, dict[str, str]] = dataclasses.field(default_factory=dict) + authors: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) + maintainers: list[tuple[str, str | None]] = dataclasses.field(default_factory=list) + urls: dict[str, str] = dataclasses.field(default_factory=dict) + classifiers: list[str] = dataclasses.field(default_factory=list) + keywords: list[str] = dataclasses.field(default_factory=list) + scripts: dict[str, str] = dataclasses.field(default_factory=dict) + gui_scripts: dict[str, str] = dataclasses.field(default_factory=dict) + import_names: list[str] | None = None + import_namespaces: list[str] | None = None + dynamic: list[Dynamic] = dataclasses.field(default_factory=list) + """ + This field is used to track dynamic fields. You can't set a field not in this list. + """ + + def __post_init__(self) -> None: + self.validate() + + @property + def canonical_name(self) -> str: + """ + Return the canonical name of the project. + """ + return utils.canonicalize_name(self.name) + + @classmethod + def from_pyproject( + cls, + data: Mapping[str, Any], + project_dir: str | os.PathLike[str] = os.path.curdir, + ) -> Self: + """ + Read metadata from a pyproject.toml table. This is the main method for + creating an instance of this class. It also supports two additional + fields: ``allow_extra_keys`` to control what happens when extra keys are + present in the pyproject table, and ``all_errors``, to raise all errors + in an ExceptionGroup instead of raising the first one. + """ + pyproject = PyProjectReader() + + pyproject_table: PyProjectTable = data # type: ignore[assignment] + if "project" not in pyproject_table: + msg = "Section {key} missing in pyproject.toml" + pyproject.config_error(msg, key="project") + pyproject.finalize("Failed to parse pyproject.toml") + msg = "Unreachable code" # pragma: no cover + raise AssertionError(msg) # pragma: no cover + + project = pyproject_table["project"] + project_dir = pathlib.Path(project_dir) + + extra_keys = extras_project(data) + if extra_keys: + extra_keys_str = ", ".join(sorted(f"{k!r}" for k in extra_keys)) + msg = "Extra keys present in {key}: {extra_keys}" + pyproject.config_error( + msg, + key="project", + extra_keys=extra_keys_str, + ) + + dynamic = pyproject.get_dynamic(project) + + for field in dynamic: + if field in data["project"]: + msg = ( + 'Field {key} declared as dynamic in "project.dynamic"' + " but is defined" + ) + pyproject.config_error(msg, key=f"project.{field}") + + raw_name = project.get("name") + name = "UNKNOWN" + if raw_name is None: + msg = "Field {key} missing" + pyproject.config_error(msg, key="project.name") + else: + tmp_name = pyproject.ensure_str(raw_name, "project.name") + if tmp_name is not None: + name = tmp_name + + version: packaging_version.Version | None = packaging_version.Version("0.0.0") + raw_version = project.get("version") + if raw_version is not None: + version_string = pyproject.ensure_str(raw_version, "project.version") + if version_string is not None: + try: + version = ( + packaging_version.Version(version_string) + if version_string + else None + ) + except packaging_version.InvalidVersion: + msg = "Invalid {key} value, expecting a valid PEP 440 version" + pyproject.config_error( + msg, key="project.version", got=version_string + ) + elif "version" not in dynamic: + msg = ( + "Field {key} missing and 'version' not specified in \"project.dynamic\"" + ) + pyproject.config_error(msg, key="project.version") + + # Description fills Summary, which cannot be multiline + # However, throwing an error isn't backward compatible, + # so leave it up to the users for now. + project_description_raw = project.get("description") + description = ( + pyproject.ensure_str(project_description_raw, "project.description") + if project_description_raw is not None + else None + ) + + requires_python_raw = project.get("requires-python") + requires_python = None + if requires_python_raw is not None: + requires_python_string = pyproject.ensure_str( + requires_python_raw, "project.requires-python" + ) + if requires_python_string is not None: + try: + requires_python = specifiers.SpecifierSet(requires_python_string) + except specifiers.InvalidSpecifier: + msg = "Invalid {key} value, expecting a valid specifier set" + pyproject.config_error( + msg, key="project.requires-python", got=requires_python_string + ) + + self = None + with pyproject.collect(): + self = cls( + name=name, + version=version, + description=description, + license=pyproject.get_license(project, project_dir), + license_files=pyproject.get_license_files(project, project_dir), + readme=pyproject.get_readme(project, project_dir), + requires_python=requires_python, + dependencies=pyproject.get_dependencies(project), + optional_dependencies=pyproject.get_optional_dependencies(project), + entrypoints=pyproject.get_entrypoints(project), + authors=pyproject.ensure_people( + project.get("authors", []), "project.authors" + ), + maintainers=pyproject.ensure_people( + project.get("maintainers", []), "project.maintainers" + ), + urls=pyproject.ensure_dict(project.get("urls", {}), "project.urls") + or {}, + classifiers=pyproject.ensure_list( + project.get("classifiers", []), "project.classifiers" + ) + or [], + keywords=pyproject.ensure_list( + project.get("keywords", []), "project.keywords" + ) + or [], + scripts=pyproject.ensure_dict( + project.get("scripts", {}), "project.scripts" + ) + or {}, + gui_scripts=pyproject.ensure_dict( + project.get("gui-scripts", {}), "project.gui-scripts" + ) + or {}, + import_names=pyproject.ensure_list( + project.get("import-names", None), "project.import-names" + ), + import_namespaces=pyproject.ensure_list( + project.get("import-namespaces", None), "project.import-namespaces" + ), + dynamic=dynamic, + ) + + pyproject.finalize("Failed to parse pyproject.toml") + assert self is not None + return self + + def validate_metadata(self, metadata_version: str) -> None: + """ + Validate metadata for consistency and correctness given a metadata + version. This is called when making metadata. Checks: + + - ``license`` is not an SPDX license expression if metadata_version + >= 2.4 (warning) + - License classifiers deprecated for metadata_version >= 2.4 (warning) + - ``license`` is an SPDX license expression if metadata_version >= 2.4 + - ``license_files`` is supported only for metadata_version >= 2.4 + - ``import-name(paces)s`` is only supported on metadata_version >= 2.5 + """ + errors = ErrorCollector() + + if not self.version: + msg = "Missing {key} field" + errors.config_error(msg, key="project.version") + + if metadata_version not in PRE_SPDX_METADATA_VERSIONS: + if isinstance(self.license, License): + warnings.warn( + 'Set "project.license" to an SPDX license expression' + " for metadata >= 2.4", + ConfigurationWarning, + stacklevel=2, + ) + elif any(c.startswith("License ::") for c in self.classifiers): + warnings.warn( + "'License ::' classifiers are deprecated for metadata >= 2.4" + ', use a SPDX license expression for "project.license" instead', + ConfigurationWarning, + stacklevel=2, + ) + + if ( + isinstance(self.license, str) + and metadata_version in PRE_SPDX_METADATA_VERSIONS + ): + msg = ( + "Setting {key} to an SPDX license expression is supported" + " only when emitting metadata version >= 2.4" + ) + errors.config_error(msg, key="project.license") + + if ( + self.license_files is not None + and metadata_version in PRE_SPDX_METADATA_VERSIONS + ): + msg = "{key} is only supported when emitting metadata version >= 2.4" + errors.config_error(msg, key="project.license-files") + + if ( + self.import_names is not None + and metadata_version in PRE_2_5_METADATA_VERSIONS + ): + msg = "{key} is only supported when emitting metadata version >= 2.5" + errors.config_error(msg, key="project.import-names") + + if ( + self.import_namespaces is not None + and metadata_version in PRE_2_5_METADATA_VERSIONS + ): + msg = "{key} is only supported when emitting metadata version >= 2.5" + errors.config_error(msg, key="project.import-namespaces") + + errors.finalize("Metadata validation failed") + + def validate(self) -> None: + """ + Validate metadata for consistency and correctness. This is called + when loading a pyproject.toml. Checks: + + - ``name`` is a valid project name + - ``license_files`` can't be used with classic ``license`` + - License classifiers can't be used with SPDX license + - ``description`` is a single line (warning) + - ``project_url`` can't contain keys over 32 characters + - ``import-name(space)s`` must be valid names, optionally with ``; private`` + - ``import-names`` and ``import-namespaces`` cannot overlap + """ + errors = ErrorCollector() + + try: + utils.canonicalize_name(self.name, validate=True) + except utils.InvalidName: + msg = ( + "Invalid project name {name!r}. A valid name consists only of ASCII" + " letters and numbers, period, underscore and hyphen. It must start" + " and end with a letter or number" + ) + errors.config_error(msg, key="project.name", name=self.name) + + if self.license_files is not None and isinstance(self.license, License): + msg = ( + '{key} must not be used when "project.license"' + " is not a SPDX license expression" + ) + errors.config_error(msg, key="project.license-files") + + if isinstance(self.license, str) and any( + c.startswith("License ::") for c in self.classifiers + ): + msg = ( + "Setting {key} to an SPDX license expression is not" + " compatible with 'License ::' classifiers" + ) + errors.config_error(msg, key="project.license") + + if self.description and "\n" in self.description: + msg = ( + 'The one-line summary "project.description" should not contain more ' + "than one line. Readers might merge or truncate newlines." + ) + errors.config_error(msg, key="project.description") + + for name in self.urls: + if len(name) > 32: + msg = "{key} names cannot be more than 32 characters long" + errors.config_error(msg, key="project.urls", got=name) + + import_names = set( + _validate_import_names( + self.import_names or [], "import-names", errors=errors + ) + ) + import_namespaces = set( + _validate_import_names( + self.import_namespaces or [], "import-namespaces", errors=errors + ) + ) + in_both = import_names & import_namespaces + if in_both: + msg = "{key} overlaps with 'project.import-namespaces': {in_both}" + errors.config_error(msg, key="project.import-names", in_both=in_both) + + _validate_dotted_names(import_names | import_namespaces, errors=errors) + + errors.finalize("[project] table validation failed") + + def metadata( + self, *, metadata_version: str, dynamic_metadata: Sequence[str] = () + ) -> packaging_metadata.Metadata: + """ + Return an Message with the metadata. + """ + self.validate_metadata(metadata_version) + + assert self.version is not None + message = packaging_metadata.RawMetadata( + metadata_version=metadata_version, name=self.name, version=str(self.version) + ) + + # skip 'Platform' + # skip 'Supported-Platform' + if self.description: + message["summary"] = self.description + if self.keywords: + message["keywords"] = self.keywords + # skip 'Home-page' + # skip 'Download-URL' + if authors := _name_list(self.authors): + message["author"] = authors + + if authors_email := _email_list(self.authors): + message["author_email"] = authors_email + + if maintainers := _name_list(self.maintainers): + message["maintainer"] = maintainers + + if maintainers_email := _email_list(self.maintainers): + message["maintainer_email"] = maintainers_email + + if isinstance(self.license, License): + message["license"] = self.license.text + elif isinstance(self.license, str): + message["license_expression"] = self.license + + if self.license_files is not None: + license_files = [ + os.fspath(license_file.as_posix()) + for license_file in sorted(set(self.license_files)) + ] + message["license_files"] = license_files + elif ( + metadata_version not in PRE_SPDX_METADATA_VERSIONS + and isinstance(self.license, License) + and self.license.file + ): + message["license_files"] = [os.fspath(self.license.file.as_posix())] + + if self.classifiers: + message["classifiers"] = self.classifiers + # skip 'Provides-Dist' + # skip 'Obsoletes-Dist' + # skip 'Requires-External' + if self.urls: + message["project_urls"] = self.urls + if self.requires_python: + message["requires_python"] = str(self.requires_python) + if self.dependencies: + message["requires_dist"] = [str(d) for d in self.dependencies] + for extra, requirements in self.optional_dependencies.items(): + norm_extra = extra.replace(".", "-").replace("_", "-").lower() + message.setdefault("provides_extra", []).append(norm_extra) + message.setdefault("requires_dist", []).extend( + str(_build_extra_req(norm_extra, requirement)) + for requirement in requirements + ) + if self.readme: + assert self.readme.content_type # verified earlier + message["description_content_type"] = self.readme.content_type + message["description"] = self.readme.text + + if self.import_names is not None: + # Special case for empty import-names + if not self.import_names: + message["import_names"] = [""] + else: + message["import_names"] = self.import_names + if self.import_namespaces is not None: + message["import_namespaces"] = self.import_namespaces + + # Core Metadata 2.2 + if metadata_version != "2.1": + for field in dynamic_metadata: + if field.lower() in {"name", "version", "dynamic"}: + msg = f"Field cannot be set as dynamic metadata: {field}" + raise ConfigurationError(msg) + if field.lower() not in packaging_metadata.ALL_FIELDS: + msg = f"Field is not known: {field}" + raise ConfigurationError(msg) + message["dynamic"] = list(dynamic_metadata) + + return packaging_metadata.Metadata.from_raw(message) + + +def _name_list(people: list[tuple[str, str | None]]) -> str | None: + """ + Build a comma-separated list of names. + """ + return ", ".join(name for name, email_ in people if not email_) or None + + +def _email_list(people: list[tuple[str, str | None]]) -> str | None: + """ + Build a comma-separated list of emails. + """ + return ( + ", ".join( + email.utils.formataddr((name, _email)) for name, _email in people if _email + ) + or None + ) + + +def _build_extra_req( + extra: str, + requirement: Requirement, +) -> Requirement: + """ + Build a new requirement with an extra marker. + """ + requirement = copy.copy(requirement) + if requirement.marker: + if "or" in requirement.marker._markers: + requirement.marker = markers.Marker( + f"({requirement.marker}) and extra == {extra!r}" + ) + else: + requirement.marker = markers.Marker( + f"{requirement.marker} and extra == {extra!r}" + ) + else: + requirement.marker = markers.Marker(f"extra == {extra!r}") + return requirement diff --git a/src/packaging/project_table.py b/src/packaging/project_table.py new file mode 100644 index 000000000..ebfb5bcae --- /dev/null +++ b/src/packaging/project_table.py @@ -0,0 +1,250 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import sys +import typing +from typing import Any, Dict, List, Literal, TypedDict, Union + +if sys.version_info < (3, 11): + if typing.TYPE_CHECKING: + from typing_extensions import Required + else: + try: + from typing_extensions import Required + except ModuleNotFoundError: + V = typing.TypeVar("V") + + class Required: + def __class_getitem__(cls, item: V) -> V: + return item +else: + from typing import Required + + +__all__ = [ + "BuildSystemTable", + "ContactTable", + "Dynamic", + "IncludeGroupTable", + "LicenseTable", + "ProjectTable", + "PyProjectTable", + "ReadmeTable", + "to_project_table", +] + + +def __dir__() -> list[str]: + return __all__ + + +class ContactTable(TypedDict, total=False): + """ + Can have either name or email. + """ + + name: str + email: str + + +class LicenseTable(TypedDict, total=False): + """ + Can have either text or file. Legacy. + """ + + text: str + file: str + + +ReadmeTable = TypedDict( + "ReadmeTable", {"file": str, "text": str, "content-type": str}, total=False +) + +Dynamic = Literal[ + "authors", + "classifiers", + "dependencies", + "description", + "dynamic", + "entry-points", + "gui-scripts", + "import-names", + "import-namespaces", + "keywords", + "license", + "maintainers", + "optional-dependencies", + "readme", + "requires-python", + "scripts", + "urls", + "version", +] + +ProjectTable = TypedDict( + "ProjectTable", + { + "name": Required[str], + "version": str, + "description": str, + "license": Union[LicenseTable, str], + "license-files": List[str], + "readme": Union[str, ReadmeTable], + "requires-python": str, + "dependencies": List[str], + "optional-dependencies": Dict[str, List[str]], + "entry-points": Dict[str, Dict[str, str]], + "authors": List[ContactTable], + "maintainers": List[ContactTable], + "urls": Dict[str, str], + "classifiers": List[str], + "keywords": List[str], + "scripts": Dict[str, str], + "gui-scripts": Dict[str, str], + "import-names": List[str], + "import-namespaces": List[str], + "dynamic": List[Dynamic], + }, + total=False, +) + +BuildSystemTable = TypedDict( + "BuildSystemTable", + { + "build-backend": str, + "requires": List[str], + "backend-path": List[str], + }, + total=False, +) + +# total=False here because this could be +# extended in the future +IncludeGroupTable = TypedDict( + "IncludeGroupTable", + {"include-group": str}, + total=False, +) + +PyProjectTable = TypedDict( + "PyProjectTable", + { + "build-system": BuildSystemTable, + "project": ProjectTable, + "tool": Dict[str, Any], + "dependency-groups": Dict[str, List[Union[str, IncludeGroupTable]]], + }, + total=False, +) + +T = typing.TypeVar("T") + + +def is_typed_dict(type_hint: object) -> bool: + if sys.version_info >= (3, 10): + return typing.is_typeddict(type_hint) + return hasattr(type_hint, "__annotations__") and hasattr(type_hint, "__total__") + + +def _cast(type_hint: type[T], data: object, prefix: str) -> T: + """ + Runtime cast for types. + + Just enough to cover the dicts above (not general or public). + """ + + # TypedDict + if is_typed_dict(type_hint): + if not isinstance(data, dict): + msg = ( + f'"{prefix}" expected dict for {type_hint.__name__},' + f" got {type(data).__name__}" + ) + raise TypeError(msg) + + hints = typing.get_type_hints(type_hint) + for key, typ in hints.items(): + if key in data: + _cast(typ, data[key], prefix + f".{key}" if prefix else key) + # Required keys could be enforced here on 3.11+ eventually + + return typing.cast("T", data) + + origin = typing.get_origin(type_hint) + # Special case Required on 3.10 + if origin is Required: + (type_hint,) = typing.get_args(type_hint) + origin = typing.get_origin(type_hint) + args = typing.get_args(type_hint) + + # Literal + if origin is typing.Literal: + if data not in args: + arg_names = ", ".join(repr(a) for a in args) + msg = f'"{prefix}" expected one of {arg_names}, got {data!r}' + raise TypeError(msg) + return typing.cast("T", data) + + # Any accepts everything, so no validation + if type_hint is Any: + return typing.cast("T", data) + + # List[T] + if origin is list: + if not isinstance(data, list): + msg = f'"{prefix}" expected list, got {type(data).__name__}' + raise TypeError(msg) + item_type = args[0] + return typing.cast( + "T", [_cast(item_type, item, f"{prefix}[]") for item in data] + ) + + # Dict[str, T] + if origin is dict: + if not isinstance(data, dict): + msg = f'"{prefix}" expected dict, got {type(data).__name__}' + raise TypeError(msg) + _, value_type = args + return typing.cast( + "T", + { + key: _cast(value_type, value, f"{prefix}.{key}") + for key, value in data.items() + }, + ) + # Union[T1, T2, ...] + if origin is typing.Union: + for arg in args: + try: + _cast(arg, data, prefix) + return typing.cast("T", data) + except TypeError: # noqa: PERF203 + continue + arg_names = " | ".join(a.__name__ for a in args) + msg = f'"{prefix}" does not match any type in {arg_names}' + raise TypeError(msg) + + # Base case (str, etc.) + if isinstance(data, origin or type_hint): + return typing.cast("T", data) + + msg = f'"{prefix}" expected {type_hint.__name__}, got {type(data).__name__}' + raise TypeError(msg) + + +def to_project_table(data: dict[str, Any], /) -> PyProjectTable: + """ + Convert a dict to a PyProjectTable, validating types at runtime. + + Note that only the types that are affected by a TypedDict are validated; + extra keys are ignored. + """ + # Handling Required here + name = data.get("project", {"name": ""}).get("name") + if name is None: + msg = 'Key "project.name" is required if "project" is present' + raise TypeError(msg) + return _cast(PyProjectTable, data, "") diff --git a/tests/project/dynamic-description/dynamic_description.py b/tests/project/dynamic-description/dynamic_description.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/dynamic-description/pyproject.toml b/tests/project/dynamic-description/pyproject.toml new file mode 100644 index 000000000..96b02af7c --- /dev/null +++ b/tests/project/dynamic-description/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = 'dynamic-description' +version = '1.0.0' +dynamic = [ + 'description', +] diff --git a/tests/project/full-metadata/README.md b/tests/project/full-metadata/README.md new file mode 100644 index 000000000..d58d5a460 --- /dev/null +++ b/tests/project/full-metadata/README.md @@ -0,0 +1 @@ +some readme 👋 diff --git a/tests/project/full-metadata/full_metadata.py b/tests/project/full-metadata/full_metadata.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/full-metadata/pyproject.toml b/tests/project/full-metadata/pyproject.toml new file mode 100644 index 000000000..fef487977 --- /dev/null +++ b/tests/project/full-metadata/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = 'full_metadata' +version = '3.2.1' +description = 'A package with all the metadata :)' +readme = 'README.md' +license = { text = 'some license text' } +keywords = ['trampolim', 'is', 'interesting'] +authors = [ + { email = 'example@example.com' }, + { name = 'Example!' }, +] +maintainers = [ + { name = 'Emailless' }, + { name = 'Other Example', email = 'other@example.com' }, +] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Programming Language :: Python', +] + +requires-python = '>=3.8' +dependencies = [ + 'dependency1', + 'dependency2>1.0.0', + 'dependency3[extra]', + 'dependency4; os_name != "nt"', + 'dependency5[other-extra]>1.0; os_name == "nt"', +] + +[project.optional-dependencies] +test = [ + 'test_dependency', + 'test_dependency[test_extra]', + 'test_dependency[test_extra2] > 3.0; os_name == "nt"', +] + +[project.urls] +homepage = 'example.com' +documentation = 'readthedocs.org' +repository = 'github.com/some/repo' +changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' + +[project.scripts] +full-metadata = 'full_metadata:main_cli' + +[project.gui-scripts] +full-metadata-gui = 'full_metadata:main_gui' + +[project.entry-points.custom] +full-metadata = 'full_metadata:main_custom' diff --git a/tests/project/full-metadata2/LICENSE b/tests/project/full-metadata2/LICENSE new file mode 100644 index 000000000..457cef814 --- /dev/null +++ b/tests/project/full-metadata2/LICENSE @@ -0,0 +1 @@ +Some license! 👋 diff --git a/tests/project/full-metadata2/README.rst b/tests/project/full-metadata2/README.rst new file mode 100644 index 000000000..d58d5a460 --- /dev/null +++ b/tests/project/full-metadata2/README.rst @@ -0,0 +1 @@ +some readme 👋 diff --git a/tests/project/full-metadata2/full_metadata2.py b/tests/project/full-metadata2/full_metadata2.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/full-metadata2/pyproject.toml b/tests/project/full-metadata2/pyproject.toml new file mode 100644 index 000000000..6a0da002f --- /dev/null +++ b/tests/project/full-metadata2/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = 'full-metadata2' +version = '3.2.1' +description = 'A package with all the metadata :)' +readme = 'README.rst' +license = { file = 'LICENSE' } +keywords = ['trampolim', 'is', 'interesting'] +authors = [ + { email = 'example@example.com' }, + { name = 'Example!' }, +] +maintainers = [ + { name = 'Other Example', email = 'other@example.com' }, +] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Programming Language :: Python', +] + +requires-python = '>=3.8' +dependencies = [ + 'dependency1', + 'dependency2>1.0.0', + 'dependency3[extra]', + 'dependency4; os_name != "nt"', + 'dependency5[other-extra]>1.0; os_name == "nt"', +] + +[project.optional-dependencies] +test = [ + 'test_dependency', + 'test_dependency[test_extra]', + 'test_dependency[test_extra2] > 3.0; os_name == "nt"', +] + +[project.urls] +homepage = 'example.com' +documentation = 'readthedocs.org' +repository = 'github.com/some/repo' +changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' + +[project.scripts] +full-metadata = 'full_metadata:main_cli' + +[project.gui-scripts] +full-metadata-gui = 'full_metadata:main_gui' + +[project.entry-points.custom] +full-metadata = 'full_metadata:main_custom' diff --git a/tests/project/fulltext_license/LICENSE.txt b/tests/project/fulltext_license/LICENSE.txt new file mode 100644 index 000000000..c3713cdcc --- /dev/null +++ b/tests/project/fulltext_license/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright © 2019 Filipe Laíns + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/tests/project/metadata-2.5/LICENSE b/tests/project/metadata-2.5/LICENSE new file mode 100644 index 000000000..c3713cdcc --- /dev/null +++ b/tests/project/metadata-2.5/LICENSE @@ -0,0 +1,20 @@ +Copyright © 2019 Filipe Laíns + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/tests/project/metadata-2.5/README.md b/tests/project/metadata-2.5/README.md new file mode 100644 index 000000000..d58d5a460 --- /dev/null +++ b/tests/project/metadata-2.5/README.md @@ -0,0 +1 @@ +some readme 👋 diff --git a/tests/project/metadata-2.5/metadata25.py b/tests/project/metadata-2.5/metadata25.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/metadata-2.5/pyproject.toml b/tests/project/metadata-2.5/pyproject.toml new file mode 100644 index 000000000..b39fee683 --- /dev/null +++ b/tests/project/metadata-2.5/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = 'metadata25' +version = '3.2.1' +description = 'A package with all the metadata :)' +readme = 'README.md' +license = "MIT" +license-files = ["LICENSE"] +keywords = ['trampolim', 'is', 'interesting'] +authors = [ + { email = 'example@example.com' }, + { name = 'Example!' }, +] +maintainers = [ + { name = 'Other Example', email = 'other@example.com' }, +] +classifiers = [ + 'Development Status :: 4 - Beta', + 'Programming Language :: Python', +] + +requires-python = '>=3.8' +dependencies = [ + 'dependency1', + 'dependency2>1.0.0', + 'dependency3[extra]', + 'dependency4; os_name != "nt"', + 'dependency5[other-extra]>1.0; os_name == "nt"', +] +import-names = ["metadata25"] + +[project.optional-dependencies] +test = [ + 'test_dependency', + 'test_dependency[test_extra]', + 'test_dependency[test_extra2] > 3.0; os_name == "nt"', +] + +[project.urls] +homepage = 'example.com' +documentation = 'readthedocs.org' +repository = 'github.com/some/repo' +changelog = 'github.com/some/repo/blob/master/CHANGELOG.rst' + +[project.scripts] +full-metadata = 'full_metadata:main_cli' + +[project.gui-scripts] +full-metadata-gui = 'full_metadata:main_gui' + +[project.entry-points.custom] +full-metadata = 'full_metadata:main_custom' diff --git a/tests/project/spdx/AUTHORS.txt b/tests/project/spdx/AUTHORS.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/spdx/LICENSE.md b/tests/project/spdx/LICENSE.md new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/spdx/LICENSE.txt b/tests/project/spdx/LICENSE.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/spdx/licenses/LICENSE.MIT b/tests/project/spdx/licenses/LICENSE.MIT new file mode 100644 index 000000000..e69de29bb diff --git a/tests/project/spdx/pyproject.toml b/tests/project/spdx/pyproject.toml new file mode 100644 index 000000000..674a88137 --- /dev/null +++ b/tests/project/spdx/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "example" +version = "1.2.3" +license = "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)" +license-files = ["LICEN[CS]E*", "AUTHORS*", "licenses/LICENSE.MIT"] diff --git a/tests/project/unknown-readme-type/README.just-made-this-up-now b/tests/project/unknown-readme-type/README.just-made-this-up-now new file mode 100644 index 000000000..a870237e2 --- /dev/null +++ b/tests/project/unknown-readme-type/README.just-made-this-up-now @@ -0,0 +1 @@ +some readme diff --git a/tests/project/unknown-readme-type/pyproject.toml b/tests/project/unknown-readme-type/pyproject.toml new file mode 100644 index 000000000..c50c28bf5 --- /dev/null +++ b/tests/project/unknown-readme-type/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = 'unknown-readme-type' +version = '1.0.0' +readme = 'README.just-made-this-up-now' diff --git a/tests/project/unknown-readme-type/unknown_readme_type.py b/tests/project/unknown-readme-type/unknown_readme_type.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 000000000..796a3f491 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,1584 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import contextlib +import pathlib +import re +import shutil +import sys +import textwrap +import warnings +from typing import TYPE_CHECKING + +import pytest + +import packaging.errors +import packaging.metadata +import packaging.project +import packaging.specifiers +import packaging.version + +if TYPE_CHECKING: + from collections.abc import Generator + +if sys.version_info < (3, 11): + import tomli as tomllib +else: + import tomllib + + +DIR = pathlib.Path(__file__).parent.resolve() +PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} + + +@pytest.fixture(params=("2.1", "2.2", "2.3", "2.4")) +def metadata_version(request: pytest.FixtureRequest) -> str: + return request.param # type: ignore[no-any-return] + + +@contextlib.contextmanager +def raises_single( + exception_type: type[Exception], contains: str, match: str +) -> Generator[pytest.ExceptionInfo[packaging.errors.ExceptionGroup], None, None]: + with pytest.raises(packaging.errors.ExceptionGroup, match=match) as excinfo: + yield excinfo + assert len(excinfo.value.exceptions) == 1 + assert isinstance(excinfo.value.exceptions[0], exception_type) + assert contains in str(excinfo.value.exceptions[0]) + + +@pytest.mark.parametrize( + ("data", "error"), + [ + pytest.param( + "", + 'Section "project" missing in pyproject.toml', + id="Missing project section", + ), + pytest.param( + """ + [project] + name = true + version = "0.1.0" + """, + 'Field "project.name" has an invalid type, expecting a string (got bool)', + id="Invalid name type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + not-real-key = true + """, + "Extra keys present in \"project\": 'not-real-key'", + id="Invalid project key", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + dynamic = [ + "name", + ] + """, + "Unsupported field 'name' in \"project.dynamic\"", + id="Unsupported field in project.dynamic", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + dynamic = [ + 3, + ] + """, + 'Field "project.dynamic" contains item with invalid type, expecting a string (got int)', + id="Unsupported type in project.dynamic", + ), + pytest.param( + """ + [project] + name = "test" + version = true + """, + 'Field "project.version" has an invalid type, expecting a string (got bool)', + id="Invalid version type", + ), + pytest.param( + """ + [project] + name = "test" + """, + 'Field "project.version" missing and \'version\' not specified in "project.dynamic"', + id="Missing version", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0-extra" + """, + "Invalid \"project.version\" value, expecting a valid PEP 440 version (got '0.1.0-extra')", + id="Invalid version value", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = true + """, + 'Field "project.license" has an invalid type, expecting a string or table of strings (got bool)', + id="License invalid type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = {} + """, + 'Invalid "project.license" contents, expecting a string or one key "file" or "text" (got {})', + id="Missing license keys", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = { file = "...", text = "..." } + """, + ( + 'Invalid "project.license" contents, expecting a string or one key "file" or "text"' + " (got {'file': '...', 'text': '...'})" + ), + id="Both keys for license", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = { made-up = ":(" } + """, + 'Unexpected field "project.license.made-up"', + id="Got made-up license field", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = { file = true } + """, + 'Field "project.license.file" has an invalid type, expecting a string (got bool)', + id="Invalid type for license.file", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = { text = true } + """, + 'Field "project.license.text" has an invalid type, expecting a string (got bool)', + id="Invalid type for license.text", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = { file = "this-file-does-not-exist" } + """, + "License file not found ('this-file-does-not-exist')", + id="License file not present", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = true + """, + ( + 'Field "project.readme" has an invalid type, expecting either ' + "a string or table of strings (got bool)" + ), + id="Invalid readme type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = {} + """, + 'Invalid "project.readme" contents, expecting either "file" or "text" (got {})', + id="Empty readme table", + ), + pytest.param( + """ + [project] + name = 'test' + version = "0.1.0" + readme = "README.jpg" + """, + "Could not infer content type for readme file 'README.jpg'", + id="Unsupported filename in readme", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { file = "...", text = "..." } + """, + ( + 'Invalid "project.readme" contents, expecting either "file" or "text"' + " (got {'file': '...', 'text': '...'})" + ), + id="Both readme fields", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { made-up = ":(" } + """, + 'Unexpected field "project.readme.made-up"', + id="Unexpected field in readme", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { file = true } + """, + 'Field "project.readme.file" has an invalid type, expecting a string (got bool)', + id="Invalid type for readme.file", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { text = true } + """, + 'Field "project.readme.text" has an invalid type, expecting a string (got bool)', + id="Invalid type for readme.text", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { file = "this-file-does-not-exist", content-type = "..." } + """, + "Readme file not found ('this-file-does-not-exist')", + id="Readme file not present", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { file = "README.md" } + """, + 'Field "project.readme.content-type" missing', + id="Missing content-type for readme", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { file = 'README.md', content-type = true } + """, + 'Field "project.readme.content-type" has an invalid type, expecting a string (got bool)', + id="Wrong content-type type for readme", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + readme = { text = "..." } + """, + 'Field "project.readme.content-type" missing', + id="Missing content-type for readme", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + description = true + """, + 'Field "project.description" has an invalid type, expecting a string (got bool)', + id="Invalid description type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + dependencies = "some string!" + """, + 'Field "project.dependencies" has an invalid type, expecting a list of strings (got str)', + id="Invalid dependencies type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + dependencies = [ + 99, + ] + """, + 'Field "project.dependencies" contains item with invalid type, expecting a string (got int)', + id="Invalid dependencies item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + dependencies = [ + "definitely not a valid PEP 508 requirement!", + ] + """, + ( + 'Field "project.dependencies" contains an invalid PEP 508 requirement ' + "string 'definitely not a valid PEP 508 requirement!' " + ), + id="Invalid dependencies item", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + optional-dependencies = true + """, + ( + 'Field "project.optional-dependencies" has an invalid type, ' + "expecting a table of PEP 508 requirement strings (got bool)" + ), + id="Invalid optional-dependencies type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.optional-dependencies] + test = "some string!" + """, + ( + 'Field "project.optional-dependencies.test" has an invalid type, ' + "expecting a table of PEP 508 requirement strings (got str)" + ), + id="Invalid optional-dependencies not list", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.optional-dependencies] + test = [ + true, + ] + """, + ( + 'Field "project.optional-dependencies.test" has an invalid type, ' + "expecting a PEP 508 requirement string (got bool)" + ), + id="Invalid optional-dependencies item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.optional-dependencies] + test = [ + "definitely not a valid PEP 508 requirement!", + ] + """, + ( + 'Field "project.optional-dependencies.test" contains an invalid ' + "PEP 508 requirement string 'definitely not a valid PEP 508 requirement!' " + ), + id="Invalid optional-dependencies item", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + requires-python = true + """, + 'Field "project.requires-python" has an invalid type, expecting a string (got bool)', + id="Invalid requires-python type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + requires-python = "3.8" + """, + "Invalid \"project.requires-python\" value, expecting a valid specifier set (got '3.8')", + id="Invalid requires-python value", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + keywords = "some string!" + """, + 'Field "project.keywords" has an invalid type, expecting a list of strings (got str)', + id="Invalid keywords type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + keywords = [3] + """, + 'Field "project.keywords" contains item with invalid type, expecting a string (got int)', + id="Invalid keyword type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + keywords = [ + true, + ] + """, + 'Field "project.keywords" contains item with invalid type, expecting a string (got bool)', + id="Invalid keywords item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + authors = {} + """, + ( + 'Field "project.authors" has an invalid type, expecting a list of ' + 'tables containing the "name" and/or "email" keys (got dict)' + ), + id="Invalid authors type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + authors = [ + true, + ] + """, + ( + 'Field "project.authors" has an invalid type, expecting a list of ' + 'tables containing the "name" and/or "email" keys (got list with bool)' + ), + id="Invalid authors item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + maintainers = {} + """, + ( + 'Field "project.maintainers" has an invalid type, expecting a list of ' + 'tables containing the "name" and/or "email" keys (got dict)' + ), + id="Invalid maintainers type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + maintainers = [ + 10 + ] + """, + ( + 'Field "project.maintainers" has an invalid type, expecting a list of ' + 'tables containing the "name" and/or "email" keys (got list with int)' + ), + id="Invalid maintainers item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + maintainers = [ + {"name" = 12} + ] + """, + ( + 'Field "project.maintainers" has an invalid type, expecting a list of ' + 'tables containing the "name" and/or "email" keys (got list with dict with int)' + ), + id="Invalid maintainers nested type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + maintainers = [ + {"name" = "me", "other" = "you"} + ] + """, + ( + 'Field "project.maintainers" has an invalid type, expecting a list of ' + 'tables containing the "name" and/or "email" keys (got list with dict with extra keys "other")' + ), + id="Invalid maintainers nested type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + classifiers = "some string!" + """, + 'Field "project.classifiers" has an invalid type, expecting a list of strings (got str)', + id="Invalid classifiers type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + classifiers = [ + true, + ] + """, + 'Field "project.classifiers" contains item with invalid type, expecting a string (got bool)', + id="Invalid classifiers item type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.urls] + homepage = true + """, + 'Field "project.urls.homepage" has an invalid type, expecting a string (got bool)', + id="Invalid urls homepage type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.urls] + Documentation = true + """, + 'Field "project.urls.Documentation" has an invalid type, expecting a string (got bool)', + id="Invalid urls documentation type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.urls] + repository = true + """, + 'Field "project.urls.repository" has an invalid type, expecting a string (got bool)', + id="Invalid urls repository type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.urls] + "I am really really too long for this place" = "url" + """, + "\"project.urls\" names cannot be more than 32 characters long (got 'I am really really too long for this place')", + id="URL name too long", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.urls] + changelog = true + """, + 'Field "project.urls.changelog" has an invalid type, expecting a string (got bool)', + id="Invalid urls changelog type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + scripts = [] + """, + 'Field "project.scripts" has an invalid type, expecting a table of strings (got list)', + id="Invalid scripts type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + gui-scripts = [] + """, + 'Field "project.gui-scripts" has an invalid type, expecting a table of strings (got list)', + id="Invalid gui-scripts type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + entry-points = [] + """, + ( + 'Field "project.entry-points" has an invalid type, ' + "expecting a table of entrypoint sections (got list)" + ), + id="Invalid entry-points type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + entry-points = { section = "something" } + """, + ( + 'Field "project.entry-points.section" has an invalid type, ' + "expecting a table of entrypoints (got str)" + ), + id="Invalid entry-points section type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.entry-points.section] + entrypoint = [] + """, + 'Field "project.entry-points.section.entrypoint" has an invalid type, expecting a string (got list)', + id="Invalid entry-points entrypoint type", + ), + pytest.param( + """ + [project] + name = ".test" + version = "0.1.0" + """, + ( + "Invalid project name '.test'. A valid name consists only of ASCII letters and " + "numbers, period, underscore and hyphen. It must start and end with a letter or number" + ), + id="Invalid project name", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + [project.entry-points.bad-name] + """, + ( + 'Field "project.entry-points" has an invalid value, expecting a name containing only ' + "alphanumeric, underscore, or dot characters (got 'bad-name')" + ), + id="Invalid entry-points name", + ), + # both license files and classic license are not allowed + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license-files = [] + license.text = 'stuff' + """, + '"project.license-files" must not be used when "project.license" is not a SPDX license expression', + id="Both license files and classic license", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license-files = ['../LICENSE'] + """, + "'../LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory", + id="Parent license-files glob", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license-files = [12] + """, + 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', + id="Parent license-files invalid type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license-files = ['this', 12] + """, + 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', + id="Parent license-files invalid type", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license-files = ['/LICENSE'] + """, + "'/LICENSE' is an invalid \"project.license-files\" glob: the pattern must match files within the project directory", + id="Absolute license-files glob", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = 'MIT' + classifiers = ['License :: OSI Approved :: MIT License'] + """, + "Setting \"project.license\" to an SPDX license expression is not compatible with 'License ::' classifiers", + id="SPDX license and License trove classifiers", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-names = ["is"] + """, + "\"import-names\" contains a Python keyword, which is not a valid import name, got 'is'", + id="Setting import-names to keyword", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-namespaces = ["from"] + """, + "\"import-namespaces\" contains a Python keyword, which is not a valid import name, got 'from'", + id="Setting import-namespaces to keyword", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-names = ["2two"] + """, + "\"import-names\" contains '2two', which is not a valid identifier", + id="Setting import-names invalid identifier", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-namespaces = ["3"] + """, + "\"import-namespaces\" contains '3', which is not a valid identifier", + id="Setting import-namespaces to invalid identifier", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-names = ["one", "two"] + import-namespaces = ["one", "three"] + """, + "\"project.import-names\" overlaps with 'project.import-namespaces': {'one'}", + id="Matching entry in import-names and import-namespaces", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-names = ["one; private", "two"] + import-namespaces = ["one", "three ; private"] + """, + "\"project.import-names\" overlaps with 'project.import-namespaces': {'one'}", + id="Matching entry in import-names and import-namespaces with private tags", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-names = ["one.two"] + """, + "\"project.import-namespaces\" is missing 'one', but submodules are present elsewhere", + id="Matching entry in import-names and import-namespaces", + ), + ], +) +def test_load(data: str, error: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/full-metadata") + with warnings.catch_warnings(): + warnings.simplefilter( + action="ignore", category=packaging.errors.ConfigurationWarning + ) + with raises_single( + packaging.errors.ConfigurationError, error, "Failed to parse pyproject.toml" + ): + packaging.project.StandardMetadata.from_pyproject( + tomllib.loads(textwrap.dedent(data)), + ) + + +@pytest.mark.parametrize( + ("data", "errors"), + [ + pytest.param( + "[project]", + [ + 'Field "project.name" missing', + 'Field "project.version" missing and \'version\' not specified in "project.dynamic"', + ], + id="Missing project name", + ), + pytest.param( + """ + [project] + name = true + version = "0.1.0" + dynamic = [ + "name", + ] + """, + [ + "Unsupported field 'name' in \"project.dynamic\"", + 'Field "project.name" has an invalid type, expecting a string (got bool)', + ], + id="Unsupported field in project.dynamic", + ), + pytest.param( + """ + [project] + name = true + version = "0.1.0" + dynamic = [ + 3, + ] + """, + [ + 'Field "project.dynamic" contains item with invalid type, expecting a string (got int)', + 'Field "project.name" has an invalid type, expecting a string (got bool)', + ], + id="Unsupported type in project.dynamic", + ), + pytest.param( + """ + [project] + name = 'test' + version = "0.1.0" + readme = "README.jpg" + license-files = [12] + """, + [ + 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', + "Could not infer content type for readme file 'README.jpg'", + ], + id="Unsupported filename in readme", + ), + pytest.param( + """ + [project] + name = 'test' + version = "0.1.0" + readme = "README.jpg" + license-files = [12] + entry-points.bad-name = {} + other-entry = {} + not-valid = true + """, + [ + "Extra keys present in \"project\": 'not-valid', 'other-entry'", + 'Field "project.license-files" contains item with invalid type, expecting a string (got int)', + "Could not infer content type for readme file 'README.jpg'", + "Field \"project.entry-points\" has an invalid value, expecting a name containing only alphanumeric, underscore, or dot characters (got 'bad-name')", + ], + id="Four errors including extra keys", + ), + pytest.param( + """ + [project] + name = 'test' + version = "0.1.0" + import-names = ["test", "other"] + import-namespaces = ["other.one.two", "invalid name", "not; public"] + """, + [ + "\"import-namespaces\" contains 'invalid name', which is not a valid identifier", + "\"import-namespaces\" contains an ending tag other than '; private', got 'not; public'", + "\"import-namespaces\" contains a Python keyword, which is not a valid import name, got 'not; public'", + "\"project.import-namespaces\" is missing 'other.one', but submodules are present elsewhere", + ], + id="Multiple errors related to names/namespaces", + ), + ], +) +def test_load_multierror( + data: str, errors: list[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(DIR / "project/full-metadata") + with warnings.catch_warnings(): + warnings.simplefilter( + action="ignore", category=packaging.errors.ConfigurationWarning + ) + with pytest.raises(packaging.errors.ExceptionGroup) as execinfo: + packaging.project.StandardMetadata.from_pyproject( + tomllib.loads(textwrap.dedent(data)), + ) + exceptions = execinfo.value.exceptions + args = [e.args[0] for e in exceptions] + assert len(args) == len(errors) + assert args == errors + assert "Failed to parse pyproject.toml" in repr(execinfo.value) + + +@pytest.mark.parametrize( + ("data", "error", "metadata_version"), + [ + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license = 'MIT' + """, + 'Setting "project.license" to an SPDX license expression is supported only when emitting metadata version >= 2.4', + "2.3", + id="SPDX with metadata_version 2.3", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license-files = ['README.md'] + """, + '"project.license-files" is only supported when emitting metadata version >= 2.4', + "2.3", + id="license-files with metadata_version 2.3", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-names = ['one'] + """, + '"project.import-names" is only supported when emitting metadata version >= 2.5', + "2.4", + id="import-names with metadata_version 2.4", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + import-namespaces = ['one'] + """, + '"project.import-namespaces" is only supported when emitting metadata version >= 2.5', + "2.4", + id="import-names with metadata_version 2.4", + ), + ], +) +def test_load_with_metadata_version( + data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(DIR / "project/full-metadata") + with raises_single( + packaging.errors.ConfigurationError, error, "Metadata validation failed" + ): + packaging.project.StandardMetadata.from_pyproject( + tomllib.loads(textwrap.dedent(data)) + ).metadata(metadata_version=metadata_version) + + +@pytest.mark.parametrize( + ("data", "error", "metadata_version"), + [ + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + license.text = 'MIT' + """, + 'Set "project.license" to an SPDX license expression for metadata >= 2.4', + "2.4", + id="Classic license with metadata 2.4", + ), + pytest.param( + """ + [project] + name = "test" + version = "0.1.0" + classifiers = ['License :: OSI Approved :: MIT License'] + """, + "'License ::' classifiers are deprecated for metadata >= 2.4, use a SPDX license expression for \"project.license\" instead", + "2.4", + id="License trove classifiers with metadata 2.4", + ), + ], +) +def test_load_with_metadata_version_warnings( + data: str, error: str, metadata_version: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(DIR / "project/full-metadata") + with pytest.warns(packaging.errors.ConfigurationWarning, match=re.escape(error)): + packaging.project.StandardMetadata.from_pyproject( + tomllib.loads(textwrap.dedent(data)) + ).metadata(metadata_version=metadata_version) + + +@pytest.mark.parametrize("after_rfc", [False, True]) +def test_value(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/full-metadata") + with open("pyproject.toml", "rb") as f: + metadata = packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + + if after_rfc: + metadata.metadata(metadata_version="2.2").as_rfc822() + + assert metadata.dynamic == [] + assert metadata.name == "full_metadata" + assert metadata.canonical_name == "full-metadata" + assert metadata.version == packaging.version.Version("3.2.1") + assert metadata.requires_python == packaging.specifiers.Specifier(">=3.8") + assert isinstance(metadata.license, packaging.project.License) + assert metadata.license.file is None + assert metadata.license.text == "some license text" + assert isinstance(metadata.readme, packaging.project.Readme) + assert metadata.readme.file == pathlib.Path("README.md") + assert metadata.readme.text == pathlib.Path("README.md").read_text(encoding="utf-8") + assert metadata.readme.content_type == "text/markdown" + assert metadata.description == "A package with all the metadata :)" + assert metadata.authors == [ + ("Unknown", "example@example.com"), + ("Example!", None), + ] + assert metadata.maintainers == [ + ("Emailless", None), + ("Other Example", "other@example.com"), + ] + assert metadata.keywords == ["trampolim", "is", "interesting"] + assert metadata.classifiers == [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + ] + assert metadata.urls == { + "changelog": "github.com/some/repo/blob/master/CHANGELOG.rst", + "documentation": "readthedocs.org", + "homepage": "example.com", + "repository": "github.com/some/repo", + } + assert metadata.entrypoints == { + "custom": { + "full-metadata": "full_metadata:main_custom", + }, + } + assert metadata.scripts == { + "full-metadata": "full_metadata:main_cli", + } + assert metadata.gui_scripts == { + "full-metadata-gui": "full_metadata:main_gui", + } + assert list(map(str, metadata.dependencies)) == [ + "dependency1", + "dependency2>1.0.0", + "dependency3[extra]", + 'dependency4; os_name != "nt"', + 'dependency5[other-extra]>1.0; os_name == "nt"', + ] + assert list(metadata.optional_dependencies.keys()) == ["test"] + assert list(map(str, metadata.optional_dependencies["test"])) == [ + "test_dependency", + "test_dependency[test_extra]", + 'test_dependency[test_extra2]>3.0; os_name == "nt"', + ] + + +def test_value_25(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/metadata-2.5") + with open("pyproject.toml", "rb") as f: + std_metadata = packaging.project.StandardMetadata.from_pyproject( + tomllib.load(f) + ) + + assert isinstance(std_metadata.license, str) + assert std_metadata.license == "MIT" + assert std_metadata.license_files == [pathlib.Path("LICENSE")] + + assert std_metadata.import_names == ["metadata25"] + assert std_metadata.import_namespaces is None + + metadata = std_metadata.metadata(metadata_version="2.5") + + assert metadata.import_names == ["metadata25"] + assert metadata.import_namespaces is None + + +def test_read_license(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/full-metadata2") + with open("pyproject.toml", "rb") as f: + metadata = packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + + assert isinstance(metadata.license, packaging.project.License) + assert metadata.license.file == pathlib.Path("LICENSE") + assert metadata.license.text == "Some license! 👋\n" + + +@pytest.mark.parametrize( + ("package", "content_type"), + [ + ("full-metadata", "text/markdown"), + ("full-metadata2", "text/x-rst"), + ], +) +def test_readme_content_type( + package: str, content_type: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(DIR / "project" / package) + with open("pyproject.toml", "rb") as f: + metadata = packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + + assert isinstance(metadata.readme, packaging.project.Readme) + assert metadata.readme.content_type == content_type + + +def test_readme_content_type_unknown(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/unknown-readme-type") + with raises_single( + packaging.errors.ConfigurationError, + "Could not infer content type for readme file 'README.just-made-this-up-now'", + "Failed to parse pyproject.toml", + ), open("pyproject.toml", "rb") as f: + packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + + +def test_readme_text() -> None: + pyproject = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "foo", + "version": "1.2.3", + "readme": {"text": "onetwothree", "content-type": "text/plain"}, + } + } + ) + assert pyproject.readme.text == "onetwothree" + + +def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/full-metadata") + + with open("pyproject.toml", "rb") as f: + metadata = packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.metadata(metadata_version="2.1").as_rfc822() + assert core_metadata.items() == [ + ("metadata-version", "2.1"), + ("name", "full_metadata"), + ("version", "3.2.1"), + ("summary", "A package with all the metadata :)"), + ("description-content-type", "text/markdown"), + ("keywords", "trampolim,is,interesting"), + ("author", "Example!"), + ("author-email", "Unknown "), + ("maintainer", "Emailless"), + ("maintainer-email", "Other Example "), + ("license", "some license text"), + ("classifier", "Development Status :: 4 - Beta"), + ("classifier", "Programming Language :: Python"), + ("requires-dist", "dependency1"), + ("requires-dist", "dependency2>1.0.0"), + ("requires-dist", "dependency3[extra]"), + ("requires-dist", 'dependency4; os_name != "nt"'), + ("requires-dist", 'dependency5[other-extra]>1.0; os_name == "nt"'), + ("requires-dist", 'test_dependency; extra == "test"'), + ("requires-dist", 'test_dependency[test_extra]; extra == "test"'), + ( + "requires-dist", + 'test_dependency[test_extra2]>3.0; os_name == "nt" and extra == "test"', + ), + ("requires-python", ">=3.8"), + ("project-url", "homepage, example.com"), + ("project-url", "documentation, readthedocs.org"), + ("project-url", "repository, github.com/some/repo"), + ("project-url", "changelog, github.com/some/repo/blob/master/CHANGELOG.rst"), + ("provides-extra", "test"), + ] + assert core_metadata.get_payload() == "some readme 👋\n" + + +def test_rfc822_empty_import_name() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + {"project": {"name": "test", "version": "0.1.0", "import-names": []}} + ) + assert metadata.import_names == [] + assert metadata.import_namespaces is None + + core_metadata = metadata.metadata(metadata_version="2.5").as_rfc822() + assert core_metadata.items() == [ + ("metadata-version", "2.5"), + ("name", "test"), + ("version", "0.1.0"), + ("import-name", ""), + ] + + +def test_rfc822_full_import_name() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "test", + "version": "0.1.0", + "import-names": ["one", "two"], + "import-namespaces": ["three"], + } + } + ) + assert metadata.import_names == ["one", "two"] + assert metadata.import_namespaces == ["three"] + + core_metadata = metadata.metadata(metadata_version="2.5").as_rfc822() + assert core_metadata.items() == [ + ("metadata-version", "2.5"), + ("name", "test"), + ("version", "0.1.0"), + ("import-name", "one"), + ("import-name", "two"), + ("import-namespace", "three"), + ] + + +def test_as_rfc822_spdx(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/spdx") + + with open("pyproject.toml", "rb") as f: + metadata = packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.metadata(metadata_version="2.4").as_rfc822() + assert core_metadata.items() == [ + ("metadata-version", "2.4"), + ("name", "example"), + ("version", "1.2.3"), + ("license-expression", "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)"), + ("license-file", "AUTHORS.txt"), + ("license-file", "LICENSE.md"), + ("license-file", "LICENSE.txt"), + ("license-file", "licenses/LICENSE.MIT"), + ] + + assert core_metadata.get_payload() is None + + +def test_as_rfc822_spdx_empty_glob( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + shutil.copytree(DIR / "project/spdx", tmp_path / "spdx") + monkeypatch.chdir(tmp_path / "spdx") + + pathlib.Path("AUTHORS.txt").unlink() + msg = "Every pattern in \"project.license-files\" must match at least one file: 'AUTHORS*' did not match any" + + with open("pyproject.toml", "rb") as f: + with pytest.raises( + packaging.errors.ExceptionGroup, + ) as execinfo: + packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + assert "Failed to parse pyproject.toml" in str(execinfo.value) + assert [msg] == [str(e) for e in execinfo.value.exceptions] + + +def test_license_file_24( + metadata_version: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(DIR / "project/fulltext_license") + pre_spdx = metadata_version in PRE_SPDX_METADATA_VERSIONS + ctx = ( + contextlib.nullcontext() + if pre_spdx + else pytest.warns( # type: ignore[attr-defined] + packaging.errors.ConfigurationWarning + ) + ) + with ctx: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "fulltext_license", + "version": "0.1.0", + "license": {"file": "LICENSE.txt"}, + }, + } + ).metadata(metadata_version=metadata_version) + message = str(metadata.as_rfc822()) + if metadata_version in PRE_SPDX_METADATA_VERSIONS: + assert "license-file: LICENSE.txt" not in message + else: + assert "license-file: LICENSE.txt" in message + + +def test_as_rfc822_dynamic(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "project/dynamic-description") + + with open("pyproject.toml", "rb") as f: + metadata = packaging.project.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.metadata( + metadata_version="2.3", dynamic_metadata=["description"] + ).as_rfc822() + assert core_metadata.items() == [ + ("metadata-version", "2.3"), + ("name", "dynamic-description"), + ("version", "1.0.0"), + ("dynamic", "description"), + ] + + +def test_as_rfc822_set_metadata(metadata_version: str) -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "hi", + "version": "1.2", + "optional-dependencies": { + "under_score": ["some_package"], + "da-sh": ["some-package"], + "do.t": ["some.package"], + "empty": [], + }, + } + } + ).metadata( + metadata_version=metadata_version, + ) + assert metadata.metadata_version == metadata_version + + rfc822 = bytes(metadata.as_rfc822()).decode("utf-8") + + assert f"metadata-version: {metadata_version}" in rfc822 + + assert "provides-extra: under-score" in rfc822 + assert "provides-extra: da-sh" in rfc822 + assert "provides-extra: do-t" in rfc822 + assert "provides-extra: empty" in rfc822 + assert 'requires-dist: some_package; extra == "under-score"' in rfc822 + assert 'requires-dist: some-package; extra == "da-sh"' in rfc822 + assert 'requires-dist: some.package; extra == "do-t"' in rfc822 + + +def test_as_rfc822_set_metadata_invalid() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "hi", + "version": "1.2", + }, + } + ) + with raises_single( + packaging.metadata.InvalidMetadata, + "'1.9' is not a valid metadata version", + "invalid metadata", + ): + metadata.metadata( + metadata_version="1.9", + ) + + +def test_as_rfc822_invalid_dynamic() -> None: + metadata = packaging.project.StandardMetadata( + name="something", + version=packaging.version.Version("1.0.0"), + ) + with pytest.raises( + packaging.errors.ConfigurationError, + match="Field cannot be set as dynamic metadata: name", + ): + metadata.metadata(metadata_version="2.3", dynamic_metadata=["name"]) + with pytest.raises( + packaging.errors.ConfigurationError, + match="Field cannot be set as dynamic metadata: version", + ): + metadata.metadata(metadata_version="2.3", dynamic_metadata=["version"]) + with pytest.raises( + packaging.errors.ConfigurationError, + match="Field is not known: unknown", + ): + metadata.metadata(metadata_version="2.3", dynamic_metadata=["unknown"]) + + +def test_as_rfc822_missing_version() -> None: + metadata = packaging.project.StandardMetadata(name="something") + with raises_single( + packaging.errors.ConfigurationError, + 'Missing "project.version" field', + "Metadata validation failed", + ): + metadata.metadata(metadata_version="2.1") + + +def test_statically_defined_dynamic_field() -> None: + with raises_single( + packaging.errors.ConfigurationError, + 'Field "project.version" declared as dynamic in "project.dynamic" but is defined', + "Failed to parse pyproject.toml", + ): + packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "1.2.3", + "dynamic": [ + "version", + ], + }, + } + ) + + +@pytest.mark.parametrize( + "value", + [ + "<3.10", + ">3.7,<3.11", + ">3.7,<3.11,!=3.8.4", + "~=3.10,!=3.10.3", + ], +) +def test_requires_python(value: str) -> None: + packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "0.1.0", + "requires-python": value, + }, + } + ) + + +def test_version_dynamic() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "dynamic": [ + "version", + ], + }, + } + ) + metadata.version = packaging.version.Version("1.2.3") + + +def test_modify_dynamic() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "1.2.3", + "dynamic": [ + "requires-python", + ], + }, + } + ) + metadata.requires_python = packaging.specifiers.SpecifierSet(">=3.12") + metadata.version = packaging.version.Version("1.2.3") + + +def test_extra_top_level() -> None: + assert not packaging.project.extras_top_level( + {"project": {}, "dependency-groups": {}} + ) + assert {"also-not-real", "not-real"} == packaging.project.extras_top_level( + { + "not-real": {}, + "also-not-real": {}, + "project": {}, + "build-system": {}, + } + ) + + +def test_extra_build_system() -> None: + assert not packaging.project.extras_build_system( + { + "build-system": { + "build-backend": "one", + "requires": ["two"], + "backend-path": "local", + }, + } + ) + assert {"also-not-real", "not-real"} == packaging.project.extras_build_system( + { + "build-system": { + "not-real": {}, + "also-not-real": {}, + } + } + ) + + +def test_multiline_description_warns() -> None: + with raises_single( + packaging.errors.ConfigurationError, + 'The one-line summary "project.description" should not contain more than one line. Readers might merge or truncate newlines.', + "Failed to parse pyproject.toml", + ): + packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "1.2.3", + "description": "this\nis multiline", + }, + } + ) diff --git a/tests/test_project_rfc822.py b/tests/test_project_rfc822.py new file mode 100644 index 000000000..4a566fe71 --- /dev/null +++ b/tests/test_project_rfc822.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import inspect + +import packaging.metadata +import packaging.project + + +def test_convert_optional_dependencies() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "0.1.0", + "optional-dependencies": { + "test": [ + 'foo; os_name == "nt" or sys_platform == "win32"', + 'bar; os_name == "posix" and sys_platform == "linux"', + ], + }, + }, + } + ) + message = metadata.metadata(metadata_version="2.1").as_rfc822() + requires = message.get_all("requires-dist") + assert requires == [ + 'foo; (os_name == "nt" or sys_platform == "win32") and extra == "test"', + 'bar; os_name == "posix" and sys_platform == "linux" and extra == "test"', + ] + + +def test_convert_author_email() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "0.1.0", + "authors": [ + { + "name": "John Doe, Inc.", + "email": "johndoe@example.com", + }, + { + "name": "Kate Doe, LLC.", + "email": "katedoe@example.com", + }, + ], + }, + } + ) + message = metadata.metadata(metadata_version="2.3").as_rfc822() + assert message.get_all("Author-Email") == [ + '"John Doe, Inc." , "Kate Doe, LLC." ' + ] + + +def test_long_version() -> None: + metadata = packaging.project.StandardMetadata.from_pyproject( + { + "project": { + "name": "example", + "version": "0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters", + } + } + ) + message = metadata.metadata(metadata_version="2.1").as_rfc822() + assert ( + message.get("Version") + == "0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters" + ) + assert ( + bytes(message) + == inspect.cleandoc( + """ + metadata-version: 2.1 + name: example + version: 0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters + """ + ).encode("utf-8") + + b"\n\n" + ) + assert ( + str(message) + == inspect.cleandoc( + """ + metadata-version: 2.1 + name: example + version: 0.0.0+super.duper.long.version.string.that.is.longer.than.sixty.seven.characters + """ + ) + + "\n\n" + ) diff --git a/tests/test_project_table.py b/tests/test_project_table.py new file mode 100644 index 000000000..44c657c00 --- /dev/null +++ b/tests/test_project_table.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import re +import sys + +import pytest + +from packaging.project_table import ( + BuildSystemTable, + IncludeGroupTable, + ProjectTable, + PyProjectTable, + to_project_table, +) + +if sys.version_info < (3, 11): + import tomli as tomllib +else: + import tomllib + + +def test_project_table() -> None: + table = PyProjectTable( + { + "build-system": BuildSystemTable( + {"build-backend": "one", "requires": ["two"]} + ), + "project": ProjectTable( + { + "name": "one", + "version": "0.1.0", + } + ), + "tool": {"thing": object()}, + "dependency-groups": { + "one": [ + "one", + IncludeGroupTable({"include-group": "two"}), + ] + }, + } + ) + + assert table.get("build-system", {}).get("build-backend", "") == "one" + assert table.get("project", {}).get("name", "") == "one" + assert table.get("tool", {}).get("thing") is not None + assert table.get("dependency-groups", {}).get("one") is not None + + +def test_project_table_type_only() -> None: + table: PyProjectTable = { + "build-system": {"build-backend": "one", "requires": ["two"]}, + "project": { + "name": "one", + "version": "0.1.0", + }, + "tool": {"thing": object()}, + "dependency-groups": { + "one": [ + "one", + {"include-group": "two"}, + ] + }, + } + + assert table.get("build-system", {}).get("build-backend", "") == "one" + assert table.get("project", {}).get("name", "") == "one" + assert table.get("tool", {}).get("thing") is not None + assert table.get("dependency-groups", {}).get("one") is not None + + +@pytest.mark.parametrize( + "toml_string", + [ + pytest.param( + """ + [build-system] + build-backend = "one" + requires = ["two"] + + [project] + name = "one" + version = "0.1.0" + license.text = "MIT" + authors = [ + { name = "Example Author", email = "author@example.com" }, + { name = "Second Author" }, + { email = "author3@example.com" }, + ] + + [project.entry-points] + some-ep = { thing = "thing:main" } + + [project.scripts] + my-script = "thing:cli" + + [project.optional-dependencies] + test = ["pytest"] + + [tool.thing] + + [dependency-groups] + one = [ + "one", + { include-group = "two" }, + ] + """, + id="large example", + ), + pytest.param( + """ + [project] + name = "example" + """, + id="minimal example", + ), + pytest.param( + """ + [project] + name = "example" + license = "MIT" + """, + id="license as str", + ), + pytest.param( + """ + [project] + name = "example" + unknown-key = 123 + authors = [ + { other-key = "also ignored" }, + ] + license.unreal = "ignored as well" + readme.nothing = "ignored too" + """, + id="extra keys are ignored", # TypedDict's are not complete + ), + pytest.param( + """ + [project] + name = "example" + dynamic = ["version", "readme"] + """, + id="dynamic field", + ), + ], +) +def test_conversion_fn(toml_string: str) -> None: + data = tomllib.loads(toml_string) + table = to_project_table(data) + assert table == data + + +@pytest.mark.parametrize( + ("toml_string", "expected_msg"), + [ + pytest.param( + """ + [project] + """, + 'Key "project.name" is required if "project" is present', + id="missing required project.name", + ), + pytest.param( + """ + [project] + name = 123 + """, + '"project.name" expected str, got int', + id="bad project.name type", + ), + pytest.param( + """ + [build-system] + build-backend = "one" + requires = "two" # should be List[str] + + [project] + name = "one" + version = "0.1.0" + """, + '"build-system.requires" expected list, got str', + id="bad build-system.requires type", + ), + pytest.param( + """ + [dependency-groups] + one = [ + "one", + { include-group = 123 }, # should be str + ] + + [project] + name = "one" + version = "0.1.0" + """, + '"dependency-groups.one[]" does not match any type in str | IncludeGroupTable', + id="bad nested in dictionary type", + ), + pytest.param( + """ + [project] + name = "example" + [project.license] + text = 123 + """, + '"project.license" does not match any type in LicenseTable | str', + id="project.license.text bad nested dict type", + ), + pytest.param( + """ + [project] + name = "example" + [project.entry-points] + console_scripts = { bad = 123 } + """, + '"project.entry-points.console_scripts.bad" expected str, got int', + id="nested dicts of dicts bad type", + ), + pytest.param( + """ + [project] + name = "example" + dynamic = ["notreal"] + """, + '"project.dynamic[]" expected one of', + id="Invalid dynamic value", + ), + pytest.param( + """ + [project] + name = "example" + + [project.optional-dependencies] + test = "notalist" + """, + '"project.optional-dependencies.test" expected list, got str', + id="bad optional-dependencies type", + ), + ], +) +def test_conversion_fn_bad_type(toml_string: str, expected_msg: str) -> None: + data = tomllib.loads(toml_string) + with pytest.raises(TypeError, match=re.escape(expected_msg)): + to_project_table(data)