From f2c9188e46b34e004bd358181e2b6955891cb76f Mon Sep 17 00:00:00 2001
From: Joost Lekkerkerker <joostlek@outlook.com>
Date: Fri, 5 Jul 2024 22:04:21 +0200
Subject: [PATCH] Add audit license script (#120683)

* Add license script

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Fix

* Remove packages

* Remove packages

* Remove packages

* Remove packages

* Fix

* Remove packages

* Remove packages

* Fix

* Fix

* Fix

* Fix exceptions
---
 .github/workflows/ci.yaml |  40 ++++++
 requirements_test.txt     |   1 +
 script/licenses.py        | 273 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 314 insertions(+)
 create mode 100644 script/licenses.py

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 64fe949ecc2..07b65cab051 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -582,6 +582,46 @@ jobs:
           . venv/bin/activate
           python -m script.gen_requirements_all validate
 
+  audit-licenses:
+    name: Audit licenses
+    runs-on: ubuntu-22.04
+    needs:
+      - info
+      - base
+    if: |
+      needs.info.outputs.requirements == 'true'
+    steps:
+      - name: Check out code from GitHub
+        uses: actions/checkout@v4.1.7
+      - name: Set up Python ${{ env.DEFAULT_PYTHON }}
+        id: python
+        uses: actions/setup-python@v5.1.0
+        with:
+          python-version: ${{ env.DEFAULT_PYTHON }}
+          check-latest: true
+      - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
+        id: cache-venv
+        uses: actions/cache/restore@v4.0.2
+        with:
+          path: venv
+          fail-on-cache-miss: true
+          key: >-
+            ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
+            needs.info.outputs.python_cache_key }}
+      - name: Run pip-licenses
+        run: |
+          . venv/bin/activate
+          pip-licenses --format=json --output-file=licenses.json
+      - name: Upload licenses
+        uses: actions/upload-artifact@v4.3.3
+        with:
+          name: licenses
+          path: licenses.json
+      - name: Process licenses
+        run: |
+          . venv/bin/activate
+          python -m script.licenses
+
   pylint:
     name: Check pylint
     runs-on: ubuntu-22.04
diff --git a/requirements_test.txt b/requirements_test.txt
index e2818b559ea..b5bba1def11 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -17,6 +17,7 @@ pydantic==1.10.17
 pylint==3.2.4
 pylint-per-file-ignores==1.3.2
 pipdeptree==2.19.0
+pip-licenses==4.4.0
 pytest-asyncio==0.23.6
 pytest-aiohttp==1.0.5
 pytest-cov==5.0.0
