Skip to content

Commit 4b9c6d3

Browse files
committed
Add support for PEP 639 license expressions.
1 parent ac6eb38 commit 4b9c6d3

21 files changed

+306
-11
lines changed

pyproject_parser/classes.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ class License:
242242
.. latex:vspace:: 20px
243243
244244
.. autosummary-widths:: 6/16
245+
246+
.. versionchanged:: 0.14.0 Add ``expression`` option.
245247
"""
246248

247249
#: The path to the license file.
@@ -250,10 +252,19 @@ class License:
250252
#: The content of the license.
251253
text: Optional[str] = attr.ib(default=None)
252254

255+
#: An SPDX License Expression (for :pep:`639`).
256+
expression: Optional[str] = attr.ib(default=None)
257+
253258
def __attrs_post_init__(self) -> None:
254259
# Sanity checks the supplied arguments
255-
if self.text is None and self.file is None:
256-
raise TypeError(f"At least one of 'text' and 'file' must be supplied to {self.__class__!r}")
260+
if self.expression:
261+
if self.text is not None or self.file is not None:
262+
raise TypeError(
263+
f"Cannot supply a licence expression alongside 'text' and/or 'file' to {self.__class__!r}"
264+
)
265+
else:
266+
if self.text is None and self.file is None:
267+
raise TypeError(f"At least one of 'text' and 'file' must be supplied to {self.__class__!r}")
257268

258269
# if self.text is not None and self.file is not None:
259270
# raise TypeError("'text' and 'filename' are mutually exclusive.")
@@ -294,6 +305,8 @@ def to_dict(self) -> Dict[str, str]:
294305
as_dict["file"] = self.file.as_posix()
295306
if self.text is not None:
296307
as_dict["text"] = self.text
308+
if self.expression is not None:
309+
as_dict["expression"] = self.expression
297310

298311
return as_dict
299312

pyproject_parser/parsers.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from typing import Any, Callable, ClassVar, Dict, Iterable, List, Mapping, Set, Union, cast
3737

3838
# 3rd party
39+
import license_expression # type: ignore[import]
3940
from apeye_core import URL
4041
from apeye_core.email_validator import EmailSyntaxError, validate_email
4142
from dom_toml.parser import TOML_TYPES, AbstractConfigParser, BadConfigError, construct_path
@@ -50,7 +51,12 @@
5051
# this package
5152
from pyproject_parser.classes import License, Readme, _NormalisedName
5253
from pyproject_parser.type_hints import Author, BuildSystemDict, DependencyGroupsDict, ProjectDict
53-
from pyproject_parser.utils import PyProjectDeprecationWarning, content_type_from_filename, render_readme
54+
from pyproject_parser.utils import (
55+
PyProjectDeprecationWarning,
56+
content_type_from_filename,
57+
indent_join,
58+
render_readme
59+
)
5460

5561
__all__ = [
5662
"RequiredKeysConfigParser",
@@ -599,13 +605,13 @@ def parse_requires_python(config: Dict[str, TOML_TYPES]) -> SpecifierSet:
599605
raise
600606

601607
@staticmethod
602-
@_documentation_url("https://whey.readthedocs.io/en/latest/configuration.html#tconf-project.license")
608+
@_documentation_url("https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license")
603609
def parse_license(config: Dict[str, TOML_TYPES]) -> License:
604610
"""
605611
Parse the :pep621:`license` key.
606612
607-
* **Format**: :toml:`Table`
608-
* **Core Metadata**: :core-meta:`License`
613+
* **Format**: :toml:`Table` or :toml:`String`
614+
* **Core Metadata**: :core-meta:`License` or :core-meta:`License-Expression`
609615
610616
The table may have one of two keys:
611617
@@ -615,6 +621,9 @@ def parse_license(config: Dict[str, TOML_TYPES]) -> License:
615621
616622
These keys are mutually exclusive, so a tool MUST raise an error if the metadata specifies both keys.
617623
624+
Alternatively, for :pep:`639` support, the value may be a string giving an
625+
`SPDX License Expression <https://packaging.python.org/en/latest/glossary/#term-License-Expression>`_.
626+
618627
:bold-title:`Example:`
619628
620629
.. code-block:: TOML
@@ -631,11 +640,31 @@ def parse_license(config: Dict[str, TOML_TYPES]) -> License:
631640
and then the user promises not to redistribute it.
632641
\"\"\"
633642
643+
[project]
644+
license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
645+
634646
:param config: The unparsed TOML config for the :pep621:`project table <table-name>`.
635647
""" # noqa: D300,D301
636648

637649
project_license = config["license"]
638650

651+
if isinstance(project_license, str):
652+
# PEP 639
653+
if project_license.startswith("LicenseRef-"):
654+
return License(expression=project_license)
655+
else:
656+
licensing = license_expression.get_spdx_licensing()
657+
validated_spdx = licensing.validate(project_license)
658+
if validated_spdx.errors:
659+
# validated_spdx.errors.append("Another Error")
660+
warning_msg = "'project.license-key' is not a valid SPDX Expression: "
661+
warning_msg += indent_join(validated_spdx.errors)
662+
raise BadConfigError(warning_msg)
663+
else:
664+
# TODO: normalise, maybe from validated_spdx object
665+
return License(expression=project_license)
666+
667+
# Traditional PEP 621
639668
if "text" in project_license and "file" in project_license:
640669
raise BadConfigError(
641670
"The 'project.license.file' and 'project.license.text' keys "

pyproject_parser/utils.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
import io
3232
import os
3333
import sys
34-
from typing import TYPE_CHECKING, Any, Dict, Optional
34+
import textwrap
35+
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Optional
3536

3637
# 3rd party
3738
import dom_toml
@@ -43,7 +44,14 @@
4344
# this package
4445
from pyproject_parser.type_hints import ContentTypes
4546

46-
__all__ = ["render_markdown", "render_rst", "content_type_from_filename", "PyProjectDeprecationWarning"]
47+
__all__ = [
48+
"render_markdown",
49+
"render_rst",
50+
"content_type_from_filename",
51+
"PyProjectDeprecationWarning",
52+
"indent_join",
53+
"indent_with_tab"
54+
]
4755

4856

4957
def render_markdown(content: str) -> None:
@@ -187,3 +195,44 @@ def _load_toml(filename: PathLike, ) -> Dict[str, Any]:
187195
"""
188196

