Repository: spdx/tools-python

Issue:
Question regarding license_expression_parser behavior
src/spdx_tools/spdx/parser/jsonlikedict/license_expression_parser.py uses License().parse(expr) directly, rather than get_spdx_licensing().parse(expr) as used in parser/tagvalue/parser.py.  The difference results in a different LicenseSymbol for GPl-2.0, e.g.

```
>>> from license_expression import Licensing
>>> Licensing().parse('GPL-2.0')
LicenseSymbol('GPL-2.0', is_exception=False)
```

```
>>> from license_expression import get_spdx_licensing
>>> get_spdx_licensing().parse('GPL-2.0')
LicenseSymbol('GPL-2.0-only', aliases=('GPL-2.0', 'GPL 2.0', 'LicenseRef-GPL-2.0'), is_exception=False)
>>> 
```

As you can see, GPL-2.0-only is the official name, and GPL-2.0 is an alias.  However, when parsing directly with Licensing(), we get a GPL-2.0 node, rather than a GPL-2.0-only node.  This causes problem later in validation, when GPL-2.0 comes back as an invalid symbol, e.g.

```
2024-06-18 16:28:31,476:WARNING:root: Unrecognized license reference: GPL-2.0. license_expression must only use IDs from the license list or extracted licensing info, but is: GPL-2.0
2024-06-18 16:28:31,476:WARNING:root: ValidationContext(spdx_id=None, parent_id='SPDXRef-base-files-Package-base-files', element_type=<SpdxElementType.LICENSE_EXPRESSION: 1>, full_element=LicenseSymbol('GPL-2.0', is_exception=False))
```

I'm wondering if this is expected behavior (and you do not with to allow aliases), or if this is a bug.  Should I filter my json file in advance to switch to GPL-2.0-only ?  Certainly GPL-2.0 should not be listed in the extracted_licensing_info section (as that would require changing it to LicenseRef-GPL-2.0 or similar), right?



Discussion:

Patch:
diff --git a/.github/workflows/check_codestyle.yml b/.github/workflows/check_codestyle.yml
index 15dfd17..92860a5 100644
--- a/.github/workflows/check_codestyle.yml
+++ b/.github/workflows/check_codestyle.yml
@@ -21,6 +21,9 @@ jobs:
       matrix:
         os: [ ubuntu-latest, macos-latest, windows-latest ]
         python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
+        exclude: # see https://github.com/actions/runner-images/issues/9770#issuecomment-2085623315
+          - python-version: "3.7"
+            os: macos-latest
 
     steps:
       - uses: actions/checkout@v3
diff --git a/src/spdx_tools/spdx/parser/jsonlikedict/license_expression_parser.py b/src/spdx_tools/spdx/parser/jsonlikedict/license_expression_parser.py
index 29c0ebb..5a3b545 100644
--- a/src/spdx_tools/spdx/parser/jsonlikedict/license_expression_parser.py
+++ b/src/spdx_tools/spdx/parser/jsonlikedict/license_expression_parser.py
@@ -2,8 +2,9 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 from beartype.typing import Union
-from license_expression import ExpressionError, LicenseExpression, Licensing
+from license_expression import ExpressionError, LicenseExpression
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.model import SpdxNoAssertion, SpdxNone
 from spdx_tools.spdx.parser.error import SPDXParsingError
 
@@ -18,7 +19,7 @@ class LicenseExpressionParser:
                 return SpdxNone()
 
         try:
-            license_expression = Licensing().parse(license_expression_str)
+            license_expression = spdx_licensing.parse(license_expression_str)
         except ExpressionError as err:
             err_msg = f'Error parsing LicenseExpression: "{license_expression_str}"'
             if err.args:


Test patch:
diff --git a/.github/workflows/install_and_test.yml b/.github/workflows/install_and_test.yml
index 4884b94..038eb47 100644
--- a/.github/workflows/install_and_test.yml
+++ b/.github/workflows/install_and_test.yml
@@ -18,6 +18,9 @@ jobs:
       matrix:
         os: [ ubuntu-latest, macos-latest, windows-latest ]
         python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
+        exclude: # see https://github.com/actions/runner-images/issues/9770#issuecomment-2085623315
+          - python-version: "3.7"
+            os: macos-latest
 
     steps:
       - uses: actions/checkout@v3