diff --git a/script/licenses.py b/script/licenses.py
new file mode 100644
index 00000000000..5365f9272e2
--- /dev/null
+++ b/script/licenses.py
@@ -0,0 +1,273 @@
+"""Tool to check the licenses."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+import sys
+
+from awesomeversion import AwesomeVersion
+
+
+@dataclass
+class PackageDefinition:
+    """Package definition."""
+
+    license: str
+    name: str
+    version: AwesomeVersion
+
+    @classmethod
+    def from_dict(cls, data: dict[str, str]) -> PackageDefinition:
+        """Create a package definition from a dictionary."""
+        return cls(
+            license=data["License"],
+            name=data["Name"],
+            version=AwesomeVersion(data["Version"]),
+        )
+
+
+OSI_APPROVED_LICENSES = {
+    "Academic Free License (AFL)",
+    "Apache Software License",
+    "Apple Public Source License",
+    "Artistic License",
+    "Attribution Assurance License",
+    "BSD License",
+    "Boost Software License 1.0 (BSL-1.0)",
+    "CEA CNRS Inria Logiciel Libre License, version 2.1 (CeCILL-2.1)",
+    "Common Development and Distribution License 1.0 (CDDL-1.0)",
+    "Common Public License",
+    "Eclipse Public License 1.0 (EPL-1.0)",
+    "Eclipse Public License 2.0 (EPL-2.0)",
+    "Educational Community License, Version 2.0 (ECL-2.0)",
+    "Eiffel Forum License",
+    "European Union Public Licence 1.0 (EUPL 1.0)",
+    "European Union Public Licence 1.1 (EUPL 1.1)",
+    "European Union Public Licence 1.2 (EUPL 1.2)",
+    "GNU Affero General Public License v3",
+    "GNU Affero General Public License v3 or later (AGPLv3+)",
+    "GNU Free Documentation License (FDL)",
+    "GNU General Public License (GPL)",
+    "GNU General Public License v2 (GPLv2)",
+    "GNU General Public License v2 or later (GPLv2+)",
+    "GNU General Public License v3 (GPLv3)",
+    "GNU General Public License v3 or later (GPLv3+)",
+    "GNU Lesser General Public License v2 (LGPLv2)",
+    "GNU Lesser General Public License v2 or later (LGPLv2+)",
+    "GNU Lesser General Public License v3 (LGPLv3)",
+    "GNU Lesser General Public License v3 or later (LGPLv3+)",
+    "GNU Library or Lesser General Public License (LGPL)",
+    "Historical Permission Notice and Disclaimer (HPND)",
+    "IBM Public License",
+    "ISC License (ISCL)",
+    "Intel Open Source License",
+    "Jabber Open Source License",
+    "MIT License",
+    "MIT No Attribution License (MIT-0)",
+    "MITRE Collaborative Virtual Workspace License (CVW)",
+    "MirOS License (MirOS)",
+    "Motosoto License",
+    "Mozilla Public License 1.0 (MPL)",
+    "Mozilla Public License 1.1 (MPL 1.1)",
+    "Mozilla Public License 2.0 (MPL 2.0)",
+    "Mulan Permissive Software License v2 (MulanPSL-2.0)",
+    "NASA Open Source Agreement v1.3 (NASA-1.3)",
+    "Nethack General Public License",
+    "Nokia Open Source License",
+    "Open Group Test Suite License",
+    "Open Software License 3.0 (OSL-3.0)",
+    "PostgreSQL License",
+    "Python License (CNRI Python License)",
+    "Python Software Foundation License",
+    "Qt Public License (QPL)",
+    "Ricoh Source Code Public License",
+    "SIL Open Font License 1.1 (OFL-1.1)",
+    "Sleepycat License",
+    "Sun Industry Standards Source License (SISSL)",
+    "Sun Public License",
+    "The Unlicense (Unlicense)",
+    "Universal Permissive License (UPL)",
+    "University of Illinois/NCSA Open Source License",
+    "Vovida Software License 1.0",
+    "W3C License",
+    "X.Net License",
+    "Zero-Clause BSD (0BSD)",
+    "Zope Public License",
+    "zlib/libpng License",
+    "Apache License",
+    "MIT",
+    "apache-2.0",
+    "GPL-3.0",
+    "GPLv3+",
+    "MPL2",
+    "MPL-2.0",
+    "Apache 2",
+    "LGPL v3",
+    "BSD",
+    "GNU-3.0",
+    "GPLv3",
+    "Eclipse Public License v2.0",
+    "ISC",
+    "GPL-2.0-only",
+    "mit",
+    "GNU General Public License v3",
+    "Unlicense",
+    "Apache-2",
+    "GPLv2",
+}
+
+EXCEPTIONS = {
+    "PyMicroBot",  # https://github.com/spycle/pyMicroBot/pull/3
+    "PySwitchmate",  # https://github.com/Danielhiversen/pySwitchmate/pull/16
+    "PyXiaomiGateway",  # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201
+    "aiocomelit",  # https://github.com/chemelli74/aiocomelit/pull/138
+    "aioecowitt",  # https://github.com/home-assistant-libs/aioecowitt/pull/180
+    "aiohappyeyeballs",  # PSF-2.0 license
+    "aiohttp-fast-url-dispatcher",  # https://github.com/bdraco/aiohttp-fast-url-dispatcher/pull/10
+    "aioopenexchangerates",  # https://github.com/MartinHjelmare/aioopenexchangerates/pull/94
+    "aiooui",  # https://github.com/Bluetooth-Devices/aiooui/pull/8
+    "aioruuvigateway",  # https://github.com/akx/aioruuvigateway/pull/6
+    "aiovodafone",  # https://github.com/chemelli74/aiovodafone/pull/131
+    "airthings-ble",  # https://github.com/Airthings/airthings-ble/pull/42
+    "apple_weatherkit",  # https://github.com/tjhorner/python-weatherkit/pull/3
+    "asyncio",  # PSF License
+    "chacha20poly1305",  # LGPL
+    "commentjson",  # https://github.com/vaidik/commentjson/pull/55
+    "crownstone-cloud",  # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5
+    "crownstone-core",  # https://github.com/crownstone/crownstone-lib-python-core/pull/6
+    "crownstone-sse",  # https://github.com/crownstone/crownstone-lib-python-sse/pull/2
+    "crownstone-uart",  # https://github.com/crownstone/crownstone-lib-python-uart/pull/12
+    "dio-chacon-wifi-api",
+    "eliqonline",  # https://github.com/molobrakos/eliqonline/pull/17
+    "enocean",  # https://github.com/kipe/enocean/pull/142
+    "gardena-bluetooth",  # https://github.com/elupus/gardena-bluetooth/pull/11
+    "heatmiserV3",  # https://github.com/andylockran/heatmiserV3/pull/94
+    "huum",  # https://github.com/frwickst/pyhuum/pull/8
+    "imutils",  # https://github.com/PyImageSearch/imutils/pull/292
+    "iso4217",  # Public domain
+    "kiwiki_client",  # https://github.com/c7h/kiwiki_client/pull/6
+    "krakenex",  # https://github.com/veox/python3-krakenex/pull/145
+    "ld2410-ble",  # https://github.com/930913/ld2410-ble/pull/7
+    "maxcube-api",  # https://github.com/uebelack/python-maxcube-api/pull/48
+    "nessclient",  # https://github.com/nickw444/nessclient/pull/65
+    "neurio",  # https://github.com/jordanh/neurio-python/pull/13
+    "nsw-fuel-api-client",  # https://github.com/nickw444/nsw-fuel-api-client/pull/14
+    "pigpio",  # https://github.com/joan2937/pigpio/pull/608
+    "pyEmby",  # https://github.com/mezz64/pyEmby/pull/12
+    "pyTibber",  # https://github.com/Danielhiversen/pyTibber/pull/294
+    "pybbox",  # https://github.com/HydrelioxGitHub/pybbox/pull/5
+    "pyeconet",  # https://github.com/w1ll1am23/pyeconet/pull/41
+    "pylutron-caseta",  # https://github.com/gurumitts/pylutron-caseta/pull/168
+    "pysabnzbd",  # https://github.com/jeradM/pysabnzbd/pull/6
+    "pyswitchbee",  # https://github.com/jafar-atili/pySwitchbee/pull/5
+    "pyvera",  # https://github.com/maximvelichko/pyvera/pull/164
+    "pyxeoma",  # https://github.com/jeradM/pyxeoma/pull/11
+    "repoze.lru",
+    "russound",  # https://github.com/laf/russound/pull/14   # codespell:ignore laf
+    "ruuvitag-ble",  # https://github.com/Bluetooth-Devices/ruuvitag-ble/pull/10
+    "sensirion-ble",  # https://github.com/akx/sensirion-ble/pull/9
+    "sharp_aquos_rc",  # https://github.com/jmoore987/sharp_aquos_rc/pull/14
+    "tapsaff",  # https://github.com/bazwilliams/python-taps-aff/pull/5
+    "tellduslive",  # https://github.com/molobrakos/tellduslive/pull/24
+    "tellsticknet",  # https://github.com/molobrakos/tellsticknet/pull/33
+    "webrtc_noise_gain",  # https://github.com/rhasspy/webrtc-noise-gain/pull/24
+    "vincenty",  # Public domain
+    "zeversolar",  # https://github.com/kvanzuijlen/zeversolar/pull/46
+}
+
+TODO = {
+    "BlinkStick": AwesomeVersion(
+        "1.2.0"
+    ),  # Proprietary license https://github.com/arvydas/blinkstick-python
+    "PyMVGLive": AwesomeVersion(
+        "1.1.4"
+    ),  # No license and archived https://github.com/pc-coholic/PyMVGLive
+    "aiocache": AwesomeVersion(
+        "0.12.2"
+    ),  # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved?
+    "asterisk_mbox": AwesomeVersion(
+        "0.5.0"
+    ),  # No license, integration is deprecated and scheduled for removal in 2024.9.0
+    "chacha20poly1305-reuseable": AwesomeVersion("0.12.1"),  # has 2 licenses
+    "concord232": AwesomeVersion(
+        "0.15"
+    ),  # No license https://github.com/JasonCarter80/concord232/issues/19
+    "dovado": AwesomeVersion(
+        "0.4.1"
+    ),  # No license https://github.com/molobrakos/dovado/issues/4
+    "mficlient": AwesomeVersion(
+        "0.3.0"
+    ),  # No license https://github.com/kk7ds/mficlient/issues/4
+    "pubnub": AwesomeVersion(
+        "8.0.0"
+    ),  # Proprietary license https://github.com/pubnub/python/blob/master/LICENSE
+    "pyElectra": AwesomeVersion(
+        "1.2.3"
+    ),  # No License https://github.com/jafar-atili/pyElectra/issues/3
+    "pyflic": AwesomeVersion("2.0.3"),  # No OSI approved license CC0-1.0 Universal)
+    "pymitv": AwesomeVersion("1.4.3"),  # Not sure why pip-licenses doesn't pick this up
+    "refoss_ha": AwesomeVersion(
+        "1.2.1"
+    ),  # No License https://github.com/ashionky/refoss_ha/issues/4
+    "uvcclient": AwesomeVersion(
+        "0.11.0"
+    ),  # No License https://github.com/kk7ds/uvcclient/issues/7
+}
+
+
+def main() -> int:
+    """Run the main script."""
+    raw_licenses = json.load(open("licenses.json"))
+    package_definitions = [PackageDefinition.from_dict(data) for data in raw_licenses]
+    exit_code = 0
+    for package in package_definitions:
+        previous_unapproved_version = TODO.get(package.name)
+        approved = False
+        for approved_license in OSI_APPROVED_LICENSES:
+            if approved_license in package.license:
+                approved = True
+                break
+        if previous_unapproved_version is not None:
+            if previous_unapproved_version < package.version:
+                if approved:
+                    print(
+                        f"Approved license detected for {package.name}@{package.version}: {package.license}"
+                    )
+                    print("Please remove the package from the TODO list.")
+                    print("")
+                else:
+                    print(
+                        f"We could not detect an OSI-approved license for {package.name}@{package.version}: {package.license}"
+                    )
+                    print("")
+                exit_code = 1
+        elif not approved and package.name not in EXCEPTIONS:
+            print(
+                f"We could not detect an OSI-approved license for {package.name}@{package.version}: {package.license}"
+            )
+            print("")
+            exit_code = 1
+        elif approved and package.name in EXCEPTIONS:
+            print(
+                f"Approved license detected for {package.name}@{package.version}: {package.license}"
+            )
+            print(f"Please remove the package from the EXCEPTIONS list: {package.name}")
+            print("")
+            exit_code = 1
+    current_packages = {package.name for package in package_definitions}
+    for package in [*TODO.keys(), *EXCEPTIONS]:
+        if package not in current_packages:
+            print(
+                f"Package {package} is tracked, but not used. Please remove from the licenses.py file."
+            )
+            print("")
+            exit_code = 1
+    return exit_code
+
+
+if __name__ == "__main__":
+    exit_code = main()
+    if exit_code == 0:
+        print("All licenses are approved!")
+    sys.exit(exit_code)
-- 
GitLab