diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index 8d8aa55..fbc267f 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -2,6 +2,7 @@ name: CI Documentation on: [push, pull_request] +permissions: {} jobs: build: runs-on: ubuntu-24.04 @@ -13,10 +14,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index c947a5e..7b5a13a 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -18,15 +18,18 @@ on: tags: - "v*.*.*" +permissions: {} jobs: build-pypi-distribs: name: Build and publish library to PyPI runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: python-version: 3.13 @@ -40,7 +43,7 @@ jobs: run: python -m twine check dist/* - name: Upload built archives - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f with: name: pypi_archives path: dist/* @@ -57,13 +60,13 @@ jobs: steps: - name: Download built archives - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: pypi_archives path: dist - name: Create GH release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda with: draft: true files: dist/* @@ -74,16 +77,17 @@ jobs: needs: - create-gh-release runs-on: ubuntu-24.04 + environment: pypi-publish + permissions: + id-token: write steps: - name: Download built archives - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: name: pypi_archives path: dist - name: Publish to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b \ No newline at end of file diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..aa8259d --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,24 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 diff --git a/.readthedocs.yml b/.readthedocs.yml index 683f3a8..a7daf3f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,9 +7,9 @@ version: 2 # Build in latest ubuntu/python build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" + python: "3.13" # Build PDF & ePub formats: diff --git a/README.rst b/README.rst index 4174607..c312853 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ simplify and normalize license expressions (such as SPDX license expressions) using boolean logic. - License: Apache-2.0 -- Python: 3.9+ +- Python: 3.10+ - Homepage: https://github.com/aboutcode-org/license-expression/ - Install: `pip install license-expression` also available in most Linux distro. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 9b8df60..dbdf8ae 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,7 +9,7 @@ jobs: parameters: job_name: run_code_checks image_name: ubuntu-24.04 - python_versions: ['3.13'] + python_versions: ['3.14'] test_suites: all: make check @@ -17,7 +17,7 @@ jobs: parameters: job_name: ubuntu22_cpython image_name: ubuntu-22.04 - python_versions: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python_versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] test_suites: all: venv/bin/pytest -n 2 -vvs @@ -25,38 +25,38 @@ jobs: parameters: job_name: ubuntu24_cpython image_name: ubuntu-24.04 - python_versions: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python_versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] test_suites: all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-posix.yml parameters: - job_name: macos13_cpython - image_name: macOS-13 - python_versions: ["3.9", "3.10", "3.11", "3.12", "3.13"] + job_name: macos14_cpython + image_name: macos-14 + python_versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] test_suites: all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-posix.yml parameters: - job_name: macos14_cpython - image_name: macOS-14 - python_versions: ["3.9", "3.10", "3.11", "3.12", "3.13"] + job_name: macos15_cpython + image_name: macos-15 + python_versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] test_suites: all: venv/bin/pytest -n 2 -vvs - template: etc/ci/azure-win.yml parameters: - job_name: win2025_cpython - image_name: windows-2025 - python_versions: ["3.9", "3.10", "3.11", "3.12", "3.13"] + job_name: win2022_cpython + image_name: windows-2022 + python_versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] test_suites: all: venv\Scripts\pytest -n 2 -vvs - template: etc/ci/azure-win.yml parameters: - job_name: win2022_cpython - image_name: windows-2022 - python_versions: ["3.9", "3.10", "3.11", "3.12", "3.13"] + job_name: win2025_cpython + image_name: windows-2025 + python_versions: ["3.10", "3.11", "3.12", "3.13", "3.14"] test_suites: all: venv\Scripts\pytest -n 2 -vvs diff --git a/etc/scripts/utils_requirements.py b/etc/scripts/utils_requirements.py index b9b2c0e..f377578 100644 --- a/etc/scripts/utils_requirements.py +++ b/etc/scripts/utils_requirements.py @@ -15,7 +15,7 @@ """ Utilities to manage requirements files and call pip. NOTE: this should use ONLY the standard library and not import anything else -because this is used for boostrapping with no requirements installed. +because this is used for bootstrapping with no requirements installed. """ @@ -31,7 +31,7 @@ def load_requirements(requirements_file="requirements.txt", with_unpinned=False) def get_required_name_versions(requirement_lines, with_unpinned=False): """ - Yield required (name, version) tuples given a`requirement_lines` iterable of + Yield required (name, version) tuples given a `requirement_lines` iterable of requirement text lines. Only accept requirements pinned to an exact version. """ @@ -47,7 +47,7 @@ def get_required_name_versions(requirement_lines, with_unpinned=False): def get_required_name_version(requirement, with_unpinned=False): """ - Return a (name, version) tuple given a`requirement` specifier string. + Return a (name, version) tuple given a `requirement` specifier string. Requirement version must be pinned. If ``with_unpinned`` is True, unpinned requirements are accepted and only the name portion is returned. diff --git a/etc/scripts/utils_thirdparty.py b/etc/scripts/utils_thirdparty.py index 6f812f0..bc68ac7 100644 --- a/etc/scripts/utils_thirdparty.py +++ b/etc/scripts/utils_thirdparty.py @@ -115,14 +115,14 @@ TRACE_ULTRA_DEEP = False # Supported environments -PYTHON_VERSIONS = "39", "310", "311", "312", "313" +PYTHON_VERSIONS = "310", "311", "312", "313", "314" PYTHON_DOT_VERSIONS_BY_VER = { - "39": "3.9", "310": "3.10", "311": "3.11", "312": "3.12", "313": "3.13", + "314": "3.14", } @@ -134,11 +134,11 @@ def get_python_dot_version(version): ABIS_BY_PYTHON_VERSION = { - "39": ["cp39", "cp39m", "abi3"], "310": ["cp310", "cp310m", "abi3"], "311": ["cp311", "cp311m", "abi3"], "312": ["cp312", "cp312m", "abi3"], "313": ["cp313", "cp313m", "abi3"], + "314": ["cp314", "cp314m", "abi3"], } PLATFORMS_BY_OS = { diff --git a/pyproject.toml b/pyproject.toml index d79574e..f106e69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools >= 50", "wheel", "setuptools_scm[toml] >= 6"] +requires = ["setuptools >= 50", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] diff --git a/setup.cfg b/setup.cfg index af335ad..3989bc2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,7 +40,7 @@ license_files = README.rst [options] -python_requires = >=3.9 +python_requires = >=3.10 package_dir = =src @@ -48,8 +48,6 @@ packages = find: include_package_data = true zip_safe = false -setup_requires = setuptools_scm[toml] >= 4 - install_requires = boolean.py >= 4.0 diff --git a/src/license_expression/__init__.py b/src/license_expression/__init__.py index dc1ab31..891a81e 100644 --- a/src/license_expression/__init__.py +++ b/src/license_expression/__init__.py @@ -716,8 +716,12 @@ def dedup(self, expression): The deduplication: + - Performs the deduplication recusively for all sub-expressions. + - Does not sort the licenses of sub-expression in an expression. They - stay in the same order as in the original expression. + stay in the same order as in the original expression. In case of two + similar expressions joined by AND, sorted differently, the sort order + of the first expression is retained. - Choices (as in "MIT or GPL") are kept as-is and not treated as simplifiable. This avoids droping important choice options in complex @@ -750,12 +754,31 @@ def dedup(self, expression): ), ): relation = exp.__class__.__name__ - deduped = combine_expressions( - expressions, - relation=relation, - unique=True, - licensing=self, - ) + # Flatten nested 'AND' expressions only (not OR) to maintain precedence + if relation == "AND": + flattened = [] + for e in expressions: + if isinstance(e, self.AND): + # Flatten nested ANDs by extending with their args + flattened.extend(e.args) + else: + flattened.append(e) + expressions = flattened + + unique_expressions = [] + for e in expressions: + if e not in unique_expressions: + unique_expressions.append(e) + + if len(unique_expressions) == 1: + deduped = unique_expressions[0] + else: + deduped = combine_expressions( + expressions, + relation=relation, + unique=True, + licensing=self, + ) else: raise ExpressionError(f"Unknown expression type: {expression!r}") return deduped diff --git a/tests/test_license_expression.py b/tests/test_license_expression.py index 193fafd..1e4b060 100644 --- a/tests/test_license_expression.py +++ b/tests/test_license_expression.py @@ -693,6 +693,62 @@ def test_dedup_expressions_can_be_simplified_2(self): expected = l.parse("(mit AND (mit OR bsd-new)) OR mit") assert result == expected + def test_dedup_expressions_can_be_simplified_3(self): + l = Licensing() + exp = "(gpl AND mit) AND mit AND (gpl OR mit)" + result = l.dedup(exp) + expected = l.parse("gpl AND mit AND (gpl OR mit)") + assert result == expected + + def test_dedup_expressions_can_be_simplified_4(self): + l = Licensing() + exp = "(gpl AND mit) AND (mit AND gpl) AND (gpl OR mit)" + result = l.dedup(exp) + expected = l.parse("gpl AND mit AND (gpl OR mit)") + assert result == expected + + def test_dedup_expressions_logically_equivalent_1(self): + l = Licensing() + exp = "(gpl OR mit) AND (mit OR gpl)" + result = l.dedup(exp) + expected = l.parse("gpl OR mit") + assert result == expected + + def test_dedup_expressions_logically_equivalent_2(self): + l = Licensing() + exp = "(gpl AND mit) AND (mit AND gpl)" + result = l.dedup(exp) + expected = l.parse("gpl AND mit") + assert result == expected + + def test_dedup_expressions_logically_equivalent_3(self): + l = Licensing() + exp = "(gpl OR mit) OR (mit OR gpl)" + result = l.dedup(exp) + expected = l.parse("gpl OR mit") + assert result == expected + + def test_dedup_expressions_logically_equivalent_4(self): + l = Licensing() + exp = "(gpl AND mit) OR (mit AND gpl)" + result = l.dedup(exp) + expected = l.parse("gpl AND mit") + assert result == expected + + def test_dedup_expressions_logically_equivalent_5(self): + l = Licensing() + exp = "(gpl OR mit) AND (mit OR gpl) AND ((gpl OR mit) AND (mit OR gpl))" + result = l.dedup(exp) + expected = l.parse("gpl OR mit") + assert result == expected + + def test_dedup_expressions_logically_equivalent_6(self): + l = Licensing() + exp = "(gpl OR mit) AND (mit OR gpl) AND ((gpl OR mit) OR (mit OR gpl))" + result = l.dedup(exp) + expected = l.parse("gpl OR mit") + assert result == expected + def test_dedup_expressions_multiple_occurrences(self): l = Licensing() exp = " GPL-2.0 or (mit and LGPL-2.1) or bsd Or GPL-2.0 or (mit and LGPL-2.1)"