diff --git a/pyproject_metadata/__init__.py b/pyproject_metadata/__init__.py index ab31190..10c1a0d 100644 --- a/pyproject_metadata/__init__.py +++ b/pyproject_metadata/__init__.py @@ -37,6 +37,8 @@ import email.message import email.policy import email.utils +import itertools +import keyword import os import os.path import pathlib @@ -50,7 +52,7 @@ from .pyproject import License, PyProjectReader, Readme if typing.TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Generator, Mapping from typing import Any from packaging.requirements import Requirement @@ -137,7 +139,7 @@ class _SmartMessageSetter: message: email.message.Message def __setitem__(self, name: str, value: str | None) -> None: - if not value: + if value is None: return self.message[name] = value @@ -225,6 +227,46 @@ def _fold( return name + ": " + self.linesep.join(lines) + self.linesep # type: ignore[arg-type] +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 + + class RFC822Message(email.message.EmailMessage): """ This is :class:`email.message.EmailMessage` with two small changes: it defaults to @@ -271,6 +313,8 @@ class StandardMetadata: 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. @@ -301,6 +345,8 @@ def auto_metadata_version(self) -> str: if self.metadata_version is not None: return self.metadata_version + if self.import_names is not None or self.import_namespaces is not None: + return "2.5" if isinstance(self.license, str) or self.license_files is not None: return "2.4" if self.dynamic_metadata: @@ -460,6 +506,12 @@ def from_pyproject( # noqa: C901 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, dynamic_metadata=dynamic_metadata or [], metadata_version=metadata_version, @@ -504,6 +556,9 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901 - ``license`` is an SPDX license expression if metadata_version >= 2.4 - ``license_files`` is supported only for metadata_version >= 2.4 - ``project_url`` can't contain keys over 32 characters + - ``import-name(paces)s`` is only supported on metadata_version >= 2.5 + - ``import-name(space)s`` must be valid names, optionally with ``; private`` + - ``import-names`` and ``import-namespaces`` cannot overlap """ errors = ErrorCollector(collect_errors=self.all_errors) @@ -570,6 +625,37 @@ def validate(self, *, warn: bool = True) -> None: # noqa: C901 msg = "{key} names cannot be more than 32 characters long" errors.config_error(msg, key="project.urls", got=name) + if ( + self.import_names is not None + and self.auto_metadata_version in constants.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 self.auto_metadata_version in constants.PRE_2_5_METADATA_VERSIONS + ): + msg = "{key} is only supported when emitting metadata version >= 2.5" + errors.config_error(msg, key="project.import-namespaces") + + 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("Metadata validation failed") def _write_metadata( # noqa: C901 @@ -637,6 +723,13 @@ def _write_metadata( # noqa: C901 if self.readme.content_type: smart_message["Description-Content-Type"] = self.readme.content_type smart_message.set_payload(self.readme.text) + for import_name in self.import_names or []: + smart_message["Import-Name"] = import_name + for import_namespace in self.import_namespaces or []: + smart_message["Import-Namespace"] = import_namespace + # Special case for empty import-names + if self.import_names is not None and not self.import_names: + smart_message["Import-Name"] = "" # Core Metadata 2.2 if self.auto_metadata_version != "2.1": for field in self.dynamic_metadata: diff --git a/pyproject_metadata/constants.py b/pyproject_metadata/constants.py index afe4281..1149ea5 100644 --- a/pyproject_metadata/constants.py +++ b/pyproject_metadata/constants.py @@ -24,8 +24,9 @@ def __dir__() -> list[str]: return __all__ -KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} +KNOWN_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4", "2.5"} PRE_SPDX_METADATA_VERSIONS = {"2.1", "2.2", "2.3"} +PRE_2_5_METADATA_VERSIONS = {"2.1", "2.2", "2.3", "2.4"} PROJECT_TO_METADATA = { "authors": frozenset(["Author", "Author-Email"]), @@ -46,6 +47,8 @@ def __dir__() -> list[str]: "scripts": frozenset(), "urls": frozenset(["Project-URL"]), "version": frozenset(["Version"]), + "import-names": frozenset(["Import-Name"]), + "import-namespaces": frozenset(["Import-Namespaces"]), } KNOWN_TOPLEVEL_FIELDS = {"build-system", "project", "tool", "dependency-groups"} @@ -83,6 +86,8 @@ def __dir__() -> list[str]: "summary", "supported-platform", # Not specified via pyproject standards "version", # Can't be in dynamic + "import-name", + "import-namespace", } KNOWN_MULTIUSE = { @@ -100,4 +105,6 @@ def __dir__() -> list[str]: "requires", # Deprecated "obsoletes", # Deprecated "provides", # Deprecated + "import-name", + "import-namespace", } diff --git a/pyproject_metadata/project_table.py b/pyproject_metadata/project_table.py index 093e3f2..e79c883 100644 --- a/pyproject_metadata/project_table.py +++ b/pyproject_metadata/project_table.py @@ -68,6 +68,8 @@ class LicenseTable(TypedDict, total=False): "scripts", "urls", "version", + "import-names", + "import-namespaces", ] ProjectTable = TypedDict( @@ -90,6 +92,8 @@ class LicenseTable(TypedDict, total=False): "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, diff --git a/pyproject_metadata/pyproject.py b/pyproject_metadata/pyproject.py index d1822e1..c221517 100644 --- a/pyproject_metadata/pyproject.py +++ b/pyproject_metadata/pyproject.py @@ -81,8 +81,10 @@ def ensure_str(self, value: str, key: str) -> str | None: self.config_error(msg, key=key, got_type=type(value)) return None - def ensure_list(self, val: list[T], key: str) -> list[T] | 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)) diff --git a/tests/packages/metadata-2.5/LICENSE b/tests/packages/metadata-2.5/LICENSE new file mode 100644 index 0000000..c3713cd --- /dev/null +++ b/tests/packages/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/packages/metadata-2.5/README.md b/tests/packages/metadata-2.5/README.md new file mode 100644 index 0000000..d58d5a4 --- /dev/null +++ b/tests/packages/metadata-2.5/README.md @@ -0,0 +1 @@ +some readme 👋 diff --git a/tests/packages/metadata-2.5/metadata25.py b/tests/packages/metadata-2.5/metadata25.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/packages/metadata-2.5/pyproject.toml b/tests/packages/metadata-2.5/pyproject.toml new file mode 100644 index 0000000..b39fee6 --- /dev/null +++ b/tests/packages/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/test_standard_metadata.py b/tests/test_standard_metadata.py index 14ea64d..9d990d5 100644 --- a/tests/test_standard_metadata.py +++ b/tests/test_standard_metadata.py @@ -780,6 +780,78 @@ def all_errors(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) "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( @@ -886,6 +958,22 @@ def test_load( ], 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( @@ -1060,6 +1148,25 @@ def test_value(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None: ] +@pytest.mark.parametrize("after_rfc", [False, True]) +def test_value_25(after_rfc: bool, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(DIR / "packages/metadata-2.5") + with open("pyproject.toml", "rb") as f: + metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) + + if after_rfc: + metadata.as_rfc822() + + assert metadata.auto_metadata_version == "2.5" + + assert isinstance(metadata.license, str) + assert metadata.license == "MIT" + assert metadata.license_files == [pathlib.Path("LICENSE")] + + assert metadata.import_names == ["metadata25"] + assert metadata.import_namespaces is None + + def test_read_license(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/full-metadata2") with open("pyproject.toml", "rb") as f: @@ -1183,11 +1290,53 @@ def test_as_rfc822(monkeypatch: pytest.MonkeyPatch) -> None: assert core_metadata.get_payload() == "some readme 👋\n" +def test_rfc822_empty_import_name() -> None: + metadata = pyproject_metadata.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.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 = pyproject_metadata.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.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_json_spdx(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(DIR / "packages/spdx") with open("pyproject.toml", "rb") as f: metadata = pyproject_metadata.StandardMetadata.from_pyproject(tomllib.load(f)) + core_metadata = metadata.as_json() assert core_metadata == { "license_expression": "MIT OR GPL-2.0-or-later OR (FSFUL AND BSD-2-Clause)",