diff --git a/tests/spdx/jsonschema/test_file_converter.py b/tests/spdx/jsonschema/test_file_converter.py
index 8e6c140..560ecb1 100644
--- a/tests/spdx/jsonschema/test_file_converter.py
+++ b/tests/spdx/jsonschema/test_file_converter.py
@@ -7,8 +7,8 @@ from unittest import mock
 from unittest.mock import MagicMock, NonCallableMagicMock
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.jsonschema.annotation_converter import AnnotationConverter
 from spdx_tools.spdx.jsonschema.file_converter import FileConverter
 from spdx_tools.spdx.jsonschema.file_properties import FileProperty
@@ -81,8 +81,8 @@ def test_successful_conversion(converter: FileConverter):
         spdx_id="spdxId",
         checksums=[Checksum(ChecksumAlgorithm.SHA224, "sha224"), Checksum(ChecksumAlgorithm.MD2, "md2")],
         file_types=[FileType.SPDX, FileType.OTHER],
-        license_concluded=Licensing().parse("MIT and GPL-2.0"),
-        license_info_in_file=[Licensing().parse("MIT"), Licensing().parse("GPL-2.0"), SpdxNoAssertion()],
+        license_concluded=spdx_licensing.parse("MIT and GPL-2.0"),
+        license_info_in_file=[spdx_licensing.parse("MIT"), spdx_licensing.parse("GPL-2.0"), SpdxNoAssertion()],
         license_comment="licenseComment",
         copyright_text="copyrightText",
         comment="comment",
@@ -115,8 +115,8 @@ def test_successful_conversion(converter: FileConverter):
         converter.json_property_name(FileProperty.FILE_NAME): "name",
         converter.json_property_name(FileProperty.FILE_TYPES): ["SPDX", "OTHER"],
         converter.json_property_name(FileProperty.LICENSE_COMMENTS): "licenseComment",
-        converter.json_property_name(FileProperty.LICENSE_CONCLUDED): "MIT AND GPL-2.0",
-        converter.json_property_name(FileProperty.LICENSE_INFO_IN_FILES): ["MIT", "GPL-2.0", "NOASSERTION"],
+        converter.json_property_name(FileProperty.LICENSE_CONCLUDED): "MIT AND GPL-2.0-only",
+        converter.json_property_name(FileProperty.LICENSE_INFO_IN_FILES): ["MIT", "GPL-2.0-only", "NOASSERTION"],
         converter.json_property_name(FileProperty.NOTICE_TEXT): "notice",
     }
 
diff --git a/tests/spdx/jsonschema/test_package_converter.py b/tests/spdx/jsonschema/test_package_converter.py
index 9365c82..c6e63d9 100644
--- a/tests/spdx/jsonschema/test_package_converter.py
+++ b/tests/spdx/jsonschema/test_package_converter.py
@@ -7,8 +7,8 @@ from unittest import mock
 from unittest.mock import MagicMock, NonCallableMagicMock
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.jsonschema.annotation_converter import AnnotationConverter
 from spdx_tools.spdx.jsonschema.package_converter import PackageConverter
 from spdx_tools.spdx.jsonschema.package_properties import PackageProperty
@@ -123,9 +123,9 @@ def test_successful_conversion(converter: PackageConverter):
         checksums=[Checksum(ChecksumAlgorithm.SHA1, "sha1"), Checksum(ChecksumAlgorithm.BLAKE2B_256, "blake")],
         homepage="homepage",
         source_info="sourceInfo",
-        license_concluded=Licensing().parse("MIT and GPL-2.0"),
-        license_info_from_files=[Licensing().parse("MIT"), Licensing().parse("GPL-2.0")],
-        license_declared=Licensing().parse("MIT or GPL-2.0 "),
+        license_concluded=spdx_licensing.parse("MIT and GPL-2.0"),
+        license_info_from_files=[spdx_licensing.parse("MIT"), spdx_licensing.parse("GPL-2.0")],
+        license_declared=spdx_licensing.parse("MIT or GPL-2.0 "),
         license_comment="licenseComment",
         copyright_text="copyrightText",
         summary="summary",
@@ -168,9 +168,9 @@ def test_successful_conversion(converter: PackageConverter):
         ],
         converter.json_property_name(PackageProperty.HOMEPAGE): "homepage",
         converter.json_property_name(PackageProperty.SOURCE_INFO): "sourceInfo",
