feat(licenses_collector): implement license collection for Python and Node dependencies, including metadata extraction and frontend license handling

This commit is contained in:
Ivan
2026-04-13 17:35:47 -05:00
parent 97a8b071a5
commit a59b20c2ce
6 changed files with 747 additions and 0 deletions

View File

@@ -0,0 +1 @@
[]

View File

@@ -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,
},
}

View File

@@ -0,0 +1,308 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-white dark:bg-zinc-950">
<div class="flex-1 flex flex-col min-h-0 overflow-y-auto overscroll-y-contain w-full">
<div class="shrink-0 border-b border-gray-200 dark:border-zinc-800 px-3 sm:px-4 md:px-6 py-4 md:py-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between lg:gap-6">
<div class="space-y-2 min-w-0 flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("licenses.section_label") }}
</div>
<div
class="text-xl sm:text-2xl md:text-3xl font-black text-gray-900 dark:text-white tracking-tight"
>
{{ $t("licenses.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-2xl">
{{ $t("licenses.description") }}
</div>
<p v-if="meta?.generated_at" class="text-xs text-gray-500 dark:text-zinc-500 break-words">
{{ $t("licenses.generated_at", { time: meta.generated_at }) }}
<span v-if="meta.frontend_source" class="ml-2 inline-block sm:inline">
({{ $t("licenses.frontend_source", { source: meta.frontend_source }) }})
</span>
</p>
</div>
<div class="w-full lg:max-w-md xl:max-w-sm shrink-0">
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MaterialDesignIcon
icon-name="magnify"
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
/>
</div>
<input
v-model="searchQuery"
type="search"
enterkeyhint="search"
autocomplete="off"
:placeholder="$t('licenses.search_placeholder')"
class="w-full min-h-[44px] sm:min-h-0 pl-10 pr-10 py-3 bg-gray-50 dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 text-base sm:text-sm"
/>
<button
v-if="searchQuery"
class="absolute inset-y-0 right-0 pr-3 flex items-center min-w-[44px] justify-end text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
type="button"
aria-label="Clear search"
@click="searchQuery = ''"
>
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
</button>
</div>
</div>
</div>
</div>
<div
class="flex-1 min-h-0 p-3 sm:p-4 md:p-6 xl:p-8 w-full max-w-6xl xl:max-w-[min(100%,96rem)] mx-auto flex flex-col gap-4"
>
<div
v-if="loadError"
class="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/30 px-4 py-3 text-sm text-red-800 dark:text-red-200"
>
{{ loadError }}
</div>
<div
v-if="loading"
class="flex flex-col items-center justify-center py-16 gap-3 text-gray-500 dark:text-zinc-400"
>
<MaterialDesignIcon icon-name="loading" class="size-10 animate-spin" />
<span>{{ $t("common.loading") }}</span>
</div>
<template v-else>
<div class="license-grid grid grid-cols-1 gap-4 xl:grid-cols-2 xl:gap-6 xl:items-start">
<details
class="license-details rounded-lg border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 open:bg-white dark:open:bg-zinc-950 overflow-hidden"
open
>
<summary
class="cursor-pointer select-none px-3 sm:px-4 py-3.5 sm:py-3 min-h-[48px] sm:min-h-0 font-semibold text-sm sm:text-base text-gray-900 dark:text-white flex items-center justify-between gap-2 list-none touch-manipulation"
>
<span class="min-w-0 break-words pr-2"
>{{ $t("licenses.backend_section") }} ({{ filteredBackend.length }})</span
>
<MaterialDesignIcon
icon-name="chevron-down"
class="license-details-chevron size-5 shrink-0 opacity-60"
/>
</summary>
<div
class="license-details-body border-t border-gray-100/80 dark:border-zinc-800/50 max-h-[min(65vh,32rem)] xl:max-h-[min(calc(100dvh-14rem),44rem)] overflow-x-auto overflow-y-auto overscroll-contain px-1 sm:px-2 pb-3 sm:pb-4"
>
<table class="min-w-full text-left border-collapse text-xs sm:text-sm">
<thead>
<tr
class="sticky top-0 z-[1] border-b border-gray-200 dark:border-zinc-800 bg-gray-50/95 dark:bg-zinc-900/95 backdrop-blur-sm text-gray-600 dark:text-zinc-400"
>
<th class="py-2 px-2 sm:px-3 font-medium">
{{ $t("licenses.col_package") }}
</th>
<th class="py-2 px-2 sm:px-3 font-medium whitespace-nowrap">
{{ $t("licenses.col_version") }}
</th>
<th class="py-2 px-2 sm:px-3 font-medium">
{{ $t("licenses.col_author") }}
</th>
<th class="py-2 px-2 sm:px-3 font-medium">
{{ $t("licenses.col_license") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in filteredBackend"
:key="'b-' + row.name + row.version"
class="border-b border-gray-100 dark:border-zinc-800/80 hover:bg-gray-50/80 dark:hover:bg-zinc-900/60"
>
<td
class="py-2 px-2 sm:px-3 font-mono text-[11px] sm:text-xs text-gray-900 dark:text-zinc-100 align-top"
>
{{ row.name }}
</td>
<td
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 align-top whitespace-nowrap"
>
{{ row.version }}
</td>
<td
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[10rem] sm:max-w-[14rem] truncate align-top"
:title="row.author"
>
{{ row.author }}
</td>
<td
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[8rem] sm:max-w-xs align-top break-words"
>
{{ row.license }}
</td>
</tr>
</tbody>
</table>
<p
v-if="filteredBackend.length === 0"
class="text-center py-8 text-gray-500 dark:text-zinc-500 text-sm"
>
{{ $t("common.no_results") }}
</p>
</div>
</details>
<details
class="license-details rounded-lg border border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/40 open:bg-white dark:open:bg-zinc-950 overflow-hidden"
open
>
<summary
class="cursor-pointer select-none px-3 sm:px-4 py-3.5 sm:py-3 min-h-[48px] sm:min-h-0 font-semibold text-sm sm:text-base text-gray-900 dark:text-white flex items-center justify-between gap-2 list-none touch-manipulation"
>
<span class="min-w-0 break-words pr-2"
>{{ $t("licenses.frontend_section") }} ({{ filteredFrontend.length }})</span
>
<MaterialDesignIcon
icon-name="chevron-down"
class="license-details-chevron size-5 shrink-0 opacity-60"
/>
</summary>
<div
class="license-details-body border-t border-gray-100/80 dark:border-zinc-800/50 max-h-[min(65vh,32rem)] xl:max-h-[min(calc(100dvh-14rem),44rem)] overflow-x-auto overflow-y-auto overscroll-contain px-1 sm:px-2 pb-3 sm:pb-4"
>
<table class="min-w-full text-left border-collapse text-xs sm:text-sm">
<thead>
<tr
class="sticky top-0 z-[1] border-b border-gray-200 dark:border-zinc-800 bg-gray-50/95 dark:bg-zinc-900/95 backdrop-blur-sm text-gray-600 dark:text-zinc-400"
>
<th class="py-2 px-2 sm:px-3 font-medium">
{{ $t("licenses.col_package") }}
</th>
<th class="py-2 px-2 sm:px-3 font-medium whitespace-nowrap">
{{ $t("licenses.col_version") }}
</th>
<th class="py-2 px-2 sm:px-3 font-medium">
{{ $t("licenses.col_author") }}
</th>
<th class="py-2 px-2 sm:px-3 font-medium">
{{ $t("licenses.col_license") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in filteredFrontend"
:key="'f-' + row.name + row.version"
class="border-b border-gray-100 dark:border-zinc-800/80 hover:bg-gray-50/80 dark:hover:bg-zinc-900/60"
>
<td
class="py-2 px-2 sm:px-3 font-mono text-[11px] sm:text-xs text-gray-900 dark:text-zinc-100 align-top"
>
{{ row.name }}
</td>
<td
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 align-top whitespace-nowrap"
>
{{ row.version }}
</td>
<td
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[10rem] sm:max-w-[14rem] truncate align-top"
:title="row.author"
>
{{ row.author }}
</td>
<td
class="py-2 px-2 sm:px-3 text-gray-700 dark:text-zinc-300 max-w-[8rem] sm:max-w-xs align-top break-words"
>
{{ row.license }}
</td>
</tr>
</tbody>
</table>
<p
v-if="filteredFrontend.length === 0"
class="text-center py-8 text-gray-500 dark:text-zinc-500 text-sm"
>
{{ $t("common.no_results") }}
</p>
</div>
</details>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "LicensesPage",
components: { MaterialDesignIcon },
data() {
return {
loading: true,
loadError: null,
searchQuery: "",
backend: [],
frontend: [],
meta: null,
};
},
computed: {
q() {
return this.searchQuery.trim().toLowerCase();
},
filteredBackend() {
return this.filterRows(this.backend);
},
filteredFrontend() {
return this.filterRows(this.frontend);
},
},
async mounted() {
await this.load();
},
methods: {
filterRows(rows) {
if (!this.q) {
return rows;
}
return rows.filter((r) => {
const blob = `${r.name} ${r.version} ${r.author} ${r.license}`.toLowerCase();
return blob.includes(this.q);
});
},
async load() {
this.loading = true;
this.loadError = null;
try {
const res = await window.api.get("/api/v1/licenses");
this.backend = res.data.backend || [];
this.frontend = res.data.frontend || [];
this.meta = res.data.meta || null;
} catch (e) {
this.loadError = e.response?.data?.error || e.message || "Failed to load licenses";
} finally {
this.loading = false;
}
},
},
};
</script>
<style scoped>
.license-details summary::-webkit-details-marker {
display: none;
}
.license-details summary::marker {
display: none;
}
.license-details[open] .license-details-chevron {
transform: rotate(180deg);
}
.license-details-chevron {
transition: transform 0.15s ease;
}
.license-details-body {
-webkit-overflow-scrolling: touch;
}
</style>

View File

@@ -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

View File

@@ -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")

View File

@@ -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: '<span class="mdi-stub" />',
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: '<span class="mdi-stub" />',
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);
});
});