Skip to content

Commit 2882a6d

Browse files
authored
Merge pull request #36 from python-project-templates/go-license-bundles
Bundle Go dependency license files
2 parents 280a888 + d3629f8 commit 2882a6d

10 files changed

Lines changed: 694 additions & 2 deletions

File tree

‎.github/workflows/build.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ jobs:
3838
with:
3939
version: "3.11"
4040

41+
- name: Setup Go
42+
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
43+
with:
44+
go-version: "1.27.x"
45+
cache: false
46+
47+
- name: Install Go license collector
48+
run: go install github.com/google/go-licenses/v2@v2.0.1
49+
4150
- name: Install dependencies
4251
run: make develop
4352

‎README.md‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,70 @@ path = "."
1818
module = "project"
1919
```
2020

21+
## Dependency license files
22+
23+
License collection is opt-in. `go-licenses` must be installed and available on `PATH`;
24+
the plugin does not install it automatically. The tested collector version is 2.0.1:
25+
26+
```bash
27+
go install github.com/google/go-licenses/v2@v2.0.1
28+
```
29+
30+
```toml
31+
[tool.hatch.build.hooks.hatch-go]
32+
path = "."
33+
module = "project"
34+
35+
[tool.hatch.build.hooks.hatch-go.licenses]
36+
destination = "project/licenses/go"
37+
```
38+
39+
The plugin runs `go-licenses save .` in the configured Go project after a successful
40+
build. Collection uses the build's `GOOS`, `GOARCH`, `CGO_ENABLED`, and Python CGO flags,
41+
plus effective `GOFLAGS` from the environment or Go's persisted configuration.
42+
Dependency-selection flags in `go_build_flags`, including `-tags`, `-mod`, `-modfile`,
43+
`-overlay`, `-compiler`, and instrumentation flags, are passed through `GOFLAGS`.
44+
Explicit build tags override inherited tags. Shell-dependent flags and values that
45+
cannot be represented safely for collection fail rather than being ignored. The
46+
collector uses Go's package loader without external `GOPACKAGESDRIVER` overrides;
47+
the loader disables PGO, so this is not a reproduction of optimization settings.
48+
49+
`destination` is a normalized, wheel-relative **directory**, not a JSON file. Saved
50+
files appear under Go library import paths, for example
51+
`project/licenses/go/example.com/dependency/LICENSE`. Their bytes, filenames, and notices
52+
remain unchanged. The directory is generated outside the project and force-included in
53+
standard and editable wheels, then removed after packaging. Source distributions do not
54+
run collection or contain these temporary files. Existing destinations, conflicting forced
55+
inclusions, and paths rewritten by Hatch sources are rejected. Each saved file is included
56+
explicitly so Hatch's directory filters do not remove collector artifacts.
57+
58+
For editable installations, use `importlib.metadata.distribution(...).locate_file(...)`
59+
to locate installed license resources. Source-package resource APIs may not see
60+
force-included files stored in the editable wheel.
61+
62+
Missing or unknown licenses, collector failures, empty license texts, and saved symlinks
63+
fail the build. This applies to the root Go package as well as dependencies: the collector
64+
needs an original license file for the project. Unknown options are rejected; there is no
65+
blanket ignore or generic license-text substitution. `SKIP_HATCH_GO`, `skip = true`, and
66+
source-distribution builds also skip collection. Builds without `licenses` are unchanged.
67+
68+
### Scope and review
69+
70+
The inventory covers direct and transitive Go packages selected by the build environment;
71+
it is not a complete module graph or a trace of linked symbols. Test-only and inactive
72+
target/tag dependencies are excluded. `go-licenses` groups packages by their selected
73+
license file, and its directory output is not a versioned SBOM.
74+
75+
The collector saves licenses and adjacent `NOTICE`, `NOTICE.txt`, or `NOTICE.md` files.
76+
For some license classifications it copies the **entire source directory containing the
77+
license**, excluding only Git metadata. This can increase wheel size and include unrelated
78+
local files. Review that output before shipping, especially with local replacements or
79+
workspace dependencies; the plugin does not remove potentially required source files.
80+
81+
Go's standard library/runtime is excluded by the collector. It also cannot discover
82+
dependencies hidden in C/C++, assembly, system libraries, or separately vendored assets.
83+
Collector warnings remain visible. Review runtime licenses and native dependencies
84+
separately; collecting files does not certify license compatibility or compliance.
85+
2186
> [!NOTE]
2287
> 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_go/licenses.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
from logging import getLogger
4+
from os import environ
5+
from pathlib import Path, PurePosixPath
6+
from shlex import split
7+
from shutil import which
8+
from subprocess import run
9+
10+
from pydantic import BaseModel, ConfigDict, field_validator
11+
12+
13+
class GoLicenseConfig(BaseModel):
14+
"""Opt-in go-licenses saved-file collection."""
15+
16+
model_config = ConfigDict(extra="forbid")
17+
destination: str
18+
19+
@field_validator("destination")
20+
@classmethod
21+
def validate_destination(cls, value: str) -> str:
22+
path = PurePosixPath(value)
23+
if (
24+
not value
25+
or path.is_absolute()
26+
or path.as_posix() != value
27+
or any(part in (".", "..") or part.endswith((".dist-info", ".data")) for part in path.parts)
28+
or any(character in value for character in "\\:*?[]\x00")
29+
or value == "."
30+
):
31+
raise ValueError("License destination must be a normalized wheel-relative directory.")
32+
return value
33+
34+
35+
def _collection_flags(flags: str | None) -> str:
36+
"""Pass dependency-selection flags to the collector through GOFLAGS."""
37+
if any(character in (flags or "") for character in "$`%;&|<>~*?[]{}\r\n"):
38+
raise ValueError("Go license collection cannot safely match shell-dependent build flags.")
39+
values = {"-tags", "-mod", "-modfile", "-overlay", "-compiler", "-pgo", "-covermode", "-coverpkg"}
40+
switches = {"-race", "-msan", "-asan", "-cover"}
41+
build_only_values = {"-ldflags", "-gcflags", "-asmflags", "-o", "-p"}
42+
build_only_switches = {"-a", "-n", "-x", "-v", "-work", "-trimpath", "-buildvcs"}
43+
result = []
44+
tokens = iter(split(flags or ""))
45+
for token in tokens:
46+
name, separator, value = token.partition("=")
47+
if name in values | build_only_values:
48+
if not separator:
49+
value = next(tokens, "")
50+
if not value:
51+
raise ValueError(f"Missing value for Go build flag '{name}'.")
52+
if name in values:
53+
if name == "-tags":
54+
value = ",".join(value.replace(",", " ").split())
55+
if any(character.isspace() for character in value):
56+
raise ValueError(f"Go license collection cannot represent '{name}' with whitespace in GOFLAGS.")
57+
result.append(f"{name}={value}")
58+
elif name in switches:
59+
result.append(token)
60+
elif name not in build_only_switches:
61+
raise ValueError(f"Unsupported Go build flag for license collection: '{token}'.")
62+
return " ".join(result)
63+
64+
65+
def collect_licenses(project: Path, output: Path, build_environment: dict[str, str], flags: str | None = None) -> None:
66+
"""Save original upstream artifacts outside the project, failing on unknown licenses."""
67+
environment = dict(environ)
68+
environment.update(build_environment)
69+
selection = _collection_flags(flags)
70+
environment["GOPACKAGESDRIVER"] = "off"
71+
collector = which("go-licenses", path=environment.get("PATH"))
72+
if collector is None:
73+
raise OSError("go-licenses is required for license collection; install github.com/google/go-licenses/v2@v2.0.1.")
74+
if selection:
75+
effective = run(["go", "env", "GOFLAGS"], cwd=project, env=environment, capture_output=True, text=True, check=False)
76+
if effective.returncode:
77+
raise RuntimeError(f"go env GOFLAGS failed: {effective.stderr.strip()}")
78+
environment["GOFLAGS"] = " ".join(part for part in (effective.stdout.strip(), selection) if part)
79+
completed = run([collector, "save", ".", f"--save_path={output}"], cwd=project, env=environment, capture_output=True, text=True, check=False)
80+
if completed.returncode:
81+
raise RuntimeError(f"go-licenses save failed with exit code {completed.returncode}: {completed.stderr.strip()}")
82+
if completed.stderr.strip():
83+
getLogger(__name__).warning("go-licenses: %s", completed.stderr.strip())
84+
if not output.is_dir():
85+
raise ValueError("go-licenses produced no saved license files.")
86+
files = []
87+
for file in output.rglob("*"):
88+
if file.is_symlink():
89+
raise ValueError(f"Saved license artifact must not be a symlink: '{file.relative_to(output)}'.")
90+
if file.is_file():
91+
files.append(file)
92+
if file.name.lower().startswith(("license", "licence", "copying")):
93+
text = file.read_bytes().strip().lower()
94+
if text in (b"", b"unknown", b"not found"):
95+
raise ValueError(f"Missing saved license text: '{file.relative_to(output)}'.")
96+
if not files:
97+
raise ValueError("go-licenses produced no saved license files.")