189197
return dom_toml.load(filename)
198+
199+
200+
def indent_join(iterable: Iterable[str]) -> str:
201+
"""
202+
Join an iterable of strings with newlines, and indent each line with a tab if there is more then one element.
203+
204+
:param iterable:
205+
206+
:rtype:
207+
208+
.. versionadded:: 0.14.0
209+
"""
210+
211+
iterable = list(iterable)
212+
if len(iterable) > 1:
213+
if not iterable[0] == '':
214+
iterable.insert(0, '')
215+
216+
return indent_with_tab(textwrap.dedent('\n'.join(iterable)))
217+
218+
219+
def indent_with_tab(
220+
text: str,
221+
depth: int = 1,
222+
predicate: Optional[Callable[[str], bool]] = None,
223+
) -> str:
224+
r"""
225+
Adds ``'\t'`` to the beginning of selected lines in 'text'.
226+
227+
:param text: The text to indent.
228+
:param depth: The depth of the indentation.
229+
:param predicate: If given, ``'\t'`` will only be added to the lines where ``predicate(line)``
230+
is :py:obj`True`. If ``predicate`` is not provided, it will default to adding ``'\t'``
231+
to all non-empty lines that do not consist solely of whitespace characters.
232+
233+
:rtype:
234+
235+
.. versionadded:: 0.14.0
236+
"""
237+
238+
return textwrap.indent(text, '\t' * depth, predicate=predicate)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ apeye-core>=1.0.0
22
attrs>=20.3.0
33
dom-toml>=2.0.0
44
domdf-python-tools>=2.8.0
5+
license-expression>=30.0.0
56
natsort>=7.1.1
67
packaging>=20.9
78
shippinglabel>=1.0.0

