Skip to content

Commit e0e53e9

Browse files
authored
Merge pull request #53 from python-project-templates/pnpm-license-bundles
Add pnpm dependency license bundles
2 parents 0f46f24 + a15532d commit e0e53e9

9 files changed

Lines changed: 883 additions & 1 deletion

File tree

‎.github/workflows/build.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jobs:
4242
uses: actions-ext/node/setup@main
4343
with:
4444
version: 22.x
45+
pnpm_version: 10.28.2
4546
js_folder: hatch_js/tests/test_project_basic/js
4647

4748
- name: Install dependencies

‎README.md‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,61 @@ targets = [ # outputs to validate after build
4040
]
4141
```
4242

43+
## Dependency licenses
44+
45+
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.
46+
47+
```toml
48+
[tool.hatch.build.hooks.hatch-js]
49+
path = "js"
50+
tool = "pnpm"
51+
52+
[tool.hatch.build.hooks.hatch-js.licenses]
53+
destination = "myproject/licenses/pnpm.json"
54+
production-only = false
55+
# previous = "js/license-overrides.json"
56+
```
57+
58+
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.
59+
60+
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.
61+
62+
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.
63+
64+
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.
65+
66+
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.
67+
68+
### Missing texts
69+
70+
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.
71+
72+
The override format is the same `libraries` structure as generated output; `root_name` is optional for overrides:
73+
74+
```json
75+
{
76+
"libraries": [
77+
{
78+
"package_name": "dependency",
79+
"package_version": "1.0.0",
80+
"license": "MIT",
81+
"files": [
82+
{"path": "LICENSE", "text": "Original text reviewed for this exact package version."}
83+
]
84+
}
85+
]
86+
}
87+
```
88+
89+
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.
90+
91+
### Scope
92+
93+
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.
94+
95+
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.
96+
97+
`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.
98+
4399
> [!NOTE]
44100
> 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).

