Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
168 changes: 168 additions & 0 deletions hatch_js/licenses.py
Original file line number Diff line number Diff line change
@@ -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)]}
67 changes: 67 additions & 0 deletions hatch_js/plugin.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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."""
Expand All @@ -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

Expand All @@ -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()
12 changes: 11 additions & 1 deletion hatch_js/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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")
Expand Down
Loading