tests/test_config.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
[
3434
*valid_pep621_config,
3535
pytest.param(f"{OPTIONAL_DEPENDENCIES}dev-test = ['black']\n", id="optional-dependencies-hyphen"),
36+
pytest.param(f"{MINIMAL_CONFIG}\nlicense = 'GPL-3.0-or-later'\n", id="pep639-gpl"),
37+
pytest.param(
38+
f"{MINIMAL_CONFIG}\nlicense = 'MIT AND (Apache-2.0 OR BSD-2-Clause)'\n",
39+
id="pep639-and-or",
40+
),
41+
pytest.param(f"{MINIMAL_CONFIG}\nlicense = 'LicenseRef-My-Custom-License'\n", id="pep639-custom"),
3642
]
3743
)
3844
def test_pep621_class_valid_config(
@@ -57,7 +63,18 @@ class ReducedPEP621Parser(PEP621Parser, inherit_defaults=True):
5763
keys = ["name", "version", "dependencies"]
5864

5965

60-
@pytest.mark.parametrize("toml_config", valid_pep621_config)
66+
@pytest.mark.parametrize(
67+
"toml_config",
68+
[
69+
*valid_pep621_config,
70+
pytest.param(f"{MINIMAL_CONFIG}\nlicense = 'GPL-3.0-or-later'\n", id="pep639-gpl"),
71+
pytest.param(
72+
f"{MINIMAL_CONFIG}\nlicense = 'MIT AND (Apache-2.0 OR BSD-2-Clause)'\n",
73+
id="pep639-and-or",
74+
),
75+
pytest.param(f"{MINIMAL_CONFIG}\nlicense = 'LicenseRef-My-Custom-License'\n", id="pep639-custom"),
76+
]
77+
)
6178
def test_pep621_subclass(
6279
toml_config: str,
6380
tmp_pathplus: PathPlus,
@@ -387,6 +404,24 @@ def test_pep621_class_bad_config_license(
387404
"Invalid extra name 'number#1'",
388405
id="extra_invalid_c",
389406
),
407+
pytest.param(
408+
f"{MINIMAL_CONFIG}\nlicense = 'GPL-4.0'\n",
409+
BadConfigError,
410+
r"'project.license-key' is not a valid SPDX Expression: \tUnknown license key\(s\): GPL-4.0",
411+
id="pep639-gpl4",
412+
),
413+
pytest.param(
414+
f"{MINIMAL_CONFIG}\nlicense = 'MIX an (Apache-2.0'\n",
415+
BadConfigError,
416+
r"'project.license-key' is not a valid SPDX Expression: \tInvalid expression nesting such as \(AND xx\) for token: \"\(\" at position: 7",
417+
id="pep639-mangled",
418+
),
419+
pytest.param(
420+
f"{MINIMAL_CONFIG}\nlicense = 'My-Custom-License'\n",
421+
BadConfigError,
422+
r"'project.license-key' is not a valid SPDX Expression: \tUnknown license key\(s\): My-Custom-License",
423+
id="pep639-custom",
424+
),
390425
]
391426
)
392427
def test_pep621_class_bad_config(
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dynamic: []
2+
license:
3+
expression: MIT AND (Apache-2.0 OR BSD-2-Clause)
4+
name: spam
5+
version: 2020.0.0
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
authors: []
2+
classifiers: []
3+
dependencies: []
4+
description: null
5+
dynamic: []
6+
entry-points: {}
7+
gui-scripts: {}
8+
keywords: []
9+
license:
10+
expression: MIT AND (Apache-2.0 OR BSD-2-Clause)
11+
maintainers: []
12+
name: spam
13+
optional-dependencies: {}
14+
readme: null
15+
requires-python: null
16+
scripts: {}
17+
urls: {}
18+
version: 2020.0.0
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dynamic: []
2+
license:
3+
expression: LicenseRef-My-Custom-License
4+
name: spam
5+
version: 2020.0.0
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
authors: []
2+
classifiers: []
3+
dependencies: []
4+
description: null
5+
dynamic: []
6+
entry-points: {}
7+
gui-scripts: {}
8+
keywords: []
9+
license:
10+
expression: LicenseRef-My-Custom-License
11+
maintainers: []
12+
name: spam
13+
optional-dependencies: {}
14+
readme: null
15+
requires-python: null
16+
scripts: {}
17+
urls: {}
18+
version: 2020.0.0
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dynamic: []
2+
license:
3+
expression: GPL-3.0-or-later
4+
name: spam
5+
version: 2020.0.0

0 commit comments

Comments
 (0)