Skip to content

Commit 149b776

Browse files
Merge branch 'pypa:main' into issue-774-mitigation-by-lazy-eval
2 parents e48ea29 + 029f415 commit 149b776

11 files changed

+1270
-7
lines changed

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ The ``packaging`` library uses calendar-based versioning (``YY.N``).
2626
version
2727
specifiers
2828
markers
29+
licenses
2930
requirements
3031
metadata
3132
tags

docs/licenses.rst

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
Licenses
2+
=========
3+
4+
.. currentmodule:: packaging.licenses
5+
6+
7+
Helper for canonicalizing SPDX
8+
`License-Expression metadata <https://peps.python.org/pep-0639/#term-license-expression>`__
9+
as `defined in PEP 639 <https://peps.python.org/pep-0639/#spdx>`__.
10+
11+
12+
Reference
13+
---------
14+
15+
.. class:: NormalizedLicenseExpression
16+
17+
A :class:`typing.NewType` of :class:`str`, representing a normalized
18+
License-Expression.
19+
20+
21+
.. exception:: InvalidLicenseExpression
22+
23+
Raised when a License-Expression is invalid.
24+
25+
26+
.. function:: canonicalize_license_expression(raw_license_expression)
27+
28+
This function takes a valid License-Expression, and returns the normalized form of it.
29+
30+
The return type is typed as :class:`NormalizedLicenseExpression`. This allows type
31+
checkers to help require that a string has passed through this function
32+
before use.
33+
34+
:param str raw_license_expression: The License-Expression to canonicalize.
35+
:raises InvalidLicenseExpression: If the License-Expression is invalid due to an
36+
invalid/unknown license identifier or invalid syntax.
37+
38+
.. doctest::
39+
40+
>>> from packaging.licenses import canonicalize_license_expression
41+
>>> canonicalize_license_expression("mit")
42+
'MIT'
43+
>>> canonicalize_license_expression("mit and (apache-2.0 or bsd-2-clause)")
44+
'MIT AND (Apache-2.0 OR BSD-2-Clause)'
45+
>>> canonicalize_license_expression("(mit")
46+
Traceback (most recent call last):
47+
...
48+
InvalidLicenseExpression: Invalid license expression: '(mit'
49+
>>> canonicalize_license_expression("Use-it-after-midnight")
50+
Traceback (most recent call last):
51+
...
52+
InvalidLicenseExpression: Unknown license: 'Use-it-after-midnight'

noxfile.py

+6
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ def release(session):
181181
webbrowser.open("https://github.com/pypa/packaging/releases")
182182

183183

184+
@nox.session
185+
def update_licenses(session: nox.Session) -> None:
186+
session.install("httpx")
187+
session.run("python", "tasks/licenses.py")
188+
189+
184190
# -----------------------------------------------------------------------------
185191
# Helpers
186192
# -----------------------------------------------------------------------------

pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ warn_unused_ignores = true
5757
module = ["_manylinux"]
5858
ignore_missing_imports = true
5959

60-
6160
[tool.ruff]
6261
src = ["src"]
62+
extend-exclude = [
63+
"src/packaging/licenses/_spdx.py"
64+
]
6365