‎hatch_go/plugin.py‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from __future__ import annotations
22

33
from logging import getLogger
4-
from os import getenv
5-
from pathlib import Path
4+
from os import getenv, sep
5+
from pathlib import Path, PurePosixPath
66
from platform import machine as platform_machine
77
from sys import platform as sys_platform, version_info
8+
from tempfile import TemporaryDirectory
89
from typing import Any
910

1011
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
12+
from hatchling.builders.utils import normalize_inclusion_map
1113

14+
from .licenses import GoLicenseConfig, collect_licenses
1215
from .structs import HatchGoBuildConfig, HatchGoBuildPlan
1316
from .utils import import_string
1417

@@ -22,6 +25,7 @@ class HatchGoBuildHook(BuildHookInterface):
2225

2326
PLUGIN_NAME = "hatch-go"
2427
_logger = log
28+
_license_directory: TemporaryDirectory[str] | None = None
2529

2630
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
2731
"""Initialize the plugin."""
@@ -46,6 +50,9 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
4650
# Instantiate build config
4751
config = build_config_class(name=project_name, **self.config)
4852

53+
if config.licenses is not None and not config.skip:
54+
self._validate_license_destination(config.licenses.destination, build_data)
55+
4956
# Get build plan class or use default
5057
build_plan_class = import_string(self.config["build-plan-class"]) if "build-plan-class" in self.config else HatchGoBuildPlan
5158