-        converter.json_property_name(PackageProperty.LICENSE_CONCLUDED): "MIT AND GPL-2.0",
-        converter.json_property_name(PackageProperty.LICENSE_INFO_FROM_FILES): ["MIT", "GPL-2.0"],
-        converter.json_property_name(PackageProperty.LICENSE_DECLARED): "MIT OR GPL-2.0",
+        converter.json_property_name(PackageProperty.LICENSE_CONCLUDED): "MIT AND GPL-2.0-only",
+        converter.json_property_name(PackageProperty.LICENSE_INFO_FROM_FILES): ["MIT", "GPL-2.0-only"],
+        converter.json_property_name(PackageProperty.LICENSE_DECLARED): "MIT OR GPL-2.0-only",
         converter.json_property_name(PackageProperty.LICENSE_COMMENTS): "licenseComment",
         converter.json_property_name(PackageProperty.COPYRIGHT_TEXT): "copyrightText",
         converter.json_property_name(PackageProperty.SUMMARY): "summary",
diff --git a/tests/spdx/jsonschema/test_snippet_converter.py b/tests/spdx/jsonschema/test_snippet_converter.py
index a677343..36eecd4 100644
--- a/tests/spdx/jsonschema/test_snippet_converter.py
+++ b/tests/spdx/jsonschema/test_snippet_converter.py
@@ -7,8 +7,8 @@ from unittest import mock
 from unittest.mock import MagicMock, NonCallableMagicMock
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.jsonschema.annotation_converter import AnnotationConverter
 from spdx_tools.spdx.jsonschema.snippet_converter import SnippetConverter
 from spdx_tools.spdx.jsonschema.snippet_properties import SnippetProperty
@@ -72,8 +72,8 @@ def test_successful_conversion(converter: SnippetConverter):
         file_spdx_id=file_spdx_id,
         byte_range=(1, 2),
         line_range=(3, 4),
-        license_concluded=Licensing().parse("MIT and GPL-2.0"),
-        license_info_in_snippet=[Licensing().parse("MIT"), Licensing().parse("GPL-2.0")],
+        license_concluded=spdx_licensing.parse("MIT and GPL-2.0"),
+        license_info_in_snippet=[spdx_licensing.parse("MIT"), spdx_licensing.parse("GPL-2.0")],
         license_comment="licenseComment",
         copyright_text="copyrightText",
         comment="comment",
@@ -98,8 +98,8 @@ def test_successful_conversion(converter: SnippetConverter):
         converter.json_property_name(SnippetProperty.COMMENT): "comment",
         converter.json_property_name(SnippetProperty.COPYRIGHT_TEXT): "copyrightText",
         converter.json_property_name(SnippetProperty.LICENSE_COMMENTS): "licenseComment",
