From d3629f8e48290d4cce503155f7ad31f68485c894 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:32:12 -0400 Subject: [PATCH] Bundle Go dependency license files Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- .github/workflows/build.yaml | 9 + README.md | 65 +++++ hatch_go/licenses.py | 97 +++++++ hatch_go/plugin.py | 77 +++++- hatch_go/structs.py | 5 + hatch_go/tests/test_license_environment.py | 72 +++++ hatch_go/tests/test_license_paths.py | 44 +++ hatch_go/tests/test_licenses.py | 306 +++++++++++++++++++++ hatch_go/tests/test_structs.py | 20 ++ pyproject.toml | 1 + 10 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 hatch_go/licenses.py create mode 100644 hatch_go/tests/test_license_environment.py create mode 100644 hatch_go/tests/test_license_paths.py create mode 100644 hatch_go/tests/test_licenses.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 10fee28..10b80ec 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -38,6 +38,15 @@ jobs: with: version: "3.11" + - name: Setup Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: "1.27.x" + cache: false + + - name: Install Go license collector + run: go install github.com/google/go-licenses/v2@v2.0.1 + - name: Install dependencies run: make develop diff --git a/README.md b/README.md index 60825ff..ba62397 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,70 @@ path = "." module = "project" ``` +## Dependency license files + +License collection is opt-in. `go-licenses` must be installed and available on `PATH`; +the plugin does not install it automatically. The tested collector version is 2.0.1: + +```bash +go install github.com/google/go-licenses/v2@v2.0.1 +``` + +```toml +[tool.hatch.build.hooks.hatch-go] +path = "." +module = "project" + +[tool.hatch.build.hooks.hatch-go.licenses] +destination = "project/licenses/go" +``` + +The plugin runs `go-licenses save .` in the configured Go project after a successful +build. Collection uses the build's `GOOS`, `GOARCH`, `CGO_ENABLED`, and Python CGO flags, +plus effective `GOFLAGS` from the environment or Go's persisted configuration. +Dependency-selection flags in `go_build_flags`, including `-tags`, `-mod`, `-modfile`, +`-overlay`, `-compiler`, and instrumentation flags, are passed through `GOFLAGS`. +Explicit build tags override inherited tags. Shell-dependent flags and values that +cannot be represented safely for collection fail rather than being ignored. The +collector uses Go's package loader without external `GOPACKAGESDRIVER` overrides; +the loader disables PGO, so this is not a reproduction of optimization settings. + +`destination` is a normalized, wheel-relative **directory**, not a JSON file. Saved +files appear under Go library import paths, for example +`project/licenses/go/example.com/dependency/LICENSE`. Their bytes, filenames, and notices +remain unchanged. The directory is generated outside the project and force-included in +standard and editable wheels, then removed after packaging. Source distributions do not +run collection or contain these temporary files. Existing destinations, conflicting forced +inclusions, and paths rewritten by Hatch sources are rejected. Each saved file is included +explicitly so Hatch's directory filters do not remove collector artifacts. + +For editable installations, use `importlib.metadata.distribution(...).locate_file(...)` +to locate installed license resources. Source-package resource APIs may not see +force-included files stored in the editable wheel. + +Missing or unknown licenses, collector failures, empty license texts, and saved symlinks +fail the build. This applies to the root Go package as well as dependencies: the collector +needs an original license file for the project. Unknown options are rejected; there is no +blanket ignore or generic license-text substitution. `SKIP_HATCH_GO`, `skip = true`, and +source-distribution builds also skip collection. Builds without `licenses` are unchanged. + +### Scope and review + +The inventory covers direct and transitive Go packages selected by the build environment; +it is not a complete module graph or a trace of linked symbols. Test-only and inactive +target/tag dependencies are excluded. `go-licenses` groups packages by their selected +license file, and its directory output is not a versioned SBOM. + +The collector saves licenses and adjacent `NOTICE`, `NOTICE.txt`, or `NOTICE.md` files. +For some license classifications it copies the **entire source directory containing the +license**, excluding only Git metadata. This can increase wheel size and include unrelated +local files. Review that output before shipping, especially with local replacements or +workspace dependencies; the plugin does not remove potentially required source files. + +Go's standard library/runtime is excluded by the collector. It also cannot discover +dependencies hidden in C/C++, assembly, system libraries, or separately vendored assets. +Collector warnings remain visible. Review runtime licenses and native dependencies +separately; collecting files does not certify license compatibility or compliance. + > [!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_go/licenses.py b/hatch_go/licenses.py new file mode 100644 index 0000000..49a1383 --- /dev/null +++ b/hatch_go/licenses.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from logging import getLogger +from os import environ +from pathlib import Path, PurePosixPath +from shlex import split +from shutil import which +from subprocess import run + +from pydantic import BaseModel, ConfigDict, field_validator + + +class GoLicenseConfig(BaseModel): + """Opt-in go-licenses saved-file collection.""" + + model_config = ConfigDict(extra="forbid") + destination: str + + @field_validator("destination") + @classmethod + def validate_destination(cls, value: str) -> str: + path = PurePosixPath(value) + if ( + not value + or path.is_absolute() + or path.as_posix() != value + or any(part in (".", "..") or part.endswith((".dist-info", ".data")) for part in path.parts) + or any(character in value for character in "\\:*?[]\x00") + or value == "." + ): + raise ValueError("License destination must be a normalized wheel-relative directory.") + return value + + +def _collection_flags(flags: str | None) -> str: + """Pass dependency-selection flags to the collector through GOFLAGS.""" + if any(character in (flags or "") for character in "$`%;&|<>~*?[]{}\r\n"): + raise ValueError("Go license collection cannot safely match shell-dependent build flags.") + values = {"-tags", "-mod", "-modfile", "-overlay", "-compiler", "-pgo", "-covermode", "-coverpkg"} + switches = {"-race", "-msan", "-asan", "-cover"} + build_only_values = {"-ldflags", "-gcflags", "-asmflags", "-o", "-p"} + build_only_switches = {"-a", "-n", "-x", "-v", "-work", "-trimpath", "-buildvcs"} + result = [] + tokens = iter(split(flags or "")) + for token in tokens: + name, separator, value = token.partition("=") + if name in values | build_only_values: + if not separator: + value = next(tokens, "") + if not value: + raise ValueError(f"Missing value for Go build flag '{name}'.") + if name in values: + if name == "-tags": + value = ",".join(value.replace(",", " ").split()) + if any(character.isspace() for character in value): + raise ValueError(f"Go license collection cannot represent '{name}' with whitespace in GOFLAGS.") + result.append(f"{name}={value}") + elif name in switches: + result.append(token) + elif name not in build_only_switches: + raise ValueError(f"Unsupported Go build flag for license collection: '{token}'.") + return " ".join(result) + + +def collect_licenses(project: Path, output: Path, build_environment: dict[str, str], flags: str | None = None) -> None: + """Save original upstream artifacts outside the project, failing on unknown licenses.""" + environment = dict(environ) + environment.update(build_environment) + selection = _collection_flags(flags) + environment["GOPACKAGESDRIVER"] = "off" + collector = which("go-licenses", path=environment.get("PATH")) + if collector is None: + raise OSError("go-licenses is required for license collection; install github.com/google/go-licenses/v2@v2.0.1.") + if selection: + effective = run(["go", "env", "GOFLAGS"], cwd=project, env=environment, capture_output=True, text=True, check=False) + if effective.returncode: + raise RuntimeError(f"go env GOFLAGS failed: {effective.stderr.strip()}") + environment["GOFLAGS"] = " ".join(part for part in (effective.stdout.strip(), selection) if part) + completed = run([collector, "save", ".", f"--save_path={output}"], cwd=project, env=environment, capture_output=True, text=True, check=False) + if completed.returncode: + raise RuntimeError(f"go-licenses save failed with exit code {completed.returncode}: {completed.stderr.strip()}") + if completed.stderr.strip(): + getLogger(__name__).warning("go-licenses: %s", completed.stderr.strip()) + if not output.is_dir(): + raise ValueError("go-licenses produced no saved license files.") + files = [] + for file in output.rglob("*"): + if file.is_symlink(): + raise ValueError(f"Saved license artifact must not be a symlink: '{file.relative_to(output)}'.") + if file.is_file(): + files.append(file) + if file.name.lower().startswith(("license", "licence", "copying")): + text = file.read_bytes().strip().lower() + if text in (b"", b"unknown", b"not found"): + raise ValueError(f"Missing saved license text: '{file.relative_to(output)}'.") + if not files: + raise ValueError("go-licenses produced no saved license files.") diff --git a/hatch_go/plugin.py b/hatch_go/plugin.py index 78af240..a62d234 100644 --- a/hatch_go/plugin.py +++ b/hatch_go/plugin.py @@ -1,14 +1,17 @@ from __future__ import annotations from logging import getLogger -from os import getenv -from pathlib import Path +from os import getenv, sep +from pathlib import Path, PurePosixPath from platform import machine as platform_machine from sys import platform as sys_platform, version_info +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 GoLicenseConfig, collect_licenses from .structs import HatchGoBuildConfig, HatchGoBuildPlan from .utils import import_string @@ -22,6 +25,7 @@ class HatchGoBuildHook(BuildHookInterface): PLUGIN_NAME = "hatch-go" _logger = log + _license_directory: TemporaryDirectory[str] | None = None def initialize(self, version: str, build_data: dict[str, Any]) -> None: """Initialize the plugin.""" @@ -46,6 +50,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 and not config.skip: + 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 HatchGoBuildPlan @@ -96,5 +103,71 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: if path.suffix in (".pyd", ".dll", ".so", ".dylib"): build_data["force_include"][str(path)] = str(path) + if config.licenses is not None: + self._validate_license_destination(config.licenses.destination, build_data) + self._cleanup_licenses() + directory = TemporaryDirectory(prefix="hatch-go-licenses-") + try: + output = Path(directory.name) / "go" + project = (Path(self.root) / (config.path or Path(self.root))).resolve() + collect_licenses(project, output, build_plan._build_environment, config.go_build_flags) + inclusions = {} + for file in sorted(output.rglob("*")): + if file.is_file(): + destination = (PurePosixPath(config.licenses.destination) / file.relative_to(output).as_posix()).as_posix() + GoLicenseConfig.validate_destination(destination) + self._validate_license_destination(destination, build_data) + inclusions[str(file)] = destination + build_data.setdefault("force_include", {}).update(inclusions) + 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"].update(inclusions) + except BaseException: + directory.cleanup() + raise + self._license_directory = directory + for path in build_data["force_include"]: self._logger.warning(f"Force include: {path}") + + def _validate_license_destination(self, value: str, build_data: dict[str, Any]) -> None: + destination = PurePosixPath(value) + root = Path(self.root).resolve() + paths = {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) + prefix = self.build_config.get_distribution_path(raw + sep) + if Path(mapped).as_posix() != value or Path(prefix).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(root / (source + raw[len(replacement) :])) + for existing in paths: + if existing.exists() or existing.is_symlink() or any(parent.is_file() or parent.is_symlink() 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_go/structs.py b/hatch_go/structs.py index 4a0f2f2..ce45ef7 100644 --- a/hatch_go/structs.py +++ b/hatch_go/structs.py @@ -10,6 +10,8 @@ from pydantic import BaseModel, Field, PrivateAttr, field_validator +from .licenses import GoLicenseConfig + __all__ = ( "HatchGoBuildConfig", "HatchGoBuildPlan", @@ -63,6 +65,7 @@ class HatchGoBuildConfig(BaseModel): module: str = Field(description="Python module name for the Go extension.") path: Path | None = Field(default=None, description="Path to the Go project root directory.") + licenses: GoLicenseConfig | None = Field(default=None, description="Optional Go dependency license collection.") build_mode: BuildMode = Field( default="c-shared", @@ -97,6 +100,7 @@ class HatchGoBuildPlan(HatchGoBuildConfig): commands: list[str] = Field(default_factory=list) _libraries: list[str] = PrivateAttr(default_factory=list) + _build_environment: dict[str, str] = PrivateAttr(default_factory=dict) def _get_platform_info(self): """Get platform and machine info, respecting env overrides.""" @@ -153,6 +157,7 @@ def generate(self): cgo_cflags, cgo_ldflags = _get_python_cgo_flags() go_env["CGO_CFLAGS"] = cgo_cflags go_env["CGO_LDFLAGS"] = cgo_ldflags + self._build_environment = go_env # Construct env prefix for the command if platform == "win32": diff --git a/hatch_go/tests/test_license_environment.py b/hatch_go/tests/test_license_environment.py new file mode 100644 index 0000000..fa7c7b9 --- /dev/null +++ b/hatch_go/tests/test_license_environment.py @@ -0,0 +1,72 @@ +"""Verify Go environment selection without modifying global Go settings.""" + +import json +from os import environ +from subprocess import check_call +from sys import executable, platform +from zipfile import ZipFile + +import pytest + +from hatch_go.licenses import collect_licenses + +pytest_plugins = ("hatch_go.tests.test_licenses",) + + +@pytest.fixture +def tagged_project(licensed_project): + dependency = licensed_project / "tagged-dependency" + dependency.mkdir() + (dependency / "go.mod").write_text("module example.com/tagged-dependency\n\ngo 1.21\n") + (dependency / "library.go").write_text("package tagged\n") + (dependency / "LICENSE").write_bytes((licensed_project / "LICENSE").read_bytes()) + manifest = licensed_project / "go.mod" + manifest.write_text( + manifest.read_text() + "require example.com/tagged-dependency v1.0.0\nreplace example.com/tagged-dependency => ./tagged-dependency\n" + ) + (licensed_project / "tagged.go").write_text('//go:build licenseextra\n\npackage main\n\nimport _ "example.com/tagged-dependency"\n') + return licensed_project + + +@pytest.mark.parametrize("flags", [None, "-compiler=gc", "-tags=inactive"]) +def test_persisted_goflags_match_compilation(tagged_project, tmp_path, monkeypatch, flags): + goenv = tmp_path / "goenv" + goenv.write_text("GOFLAGS=-tags=licenseextra\n") + monkeypatch.setenv("GOENV", str(goenv)) + monkeypatch.delenv("GOFLAGS", raising=False) + if flags: + config = tagged_project / "pyproject.toml" + config.write_text( + config.read_text().replace( + "[tool.hatch.build.hooks.hatch-go]\n", f"[tool.hatch.build.hooks.hatch-go]\ngo_build_flags = {json.dumps(flags)}\n" + ) + ) + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=tagged_project) + (wheel,) = (tagged_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + assert ("project/licenses/go/example.com/tagged-dependency/LICENSE" in archive.namelist()) == (flags != "-tags=inactive") + + +@pytest.mark.parametrize("flags", ["-tags=$FEATURE_TAGS", "-tags=`printf licenseextra`", "-tags=%FEATURE_TAGS%", "-tags=license{,extra}"]) +def test_shell_dependent_selection_fails_closed(tagged_project, tmp_path, monkeypatch, flags): + monkeypatch.setenv("FEATURE_TAGS", "licenseextra") + with pytest.raises(ValueError, match="shell"): + collect_licenses(tagged_project, tmp_path / "saved", {"CGO_ENABLED": "0"}, flags) + + +@pytest.mark.skipif(platform == "win32", reason="Sentinel uses a POSIX shell executable.") +@pytest.mark.parametrize("automatic", [False, True]) +def test_external_package_driver_cannot_replace_the_graph(licensed_project, tmp_path, monkeypatch, automatic): + driver = tmp_path / "gopackagesdriver" + marker = tmp_path / "driver-ran" + driver.write_text(f'#!/bin/sh\nprintf intercepted > "{marker}"\nexit 1\n') + driver.chmod(0o755) + if automatic: + monkeypatch.delenv("GOPACKAGESDRIVER", raising=False) + monkeypatch.setenv("PATH", str(tmp_path) + ":" + environ["PATH"]) + else: + monkeypatch.setenv("GOPACKAGESDRIVER", str(driver)) + output = tmp_path / "saved" + collect_licenses(licensed_project, output, {"CGO_ENABLED": "0"}) + assert not marker.exists() + assert (output / "example.com/transitive/LICENSE").is_file() diff --git a/hatch_go/tests/test_license_paths.py b/hatch_go/tests/test_license_paths.py new file mode 100644 index 0000000..a37fb37 --- /dev/null +++ b/hatch_go/tests/test_license_paths.py @@ -0,0 +1,44 @@ +"""Check Hatch mappings and complete collector trees with real builds.""" + +import json +from pathlib import Path +from subprocess import CalledProcessError, check_call, check_output +from sys import executable +from zipfile import ZipFile + +import pytest + +from hatch_go.licenses import collect_licenses + +pytest_plugins = ("hatch_go.tests.test_licenses",) + + +@pytest.mark.parametrize("source", ["project/licenses/go", "project/licenses/go/example.com/license-dependency"]) +@pytest.mark.parametrize("replacement", ["renamed", "../escaped"]) +def test_saved_directory_rewrites_fail(licensed_project, source, replacement): + config = licensed_project / "pyproject.toml" + config.write_text(config.read_text().replace('src = "/"', f"{json.dumps(source)} = {json.dumps(replacement)}")) + with pytest.raises(CalledProcessError): + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) + + +@pytest.mark.parametrize("version", ["standard", "editable"]) +def test_every_collector_artifact_is_preserved(licensed_project, tmp_path, version): + check_call(["go", "mod", "download", "github.com/hashicorp/golang-lru@v1.0.2"], cwd=licensed_project) + module = json.loads(check_output(["go", "mod", "download", "-json", "github.com/hashicorp/golang-lru@v1.0.2"], cwd=licensed_project)) + dependency = licensed_project / "dependency" + (dependency / "LICENSE").write_bytes((Path(module["Dir"]) / "LICENSE").read_bytes()) + for name in (".venv/source.go", ".hg/source.go", "__pycache__/source.go", ".DS_Store"): + file = dependency / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(b"Original collector artifact.\r\n") + output = tmp_path / "saved" + collect_licenses(licensed_project, output, {"CGO_ENABLED": "0"}) + originals = {file.relative_to(output).as_posix(): file.read_bytes() for file in output.rglob("*") if file.is_file()} + assert "example.com/license-dependency/.venv/source.go" in originals + check_call([executable, "-m", "hatchling", "build", "-t", f"wheel:{version}"], cwd=licensed_project) + (wheel,) = (licensed_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + prefix = "project/licenses/go/" + saved = {name[len(prefix) :]: archive.read(name) for name in archive.namelist() if name.startswith(prefix)} + assert saved == originals diff --git a/hatch_go/tests/test_licenses.py b/hatch_go/tests/test_licenses.py new file mode 100644 index 0000000..53e5b3f --- /dev/null +++ b/hatch_go/tests/test_licenses.py @@ -0,0 +1,306 @@ +"""Exercise saved license resources with real Go and Hatchling builds.""" + +import json +import tarfile +import venv +from importlib.metadata import distribution +from pathlib import Path +from shutil import copytree, ignore_patterns +from subprocess import CalledProcessError, CompletedProcess, check_call, check_output +from sys import executable, platform +from zipfile import ZipFile + +import pytest +from hatchling.builders.wheel import WheelBuilder + +from hatch_go.licenses import collect_licenses +from hatch_go.plugin import HatchGoBuildHook + + +@pytest.fixture +def licensed_project(tmp_path): + project = tmp_path / "application" + copytree(Path(__file__).parent / "test_project_basic", project, ignore=ignore_patterns("dist", "*.so", "*.pyd", "*.dll", "*.dylib", "*.h")) + dist = distribution("hatch-go") + license_path = next(path for path in (dist.files or []) if path.name == "LICENSE" and "licenses" in path.parts) + license_text = Path(str(dist.locate_file(license_path))).read_bytes() + (project / "LICENSE").write_bytes(license_text) + (project / "NOTICE").write_bytes(b"Original notice for this test project.\r\n") + dependency = project / "dependency" + dependency.mkdir() + (dependency / "go.mod").write_text("module example.com/license-dependency\n\ngo 1.21\n\nrequire example.com/transitive v0.4.0\n") + (dependency / "library.go").write_text('package dependency\n\nimport _ "example.com/transitive"\n\nfunc Value() int { return 42 }\n') + (dependency / "LICENSE").write_bytes(license_text) + (dependency / "NOTICE.md").write_bytes(b"Original notice for this test dependency.\r\n") + transitive = project / "transitive" + transitive.mkdir() + (transitive / "go.mod").write_text("module example.com/transitive\n\ngo 1.21\n") + (transitive / "library.go").write_text("package transitive\n") + (transitive / "LICENSE").write_bytes(license_text) + (project / "go.mod").write_text( + "module example.com/application\n\ngo 1.21\n\nrequire example.com/license-dependency v1.2.3\n" + "require example.com/transitive v0.4.0 // indirect\n" + "replace example.com/license-dependency => ./dependency\nreplace example.com/transitive => ./transitive\n" + ) + (project / "dependency.go").write_text('package main\n\nimport _ "example.com/license-dependency"\n') + config = project / "pyproject.toml" + config.write_text( + config.read_text().replace( + '[tool.hatch.build.targets.sdist]\npackages = ["project"]', + "[tool.hatch.build.targets.sdist]\nartifacts = []\n" + 'packages = ["project", "go.mod", "main.go", "exports.go", "dependency.go", "LICENSE", "NOTICE", "dependency", "transitive"]\n' + 'exclude = ["project/*.so", "project/*.pyd"]', + ) + + '\n[tool.hatch.build.hooks.hatch-go.licenses]\ndestination = "project/licenses/go"\n' + ) + return project + + +@pytest.mark.parametrize("version", ["standard", "editable"]) +def test_real_wheel_preserves_license_and_notices(licensed_project, version): + check_call([executable, "-m", "hatchling", "build", "-t", f"wheel:{version}"], cwd=licensed_project) + (wheel,) = (licensed_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + names = archive.namelist() + assert len(names) == len(set(names)) + for name, source in ( + ("example.com/application/LICENSE", "LICENSE"), + ("example.com/application/NOTICE", "NOTICE"), + ("example.com/license-dependency/LICENSE", "dependency/LICENSE"), + ("example.com/license-dependency/NOTICE.md", "dependency/NOTICE.md"), + ("example.com/transitive/LICENSE", "transitive/LICENSE"), + ): + assert archive.read(f"project/licenses/go/{name}") == (licensed_project / source).read_bytes() + assert any(name.startswith("project/project.") and name.endswith((".so", ".pyd")) for name in names) + assert not (licensed_project / "project/licenses").exists() + + +def test_missing_license_fails_real_build(licensed_project): + (licensed_project / "dependency/LICENSE").unlink() + with pytest.raises(CalledProcessError): + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) + + +def test_existing_destination_is_not_overwritten(licensed_project): + destination = licensed_project / "project/licenses/go" + destination.mkdir(parents=True) + sentinel = destination / "sentinel" + sentinel.write_bytes(b"Do not replace this existing file.") + with pytest.raises(CalledProcessError): + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) + assert sentinel.read_bytes() == b"Do not replace this existing file." + + +@pytest.mark.parametrize("flags", [None, "-tags=licenseextra", '-tags "licenseextra other"', "-tags=inactive", "-tags=licenseextra -ldflags='-s -w'"]) +def test_build_tags_select_the_same_dependencies(licensed_project, flags, monkeypatch): + dependency = licensed_project / "tagged-dependency" + dependency.mkdir() + (dependency / "go.mod").write_text("module example.com/tagged-dependency\n\ngo 1.21\n") + (dependency / "library.go").write_text("package tagged\n") + (dependency / "LICENSE").write_bytes((licensed_project / "LICENSE").read_bytes()) + manifest = licensed_project / "go.mod" + manifest.write_text( + manifest.read_text() + "require example.com/tagged-dependency v1.0.0\nreplace example.com/tagged-dependency => ./tagged-dependency\n" + ) + (licensed_project / "tagged.go").write_text('//go:build licenseextra\n\npackage main\n\nimport _ "example.com/tagged-dependency"\n') + monkeypatch.setenv("GOFLAGS", "-tags=licenseextra") + if flags is not None: + config = licensed_project / "pyproject.toml" + config.write_text( + config.read_text().replace( + "[tool.hatch.build.hooks.hatch-go]\n", f"[tool.hatch.build.hooks.hatch-go]\ngo_build_flags = {json.dumps(flags)}\n" + ) + ) + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) + (wheel,) = (licensed_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + assert ("project/licenses/go/example.com/tagged-dependency/LICENSE" in archive.namelist()) == (flags != "-tags=inactive") + + +def test_wheel_from_sdist_has_fresh_license_files(licensed_project): + check_call([executable, "-m", "build", "--no-isolation"], cwd=licensed_project) + (source,) = (licensed_project / "dist").glob("*.tar.gz") + with tarfile.open(source) as archive: + names = archive.getnames() + assert not any("/project/licenses/" in name or name.endswith((".so", ".pyd")) for name in names) + assert any(name.endswith("/dependency/LICENSE") for name in names) + (wheel,) = (licensed_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + assert archive.read("project/licenses/go/example.com/transitive/LICENSE") == (licensed_project / "transitive/LICENSE").read_bytes() + + +@pytest.mark.parametrize("version", ["standard", "editable"]) +def test_configured_force_include_is_preserved(licensed_project, version): + (licensed_project / "extra.txt").write_bytes(b"Another configured artifact.") + config = licensed_project / "pyproject.toml" + config.write_text(config.read_text() + '\n[tool.hatch.build.targets.wheel.force-include]\n"extra.txt" = "project/extra.txt"\n') + check_call([executable, "-m", "hatchling", "build", "-t", f"wheel:{version}"], cwd=licensed_project) + (wheel,) = (licensed_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + assert archive.read("project/extra.txt") == b"Another configured artifact." + assert archive.read("project/licenses/go/example.com/transitive/LICENSE") == (licensed_project / "transitive/LICENSE").read_bytes() + + +@pytest.mark.parametrize("inclusion", ["project/licenses/go", "project/licenses/go/existing", "project/licenses"]) +def test_forced_destination_collisions_fail(licensed_project, inclusion): + (licensed_project / "extra.txt").write_bytes(b"Existing artifact.") + config = licensed_project / "pyproject.toml" + config.write_text(config.read_text() + f'\n[tool.hatch.build.targets.wheel.force-include]\n"extra.txt" = "{inclusion}"\n') + with pytest.raises(CalledProcessError): + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) + + +def test_missing_collector_has_install_guidance(tmp_path): + with pytest.raises(OSError, match="go-licenses is required"): + collect_licenses(tmp_path, tmp_path / "saved", {"PATH": str(tmp_path)}) + + +@pytest.mark.parametrize("contents", [b"", b"UNKNOWN", b"NOT FOUND"]) +def test_placeholder_saved_text_is_rejected(tmp_path, monkeypatch, contents): + output = tmp_path / "saved" + + def save(*args, **kwargs): + output.mkdir() + (output / "LICENSE").write_bytes(contents) + return CompletedProcess(args[0], 0, "", "") + + monkeypatch.setattr("hatch_go.licenses.run", save) + with pytest.raises(ValueError, match="Missing saved license text"): + collect_licenses(tmp_path, output, {}) + + +def test_symlink_saved_file_is_rejected(tmp_path, monkeypatch): + output = tmp_path / "saved" + source = tmp_path / "original" + source.write_bytes(b"External data must not be packaged through a symlink.") + + def save(*args, **kwargs): + output.mkdir() + (output / "LICENSE").symlink_to(source) + return CompletedProcess(args[0], 0, "", "") + + monkeypatch.setattr("hatch_go.licenses.run", save) + with pytest.raises(ValueError, match="symlink"): + collect_licenses(tmp_path, output, {}) + + +@pytest.mark.parametrize("cleanup", ["finalize", "clean"]) +def test_hook_cleans_its_temporary_saved_files(licensed_project, monkeypatch, cleanup): + monkeypatch.chdir(licensed_project) + builder = WheelBuilder(str(licensed_project)) + config = {"module": "project", "path": str(licensed_project), "licenses": {"destination": "project/licenses/go"}} + hook = HatchGoBuildHook(str(licensed_project), config, builder.config, builder.metadata, "dist", "wheel") + build_data = {"force_include": {}} + hook.initialize("standard", build_data) + saved = next(Path(path).parent for path in build_data["force_include"] if "hatch-go-licenses-" in path) + assert saved.is_dir() + if cleanup == "finalize": + hook.finalize("standard", build_data, "unused.whl") + else: + hook.clean(["standard"]) + assert not saved.exists() + + +def test_symlinked_project_root_does_not_collide(licensed_project, monkeypatch, tmp_path): + alias = tmp_path / "alias" + alias.symlink_to(licensed_project, target_is_directory=True) + monkeypatch.chdir(licensed_project) + builder = WheelBuilder(str(alias)) + config = {"module": "project", "path": str(alias), "licenses": {"destination": "project/licenses/go"}} + hook = HatchGoBuildHook(str(alias), config, builder.config, builder.metadata, "dist", "wheel") + build_data = {"force_include": {}} + try: + hook.initialize("standard", build_data) + assert any("hatch-go-licenses-" in path for path in build_data["force_include"]) + finally: + hook.clean(["standard"]) + + +@pytest.mark.parametrize("version", ["standard", "editable"]) +def test_installed_native_extension_and_license_resources(licensed_project, tmp_path, version): + check_call([executable, "-m", "hatchling", "build", "-t", f"wheel:{version}"], cwd=licensed_project) + (wheel,) = (licensed_project / "dist").glob("*.whl") + environment = tmp_path / "environment" + venv.EnvBuilder().create(environment) + python = environment / ("Scripts/python.exe" if platform == "win32" else "bin/python") + check_call(["uv", "pip", "install", "--python", str(python), "--no-deps", str(wheel)]) + check_call( + [ + str(python), + "-I", + "-c", + ( + "from pathlib import Path; from importlib.metadata import distribution; import project.project as native; " + 'assert native.hello() == "A string from Go"; assert native.add(2, 3) == 5; ' + 'dist = distribution("hatch-go-test-project-basic"); ' + 'prefix = "project/licenses/go/"; ' + 'assert Path(str(dist.locate_file(prefix + "example.com/license-dependency/NOTICE.md"))).read_bytes() ' + '== b"Original notice for this test dependency.\\r\\n"; ' + 'assert Path(str(dist.locate_file(prefix + "example.com/transitive/LICENSE"))).stat().st_size > 1000' + ), + ], + cwd=tmp_path, + ) + + +def test_reciprocal_dependency_retains_original_source(licensed_project): + manifest = licensed_project / "go.mod" + manifest.write_text(manifest.read_text() + "require github.com/hashicorp/golang-lru v1.0.2\n") + (licensed_project / "third_party.go").write_text('package main\n\nimport _ "github.com/hashicorp/golang-lru"\n') + check_call(["go", "mod", "tidy"], cwd=licensed_project) + module = json.loads(check_output(["go", "list", "-m", "-json", "github.com/hashicorp/golang-lru"], cwd=licensed_project)) + original = Path(module["Dir"]) + assert module["Version"] == "v1.0.2" + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) + (wheel,) = (licensed_project / "dist").glob("*.whl") + with ZipFile(wheel) as archive: + for file in ("LICENSE", "2q.go", "simplelru/lru.go"): + assert archive.read(f"project/licenses/go/github.com/hashicorp/golang-lru/{file}") == (original / file).read_bytes() + + +@pytest.mark.parametrize("reason", ["sdist", "skip", "environment"]) +def test_skipped_builds_do_not_run_collector(licensed_project, monkeypatch, reason): + builder = WheelBuilder(str(licensed_project)) + config = {"module": "project", "path": str(licensed_project), "licenses": {"destination": "project/licenses/go"}} + if reason == "skip": + config["skip"] = True + if reason == "environment": + monkeypatch.setenv("SKIP_HATCH_GO", "1") + hook = HatchGoBuildHook(str(licensed_project), config, builder.config, builder.metadata, "dist", "sdist" if reason == "sdist" else "wheel") + build_data = {"force_include": {}} + hook.initialize("standard", build_data) + assert build_data == {"force_include": {}} + assert not (licensed_project / "project/licenses").exists() + + +@pytest.mark.parametrize("flags", ["-tags", "-C=other", '-overlay="path with spaces.json"']) +def test_unrepresentable_selection_flags_fail_before_collection(tmp_path, flags): + with pytest.raises(ValueError): + collect_licenses(tmp_path, tmp_path / "saved", {}, flags) + + +def test_package_load_error_fails_collection(licensed_project, tmp_path): + (licensed_project / "invalid.go").write_text('package main\nimport "example.com/not-a-real-dependency"\n') + with pytest.raises(RuntimeError, match="go-licenses save failed"): + collect_licenses(licensed_project, tmp_path / "saved", {"CGO_ENABLED": "0"}) + + +@pytest.mark.parametrize("create_directory", [False, True]) +def test_missing_or_empty_collector_output_fails(tmp_path, monkeypatch, create_directory): + output = tmp_path / "saved" + + def save(*args, **kwargs): + if create_directory: + output.mkdir() + return CompletedProcess(args[0], 0, "", "") + + monkeypatch.setattr("hatch_go.licenses.run", save) + with pytest.raises(ValueError, match="no saved license files"): + collect_licenses(tmp_path, output, {}) + + +def test_rewritten_license_destination_is_rejected(licensed_project): + config = licensed_project / "pyproject.toml" + config.write_text(config.read_text().replace('src = "/"', 'project = "renamed"')) + with pytest.raises(CalledProcessError): + check_call([executable, "-m", "hatchling", "build", "-t", "wheel"], cwd=licensed_project) diff --git a/hatch_go/tests/test_structs.py b/hatch_go/tests/test_structs.py index d79c653..e2bfdfd 100644 --- a/hatch_go/tests/test_structs.py +++ b/hatch_go/tests/test_structs.py @@ -13,6 +13,26 @@ def test_defaults(self): assert config.cgo_enabled is True assert config.go_build_flags is None + def test_license_collection_is_opt_in(self): + config = HatchGoBuildConfig(module="mymodule") + assert config.licenses is None + + def test_license_destination(self): + config = HatchGoBuildConfig(module="mymodule", licenses={"destination": "mymodule/licenses/go"}) + assert config.licenses is not None + assert config.licenses.destination == "mymodule/licenses/go" + + @pytest.mark.parametrize( + "destination", ["", ".", "../licenses", "/licenses", "C:/licenses", "module\\licenses", "module/*.json", "x.dist-info/go"] + ) + def test_invalid_license_destination(self, destination): + with pytest.raises(ValueError): + HatchGoBuildConfig(module="mymodule", licenses={"destination": destination}) + + def test_unknown_license_option(self): + with pytest.raises(ValueError): + HatchGoBuildConfig(module="mymodule", licenses={"destination": "mymodule/licenses/go", "ignore": "example.com"}) + def test_custom_values(self): config = HatchGoBuildConfig( module="mymodule", diff --git a/pyproject.toml b/pyproject.toml index 21acf4f..c3042fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ develop = [ "bump-my-version", "check-manifest", "codespell>=2.4,<2.5", + "editables", "hatchling", "mdformat>=0.7.22,<1.1", "mdformat-frontmatter",