‎hatch_js/licenses.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import re
5+
import subprocess
6+
from pathlib import Path, PurePosixPath
7+
8+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator
9+
10+
_LICENSE = re.compile(r"^(?:licen[cs]es?|copying)(?:[._-].*)?$", re.IGNORECASE)
11+
_NOTICE = re.compile(r"^(?:copyright|notices?|authors?|third[-_]?party[-_]?(?:notices?|licenses?))(?:[._-].*)?$", re.IGNORECASE)
12+
13+
14+
def relative_path(value: str) -> str:
15+
parts = value.split("/")
16+
if not value or ":" in value or "\\" in value or any(part in ("", ".", "..") or part.endswith((".dist-info", ".data")) for part in parts):
17+
raise ValueError("License destination/file path must be a relative path without traversal or reserved wheel directories.")
18+
return value
19+
20+
21+
class PnpmLicenseConfig(BaseModel):
22+
"""Opt-in collection of original license texts from pnpm dependencies."""
23+
24+
model_config = ConfigDict(populate_by_name=True)
25+
26+
destination: str = Field(description="Package-relative path of the license JSON in the wheel.")
27+
production_only: bool = Field(default=False, alias="production-only")
28+
previous: Path | None = Field(default=None, description="Reviewed bundle used to fill missing license texts, relative to the project root.")
29+
30+
@field_validator("destination")
31+
@classmethod
32+
def validate_destination(cls, value: str) -> str:
33+
relative_path(value)
34+
if len(PurePosixPath(value).parts) < 2:
35+
raise ValueError("License destination must be inside a package directory.")
36+
return value
37+
38+
39+
class LicenseFile(BaseModel):
40+
path: str
41+
text: str
42+
43+
@field_validator("path")
44+
@classmethod
45+
def validate_path(cls, value: str) -> str:
46+
return relative_path(value)
47+
48+
@field_validator("text")
49+
@classmethod
50+
def validate_text(cls, value: str) -> str:
51+
if not value.strip() or value.strip().casefold() in ("not found", "unknown"):
52+
raise ValueError("Dependency is missing license text; supply original text in a reviewed licenses.previous bundle.")
53+
return value
54+
55+
56+
class LibraryLicense(BaseModel):
57+
package_name: str = Field(min_length=1)
58+
package_version: str = Field(min_length=1)
59+
license: str = Field(min_length=1)
60+
files: list[LicenseFile] = Field(min_length=1)
61+
62+
@model_validator(mode="after")
63+
def unique_paths(self) -> LibraryLicense:
64+
if len({file.path for file in self.files}) != len(self.files):
65+
raise ValueError("Duplicate license file paths in reviewed bundle.")
66+
return self
67+
68+
69+
class PnpmPackage(BaseModel):
70+
name: str = Field(min_length=1)
71+
versions: list[str] = Field(min_length=1)
72+
paths: list[Path] = Field(min_length=1)
73+
74+
@model_validator(mode="after")
75+
def matching_paths(self) -> PnpmPackage:
76+
if len(self.versions) != len(self.paths):
77+
raise ValueError("pnpm license package versions and paths do not match.")
78+
return self
79+
80+
81+
def _read_file(directory: Path, file: Path) -> LicenseFile:
82+
if not file.resolve().is_relative_to(directory.resolve()):
83+
raise ValueError(f"License file '{file.name}' points outside its dependency package.")
84+
if not file.is_file():
85+
raise ValueError(f"Dependency is missing license text in '{file.name}'.")
86+
return LicenseFile(path=file.relative_to(directory).as_posix(), text=file.read_bytes().decode("utf-8"))
87+
88+
89+
def _read_files(directory: Path, manifest: dict) -> tuple[dict[str, LicenseFile], bool]:
90+
candidates: set[Path] = set()
91+
primary: set[Path] = set()
92+
for entry in directory.iterdir():
93+
if entry.is_dir() and entry.name.casefold() in ("license", "licenses", "licence", "licences"):
94+
for file in entry.rglob("*"):
95+
if file.is_file():
96+
candidates.add(file)
97+
if not _NOTICE.fullmatch(file.name):
98+
primary.add(file)
99+
elif _LICENSE.fullmatch(entry.name) or _NOTICE.fullmatch(entry.name):
100+
candidates.add(entry)
101+
if _LICENSE.fullmatch(entry.name):
102+
primary.add(entry)
103+
expression = manifest.get("license", "")
104+
if isinstance(expression, str) and expression.upper().startswith("SEE LICENSE IN "):
105+
declared = directory / relative_path(expression[len("SEE LICENSE IN ") :].strip())
106+
if declared.exists() or declared.is_symlink():
107+
candidates.add(declared)
108+
primary.add(declared)
109+
files = {}
110+
for file in sorted(candidates):
111+
record = _read_file(directory, file)
112+
files[record.path] = record
113+
return files, bool(primary)
114+
115+
116+
def collect_licenses(directory: Path, config: PnpmLicenseConfig, root: Path) -> dict:
117+
"""Collect the pnpm graph and retain original file contents without absolute paths."""
118+
manifest = json.loads((directory / "package.json").read_bytes())
119+
previous = {}
120+
if config.previous is not None:
121+
reviewed = json.loads((root / config.previous).read_bytes())
122+
if not isinstance(reviewed, dict) or not isinstance(reviewed.get("libraries"), list):
123+
raise ValueError("Reviewed license bundle must contain a libraries list.")
124+
for library in TypeAdapter(list[LibraryLicense]).validate_python(reviewed["libraries"]):
125+
key = (library.package_name, library.package_version, library.license)
126+
if key in previous:
127+
raise ValueError("Duplicate dependency in reviewed license bundle.")
128+
previous[key] = library
129+
command = ["pnpm", "licenses", "list", "--json"]
130+
if config.production_only:
131+
command.append("--prod")
132+
result = subprocess.run(command, cwd=directory, check=True, capture_output=True, text=True)
133+
output = {} if result.stdout.strip() == "No licenses in packages found" else json.loads(result.stdout)
134+
groups = TypeAdapter(dict[str, list[PnpmPackage]]).validate_python(output)
135+
dependency_fields = ["dependencies", "optionalDependencies"]
136+
if not config.production_only:
137+
dependency_fields.append("devDependencies")
138+
if not any(groups.values()) and any(manifest.get(field) for field in dependency_fields):
139+
raise ValueError("pnpm returned an empty license graph for a project with dependencies.")
140+
libraries = {}
141+
for expression, packages in groups.items():
142+
for package in packages:
143+
for version, path in zip(package.versions, package.paths, strict=True):
144+
package_manifest = json.loads((path / "package.json").read_bytes())
145+
if package_manifest.get("name") != package.name or package_manifest.get("version") != version:
146+
raise ValueError(f"pnpm package identity does not match its manifest: {package.name}@{version}.")
147+
files, has_license = _read_files(path, package_manifest)
148+
key = (package.name, version, expression)
149+
if not has_license:
150+
override = previous.get(key)
151+
if override is None or not any(not _NOTICE.fullmatch(PurePosixPath(file.path).name) for file in override.files):
152+
raise ValueError(
153+
f"Dependency '{package.name}@{version}' is missing license text. Supply a reviewed licenses.previous bundle."
154+
)
155+
for file in override.files:
156+
original = path / file.path
157+
if original.exists() or original.is_symlink():
158+
files[file.path] = _read_file(path, original)
159+
if file.path in files and files[file.path] != file:
160+
raise ValueError(f"Reviewed license text conflicts with original '{file.path}' for '{package.name}@{version}'.")
161+
files[file.path] = file
162+
library = LibraryLicense(
163+
package_name=package.name, package_version=version, license=expression, files=[files[name] for name in sorted(files)]
164+
)
165+
if key in libraries and libraries[key] != library:
166+
raise ValueError(f"Conflicting installed copies of dependency '{package.name}@{version}'.")
167+
libraries[key] = library
168+
return {"root_name": manifest["name"], "libraries": [libraries[key].model_dump() for key in sorted(libraries)]}

