Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions src/validate_pyproject/extra_validations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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", [])
Expand Down Expand Up @@ -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",
)
Comment on lines +113 to +125
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the duplication check can be added to the JSONSchema itself, by leveraging uniqueItems.

There is variability around \s*;\s*private, so the check would not be 100% perfect (so the extra validation may still be needed), but it would make the JSON schema more useful on its own.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do quite a bit more with the schema itself, and that's done in the SchemaStore version of the project table. For example, "dynamic" checking can be done via the schema. For this PR, the regex can be implemented too, so that the "format" only would add keyword checking. But I'd assume if you want these, you could use the SchemaStore versions, instead of pulling these out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, there's not a way to makes sure it's unique across multiple arrays?


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,
)
25 changes: 25 additions & 0 deletions src/validate_pyproject/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import builtins
import keyword
import logging
import os
import re
Expand Down Expand Up @@ -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("."))
20 changes: 19 additions & 1 deletion src/validate_pyproject/project_metadata.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -256,7 +272,9 @@
"gui-scripts",
"entry-points",
"dependencies",
"optional-dependencies"
"optional-dependencies",
"import-names",
"import-namespaces"
]
}
}
Expand Down
2 changes: 2 additions & 0 deletions tests/examples/pdm/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions tests/examples/simple/import_names.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "hi"
version = "1.0.0"
import-names = ["one", "one.two", "two; private"]
import-namespaces = ["_other ; private"]
2 changes: 1 addition & 1 deletion tests/examples/trampolim/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
'Development Status :: 4 - Beta',
'Programming Language :: Python'
]

import-namespaces = ["trampolim"]
dependencies = [
'tomli>=1.0.0',
'packaging',
Expand Down
1 change: 1 addition & 0 deletions tests/invalid-examples/simple/pep794-dup.errors.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Duplicated names are not allowed in import-names/import-namespaces
5 changes: 5 additions & 0 deletions tests/invalid-examples/simple/pep794-dup.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "hi"
version = "1.0.0"
import-names = ["one"]
import-namespaces = ["one; private"]
1 change: 1 addition & 0 deletions tests/invalid-examples/simple/pep794-keyword.errors.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
project.import-namespaces[0]` must be import-name
4 changes: 4 additions & 0 deletions tests/invalid-examples/simple/pep794-keyword.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "hi"
version = "1.0.0"
import-namespaces = ["class"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
All parents of an import name must also be listed in import-namespace/import-names
4 changes: 4 additions & 0 deletions tests/invalid-examples/simple/pep794-missing-parents.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "test"
version = "1.0.0"
import-names = ["one.two"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
project.import-names[0]` must be import-name
4 changes: 4 additions & 0 deletions tests/invalid-examples/simple/pep794-not-private.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[project]
name = "test"
version = "1.0.0"
import-names = ["one; public"]
13 changes: 13 additions & 0 deletions tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")