@@ -96,5 +103,71 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
96103
if path.suffix in (".pyd", ".dll", ".so", ".dylib"):
97104
build_data["force_include"][str(path)] = str(path)
98105

106+
if config.licenses is not None:
107+
self._validate_license_destination(config.licenses.destination, build_data)
108+
self._cleanup_licenses()
109+
directory = TemporaryDirectory(prefix="hatch-go-licenses-")
110+
try:
111+
output = Path(directory.name) / "go"
112+
project = (Path(self.root) / (config.path or Path(self.root))).resolve()
113+
collect_licenses(project, output, build_plan._build_environment, config.go_build_flags)
114+
inclusions = {}
115+
for file in sorted(output.rglob("*")):
116+
if file.is_file():
117+
destination = (PurePosixPath(config.licenses.destination) / file.relative_to(output).as_posix()).as_posix()
118+
GoLicenseConfig.validate_destination(destination)
119+
self._validate_license_destination(destination, build_data)
120+
inclusions[str(file)] = destination
121+
build_data.setdefault("force_include", {}).update(inclusions)
122+
if version == "editable":
123+
if not build_data.get("force_include_editable"):
124+
normal = build_data["force_include"]
125+
combined = self.build_config.get_force_include() if self.build_config is not None else {}
126+
combined.update(normalize_inclusion_map(normal, self.root))
127+
normal.clear()
128+
normal.update(combined)
129+
build_data["force_include_editable"] = normal
130+
build_data["force_include_editable"].update(inclusions)
131+
except BaseException:
132+
directory.cleanup()
133+
raise
134+
self._license_directory = directory
135+
99136
for path in build_data["force_include"]:
100137
self._logger.warning(f"Force include: {path}")
138+
139+
def _validate_license_destination(self, value: str, build_data: dict[str, Any]) -> None:
140+
destination = PurePosixPath(value)
141+
root = Path(self.root).resolve()
142+
paths = {root / value}
143+
inclusions = list(build_data.get("force_include", {}).values())
144+
inclusions.extend(build_data.get("force_include_editable", {}).values())
145+
if self.build_config is not None:
146+
raw = str(Path(value))
147+
mapped = self.build_config.get_distribution_path(raw)
148+
prefix = self.build_config.get_distribution_path(raw + sep)
149+
if Path(mapped).as_posix() != value or Path(prefix).as_posix() != value:
150+
raise ValueError("License destination is rewritten by Hatch sources; choose an unmapped wheel-relative destination.")
151+
inclusions.extend(self.build_config.get_force_include().values())
152+
for source, replacement in self.build_config.sources.items():
153+
if raw.startswith(replacement):
154+
paths.add(root / (source + raw[len(replacement) :]))
155+
for existing in paths:
156+
if existing.exists() or existing.is_symlink() or any(parent.is_file() or parent.is_symlink() for parent in existing.parents):
157+
raise ValueError(f"License destination collision: '{destination}' already exists in the project.")
158+
for included in inclusions:
159+
mapped = self.build_config.get_distribution_path(str(Path(included))) if self.build_config is not None else included
160+
other = PurePosixPath(Path(mapped).as_posix())
161+
if destination == other or destination in other.parents or other in destination.parents:
162+
raise ValueError(f"License destination collision with force_include: '{included}'.")
163+
164+
def _cleanup_licenses(self) -> None:
165+
if self._license_directory is not None:
166+
self._license_directory.cleanup()
167+
self._license_directory = None
168+
169+
def finalize(self, version: str, build_data: dict[str, Any], artifact_path: str) -> None:
170+
self._cleanup_licenses()
171+
172+
def clean(self, versions: list[str]) -> None:
173+
self._cleanup_licenses()