‎hatch_js/plugin.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
from __future__ import annotations
22

3+
import json
34
from logging import getLogger
45
from os import getenv
6+
from pathlib import Path, PurePosixPath
7+
from tempfile import TemporaryDirectory
58
from typing import Any
69

710
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
11+
from hatchling.builders.utils import normalize_inclusion_map
812

13+
from .licenses import collect_licenses
914
from .structs import HatchJsBuildConfig, HatchJsBuildPlan
1015
from .utils import import_string
1116

@@ -17,6 +22,7 @@ class HatchJsBuildHook(BuildHookInterface):
1722

1823
PLUGIN_NAME = "hatch-js"
1924
_logger = getLogger(__name__)
25+
_license_directory: TemporaryDirectory[str] | None = None
2026

2127
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
2228
"""Initialize the plugin."""
@@ -43,6 +49,9 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
4349
# Instantiate build config
4450
config = build_config_class(name=project_name, **self.config)
4551

52+
if config.licenses is not None:
53+
self._validate_license_destination(config.licenses.destination, build_data)
54+
4655
# Get build plan class or use default
4756
build_plan_class = import_string(self.config["build-plan-class"]) if "build-plan-class" in self.config else HatchJsBuildPlan
4857

@@ -60,5 +69,63 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
6069
# Execute build plan
6170
build_plan.execute()
6271