6466
[tool.ruff.lint]
6567
extend-select = [

src/packaging/licenses/__init__.py

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#######################################################################################
2+
#
3+
# Adapted from:
4+
# https://github.com/pypa/hatch/blob/5352e44/backend/src/hatchling/licenses/parse.py
5+
#
6+
# MIT License
7+
#
8+
# Copyright (c) 2017-present Ofek Lev <[email protected]>
9+
#
10+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this
11+
# software and associated documentation files (the "Software"), to deal in the Software
12+
# without restriction, including without limitation the rights to use, copy, modify,
13+
# merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
14+
# permit persons to whom the Software is furnished to do so, subject to the following
15+
# conditions:
16+
#
17+
# The above copyright notice and this permission notice shall be included in all copies
18+
# or substantial portions of the Software.
19+
#
20+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
21+
# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
22+
# PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23+
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
24+
# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
25+
# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26+
#
27+
#
28+
# With additional allowance of arbitrary `LicenseRef-` identifiers, not just
29+
# `LicenseRef-Public-Domain` and `LicenseRef-Proprietary`.
30+
#
31+
#######################################################################################
32+
from __future__ import annotations
33+
34+
import re
35+
from typing import NewType, cast
36+
37+
from packaging.licenses._spdx import EXCEPTIONS, LICENSES
38+
39+
__all__ = [
40+
"NormalizedLicenseExpression",
41+
"InvalidLicenseExpression",
42+
"canonicalize_license_expression",
43+
]
44+
45+
license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$")
46+
47+
NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str)
48+
49+
50+
class InvalidLicenseExpression(ValueError):
51+
"""Raised when a license-expression string is invalid
52+
53+
>>> canonicalize_license_expression("invalid")
54+
Traceback (most recent call last):
55+
...
56+
packaging.licenses.InvalidLicenseExpression: Invalid license expression: 'invalid'
57+
"""
58+
59+
60+
def canonicalize_license_expression(
61+
raw_license_expression: str,
62+
) -> NormalizedLicenseExpression:
63+
if not raw_license_expression:
64+
message = f"Invalid license expression: {raw_license_expression!r}"
65+
raise InvalidLicenseExpression(message)
66+
67+
# Pad any parentheses so tokenization can be achieved by merely splitting on
68+
# whitespace.
69+
license_expression = raw_license_expression.replace("(", " ( ").replace(")", " ) ")
70+
licenseref_prefix = "LicenseRef-"
71+
license_refs = {
72+
ref.lower(): "LicenseRef-" + ref[len(licenseref_prefix) :]
73+
for ref in license_expression.split()
74+
if ref.lower().startswith(licenseref_prefix.lower())
75+
}
76+
77+
# Normalize to lower case so we can look up licenses/exceptions
78+
# and so boolean operators are Python-compatible.
79+
license_expression = license_expression.lower()
80+
81+
tokens = license_expression.split()
82+
83+
# Rather than implementing boolean logic, we create an expression that Python can
84+
# parse. Everything that is not involved with the grammar itself is treated as
85+
# `False` and the expression should evaluate as such.
86+
python_tokens = []
87+
for token in tokens:
88+
if token not in {"or", "and", "with", "(", ")"}:
89+
python_tokens.append("False")
90+
elif token == "with":
91+
python_tokens.append("or")
92+
elif token == "(" and python_tokens and python_tokens[-1] not in {"or", "and"}:
93+
message = f"Invalid license expression: {raw_license_expression!r}"
94+
raise InvalidLicenseExpression(message)
95+
else:
96+
python_tokens.append(token)
97+
98+
python_expression = " ".join(python_tokens)
99+
try:
100+
invalid = eval(python_expression, globals(), locals())
101+
except Exception:
102+
invalid = True
103+
104+
if invalid is not False:
105+
message = f"Invalid license expression: {raw_license_expression!r}"
106+
raise InvalidLicenseExpression(message) from None
107+
108+
# Take a final pass to check for unknown licenses/exceptions.
109+
normalized_tokens = []
110+
for token in tokens:
111+
if token in {"or", "and", "with", "(", ")"}:
112+
normalized_tokens.append(token.upper())
113+
continue
114+
115+
if normalized_tokens and normalized_tokens[-1] == "WITH":
116+
if token not in EXCEPTIONS:
117+
message = f"Unknown license exception: {token!r}"
118+
raise InvalidLicenseExpression(message)
119+
120+
normalized_tokens.append(EXCEPTIONS[token]["id"])
121+
else:
122+
if token.endswith("+"):
123+
final_token = token[:-1]
124+
suffix = "+"
125+
else:
126+
final_token = token
127+
suffix = ""
128+
129+
if final_token.startswith("licenseref-"):
130+
if not license_ref_allowed.match(final_token):
131+
message = f"Invalid licenseref: {final_token!r}"
132+
raise InvalidLicenseExpression(message)
133+
normalized_tokens.append(license_refs[final_token] + suffix)
134+
else:
135+
if final_token not in LICENSES:
136+
message = f"Unknown license: {final_token!r}"
137+
raise InvalidLicenseExpression(message)
138+
normalized_tokens.append(LICENSES[final_token]["id"] + suffix)
139+
140+
normalized_expression = " ".join(normalized_tokens)
141+
142+
return cast(
143+
NormalizedLicenseExpression,
144+
normalized_expression.replace("( ", "(").replace(" )", ")"),
145+
)

0 commit comments

Comments
 (0)