‎hatch_go/structs.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from pydantic import BaseModel, Field, PrivateAttr, field_validator
1212

13+
from .licenses import GoLicenseConfig
14+
1315
__all__ = (
1416
"HatchGoBuildConfig",
1517
"HatchGoBuildPlan",
@@ -63,6 +65,7 @@ class HatchGoBuildConfig(BaseModel):
6365

6466
module: str = Field(description="Python module name for the Go extension.")
6567
path: Path | None = Field(default=None, description="Path to the Go project root directory.")
68+
licenses: GoLicenseConfig | None = Field(default=None, description="Optional Go dependency license collection.")
6669

6770
build_mode: BuildMode = Field(
6871
default="c-shared",
@@ -97,6 +100,7 @@ class HatchGoBuildPlan(HatchGoBuildConfig):
97100
commands: list[str] = Field(default_factory=list)
98101

99102
_libraries: list[str] = PrivateAttr(default_factory=list)
103+
_build_environment: dict[str, str] = PrivateAttr(default_factory=dict)
100104

101105
def _get_platform_info(self):
102106
"""Get platform and machine info, respecting env overrides."""
@@ -153,6 +157,7 @@ def generate(self):
153157
cgo_cflags, cgo_ldflags = _get_python_cgo_flags()
154158
go_env["CGO_CFLAGS"] = cgo_cflags
155159
go_env["CGO_LDFLAGS"] = cgo_ldflags
160+
self._build_environment = go_env
156161

157162
# Construct env prefix for the command
158163
if platform == "win32":

0 commit comments

Comments
 (0)