72+
if config.licenses is not None:
73+
self._validate_license_destination(config.licenses.destination, build_data)
74+
self._cleanup_licenses()
75+
directory = TemporaryDirectory(prefix="hatch-js-licenses-")
76+
try:
77+
project = Path(self.root) / (config.path or Path(self.root))
78+
bundle = collect_licenses(project.resolve(), config.licenses, Path(self.root))
79+
output = Path(directory.name) / "pnpm.json"
80+
output.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
81+
build_data.setdefault("force_include", {})[str(output)] = config.licenses.destination
82+
if version == "editable":
83+
if not build_data.get("force_include_editable"):
84+
normal = build_data["force_include"]
85+
combined = self.build_config.get_force_include() if self.build_config is not None else {}
86+
combined.update(normalize_inclusion_map(normal, self.root))
87+
normal.clear()
88+
normal.update(combined)
89+
build_data["force_include_editable"] = normal
90+
build_data["force_include_editable"][str(output)] = config.licenses.destination
91+
except BaseException:
92+
directory.cleanup()
93+
raise
94+
self._license_directory = directory
95+
6396
# Perform any cleanup actions
6497
build_plan.cleanup()
98+
99+
def _validate_license_destination(self, value: str, build_data: dict[str, Any]) -> None:
100+
destination = PurePosixPath(value)
101+
paths = {Path(self.root) / value}
102+
inclusions = list(build_data.get("force_include", {}).values())
103+
inclusions.extend(build_data.get("force_include_editable", {}).values())
104+
if self.build_config is not None:
105+
raw = str(Path(value))
106+
mapped = self.build_config.get_distribution_path(raw)
107+
if Path(mapped).as_posix() != value:
108+
raise ValueError("License destination is rewritten by Hatch sources; choose an unmapped wheel-relative destination.")
109+
inclusions.extend(self.build_config.get_force_include().values())
110+
for source, replacement in self.build_config.sources.items():
111+
if raw.startswith(replacement):
112+
paths.add(Path(self.root) / (source + raw[len(replacement) :]))
113+
for existing in paths:
114+
if existing.exists() or existing.is_symlink() or any(parent.is_file() for parent in existing.parents):
115+
raise ValueError(f"License destination collision: '{destination}' already exists in the project.")
116+
for included in inclusions:
117+
mapped = self.build_config.get_distribution_path(str(Path(included))) if self.build_config is not None else included
118+
other = PurePosixPath(Path(mapped).as_posix())
119+
if destination == other or destination in other.parents or other in destination.parents:
120+
raise ValueError(f"License destination collision with force_include: '{included}'.")
121+
122+
def _cleanup_licenses(self) -> None:
123+
if self._license_directory is not None:
124+
self._license_directory.cleanup()
125+
self._license_directory = None
126+
127+
def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None:
128+
self._cleanup_licenses()
129+
130+
def clean(self, versions: list[str]) -> None:
131+
self._cleanup_licenses()

‎hatch_js/structs.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
from shutil import which
66
from typing import Literal
77

8-
from pydantic import BaseModel, Field, field_validator
8+
from pydantic import BaseModel, Field, field_validator, model_validator
9+
10+
from .licenses import PnpmLicenseConfig
911

1012
__all__ = (
1113
"HatchJsBuildConfig",
1214
"HatchJsBuildPlan",
15+
"PnpmLicenseConfig",
1316
)
1417

1518
Toolchain = Literal["npm", "yarn", "pnpm", "jlpm"]
@@ -32,6 +35,13 @@ class HatchJsBuildConfig(BaseModel):
3235
)
3336

3437
targets: list[str] | None = Field(default_factory=list, description="List of ensured targets to build")
38+
licenses: PnpmLicenseConfig | None = Field(default=None, description="Opt-in pnpm dependency license collection.")
39+
40+
@model_validator(mode="after")
41+
def validate_license_tool(self) -> HatchJsBuildConfig:
42+
if self.licenses is not None and self.tool != "pnpm":
43+
raise ValueError("License collection currently requires tool = 'pnpm'.")
44+
return self
3545

3646
# Check that tool exists
3747
@field_validator("tool", mode="before")

0 commit comments

Comments
 (0)