diff --git a/scripts/build.sh b/scripts/build.sh index f2542ec5..30329914 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -522,6 +522,16 @@ else "$SIZE_PY" "$SCRIPT_DIR/verify_wheel_size.py" "$NEWEST_WHEEL" || exit 1 fi +# THE LICENCE GUARD: this package is Apache-2.0 and ships only open source. Studio bundled ApexCharts (not open +# source since 5.1.0) until engine 26.11.1 replaced it with ECharts (ArcadeData/arcadedb#10627). A wheel with an +# ApexCharts file fails here (https://github.com/ArcadeData/arcadedb/issues/10579). +if [[ -z "$SIZE_PY" ]]; then + echo -e "${RED}❌ No python found: the licence check cannot run${NC}" + exit 1 +else + "$SIZE_PY" "$SCRIPT_DIR/verify_wheel_licences.py" "$NEWEST_WHEEL" || exit 1 +fi + echo -e "${GREEN}🎉 Build completed successfully!${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" diff --git a/scripts/jar_exclusions.txt b/scripts/jar_exclusions.txt index d8752474..e959a09f 100644 --- a/scripts/jar_exclusions.txt +++ b/scripts/jar_exclusions.txt @@ -30,3 +30,7 @@ jline-*.jar # snappy-java-*.jar CompressedBodyDecoder and the Prometheus remote read and # write routes # commons-math3-*.jar jvector's ScoreTracker$TwoPhaseTracker +# arcadedb-studio-*.jar the Studio web UI. It bundled ApexCharts (not open source since 5.1.0) until +# ArcadeData/arcadedb#10627 replaced it with Apache ECharts, first in engine 26.11.1. +# scripts/verify_wheel_licences.py fails a wheel that carries ApexCharts files, so a +# wheel built from an older engine cannot ship. diff --git a/scripts/verify_wheel_licences.py b/scripts/verify_wheel_licences.py new file mode 100644 index 00000000..9677775a --- /dev/null +++ b/scripts/verify_wheel_licences.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Fail a wheel that contains a file that is not open source. + +This package is Apache-2.0 and ships nothing that is not open source. One known case today: ApexCharts, which has not +been open source since 5.1.0 (Community/Commercial licence). Studio, the web UI in `arcadedb-studio-*.jar`, bundled it +from upstream 26.1.1 until ArcadeData/arcadedb#10627 replaced it with Apache ECharts (first in engine 26.11.1). Studio +ships in the wheels again, so this check does not look for the Studio jar. It looks for the files that make a wheel +unfit to publish, whatever the cause: a wheel built from an engine older than 26.11.1, a shaded or renamed jar, a JAR +directory that brings its own copy. A wheel whose Studio carries ECharts passes. +See https://github.com/ArcadeData/arcadedb/issues/10579 and https://github.com/ArcadeData/arcadedb/pull/10627. + +To add a case, add a row to NON_OPEN_SOURCE: a lower-case marker that appears in the path of every file of that product, +and a short description for the error message. + +Usage: + python verify_wheel_licences.py WHEEL [WHEEL ...] + +Exit status: 0 all wheels clean, 1 a wheel contains a file that is not open source, 2 usage error or not a wheel. +""" + +from __future__ import annotations + +import io +import sys +import zipfile +from pathlib import Path + +# (marker in the lower-cased path, description). A file matches when its path, or the path of an entry inside a jar, +# contains the marker. +NON_OPEN_SOURCE: tuple[tuple[str, str], ...] = ( + ( + "apexcharts", + "ApexCharts, not open source since 5.1.0 (Studio in engines before 26.11.1)", + ), +) + +HELP = ( + "Do not publish this wheel. Studio uses Apache ECharts from engine 26.11.1 " + "(https://github.com/ArcadeData/arcadedb/pull/10627): build from the 26.11.1 jars or newer " + "(see https://github.com/ArcadeData/arcadedb/issues/10579)." +) + + +def _match(path: str) -> str | None: + """The description of the non-open-source product the path belongs to, else None.""" + lowered = path.lower() + for marker, description in NON_OPEN_SOURCE: + if marker in lowered: + return description + return None + + +def find_offenders(wheel: Path) -> list[str]: + """Names inside the wheel (and inside its jars) that belong to a product that is not open source.""" + offenders: list[str] = [] + with zipfile.ZipFile(wheel) as zf: + for info in zf.infolist(): + name = info.filename + description = _match(name) + if description is not None: + offenders.append(f"{name} ({description})") + elif name.lower().endswith(".jar"): + try: + inner = zipfile.ZipFile(io.BytesIO(zf.read(info))) + except zipfile.BadZipFile: + offenders.append( + f"{name} (jar that is not a zip file: cannot be checked)" + ) + continue + for entry in inner.namelist(): + description = _match(entry) + if description is not None: + offenders.append(f"{name} contains {entry} ({description})") + break + return offenders + + +def main(argv: list[str] | None = None) -> int: + wheels = [Path(a) for a in (sys.argv[1:] if argv is None else argv)] + if not wheels: + print(__doc__.strip().splitlines()[0], file=sys.stderr) + print("usage: verify_wheel_licences.py WHEEL [WHEEL ...]", file=sys.stderr) + return 2 + status = 0 + for wheel in wheels: + if not wheel.is_file() or not zipfile.is_zipfile(wheel): + print(f"ERROR: {wheel} is not a wheel file", file=sys.stderr) + return 2 + offenders = find_offenders(wheel) + if offenders: + status = 1 + print( + f"ERROR: {wheel.name} contains files that are not open source:", + file=sys.stderr, + ) + for entry in offenders: + print(f" {entry}", file=sys.stderr) + print(f" {HELP}", file=sys.stderr) + else: + print(f"OK: {wheel.name} has no file that is not open source") + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_server_packaging.py b/tests/test_server_packaging.py index 2514fe20..ddc8f6ef 100644 --- a/tests/test_server_packaging.py +++ b/tests/test_server_packaging.py @@ -100,6 +100,30 @@ def test_studio_jar_carries_no_classes(): ) +def test_no_apexcharts_in_the_package(): + """No ApexCharts file in the installed package, in or outside a jar. + + Studio bundled ApexCharts, which has not been open source since 5.1.0, until + engine 26.11.1 replaced it with Apache ECharts (ArcadeData/arcadedb#10627). This + package is Apache-2.0, so a Studio jar from an older engine must not be installed. + scripts/verify_wheel_licences.py guards the wheel at build time; this checks what + pip actually installed. + """ + from arcadedb_embedded.jvm import get_jar_path + + package_dir = os.path.dirname(get_jar_path()) + for root, _dirs, files in os.walk(package_dir): + for name in files: + path = os.path.join(root, name) + assert ( + "apexcharts" not in name.lower() + ), f"ApexCharts file in the wheel: {path}" + if name.endswith(".jar"): + with zipfile.ZipFile(path) as z: + hits = [n for n in z.namelist() if "apexcharts" in n.lower()] + assert not hits, f"{name} carries ApexCharts files: {hits[:3]}" + + @pytest.mark.server def test_server_starts_and_serves_http(temp_server_root): """End-to-end: the bundled stack actually starts and answers. diff --git a/tests/test_wheel_licences.py b/tests/test_wheel_licences.py new file mode 100644 index 00000000..d5030f9b --- /dev/null +++ b/tests/test_wheel_licences.py @@ -0,0 +1,147 @@ +"""The licence guard: a wheel that carries a file that is not open source fails the build. + +Studio bundled ApexCharts, which is not open source since 5.1.0 (ArcadeData/arcadedb#10579), until engine 26.11.1 +replaced it with Apache ECharts (ArcadeData/arcadedb#10627). The tests build tiny wheels in a temporary directory, so +they run in milliseconds and need no built wheel. +""" + +from __future__ import annotations + +import importlib.util +import io +import zipfile +from pathlib import Path + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "verify_wheel_licences.py" +) +EXCLUSIONS = Path(__file__).resolve().parents[1] / "scripts" / "jar_exclusions.txt" + + +def _load(): + spec = importlib.util.spec_from_file_location("verify_wheel_licences", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _jar(members: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in members.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def _wheel(path: Path, members: dict[str, bytes]) -> Path: + with zipfile.ZipFile(path, "w") as zf: + for name, data in members.items(): + zf.writestr(name, data) + return path + + +def test_studio_jar_is_not_in_the_exclusion_list(): + # Studio ships from 26.11.1 (ECharts); the guard, not an exclusion, keeps ApexCharts out. + lines = [ln.strip() for ln in EXCLUSIONS.read_text().splitlines()] + assert not [ln for ln in lines if not ln.startswith("#") and "studio" in ln] + + +def test_a_clean_wheel_passes(tmp_path, capsys): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + { + "arcadedb_embedded/jars/arcadedb-server-1.jar": _jar({"A.class": b"x"}), + "arcadedb_embedded/server.py": b"", + }, + ) + assert module.main([str(wheel)]) == 0 + assert "OK:" in capsys.readouterr().out + + +def test_a_studio_jar_with_echarts_passes(tmp_path, capsys): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + { + "arcadedb_embedded/jars/arcadedb-studio-26.11.1.jar": _jar( + { + "static/index.html": b"x", + "static/dist/js/echarts.min.js": b"x", + } + ) + }, + ) + assert module.main([str(wheel)]) == 0 + assert "OK:" in capsys.readouterr().out + + +def test_an_echarts_file_outside_a_jar_passes(tmp_path): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + {"arcadedb_embedded/static/dist/js/echarts.min.js": b"x"}, + ) + assert module.main([str(wheel)]) == 0 + + +def test_an_apexcharts_file_fails_the_wheel(tmp_path, capsys): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + {"arcadedb_embedded/static/dist/js/apexcharts.min.js": b"x"}, + ) + assert module.main([str(wheel)]) == 1 + err = capsys.readouterr().err + assert "ApexCharts" in err + assert "apexcharts.min.js" in err + + +def test_a_studio_jar_from_an_older_engine_fails_the_wheel(tmp_path, capsys): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + { + "arcadedb_embedded/jars/arcadedb-studio-26.10.2.jar": _jar( + { + "static/index.html": b"x", + "static/dist/js/apexcharts.min.js": b"x", + } + ) + }, + ) + assert module.main([str(wheel)]) == 1 + err = capsys.readouterr().err + assert "arcadedb-studio-26.10.2.jar contains" in err + assert "26.11.1" in err + + +def test_a_renamed_jar_that_carries_apexcharts_fails_the_wheel(tmp_path, capsys): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + { + "arcadedb_embedded/jars/ui-1.jar": _jar( + {"static/dist/js/ApexCharts.min.js": b"x"} + ) + }, + ) + assert module.main([str(wheel), str(wheel)]) == 1 + assert "ui-1.jar contains" in capsys.readouterr().err + + +def test_a_jar_that_is_not_a_zip_fails_the_wheel(tmp_path, capsys): + module = _load() + wheel = _wheel( + tmp_path / "a-1-py3-none-any.whl", + {"arcadedb_embedded/jars/broken-1.jar": b"not a zip"}, + ) + assert module.main([str(wheel)]) == 1 + assert "cannot be checked" in capsys.readouterr().err + + +def test_a_missing_file_is_a_usage_error(tmp_path): + module = _load() + assert module.main([str(tmp_path / "nope.whl")]) == 2 + assert module.main([]) == 2