From a15532dc1c17321b85939969db321e03c53f21c1 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:25:35 -0400 Subject: [PATCH] Add pnpm dependency license bundles Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- .github/workflows/build.yaml | 1 + README.md | 56 +++ hatch_js/licenses.py | 168 +++++++++ hatch_js/plugin.py | 67 ++++ hatch_js/structs.py | 12 +- hatch_js/tests/test_editable_licenses.py | 18 + hatch_js/tests/test_license_projects.py | 117 ++++++ hatch_js/tests/test_licenses.py | 444 +++++++++++++++++++++++ pyproject.toml | 1 + 9 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 hatch_js/licenses.py create mode 100644 hatch_js/tests/test_editable_licenses.py create mode 100644 hatch_js/tests/test_license_projects.py create mode 100644 hatch_js/tests/test_licenses.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3fc0c38..b141a6e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -42,6 +42,7 @@ jobs: uses: actions-ext/node/setup@main with: version: 22.x + pnpm_version: 10.28.2 js_folder: hatch_js/tests/test_project_basic/js - name: Install dependencies diff --git a/README.md b/README.md index 18e6619..7e23108 100644 --- a/README.md +++ b/README.md @@ -40,5 +40,61 @@ targets = [ # outputs to validate after build ] ``` +## Dependency licenses + +License collection is opt-in and currently supports `tool = "pnpm"`. Install pnpm before building; CI tests use pnpm 10.28.2 with Node 22. No separate license collector or Python dependency is needed. + +```toml +[tool.hatch.build.hooks.hatch-js] +path = "js" +tool = "pnpm" + +[tool.hatch.build.hooks.hatch-js.licenses] +destination = "myproject/licenses/pnpm.json" +production-only = false +# previous = "js/license-overrides.json" +``` + +After a successful JavaScript build, hatch-js runs `pnpm licenses list --json` and reads original license and notice files from the listed package paths. The destination must be inside a package directory, without absolute paths, traversal, or collisions with project files and other force-included outputs. + +Destinations are wheel-relative. If Hatchling's `sources` configuration would rewrite the destination, collection fails rather than placing the bundle at a different path. Choose a destination that is not itself a mapped source prefix. Existing files in source layouts such as `src/myproject` are also checked for collisions. + +The JSON contains `root_name` and a `libraries` list. Each library records `package_name`, `package_version`, the collector's `license` expression, and a `files` list of package-relative `path` and original `text`. Different versions remain separate. No machine-specific absolute paths are packaged. + +Collection includes root LICENSE, LICENCE, COPYING, COPYRIGHT, NOTICE, AUTHORS, and third-party notice files, including common suffixes, plus files under license/licence directories and files referenced by `SEE LICENSE IN ...`. UTF-8 contents are retained without changing copyright notices or line endings. A notice or author list alone does not satisfy the requirement for a license text. README files are included only when explicitly referenced by the package's license field; hatch-js does not substitute generic SPDX license templates. + +The bundle is force-included in standard and editable wheels, even if an ignore rule would exclude it. Temporary collection files are removed after packaging; no bundle is written into the source package. An editable import points at the source tree, so inspect the installed wheel files rather than expecting the generated bundle under that source package. Source distributions do not run JavaScript installation or license collection. Keep the JS project, lockfile, and any override inputs in the sdist so a wheel built from it can collect licenses. + +### Missing texts + +A missing, empty, placeholder, or non-UTF-8 license file fails the build. When a dependency's license is stored in an unrecognized location, supply a reviewed JSON bundle through `previous`. The path is relative to the Python project root, not the JS directory. + +The override format is the same `libraries` structure as generated output; `root_name` is optional for overrides: + +```json +{ + "libraries": [ + { + "package_name": "dependency", + "package_version": "1.0.0", + "license": "MIT", + "files": [ + {"path": "LICENSE", "text": "Original text reviewed for this exact package version."} + ] + } + ] +} +``` + +Replace the example text with the dependency's complete original license and required notices. Overrides match exact package name, version, and collected license expression. They fill absent license files, including missing `SEE LICENSE IN ...` targets, retain discovered notices, and cannot replace a conflicting original file. Files named only NOTICE, AUTHORS, COPYRIGHT, or third-party notices do not satisfy the license requirement in an override. Existing unrecognized files referenced by an override must match the original text. Empty or invalid files already present in the package still fail. Dependency updates may require new reviewed overrides. + +### Scope + +By default, the pnpm listing includes development and optional dependencies as well as runtime dependencies. Bundlers can embed code from dev dependencies, so `production-only = true` is appropriate only when those packages are not shipped. That option passes `--prod` to pnpm; it does not change dependency installation or the JS build. + +This is a conservative dependency-graph inventory, not a bundler input trace. It can include build tools, unrelated workspace dependencies, and packages not present in the final output. Workspace and platform selection follow the installed pnpm version; linked workspace packages and dependencies outside the listing need separate review. pnpm must have a lockfile and its installed package/store data available. Python dependencies, separately vendored assets, and native-library notices are outside this collector's scope. + +`SKIP_HATCH_JS` also skips license collection. Existing npm, yarn, and jlpm builds are unchanged; enabling `licenses` with those tools fails explicitly. Collected metadata and texts do not establish license compatibility or certify compliance. Review the shipped dependency set and required notices before release. + > [!NOTE] > This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base). diff --git a/hatch_js/licenses.py b/hatch_js/licenses.py new file mode 100644 index 0000000..020248d --- /dev/null +++ b/hatch_js/licenses.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path, PurePosixPath + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator + +_LICENSE = re.compile(r"^(?:licen[cs]es?|copying)(?:[._-].*)?$", re.IGNORECASE) +_NOTICE = re.compile(r"^(?:copyright|notices?|authors?|third[-_]?party[-_]?(?:notices?|licenses?))(?:[._-].*)?$", re.IGNORECASE) + + +def relative_path(value: str) -> str: + parts = value.split("/") + if not value or ":" in value or "\\" in value or any(part in ("", ".", "..") or part.endswith((".dist-info", ".data")) for part in parts): + raise ValueError("License destination/file path must be a relative path without traversal or reserved wheel directories.") + return value + + +class PnpmLicenseConfig(BaseModel): + """Opt-in collection of original license texts from pnpm dependencies.""" + + model_config = ConfigDict(populate_by_name=True) + + destination: str = Field(description="Package-relative path of the license JSON in the wheel.") + production_only: bool = Field(default=False, alias="production-only") + previous: Path | None = Field(default=None, description="Reviewed bundle used to fill missing license texts, relative to the project root.") + + @field_validator("destination") + @classmethod + def validate_destination(cls, value: str) -> str: + relative_path(value) + if len(PurePosixPath(value).parts) < 2: + raise ValueError("License destination must be inside a package directory.") + return value + + +class LicenseFile(BaseModel): + path: str + text: str + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + return relative_path(value) + + @field_validator("text") + @classmethod + def validate_text(cls, value: str) -> str: + if not value.strip() or value.strip().casefold() in ("not found", "unknown"): + raise ValueError("Dependency is missing license text; supply original text in a reviewed licenses.previous bundle.") + return value + + +class LibraryLicense(BaseModel): + package_name: str = Field(min_length=1) + package_version: str = Field(min_length=1) + license: str = Field(min_length=1) + files: list[LicenseFile] = Field(min_length=1) + + @model_validator(mode="after") + def unique_paths(self) -> LibraryLicense: + if len({file.path for file in self.files}) != len(self.files): + raise ValueError("Duplicate license file paths in reviewed bundle.") + return self + + +class PnpmPackage(BaseModel): + name: str = Field(min_length=1) + versions: list[str] = Field(min_length=1) + paths: list[Path] = Field(min_length=1) + + @model_validator(mode="after") + def matching_paths(self) -> PnpmPackage: + if len(self.versions) != len(self.paths): + raise ValueError("pnpm license package versions and paths do not match.") + return self + + +def _read_file(directory: Path, file: Path) -> LicenseFile: + if not file.resolve().is_relative_to(directory.resolve()): + raise ValueError(f"License file '{file.name}' points outside its dependency package.") + if not file.is_file(): + raise ValueError(f"Dependency is missing license text in '{file.name}'.") + return LicenseFile(path=file.relative_to(directory).as_posix(), text=file.read_bytes().decode("utf-8")) + + +def _read_files(directory: Path, manifest: dict) -> tuple[dict[str, LicenseFile], bool]: + candidates: set[Path] = set() + primary: set[Path] = set() + for entry in directory.iterdir(): + if entry.is_dir() and entry.name.casefold() in ("license", "licenses", "licence", "licences"): + for file in entry.rglob("*"): + if file.is_file(): + candidates.add(file) + if not _NOTICE.fullmatch(file.name): + primary.add(file) + elif _LICENSE.fullmatch(entry.name) or _NOTICE.fullmatch(entry.name): + candidates.add(entry) + if _LICENSE.fullmatch(entry.name): + primary.add(entry) + expression = manifest.get("license", "") + if isinstance(expression, str) and expression.upper().startswith("SEE LICENSE IN "): + declared = directory / relative_path(expression[len("SEE LICENSE IN ") :].strip()) + if declared.exists() or declared.is_symlink(): + candidates.add(declared) + primary.add(declared) + files = {} + for file in sorted(candidates): + record = _read_file(directory, file) + files[record.path] = record + return files, bool(primary) + + +def collect_licenses(directory: Path, config: PnpmLicenseConfig, root: Path) -> dict: + """Collect the pnpm graph and retain original file contents without absolute paths.""" + manifest = json.loads((directory / "package.json").read_bytes()) + previous = {} + if config.previous is not None: + reviewed = json.loads((root / config.previous).read_bytes()) + if not isinstance(reviewed, dict) or not isinstance(reviewed.get("libraries"), list): + raise ValueError("Reviewed license bundle must contain a libraries list.") + for library in TypeAdapter(list[LibraryLicense]).validate_python(reviewed["libraries"]): + key = (library.package_name, library.package_version, library.license) + if key in previous: + raise ValueError("Duplicate dependency in reviewed license bundle.") + previous[key] = library + command = ["pnpm", "licenses", "list", "--json"] + if config.production_only: + command.append("--prod") + result = subprocess.run(command, cwd=directory, check=True, capture_output=True, text=True) + output = {} if result.stdout.strip() == "No licenses in packages found" else json.loads(result.stdout) + groups = TypeAdapter(dict[str, list[PnpmPackage]]).validate_python(output) + dependency_fields = ["dependencies", "optionalDependencies"] + if not config.production_only: + dependency_fields.append("devDependencies") + if not any(groups.values()) and any(manifest.get(field) for field in dependency_fields): + raise ValueError("pnpm returned an empty license graph for a project with dependencies.") + libraries = {} + for expression, packages in groups.items(): + for package in packages: + for version, path in zip(package.versions, package.paths, strict=True): + package_manifest = json.loads((path / "package.json").read_bytes()) + if package_manifest.get("name") != package.name or package_manifest.get("version") != version: + raise ValueError(f"pnpm package identity does not match its manifest: {package.name}@{version}.") + files, has_license = _read_files(path, package_manifest) + key = (package.name, version, expression) + if not has_license: + override = previous.get(key) + if override is None or not any(not _NOTICE.fullmatch(PurePosixPath(file.path).name) for file in override.files): + raise ValueError( + f"Dependency '{package.name}@{version}' is missing license text. Supply a reviewed licenses.previous bundle." + ) + for file in override.files: + original = path / file.path + if original.exists() or original.is_symlink(): + files[file.path] = _read_file(path, original) + if file.path in files and files[file.path] != file: + raise ValueError(f"Reviewed license text conflicts with original '{file.path}' for '{package.name}@{version}'.") + files[file.path] = file + library = LibraryLicense( + package_name=package.name, package_version=version, license=expression, files=[files[name] for name in sorted(files)] + ) + if key in libraries and libraries[key] != library: + raise ValueError(f"Conflicting installed copies of dependency '{package.name}@{version}'.") + libraries[key] = library + return {"root_name": manifest["name"], "libraries": [libraries[key].model_dump() for key in sorted(libraries)]} diff --git a/hatch_js/plugin.py b/hatch_js/plugin.py index 67a6657..3e61ed5 100644 --- a/hatch_js/plugin.py +++ b/hatch_js/plugin.py @@ -1,11 +1,16 @@ from __future__ import annotations +import json from logging import getLogger from os import getenv +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory from typing import Any from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.builders.utils import normalize_inclusion_map +from .licenses import collect_licenses from .structs import HatchJsBuildConfig, HatchJsBuildPlan from .utils import import_string @@ -17,6 +22,7 @@ class HatchJsBuildHook(BuildHookInterface): PLUGIN_NAME = "hatch-js" _logger = getLogger(__name__) + _license_directory: TemporaryDirectory[str] | None = None def initialize(self, version: str, build_data: dict[str, Any]) -> None: """Initialize the plugin.""" @@ -43,6 +49,9 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: # Instantiate build config config = build_config_class(name=project_name, **self.config) + if config.licenses is not None: + self._validate_license_destination(config.licenses.destination, build_data) + # Get build plan class or use default build_plan_class = import_string(self.config["build-plan-class"]) if "build-plan-class" in self.config else HatchJsBuildPlan @@ -60,5 +69,63 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: # Execute build plan build_plan.execute() + if config.licenses is not None: + self._validate_license_destination(config.licenses.destination, build_data) + self._cleanup_licenses() + directory = TemporaryDirectory(prefix="hatch-js-licenses-") + try: + project = Path(self.root) / (config.path or Path(self.root)) + bundle = collect_licenses(project.resolve(), config.licenses, Path(self.root)) + output = Path(directory.name) / "pnpm.json" + output.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + build_data.setdefault("force_include", {})[str(output)] = config.licenses.destination + if version == "editable": + if not build_data.get("force_include_editable"): + normal = build_data["force_include"] + combined = self.build_config.get_force_include() if self.build_config is not None else {} + combined.update(normalize_inclusion_map(normal, self.root)) + normal.clear() + normal.update(combined) + build_data["force_include_editable"] = normal + build_data["force_include_editable"][str(output)] = config.licenses.destination + except BaseException: + directory.cleanup() + raise + self._license_directory = directory + # Perform any cleanup actions build_plan.cleanup() + + def _validate_license_destination(self, value: str, build_data: dict[str, Any]) -> None: + destination = PurePosixPath(value) + paths = {Path(self.root) / value} + inclusions = list(build_data.get("force_include", {}).values()) + inclusions.extend(build_data.get("force_include_editable", {}).values()) + if self.build_config is not None: + raw = str(Path(value)) + mapped = self.build_config.get_distribution_path(raw) + if Path(mapped).as_posix() != value: + raise ValueError("License destination is rewritten by Hatch sources; choose an unmapped wheel-relative destination.") + inclusions.extend(self.build_config.get_force_include().values()) + for source, replacement in self.build_config.sources.items(): + if raw.startswith(replacement): + paths.add(Path(self.root) / (source + raw[len(replacement) :])) + for existing in paths: + if existing.exists() or existing.is_symlink() or any(parent.is_file() for parent in existing.parents): + raise ValueError(f"License destination collision: '{destination}' already exists in the project.") + for included in inclusions: + mapped = self.build_config.get_distribution_path(str(Path(included))) if self.build_config is not None else included + other = PurePosixPath(Path(mapped).as_posix()) + if destination == other or destination in other.parents or other in destination.parents: + raise ValueError(f"License destination collision with force_include: '{included}'.") + + def _cleanup_licenses(self) -> None: + if self._license_directory is not None: + self._license_directory.cleanup() + self._license_directory = None + + def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None: + self._cleanup_licenses() + + def clean(self, versions: list[str]) -> None: + self._cleanup_licenses() diff --git a/hatch_js/structs.py b/hatch_js/structs.py index 94a67d4..f85ce66 100644 --- a/hatch_js/structs.py +++ b/hatch_js/structs.py @@ -5,11 +5,14 @@ from shutil import which from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator + +from .licenses import PnpmLicenseConfig __all__ = ( "HatchJsBuildConfig", "HatchJsBuildPlan", + "PnpmLicenseConfig", ) Toolchain = Literal["npm", "yarn", "pnpm", "jlpm"] @@ -32,6 +35,13 @@ class HatchJsBuildConfig(BaseModel): ) targets: list[str] | None = Field(default_factory=list, description="List of ensured targets to build") + licenses: PnpmLicenseConfig | None = Field(default=None, description="Opt-in pnpm dependency license collection.") + + @model_validator(mode="after") + def validate_license_tool(self) -> HatchJsBuildConfig: + if self.licenses is not None and self.tool != "pnpm": + raise ValueError("License collection currently requires tool = 'pnpm'.") + return self # Check that tool exists @field_validator("tool", mode="before") diff --git a/hatch_js/tests/test_editable_licenses.py b/hatch_js/tests/test_editable_licenses.py new file mode 100644 index 0000000..4935d4d --- /dev/null +++ b/hatch_js/tests/test_editable_licenses.py @@ -0,0 +1,18 @@ +from hatchling.builders.utils import normalize_inclusion_map + +from hatch_js.tests.test_licenses import hook, wheel_config + +pytest_plugins = ["hatch_js.tests.test_licenses"] + + +def test_normal_hook_override_survives_editable_license_collection(project): + root, _, _ = project + (root / "normal.txt").write_text("Original forced content", encoding="utf-8") + config = wheel_config(root, {"packages": ["project"], "force-include": {"normal.txt": "project/normal.txt"}}) + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}, build_config=config) + data = {"force_include": {"normal.txt": "project/override.txt"}, "force_include_editable": {}} + builder.initialize("editable", data) + effective = normalize_inclusion_map(data["force_include_editable"], str(root)) + assert effective[str(root / "normal.txt")] == "project/override.txt" + assert "project/licenses/pnpm.json" in effective.values() + builder.finalize("editable", data, "unused.whl") diff --git a/hatch_js/tests/test_license_projects.py b/hatch_js/tests/test_license_projects.py new file mode 100644 index 0000000..dbe73c3 --- /dev/null +++ b/hatch_js/tests/test_license_projects.py @@ -0,0 +1,117 @@ +import json +import os +import subprocess +import sys +import tarfile +import zipfile +from importlib.metadata import distribution +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("production_only", [False, True]) +@pytest.mark.parametrize("newline", [b"\n", b"\r\n"]) +@pytest.mark.parametrize("version", ["standard", "editable"]) +@pytest.mark.parametrize("custom_first", [True, False]) +def test_pnpm_licenses_in_wheel_built_from_sdist(tmp_path, production_only, newline, version, custom_first): + project = tmp_path / "source" + (project / "project").mkdir(parents=True) + (project / "project/__init__.py").write_text("", encoding="utf-8") + (project / "js").mkdir() + dist = distribution("hatch-js") + license_file = next(file for file in dist.files or [] if file.name == "LICENSE" and any(part.endswith(".dist-info") for part in file.parts)) + original = Path(str(dist.locate_file(license_file))).read_bytes().replace(b"\r\n", b"\n").replace(b"\n", newline) + for name in ("license-fixture", "license-dev-fixture"): + dependency = tmp_path / name + dependency.mkdir() + (dependency / "package.json").write_text(json.dumps({"name": name, "version": "1.0.0", "license": "Apache-2.0"}), encoding="utf-8") + (dependency / "LICENSE").write_bytes(original) + (dependency / "NOTICE").write_bytes(b"Fixture authors and attribution.\n") + with tarfile.open(project / f"{name}.tgz", "w:gz") as archive: + archive.add(dependency, arcname="package") + (project / "js/package.json").write_text( + json.dumps( + { + "name": "frontend", + "private": True, + "scripts": {"build": "node build.mjs"}, + "dependencies": {"license-fixture": "file:../license-fixture.tgz"}, + "devDependencies": {"license-dev-fixture": "file:../license-dev-fixture.tgz"}, + } + ), + encoding="utf-8", + ) + (project / "js/build.mjs").write_text( + "import { writeFileSync } from 'node:fs';\nwriteFileSync('../project/frontend.js', 'built frontend');\n", encoding="utf-8" + ) + (project / "extra.txt").write_text("Other hook's editable output", encoding="utf-8") + (project / "normal.txt").write_text("Configured forced output", encoding="utf-8") + (project / "hatch_build.py").write_text( + "from hatchling.builders.hooks.plugin.interface import BuildHookInterface\n" + "class CustomBuildHook(BuildHookInterface):\n" + " def initialize(self, version, build_data):\n" + " if version == 'editable':\n" + " build_data['force_include_editable']['extra.txt'] = 'project/extra.txt'\n", + encoding="utf-8", + ) + (project / "pyproject.toml").write_text( + f"""[build-system] +requires = ["hatchling", "hatch-js"] +build-backend = "hatchling.build" +[project] +name = "license-project" +version = "0.1.0" +[tool.hatch.build.targets.sdist] +include = ["/project", "/js", "/*.tgz", "/pyproject.toml", "/hatch_build.py", "/extra.txt", "/normal.txt"] +[tool.hatch.build.targets.wheel] +packages = ["project"] +[tool.hatch.build.targets.wheel.force-include] +"normal.txt" = "project/normal.txt" +{"[tool.hatch.build.hooks.custom]" if custom_first else ""} +[tool.hatch.build.hooks.hatch-js] +path = "js" +tool = "pnpm" +targets = ["project/frontend.js"] +[tool.hatch.build.hooks.hatch-js.licenses] +destination = "project/licenses/pnpm.json" +production-only = {str(production_only).lower()} +{"[tool.hatch.build.hooks.custom]" if not custom_first else ""} +""", + encoding="utf-8", + ) + environment = {key: value for key, value in os.environ.items() if key != "SKIP_HATCH_JS"} + environment["CI"] = "true" + subprocess.run([sys.executable, "-m", "build", "--sdist", "--no-isolation"], cwd=project, env=environment, check=True) + assert not (project / "js/node_modules").exists() + extracted = tmp_path / "extracted" + with tarfile.open(next((project / "dist").glob("*.tar.gz"))) as archive: + assert not any(name.endswith("licenses/pnpm.json") for name in archive.getnames()) + archive.extractall(extracted, filter="data") + source = next(extracted.iterdir()) + command = ( + [sys.executable, "-m", "build", "--wheel", "--no-isolation"] + if version == "standard" + else [sys.executable, "-c", "from hatchling.build import build_editable; build_editable('dist')"] + ) + subprocess.run(command, cwd=source, env=environment, check=True) + with zipfile.ZipFile(next((source / "dist").glob("*.whl"))) as wheel: + if version == "standard": + assert wheel.read("project/frontend.js") == b"built frontend" + else: + assert wheel.read("project/extra.txt") == b"Other hook's editable output" + if version == "standard" or not custom_first: + assert wheel.read("project/normal.txt") == b"Configured forced output" + assert wheel.namelist().count("project/licenses/pnpm.json") == 1 + output = json.loads(wheel.read("project/licenses/pnpm.json")) + assert output["root_name"] == "frontend" + names = {library["package_name"] for library in output["libraries"]} + assert names == ({"license-fixture"} if production_only else {"license-fixture", "license-dev-fixture"}) + for library in output["libraries"]: + assert library["package_version"] == "1.0.0" + assert library["license"] == "Apache-2.0" + assert library["files"] == [ + {"path": "LICENSE", "text": original.decode("utf-8")}, + {"path": "NOTICE", "text": "Fixture authors and attribution.\n"}, + ] + assert not (source / "project/licenses").exists() diff --git a/hatch_js/tests/test_licenses.py b/hatch_js/tests/test_licenses.py new file mode 100644 index 0000000..1b5a482 --- /dev/null +++ b/hatch_js/tests/test_licenses.py @@ -0,0 +1,444 @@ +import json +import subprocess +from pathlib import Path + +import pytest +from hatchling.builders.wheel import WheelBuilder +from hatchling.metadata.core import ProjectMetadata + +from hatch_js.plugin import HatchJsBuildHook + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "js").mkdir() + (tmp_path / "project").mkdir() + (tmp_path / "js/package.json").write_text(json.dumps({"name": "frontend", "dependencies": {"dependency": "1.0.0"}}), encoding="utf-8") + dependency = tmp_path / "js/node_modules/dependency" + dependency.mkdir(parents=True) + (dependency / "package.json").write_text(json.dumps({"name": "dependency", "version": "1.0.0", "license": "MIT"}), encoding="utf-8") + (dependency / "LICENSE").write_bytes(b"Copyright fixture authors\r\nPermission is hereby granted.\r\n") + (dependency / "NOTICE").write_bytes(b"Original third-party attribution.\n") + monkeypatch.setattr("hatch_js.structs.system_call", lambda command: 0) + output = {"MIT": [{"name": "dependency", "versions": ["1.0.0"], "paths": [str(dependency)], "license": "MIT"}]} + + def collect(command, **kwargs): + assert command[:4] == ["pnpm", "licenses", "list", "--json"] + assert Path(kwargs["cwd"]).resolve() == (tmp_path / "js").resolve() + return subprocess.CompletedProcess(command, 0, json.dumps(output), "") + + monkeypatch.setattr(subprocess, "run", collect) + return tmp_path, dependency, output + + +def hook(root, config=None, target="wheel", build_config=None): + return HatchJsBuildHook( + root=str(root), + config={"path": "js", "tool": "pnpm", **(config or {})}, + build_config=build_config, + metadata=ProjectMetadata(str(root), None, {"project": {"name": "project", "version": "0.1.0"}}), + directory=str(root / "dist"), + target_name=target, + ) + + +def bundle(build_data): + source, destination = next(iter(build_data["force_include"].items())) + assert destination == "project/licenses/pnpm.json" + return json.loads(Path(source).read_bytes()), Path(source) + + +def test_wheel_bundle_preserves_original_license_and_notice(project): + root, _, _ = project + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}) + data = {} + builder.initialize("standard", data) + result, source = bundle(data) + assert result == { + "root_name": "frontend", + "libraries": [ + { + "package_name": "dependency", + "package_version": "1.0.0", + "license": "MIT", + "files": [ + {"path": "LICENSE", "text": "Copyright fixture authors\r\nPermission is hereby granted.\r\n"}, + {"path": "NOTICE", "text": "Original third-party attribution.\n"}, + ], + } + ], + } + assert not (root / "project/licenses").exists() + builder.finalize("standard", data, "unused.whl") + assert not source.exists() + + +@pytest.mark.parametrize("version", ["standard", "editable"]) +def test_builds_collect_licenses_and_cleanup_on_clean(project, version): + root, _, _ = project + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}) + data = {} + builder.initialize(version, data) + _, source = bundle(data) + assert source.exists() + builder.clean([version]) + assert not source.exists() + + +@pytest.mark.parametrize("skip", ["disabled", "sdist", "environment"]) +def test_collection_is_opt_in_and_respects_skips(project, monkeypatch, skip): + root, _, _ = project + if skip == "environment": + monkeypatch.setenv("SKIP_HATCH_JS", "1") + config = {} if skip == "disabled" else {"licenses": {"destination": "project/licenses/pnpm.json"}} + builder = hook(root, config, target="sdist" if skip == "sdist" else "wheel") + data = {} + builder.initialize("standard", data) + assert "force_include" not in data + + +def test_notice_only_override_does_not_satisfy_missing_license(project): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + (root / "overrides.json").write_text( + json.dumps( + { + "libraries": [ + { + "package_name": "dependency", + "package_version": "1.0.0", + "license": "MIT", + "files": [{"path": "NOTICE", "text": "Original third-party attribution.\n"}], + } + ] + } + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="missing license text"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json", "previous": "overrides.json"}}).initialize("standard", {}) + + +@pytest.mark.parametrize("text", ["Original terms", "Replacement terms"]) +def test_override_checks_original_unrecognized_file(project, text): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + (dependency / "terms.txt").write_text("Original terms", encoding="utf-8") + (root / "overrides.json").write_text( + json.dumps( + { + "libraries": [ + {"package_name": "dependency", "package_version": "1.0.0", "license": "MIT", "files": [{"path": "terms.txt", "text": text}]} + ] + } + ), + encoding="utf-8", + ) + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json", "previous": "overrides.json"}}) + data = {} + if text != "Original terms": + with pytest.raises(ValueError, match="conflicts"): + builder.initialize("standard", data) + else: + builder.initialize("standard", data) + result, _ = bundle(data) + assert result["libraries"][0]["files"][-1] == {"path": "terms.txt", "text": "Original terms"} + builder.finalize("standard", data, "unused.whl") + + +def test_override_fills_absent_declared_license_file(project): + root, dependency, output = project + (dependency / "LICENSE").unlink() + expression = "SEE LICENSE IN terms.txt" + (dependency / "package.json").write_text(json.dumps({"name": "dependency", "version": "1.0.0", "license": expression}), encoding="utf-8") + output[expression] = output.pop("MIT") + output[expression][0]["license"] = expression + (root / "overrides.json").write_text( + json.dumps( + { + "libraries": [ + { + "package_name": "dependency", + "package_version": "1.0.0", + "license": expression, + "files": [{"path": "terms.txt", "text": "Original terms"}], + } + ] + } + ), + encoding="utf-8", + ) + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json", "previous": "overrides.json"}}) + data = {} + builder.initialize("standard", data) + result, _ = bundle(data) + assert result["libraries"][0]["files"] == [ + {"path": "NOTICE", "text": "Original third-party attribution.\n"}, + {"path": "terms.txt", "text": "Original terms"}, + ] + builder.finalize("standard", data, "unused.whl") + + +def wheel_config(root, target_config): + builder = WheelBuilder( + str(root), config={"project": {"name": "project", "version": "0.1.0"}, "tool": {"hatch": {"build": {"targets": {"wheel": target_config}}}}} + ) + return builder.config + + +def test_collection_checks_configured_force_include(project): + root, _, _ = project + (root / "existing.txt").write_text("Existing content", encoding="utf-8") + config = wheel_config(root, {"packages": ["project"], "force-include": {"existing.txt": "project/licenses/pnpm.json"}}) + with pytest.raises(ValueError, match="collision"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}, build_config=config).initialize("standard", {}) + + +def test_collection_checks_source_layout_collision(project): + root, _, _ = project + (root / "src/project/licenses").mkdir(parents=True) + (root / "src/project/licenses/pnpm.json").write_text("Source original", encoding="utf-8") + config = wheel_config(root, {"packages": ["src/project"]}) + with pytest.raises(ValueError, match="collision"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}, build_config=config).initialize("standard", {}) + assert (root / "src/project/licenses/pnpm.json").read_text() == "Source original" + + +def test_collection_rejects_rewritten_destination(project): + root, _, _ = project + config = wheel_config(root, {"packages": ["project"], "sources": {"project": "mapped"}}) + with pytest.raises(ValueError, match="sources"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}, build_config=config).initialize("standard", {}) + + +def test_collection_checks_post_build_destination(project, monkeypatch): + root, _, _ = project + + def build(command): + (root / "project/licenses").mkdir(exist_ok=True) + (root / "project/licenses/pnpm.json").write_text("Build output", encoding="utf-8") + return 0 + + monkeypatch.setattr("hatch_js.structs.system_call", build) + with pytest.raises(ValueError, match="collision"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +@pytest.mark.parametrize("tool", ["npm", "yarn", "jlpm"]) +def test_collection_rejects_unsupported_managers(project, tool, monkeypatch): + root, _, _ = project + monkeypatch.setattr("hatch_js.structs.which", lambda value: value) + with pytest.raises(ValueError, match="pnpm"): + hook(root, {"tool": tool, "licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +@pytest.mark.parametrize( + "destination", + [ + "", + "/tmp/licenses.json", + "../escape.json", + "project/../escape.json", + "C:/licenses.json", + "project\\licenses.json", + "project//licenses.json", + "project/./licenses.json", + "project.dist-info/licenses.json", + ], +) +def test_collection_rejects_unsafe_destinations(project, destination): + root, _, _ = project + with pytest.raises(ValueError, match="destination"): + hook(root, {"licenses": {"destination": destination}}).initialize("standard", {}) + + +def test_collection_rejects_existing_package_file(project): + root, _, _ = project + (root / "project/licenses").mkdir() + (root / "project/licenses/pnpm.json").write_text("original", encoding="utf-8") + with pytest.raises(ValueError, match="collision"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + assert (root / "project/licenses/pnpm.json").read_text() == "original" + + +@pytest.mark.parametrize("destination", ["project/licenses/pnpm.json", "project/licenses", "project/licenses/pnpm.json/child"]) +def test_collection_rejects_force_include_collisions(project, destination): + root, _, _ = project + with pytest.raises(ValueError, match="collision"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {"force_include": {"existing": destination}}) + + +def test_collection_checks_both_inclusion_maps_for_same_source(project): + root, _, _ = project + with pytest.raises(ValueError, match="collision"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize( + "standard", + { + "force_include": {"existing": "project/licenses/pnpm.json"}, + "force_include_editable": {"existing": "project/other.txt"}, + }, + ) + + +def test_notice_alone_does_not_satisfy_missing_license(project): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + with pytest.raises(ValueError, match="missing license text"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +def test_reviewed_override_fills_missing_license_but_retains_notice(project): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + (root / "overrides.json").write_text( + json.dumps( + { + "libraries": [ + { + "package_name": "dependency", + "package_version": "1.0.0", + "license": "MIT", + "files": [{"path": "LICENSE", "text": "Reviewed original license."}], + } + ] + } + ), + encoding="utf-8", + ) + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json", "previous": "overrides.json"}}) + data = {} + builder.initialize("standard", data) + result, _ = bundle(data) + assert result["libraries"][0]["files"] == [ + {"path": "LICENSE", "text": "Reviewed original license."}, + {"path": "NOTICE", "text": "Original third-party attribution.\n"}, + ] + builder.finalize("standard", data, "unused.whl") + + +@pytest.mark.parametrize("change", ["version", "expression"]) +def test_reviewed_override_does_not_match_different_version_or_expression(project, change): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + (root / "overrides.json").write_text( + json.dumps( + { + "libraries": [ + { + "package_name": "dependency", + "package_version": "2.0.0" if change == "version" else "1.0.0", + "license": "Apache-2.0" if change == "expression" else "MIT", + "files": [{"path": "LICENSE", "text": "Reviewed original license."}], + } + ] + } + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="missing license text"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json", "previous": "overrides.json"}}).initialize("standard", {}) + + +@pytest.mark.parametrize("text", [b"", b" \n", b"NOT FOUND", b"\xff"]) +def test_collection_rejects_empty_placeholder_or_non_utf8_license(project, text): + root, dependency, _ = project + (dependency / "LICENSE").write_bytes(text) + with pytest.raises(ValueError): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +def test_collection_rejects_symlink_escape(project): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + (root / "outside").write_text("External private text", encoding="utf-8") + (dependency / "LICENSE").symlink_to(root / "outside") + with pytest.raises(ValueError, match="outside"): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +def test_collection_keeps_multiple_versions_separate(project): + root, dependency, output = project + other = dependency.with_name("older") + other.mkdir() + (other / "package.json").write_text(json.dumps({"name": "dependency", "version": "0.9.0", "license": "MIT"}), encoding="utf-8") + (other / "LICENSE").write_text("Older original license", encoding="utf-8") + output["MIT"][0]["versions"].append("0.9.0") + output["MIT"][0]["paths"].append(str(other)) + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}) + data = {} + builder.initialize("standard", data) + result, _ = bundle(data) + assert [(item["package_version"], item["files"][0]["text"]) for item in result["libraries"]] == [ + ("0.9.0", "Older original license"), + ("1.0.0", "Copyright fixture authors\r\nPermission is hereby granted.\r\n"), + ] + builder.finalize("standard", data, "unused.whl") + + +def test_collection_includes_license_directory_and_declared_file(project): + root, dependency, _ = project + (dependency / "LICENSE").unlink() + (dependency / "licenses").mkdir() + (dependency / "licenses/MIT.txt").write_text("License in directory", encoding="utf-8") + (dependency / "terms.txt").write_text("Declared license terms", encoding="utf-8") + (dependency / "package.json").write_text( + json.dumps({"name": "dependency", "version": "1.0.0", "license": "SEE LICENSE IN terms.txt"}), encoding="utf-8" + ) + builder = hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}) + data = {} + builder.initialize("standard", data) + result, _ = bundle(data) + assert result["libraries"][0]["files"] == [ + {"path": "NOTICE", "text": "Original third-party attribution.\n"}, + {"path": "licenses/MIT.txt", "text": "License in directory"}, + {"path": "terms.txt", "text": "Declared license terms"}, + ] + builder.finalize("standard", data, "unused.whl") + + +@pytest.mark.parametrize("malformation", ["paths", "name", "version", "empty", "empty-group"]) +def test_collection_rejects_incomplete_or_mismatched_graph(project, malformation): + root, _, output = project + if malformation == "paths": + output["MIT"][0]["paths"] = [] + elif malformation == "empty": + output.clear() + elif malformation == "empty-group": + output["MIT"].clear() + elif malformation == "name": + output["MIT"][0]["name"] = "different" + else: + output["MIT"][0]["versions"] = ["0.9.0"] + with pytest.raises(ValueError): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +def test_failed_javascript_build_does_not_collect(project, monkeypatch): + root, _, _ = project + monkeypatch.setattr("hatch_js.structs.system_call", lambda command: 1) + data = {} + with pytest.raises(RuntimeError): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", data) + assert "force_include" not in data + + +@pytest.mark.parametrize("output", ["invalid json", "[]", '{"error": {"message": "missing store"}}']) +def test_collection_rejects_malformed_collector_output(project, monkeypatch, output): + root, _, _ = project + monkeypatch.setattr(subprocess, "run", lambda command, **kwargs: subprocess.CompletedProcess(command, 0, output, "")) + with pytest.raises(ValueError): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", {}) + + +def test_collector_failure_does_not_register_bundle(project, monkeypatch): + root, _, _ = project + + def fail(command, **kwargs): + raise subprocess.CalledProcessError(1, command, stderr="missing pnpm store") + + monkeypatch.setattr(subprocess, "run", fail) + data = {} + with pytest.raises(subprocess.CalledProcessError): + hook(root, {"licenses": {"destination": "project/licenses/pnpm.json"}}).initialize("standard", data) + assert "force_include" not in data diff --git a/pyproject.toml b/pyproject.toml index 1f792e3..7fcb2f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ develop = [ "bump-my-version", "check-dist", "codespell", + "editables", "hatchling", "mdformat", "mdformat-frontmatter",