-        converter.json_property_name(SnippetProperty.LICENSE_CONCLUDED): "MIT AND GPL-2.0",
-        converter.json_property_name(SnippetProperty.LICENSE_INFO_IN_SNIPPETS): ["MIT", "GPL-2.0"],
+        converter.json_property_name(SnippetProperty.LICENSE_CONCLUDED): "MIT AND GPL-2.0-only",
+        converter.json_property_name(SnippetProperty.LICENSE_INFO_IN_SNIPPETS): ["MIT", "GPL-2.0-only"],
         converter.json_property_name(SnippetProperty.NAME): "name",
         converter.json_property_name(SnippetProperty.RANGES): [
             {
diff --git a/tests/spdx/model/test_package.py b/tests/spdx/model/test_package.py
index c533b28..2f015f1 100644
--- a/tests/spdx/model/test_package.py
+++ b/tests/spdx/model/test_package.py
@@ -6,8 +6,9 @@ from datetime import datetime
 from unittest import mock
 
 import pytest
-from license_expression import LicenseExpression, Licensing
+from license_expression import LicenseExpression
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.model import Checksum, ChecksumAlgorithm, Package, PackagePurpose, SpdxNoAssertion, SpdxNone
 
 
@@ -30,7 +31,7 @@ def test_correct_initialization(actor, verif_code, checksum, ext_ref):
         "homepage",
         "source_info",
         None,
-        [Licensing().parse("license and expression"), SpdxNoAssertion()],
+        [spdx_licensing.parse("license and expression"), SpdxNoAssertion()],
         SpdxNone(),
         "comment on license",
         "copyright",
@@ -57,7 +58,7 @@ def test_correct_initialization(actor, verif_code, checksum, ext_ref):
     assert package.homepage == "homepage"
     assert package.source_info == "source_info"
     assert package.license_concluded is None
-    assert package.license_info_from_files == [Licensing().parse("license and expression"), SpdxNoAssertion()]
+    assert package.license_info_from_files == [spdx_licensing.parse("license and expression"), SpdxNoAssertion()]
     assert package.license_declared == SpdxNone()
     assert package.license_comment == "comment on license"
     assert package.copyright_text == "copyright"
diff --git a/tests/spdx/parser/jsonlikedict/test_file_parser.py b/tests/spdx/parser/jsonlikedict/test_file_parser.py
index e1c6b7a..bbe5b70 100644
--- a/tests/spdx/parser/jsonlikedict/test_file_parser.py
+++ b/tests/spdx/parser/jsonlikedict/test_file_parser.py
@@ -4,8 +4,8 @@
 from unittest import TestCase
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.model import Checksum, ChecksumAlgorithm, FileType, SpdxNoAssertion, SpdxNone
 from spdx_tools.spdx.parser.error import SPDXParsingError
 from spdx_tools.spdx.parser.jsonlikedict.dict_parsing_functions import parse_list_of_elements
@@ -82,10 +82,10 @@ def test_parse_file(copyright_text, expected_copyright_text):
             "IBM Corporation",
         ],
     )
