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
9 changes: 9 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
97 changes: 97 additions & 0 deletions hatch_go/licenses.py
Original file line number Diff line number Diff line change
@@ -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.")
77 changes: 75 additions & 2 deletions hatch_go/plugin.py
Original file line number Diff line number Diff line change
@@ -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

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

Expand Down Expand Up @@ -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()
5 changes: 5 additions & 0 deletions hatch_go/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from pydantic import BaseModel, Field, PrivateAttr, field_validator

from .licenses import GoLicenseConfig

__all__ = (
"HatchGoBuildConfig",
"HatchGoBuildPlan",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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":
Expand Down
Loading