From a59b20c2ced911ca31d2b3866e18e41ca5c7ddd4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 13 Apr 2026 17:35:47 -0500 Subject: [PATCH] feat(licenses_collector): implement license collection for Python and Node dependencies, including metadata extraction and frontend license handling --- .../src/backend/data/licenses_frontend.json | 1 + meshchatx/src/backend/licenses_collector.py | 223 +++++++++++++ .../components/licenses/LicensesPage.vue | 308 ++++++++++++++++++ tests/backend/test_licenses_api.py | 77 +++++ tests/backend/test_licenses_collector.py | 60 ++++ tests/frontend/LicensesPage.test.js | 78 +++++ 6 files changed, 747 insertions(+) create mode 100644 meshchatx/src/backend/data/licenses_frontend.json create mode 100644 meshchatx/src/backend/licenses_collector.py create mode 100644 meshchatx/src/frontend/components/licenses/LicensesPage.vue create mode 100644 tests/backend/test_licenses_api.py create mode 100644 tests/backend/test_licenses_collector.py create mode 100644 tests/frontend/LicensesPage.test.js diff --git a/meshchatx/src/backend/data/licenses_frontend.json b/meshchatx/src/backend/data/licenses_frontend.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/meshchatx/src/backend/data/licenses_frontend.json @@ -0,0 +1 @@ +[] diff --git a/meshchatx/src/backend/licenses_collector.py b/meshchatx/src/backend/licenses_collector.py new file mode 100644 index 0000000..b2acab1 --- /dev/null +++ b/meshchatx/src/backend/licenses_collector.py @@ -0,0 +1,223 @@ +"""Collect third-party license metadata for Python (backend) and Node (frontend).""" + +from __future__ import annotations + +import importlib.metadata +import json +import shutil +import subprocess +import tomllib +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +_ROOT_DIST_CANDIDATES = ("reticulum-meshchatx", "reticulum_meshchatx") + + +def _repo_root() -> Path: + import meshchatx + + return Path(meshchatx.__file__).resolve().parent.parent + + +def _license_from_metadata(meta: importlib.metadata.Metadata) -> str: + le = meta.get("License-Expression") + if le: + return str(le).strip() + lic = meta.get("License") + if lic and str(lic).strip() and str(lic).strip().upper() != "UNKNOWN": + return str(lic).strip() + classifiers = meta.get_all("Classifier") or [] + for line in classifiers: + if line.startswith("License ::"): + return line.split("::", 1)[-1].strip() + return "—" + + +def _author_from_metadata(meta: importlib.metadata.Metadata) -> str: + a = (meta.get("Author") or "").strip() + if a: + return a + ae = (meta.get("Author-email") or "").strip() + if ae: + return ae + m = (meta.get("Maintainer") or "").strip() + if m: + return m + return "—" + + +def _dist_for_requirement_name(name: str) -> importlib.metadata.Distribution | None: + key = canonicalize_name(name) + try: + return importlib.metadata.distribution(key) + except importlib.metadata.PackageNotFoundError: + pass + try: + return importlib.metadata.distribution(name) + except importlib.metadata.PackageNotFoundError: + return None + + +def _collect_python_transitive(root_names: tuple[str, ...]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + + def visit(name: str) -> None: + cname = canonicalize_name(name) + if cname in seen: + return + dist = _dist_for_requirement_name(name) + if dist is None: + return + seen.add(cname) + meta = dist.metadata + pkg_name = meta.get("Name") or name + rows.append( + { + "name": str(pkg_name), + "version": dist.version, + "author": _author_from_metadata(meta), + "license": _license_from_metadata(meta), + }, + ) + for req_str in dist.requires or []: + if not (req_str or "").strip(): + continue + try: + req = Requirement(req_str) + except Exception: + continue + if req.extras and not req.marker: + pass + if req.marker is not None and not req.marker.evaluate(): + continue + visit(req.name) + + for root in root_names: + visit(root) + + rows.sort(key=lambda r: r["name"].lower()) + return rows + + +def _python_roots_from_pyproject(repo_root: Path) -> tuple[str, ...]: + pyproject = repo_root / "pyproject.toml" + if not pyproject.is_file(): + return _ROOT_DIST_CANDIDATES[:1] + try: + with pyproject.open("rb") as f: + data = tomllib.load(f) + except OSError: + return _ROOT_DIST_CANDIDATES[:1] + deps = (data.get("project") or {}).get("dependencies") or [] + names: list[str] = [] + for line in deps: + try: + req = Requirement(line) + except Exception: + continue + names.append(req.name) + if not names: + return _ROOT_DIST_CANDIDATES[:1] + return tuple(sorted(set(names), key=lambda n: n.lower())) + + +def collect_backend_licenses() -> list[dict[str, Any]]: + for root in _ROOT_DIST_CANDIDATES: + if _dist_for_requirement_name(root) is not None: + return _collect_python_transitive((root,)) + repo = _repo_root() + roots = _python_roots_from_pyproject(repo) + return _collect_python_transitive(roots) + + +def _flatten_pnpm_licenses_json(data: dict[str, Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for _license_key, packages in data.items(): + if not isinstance(packages, list): + continue + for pkg in packages: + if not isinstance(pkg, dict): + continue + name = pkg.get("name") or "?" + versions = pkg.get("versions") or [] + version = versions[0] if versions else "?" + author = pkg.get("author") or "—" + if not isinstance(author, str): + author = str(author) + lic = pkg.get("license") or _license_key or "—" + out.append( + { + "name": name, + "version": str(version), + "author": author, + "license": str(lic), + }, + ) + out.sort(key=lambda r: r["name"].lower()) + return out + + +def _load_embedded_frontend_licenses() -> list[dict[str, Any]] | None: + data_dir = Path(__file__).resolve().parent / "data" + path = data_dir / "licenses_frontend.json" + if not path.is_file(): + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(raw, list): + return None + return [x for x in raw if isinstance(x, dict)] + + +def collect_frontend_licenses() -> tuple[list[dict[str, Any]], str]: + embedded = _load_embedded_frontend_licenses() + repo = _repo_root() + if not (repo / "package.json").is_file(): + if embedded is not None: + return embedded, "embedded" + return [], "none" + + pnpm = shutil.which("pnpm") + if pnpm: + try: + proc = subprocess.run( + [pnpm, "licenses", "list", "--json"], + cwd=repo, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if proc.returncode == 0 and proc.stdout.strip(): + parsed = json.loads(proc.stdout) + if isinstance(parsed, dict): + return _flatten_pnpm_licenses_json(parsed), "pnpm" + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): + pass + + if embedded is not None: + return embedded, "embedded" + + return [], "none" + + +def build_licenses_payload() -> dict[str, Any]: + backend = collect_backend_licenses() + frontend, fe_source = collect_frontend_licenses() + return { + "backend": backend, + "frontend": frontend, + "meta": { + "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "backend_count": len(backend), + "frontend_count": len(frontend), + "frontend_source": fe_source, + }, + } diff --git a/meshchatx/src/frontend/components/licenses/LicensesPage.vue b/meshchatx/src/frontend/components/licenses/LicensesPage.vue new file mode 100644 index 0000000..5a986de --- /dev/null +++ b/meshchatx/src/frontend/components/licenses/LicensesPage.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/tests/backend/test_licenses_api.py b/tests/backend/test_licenses_api.py new file mode 100644 index 0000000..ae8faed --- /dev/null +++ b/tests/backend/test_licenses_api.py @@ -0,0 +1,77 @@ +import json +import shutil +import tempfile +from unittest.mock import MagicMock, patch + +import pytest +import RNS + +from meshchatx.meshchat import ReticulumMeshChat + + +@pytest.fixture +def temp_dir(): + dir_path = tempfile.mkdtemp() + yield dir_path + shutil.rmtree(dir_path) + + +@pytest.fixture +def mock_rns_minimal(): + with ( + patch("RNS.Reticulum") as mock_rns, + patch("RNS.Transport"), + patch("LXMF.LXMRouter"), + patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"), + ): + mock_rns_instance = mock_rns.return_value + mock_rns_instance.configpath = "/tmp/mock_config" + mock_rns_instance.is_connected_to_shared_instance = False + mock_rns_instance.transport_enabled.return_value = True + + mock_id = MagicMock(spec=RNS.Identity) + mock_id.hash = b"test_hash_32_bytes_long_01234567" + mock_id.hexhash = mock_id.hash.hex() + mock_id.get_private_key.return_value = b"test_private_key" + yield mock_id + + +@pytest.mark.asyncio +async def test_licenses_endpoint_returns_json(mock_rns_minimal, temp_dir): + payload = { + "backend": [ + {"name": "aiohttp", "version": "1", "author": "x", "license": "Apache-2.0"} + ], + "frontend": [], + "meta": { + "generated_at": "2020-01-01T00:00:00Z", + "backend_count": 1, + "frontend_count": 0, + "frontend_source": "none", + }, + } + with ( + patch("meshchatx.meshchat.generate_ssl_certificate"), + patch( + "meshchatx.src.backend.licenses_collector.build_licenses_payload", + return_value=payload, + ), + ): + app_instance = ReticulumMeshChat( + identity=mock_rns_minimal, + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + handler = None + for route in app_instance.get_routes(): + if route.path == "/api/v1/licenses" and route.method == "GET": + handler = route.handler + break + + assert handler is not None + request = MagicMock() + response = await handler(request) + assert response.status == 200 + data = json.loads(response.body) + assert data == payload diff --git a/tests/backend/test_licenses_collector.py b/tests/backend/test_licenses_collector.py new file mode 100644 index 0000000..7511025 --- /dev/null +++ b/tests/backend/test_licenses_collector.py @@ -0,0 +1,60 @@ +from unittest.mock import patch + +from meshchatx.src.backend.licenses_collector import ( + _flatten_pnpm_licenses_json, + build_licenses_payload, +) + + +def test_flatten_pnpm_licenses_json_maps_and_sorts(): + data = { + "MIT": [ + { + "name": "zebra-pkg", + "versions": ["2.0.0"], + "author": "Z", + "license": "MIT", + }, + ], + "Apache-2.0": [ + {"name": "alpha-pkg", "versions": ["1.0.0"], "author": "Alice"}, + {"name": "no-version", "versions": [], "author": "—"}, + ], + } + rows = _flatten_pnpm_licenses_json(data) + assert [r["name"] for r in rows] == ["alpha-pkg", "no-version", "zebra-pkg"] + alpha = next(r for r in rows if r["name"] == "alpha-pkg") + assert alpha["version"] == "1.0.0" + assert alpha["author"] == "Alice" + assert alpha["license"] == "Apache-2.0" + nov = next(r for r in rows if r["name"] == "no-version") + assert nov["version"] == "?" + + +def test_flatten_pnpm_licenses_json_non_dict_package_skipped(): + data = {"MIT": ["not-a-dict", {"name": "ok", "versions": ["1"], "author": "x"}]} + rows = _flatten_pnpm_licenses_json(data) + assert len(rows) == 1 + assert rows[0]["name"] == "ok" + + +def test_build_licenses_payload_composes_counts_and_meta(): + be = [{"name": "rns", "version": "1", "author": "a", "license": "MIT"}] + fe = [{"name": "vue", "version": "3", "author": "b", "license": "MIT"}] + with ( + patch( + "meshchatx.src.backend.licenses_collector.collect_backend_licenses", + return_value=be, + ), + patch( + "meshchatx.src.backend.licenses_collector.collect_frontend_licenses", + return_value=(fe, "pnpm"), + ), + ): + payload = build_licenses_payload() + assert payload["backend"] == be + assert payload["frontend"] == fe + assert payload["meta"]["backend_count"] == 1 + assert payload["meta"]["frontend_count"] == 1 + assert payload["meta"]["frontend_source"] == "pnpm" + assert payload["meta"]["generated_at"].endswith("Z") diff --git a/tests/frontend/LicensesPage.test.js b/tests/frontend/LicensesPage.test.js new file mode 100644 index 0000000..08e9126 --- /dev/null +++ b/tests/frontend/LicensesPage.test.js @@ -0,0 +1,78 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import LicensesPage from "@/components/licenses/LicensesPage.vue"; + +window.api = { + get: vi.fn(), +}; + +describe("LicensesPage.vue", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads licenses from the API and renders rows", async () => { + window.api.get.mockResolvedValue({ + data: { + backend: [{ name: "alpha-be", version: "1.0.0", author: "A", license: "MIT" }], + frontend: [{ name: "zebra-fe", version: "2.0.0", author: "Z", license: "Apache-2.0" }], + meta: { + generated_at: "2020-01-01T00:00:00Z", + frontend_source: "pnpm", + }, + }, + }); + + const wrapper = mount(LicensesPage, { + global: { + mocks: { $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) }, + stubs: { + MaterialDesignIcon: { + template: '', + props: ["iconName"], + }, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await wrapper.vm.$nextTick(); + + expect(window.api.get).toHaveBeenCalledWith("/api/v1/licenses"); + expect(wrapper.text()).toContain("alpha-be"); + expect(wrapper.text()).toContain("zebra-fe"); + }); + + it("filters both sections with the search query", async () => { + window.api.get.mockResolvedValue({ + data: { + backend: [{ name: "keep-be", version: "1", author: "x", license: "MIT" }], + frontend: [{ name: "other-fe", version: "2", author: "y", license: "MIT" }], + meta: {}, + }, + }); + + const wrapper = mount(LicensesPage, { + global: { + mocks: { $t: (key) => key }, + stubs: { + MaterialDesignIcon: { + template: '', + props: ["iconName"], + }, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + await wrapper.vm.$nextTick(); + + await wrapper.find('input[type="search"]').setValue("keep-be"); + expect(wrapper.vm.filteredBackend.length).toBe(1); + expect(wrapper.vm.filteredFrontend.length).toBe(0); + + await wrapper.find('input[type="search"]').setValue("other-fe"); + expect(wrapper.vm.filteredBackend.length).toBe(0); + expect(wrapper.vm.filteredFrontend.length).toBe(1); + }); +});