diff --git a/src/validate_pyproject/extra_validations.py b/src/validate_pyproject/extra_validations.py index b99d9c91..72a7132f 100644 --- a/src/validate_pyproject/extra_validations.py +++ b/src/validate_pyproject/extra_validations.py @@ -3,8 +3,10 @@ JSON Schema library). """ +import collections +import itertools from inspect import cleandoc -from typing import Mapping, TypeVar +from typing import Generator, Iterable, Mapping, TypeVar from .error_reporting import ValidationError @@ -30,6 +32,24 @@ class IncludedDependencyGroupMustExist(ValidationError): _URL = "https://peps.python.org/pep-0735/" +class ImportNameCollision(ValidationError): + _DESC = """According to PEP 794: + + All import-names and import-namespaces items must be unique. + """ + __doc__ = _DESC + _URL = "https://peps.python.org/pep-0794/" + + +class ImportNameMissing(ValidationError): + _DESC = """According to PEP 794: + + An import name must have all parents listed. + """ + __doc__ = _DESC + _URL = "https://peps.python.org/pep-0794/" + + def validate_project_dynamic(pyproject: T) -> T: project_table = pyproject.get("project", {}) dynamic = project_table.get("dynamic", []) @@ -78,4 +98,54 @@ def validate_include_depenency(pyproject: T) -> T: return pyproject -EXTRA_VALIDATIONS = (validate_project_dynamic, validate_include_depenency) +def _remove_private(items: Iterable[str]) -> Generator[str, None, None]: + for item in items: + yield item.partition(";")[0].rstrip() + + +def validate_import_name_issues(pyproject: T) -> T: + project = pyproject.get("project", {}) + import_names = collections.Counter(_remove_private(project.get("import-names", []))) + import_namespaces = collections.Counter( + _remove_private(project.get("import-namespaces", [])) + ) + + duplicated = [k for k, v in (import_names + import_namespaces).items() if v > 1] + + if duplicated: + raise ImportNameCollision( + message="Duplicated names are not allowed in import-names/import-namespaces", + value=duplicated, + name="data.project.importnames(paces)", + definition={ + "description": cleandoc(ImportNameCollision._DESC), + "see": ImportNameCollision._URL, + }, + rule="PEP 794", + ) + + names = frozenset(import_names + import_namespaces) + for name in names: + for parent in itertools.accumulate( + name.split(".")[:-1], lambda a, b: f"{a}.{b}" + ): + if parent not in names: + raise ImportNameMissing( + message="All parents of an import name must also be listed in import-namespace/import-names", + value=name, + name="data.project.importnames(paces)", + definition={ + "description": cleandoc(ImportNameMissing._DESC), + "see": ImportNameMissing._URL, + }, + rule="PEP 794", + ) + + return pyproject + + +EXTRA_VALIDATIONS = ( + validate_project_dynamic, + validate_include_depenency, + validate_import_name_issues, +) diff --git a/src/validate_pyproject/formats.py b/src/validate_pyproject/formats.py index 1cf4a465..bb70b882 100644 --- a/src/validate_pyproject/formats.py +++ b/src/validate_pyproject/formats.py @@ -8,6 +8,7 @@ """ import builtins +import keyword import logging import os import re @@ -400,3 +401,27 @@ def SPDX(value: str) -> bool: def SPDX(value: str) -> bool: return True + + +VALID_IMPORT_NAME = re.compile( + r""" + ^ # start of string + [A-Za-z_][A-Za-z_0-9]+ # a valid Python identifier + (?:\.[A-Za-z_][A-Za-z_0-9]*)* # optionally followed by .identifier's + (?:\s*;\s*private)? # optionally followed by ; private + $ # end of string + """, + re.VERBOSE, +) + + +def import_name(value: str) -> bool: + """This is a valid import name. It has to be series of python identifiers + (not keywords), separated by dots, optionally followed by a semicolon and + the keyword "private". + """ + if VALID_IMPORT_NAME.match(value) is None: + return False + + idents, _, _ = value.partition(";") + return all(not keyword.iskeyword(ident) for ident in idents.rstrip().split(".")) diff --git a/src/validate_pyproject/project_metadata.schema.json b/src/validate_pyproject/project_metadata.schema.json index c04e6d3a..78b06de4 100644 --- a/src/validate_pyproject/project_metadata.schema.json +++ b/src/validate_pyproject/project_metadata.schema.json @@ -233,6 +233,22 @@ } } }, + "import-names": { + "description": "Lists import names which a project, when installed, would exclusively provide.", + "type": "array", + "items": { + "type": "string", + "format": "import-name" + } + }, + "import-namespaces": { + "description": "Lists import names that, when installed, would be provided by the project, but not exclusively.", + "type": "array", + "items": { + "type": "string", + "format": "import-name" + } + }, "dynamic": { "type": "array", "$$description": [ @@ -256,7 +272,9 @@ "gui-scripts", "entry-points", "dependencies", - "optional-dependencies" + "optional-dependencies", + "import-names", + "import-namespaces" ] } } diff --git a/tests/examples/pdm/pyproject.toml b/tests/examples/pdm/pyproject.toml index dd22caa9..fa69c2e8 100644 --- a/tests/examples/pdm/pyproject.toml +++ b/tests/examples/pdm/pyproject.toml @@ -11,6 +11,8 @@ requires-python = ">=3.9" readme = "README.md" keywords = ["packaging", "PEP 517", "build"] dynamic = ["version"] +import-names = ["pdm.backend"] +import-namespaces = ["pdm"] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/examples/simple/import_names.toml b/tests/examples/simple/import_names.toml new file mode 100644 index 00000000..a8581fa4 --- /dev/null +++ b/tests/examples/simple/import_names.toml @@ -0,0 +1,5 @@ +[project] +name = "hi" +version = "1.0.0" +import-names = ["one", "one.two", "two; private"] +import-namespaces = ["_other ; private"] diff --git a/tests/examples/trampolim/pyproject.toml b/tests/examples/trampolim/pyproject.toml index 9bc98867..87a4ca4b 100644 --- a/tests/examples/trampolim/pyproject.toml +++ b/tests/examples/trampolim/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ 'Development Status :: 4 - Beta', 'Programming Language :: Python' ] - +import-namespaces = ["trampolim"] dependencies = [ 'tomli>=1.0.0', 'packaging', diff --git a/tests/invalid-examples/simple/pep794-dup.errors.txt b/tests/invalid-examples/simple/pep794-dup.errors.txt new file mode 100644 index 00000000..447482e2 --- /dev/null +++ b/tests/invalid-examples/simple/pep794-dup.errors.txt @@ -0,0 +1 @@ +Duplicated names are not allowed in import-names/import-namespaces diff --git a/tests/invalid-examples/simple/pep794-dup.toml b/tests/invalid-examples/simple/pep794-dup.toml new file mode 100644 index 00000000..7613dcc8 --- /dev/null +++ b/tests/invalid-examples/simple/pep794-dup.toml @@ -0,0 +1,5 @@ +[project] +name = "hi" +version = "1.0.0" +import-names = ["one"] +import-namespaces = ["one; private"] diff --git a/tests/invalid-examples/simple/pep794-keyword.errors.txt b/tests/invalid-examples/simple/pep794-keyword.errors.txt new file mode 100644 index 00000000..071be3a7 --- /dev/null +++ b/tests/invalid-examples/simple/pep794-keyword.errors.txt @@ -0,0 +1 @@ +project.import-namespaces[0]` must be import-name diff --git a/tests/invalid-examples/simple/pep794-keyword.toml b/tests/invalid-examples/simple/pep794-keyword.toml new file mode 100644 index 00000000..052701bb --- /dev/null +++ b/tests/invalid-examples/simple/pep794-keyword.toml @@ -0,0 +1,4 @@ +[project] +name = "hi" +version = "1.0.0" +import-namespaces = ["class"] diff --git a/tests/invalid-examples/simple/pep794-missing-parents.errors.txt b/tests/invalid-examples/simple/pep794-missing-parents.errors.txt new file mode 100644 index 00000000..ac1a1c9c --- /dev/null +++ b/tests/invalid-examples/simple/pep794-missing-parents.errors.txt @@ -0,0 +1 @@ +All parents of an import name must also be listed in import-namespace/import-names diff --git a/tests/invalid-examples/simple/pep794-missing-parents.toml b/tests/invalid-examples/simple/pep794-missing-parents.toml new file mode 100644 index 00000000..f1f09d02 --- /dev/null +++ b/tests/invalid-examples/simple/pep794-missing-parents.toml @@ -0,0 +1,4 @@ +[project] +name = "test" +version = "1.0.0" +import-names = ["one.two"] diff --git a/tests/invalid-examples/simple/pep794-not-private.errors.txt b/tests/invalid-examples/simple/pep794-not-private.errors.txt new file mode 100644 index 00000000..1f85645d --- /dev/null +++ b/tests/invalid-examples/simple/pep794-not-private.errors.txt @@ -0,0 +1 @@ +project.import-names[0]` must be import-name diff --git a/tests/invalid-examples/simple/pep794-not-private.toml b/tests/invalid-examples/simple/pep794-not-private.toml new file mode 100644 index 00000000..540d1705 --- /dev/null +++ b/tests/invalid-examples/simple/pep794-not-private.toml @@ -0,0 +1,4 @@ +[project] +name = "test" +version = "1.0.0" +import-names = ["one; public"] diff --git a/tests/test_formats.py b/tests/test_formats.py index 5bcea215..3ba17ca1 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -455,3 +455,16 @@ def _failed_download(): def test_private_classifier(): assert formats.trove_classifier("private :: Keep Off PyPI") is True assert formats.trove_classifier("private:: Keep Off PyPI") is False + + +def test_import_name(): + assert formats.import_name("simple") + assert formats.import_name("some1; private") + assert formats.import_name("other.thing ; private") + assert formats.import_name("_other._thing ; private") + + assert not formats.import_name("one two") + assert not formats.import_name("one; two") + assert not formats.import_name("1thing") + assert not formats.import_name("for") + assert not formats.import_name("thing.is.keyword")