__init__.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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 <oss@ofek.dev>
  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. import re
  34. from typing import NewType, cast
  35. from ._spdx import EXCEPTIONS, LICENSES
  36. __all__ = [
  37. "InvalidLicenseExpression",
  38. "NormalizedLicenseExpression",
  39. "canonicalize_license_expression",
  40. ]
  41. # Simple __dir__ implementation since there are no public submodules
  42. def __dir__() -> list[str]:
  43. return __all__
  44. license_ref_allowed = re.compile("^[A-Za-z0-9.-]*$")
  45. NormalizedLicenseExpression = NewType("NormalizedLicenseExpression", str)
  46. """
  47. A :class:`typing.NewType` of :class:`str`, representing a normalized
  48. License-Expression.
  49. """
  50. class InvalidLicenseExpression(ValueError):
  51. """Raised when a license-expression string is invalid
  52. >>> from packaging.licenses import canonicalize_license_expression
  53. >>> canonicalize_license_expression("invalid")
  54. Traceback (most recent call last):
  55. ...
  56. packaging.licenses.InvalidLicenseExpression: Invalid license expression: 'invalid'
  57. """
  58. def canonicalize_license_expression(
  59. raw_license_expression: str,
  60. ) -> NormalizedLicenseExpression:
  61. """
  62. This function takes a valid License-Expression, and returns the normalized
  63. form of it.
  64. The return type is typed as :class:`NormalizedLicenseExpression`. This
  65. allows type checkers to help require that a string has passed through this
  66. function before use.
  67. :param str raw_license_expression: The License-Expression to canonicalize.
  68. :raises InvalidLicenseExpression: If the License-Expression is invalid due to an
  69. invalid/unknown license identifier or invalid syntax.
  70. .. doctest::
  71. >>> from packaging.licenses import canonicalize_license_expression
  72. >>> canonicalize_license_expression("mit")
  73. 'MIT'
  74. >>> canonicalize_license_expression("mit and (apache-2.0 or bsd-2-clause)")
  75. 'MIT AND (Apache-2.0 OR BSD-2-Clause)'
  76. >>> canonicalize_license_expression("(mit")
  77. Traceback (most recent call last):
  78. ...
  79. InvalidLicenseExpression: Invalid license expression: '(mit'
  80. >>> canonicalize_license_expression("Use-it-after-midnight")
  81. Traceback (most recent call last):
  82. ...
  83. InvalidLicenseExpression: Unknown license: 'Use-it-after-midnight'
  84. """
  85. if not raw_license_expression:
  86. message = f"Invalid license expression: {raw_license_expression!r}"
  87. raise InvalidLicenseExpression(message)
  88. # Pad any parentheses so tokenization can be achieved by merely splitting on
  89. # whitespace.
  90. license_expression = raw_license_expression.replace("(", " ( ").replace(")", " ) ")
  91. licenseref_prefix = "LicenseRef-"
  92. license_refs = {
  93. ref.lower(): "LicenseRef-" + ref[len(licenseref_prefix) :]
  94. for ref in license_expression.split()
  95. if ref.lower().startswith(licenseref_prefix.lower())
  96. }
  97. # Normalize to lower case so we can look up licenses/exceptions
  98. # and so boolean operators are Python-compatible.
  99. license_expression = license_expression.lower()
  100. tokens = license_expression.split()
  101. # Rather than implementing a parenthesis/boolean logic parser, create an
  102. # expression that Python can parse. Everything that is not involved with the
  103. # grammar itself is replaced with the placeholder `False` and the resultant
  104. # expression should become a valid Python expression.
  105. python_tokens = []
  106. for token in tokens:
  107. if token not in {"or", "and", "with", "(", ")"}:
  108. python_tokens.append("False")
  109. elif token == "with":
  110. python_tokens.append("or")
  111. elif (
  112. token == "("
  113. and python_tokens
  114. and python_tokens[-1] not in {"or", "and", "("}
  115. ) or (token == ")" and python_tokens and python_tokens[-1] == "("):
  116. message = f"Invalid license expression: {raw_license_expression!r}"
  117. raise InvalidLicenseExpression(message)
  118. else:
  119. python_tokens.append(token)
  120. python_expression = " ".join(python_tokens)
  121. try:
  122. compile(python_expression, "", "eval")
  123. except SyntaxError:
  124. message = f"Invalid license expression: {raw_license_expression!r}"
  125. raise InvalidLicenseExpression(message) from None
  126. # Take a final pass to check for unknown licenses/exceptions.
  127. normalized_tokens = []
  128. for token in tokens:
  129. if token in {"or", "and", "with", "(", ")"}:
  130. normalized_tokens.append(token.upper())
  131. continue
  132. if normalized_tokens and normalized_tokens[-1] == "WITH":
  133. if token not in EXCEPTIONS:
  134. message = f"Unknown license exception: {token!r}"
  135. raise InvalidLicenseExpression(message)
  136. normalized_tokens.append(EXCEPTIONS[token]["id"])
  137. else:
  138. if token.endswith("+"):
  139. final_token = token[:-1]
  140. suffix = "+"
  141. else:
  142. final_token = token
  143. suffix = ""
  144. if final_token.startswith("licenseref-"):
  145. if not license_ref_allowed.match(final_token):
  146. message = f"Invalid licenseref: {final_token!r}"
  147. raise InvalidLicenseExpression(message)
  148. normalized_tokens.append(license_refs[final_token] + suffix)
  149. else:
  150. if final_token not in LICENSES:
  151. message = f"Unknown license: {final_token!r}"
  152. raise InvalidLicenseExpression(message)
  153. normalized_tokens.append(LICENSES[final_token]["id"] + suffix)
  154. normalized_expression = " ".join(normalized_tokens)
  155. return cast(
  156. "NormalizedLicenseExpression",
  157. normalized_expression.replace("( ", "(").replace(" )", ")"),
  158. )