-    assert file.license_concluded == Licensing().parse("(LGPL-2.0-only OR LicenseRef-2)")
+    assert file.license_concluded == spdx_licensing.parse("(LGPL-2.0-only OR LicenseRef-2)")
     TestCase().assertCountEqual(
         file.license_info_in_file,
-        [Licensing().parse("GPL-2.0-only"), Licensing().parse("LicenseRef-2"), SpdxNoAssertion()],
+        [spdx_licensing.parse("GPL-2.0-only"), spdx_licensing.parse("LicenseRef-2"), SpdxNoAssertion()],
     )
     assert (
         file.license_comment == "The concluded license was taken from the package level that the file was included in."
diff --git a/tests/spdx/parser/jsonlikedict/test_license_expression_parser.py b/tests/spdx/parser/jsonlikedict/test_license_expression_parser.py
index fca36b8..d78a88a 100644
--- a/tests/spdx/parser/jsonlikedict/test_license_expression_parser.py
+++ b/tests/spdx/parser/jsonlikedict/test_license_expression_parser.py
@@ -16,6 +16,8 @@ from spdx_tools.spdx.parser.jsonlikedict.license_expression_parser import Licens
     [
         ("First License", spdx_licensing.parse("First License")),
         ("Second License", spdx_licensing.parse("Second License")),
+        ("Apache-1.1", spdx_licensing.parse("Apache-1.1")),
+        ("Zlib", spdx_licensing.parse("zlib")),
         ("NOASSERTION", SpdxNoAssertion()),
         ("NONE", SpdxNone()),
     ],
@@ -34,7 +36,8 @@ def test_parse_license_expression(license_expression_str, expected_license):
         (
             "LGPL-2.1, GPL-2.0, GPL-3.0",
             [
-                "Error parsing LicenseExpression: \"LGPL-2.1, GPL-2.0, GPL-3.0\": Invalid license key: the valid characters are: letters and numbers, underscore, dot, colon or hyphen signs and spaces: 'LGPL-2.1, GPL-2.0, GPL-3.0'"  # noqa: E501
+                # the error message we receive from the license_expression library somehow cuts off the last license
+                "Error parsing LicenseExpression: \"LGPL-2.1, GPL-2.0, GPL-3.0\": Invalid license key: the valid characters are: letters and numbers, underscore, dot, colon or hyphen signs and spaces: 'LGPL-2.1, GPL-2.0,'"  # noqa: E501
             ],
         ),
         ("Apache License (2.0)", ['Error parsing LicenseExpression: "Apache License (2.0)"']),
diff --git a/tests/spdx/parser/jsonlikedict/test_package_parser.py b/tests/spdx/parser/jsonlikedict/test_package_parser.py
index 5b933df..209cf5c 100644
--- a/tests/spdx/parser/jsonlikedict/test_package_parser.py
+++ b/tests/spdx/parser/jsonlikedict/test_package_parser.py
@@ -5,8 +5,8 @@ from datetime import datetime
 from unittest import TestCase
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.model import (
     Actor,
     ActorType,
@@ -173,17 +173,17 @@ def test_parse_package(
     )
     assert package.homepage == expected_homepage
     assert package.source_info == "uses glibc-2_11-branch from git://sourceware.org/git/glibc.git."
-    assert package.license_concluded == Licensing().parse("(LGPL-2.0-only OR LicenseRef-3)")
+    assert package.license_concluded == spdx_licensing.parse("(LGPL-2.0-only OR LicenseRef-3)")
     TestCase().assertCountEqual(
         package.license_info_from_files,
         [
-            Licensing().parse("GPL-2.0-only"),
-            Licensing().parse("LicenseRef-2"),
-            Licensing().parse("LicenseRef-1"),
+            spdx_licensing.parse("GPL-2.0-only"),
+            spdx_licensing.parse("LicenseRef-2"),
+            spdx_licensing.parse("LicenseRef-1"),
             SpdxNoAssertion(),
         ],
     )
-    assert package.license_declared == Licensing().parse("(LGPL-2.0-only AND LicenseRef-3)")
+    assert package.license_declared == spdx_licensing.parse("(LGPL-2.0-only AND LicenseRef-3)")
     assert (
         package.license_comment
         == "The license for this project changed with the release of version x.y.  The version of the project included"
diff --git a/tests/spdx/parser/jsonlikedict/test_snippet_parser.py b/tests/spdx/parser/jsonlikedict/test_snippet_parser.py
index 257a00e..3b9b62a 100644
--- a/tests/spdx/parser/jsonlikedict/test_snippet_parser.py
+++ b/tests/spdx/parser/jsonlikedict/test_snippet_parser.py
@@ -4,8 +4,8 @@
 from unittest import TestCase
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.model import SpdxNoAssertion, SpdxNone
 from spdx_tools.spdx.parser.error import SPDXParsingError
 from spdx_tools.spdx.parser.jsonlikedict.snippet_parser import SnippetParser
@@ -65,8 +65,8 @@ def test_parse_snippet(copyright_text, expected_copyright_text):
     assert snippet.byte_range == (310, 420)
     assert snippet.line_range == (5, 23)
     assert snippet.file_spdx_id == "SPDXRef-DoapSource"
-    assert snippet.license_info_in_snippet == [Licensing().parse("GPL-2.0-only"), SpdxNoAssertion()]
-    assert snippet.license_concluded == Licensing().parse("GPL-2.0-only")
+    assert snippet.license_info_in_snippet == [spdx_licensing.parse("GPL-2.0-only"), SpdxNoAssertion()]
+    assert snippet.license_concluded == spdx_licensing.parse("GPL-2.0-only")
     assert snippet.attribution_texts == ["Some example attibution text."]
 
 
diff --git a/tests/spdx/validation/test_package_validator.py b/tests/spdx/validation/test_package_validator.py
index a6ef976..d0516ff 100644
--- a/tests/spdx/validation/test_package_validator.py
+++ b/tests/spdx/validation/test_package_validator.py
@@ -6,8 +6,8 @@ from typing import List
 from unittest import TestCase
 
 import pytest
-from license_expression import Licensing
 
+from spdx_tools.common.spdx_licensing import spdx_licensing
 from spdx_tools.spdx.constants import DOCUMENT_SPDX_ID
 from spdx_tools.spdx.model import Relationship, RelationshipType, SpdxNoAssertion, SpdxNone
 from spdx_tools.spdx.validation.package_validator import validate_package, validate_package_within_document
@@ -45,7 +45,7 @@ def test_valid_package():
         (
             package_fixture(
                 files_analyzed=False,
-                license_info_from_files=[Licensing().parse("some_license")],
+                license_info_from_files=[spdx_licensing.parse("some_license")],
                 verification_code=None,
             ),
             "license_info_from_files must be None if files_analyzed is False, but is: [LicenseSymbol('some_license', "