|
| 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)]} |
0 commit comments