radlermass 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- radlermass-0.1.0/PKG-INFO +60 -0
- radlermass-0.1.0/README.md +50 -0
- radlermass-0.1.0/pyproject.toml +42 -0
- radlermass-0.1.0/pyproject.toml.orig +35 -0
- radlermass-0.1.0/src/radlermass/__init__.py +5 -0
- radlermass-0.1.0/src/radlermass/__main__.py +3 -0
- radlermass-0.1.0/src/radlermass/_build.py +149 -0
- radlermass-0.1.0/src/radlermass/_cli.py +82 -0
- radlermass-0.1.0/src/radlermass/_go.py +97 -0
- radlermass-0.1.0/src/radlermass/_metadata.py +57 -0
- radlermass-0.1.0/src/radlermass/_platforms.py +74 -0
- radlermass-0.1.0/src/radlermass/_wheel.py +90 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: radlermass
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Go binaries in Python wheels.
|
|
5
|
+
Author: William Woodruff
|
|
6
|
+
Author-email: William Woodruff <william@yossarian.net>
|
|
7
|
+
Requires-Dist: packaging>=26.0
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# radlermass
|
|
12
|
+
|
|
13
|
+
Go binaries in Python wheels.
|
|
14
|
+
|
|
15
|
+
Inspired by [go-to-wheel](https://github.com/simonw/go-to-wheel). The wheels
|
|
16
|
+
install native executables, with no Python wrapper.
|
|
17
|
+
|
|
18
|
+
Requires Python 3.14+ and Go. Cgo is not supported.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
From a checkout:
|
|
23
|
+
|
|
24
|
+
```console
|
|
25
|
+
$ uv run radlermass ./mytool --version 1.2.3
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This builds the root `main` package for Linux, macOS, and Windows (amd64 and
|
|
29
|
+
arm64), writing wheels to `./dist`.
|
|
30
|
+
|
|
31
|
+
Use `--package-path cmd/mytool` for a subdirectory. See `uv run radlermass --help`
|
|
32
|
+
for names, target selection, metadata, and linker flags.
|
|
33
|
+
|
|
34
|
+
## Python API
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from radlermass import build_wheels
|
|
38
|
+
|
|
39
|
+
wheels = build_wheels(
|
|
40
|
+
"./mytool",
|
|
41
|
+
package_path="cmd/mytool",
|
|
42
|
+
version="1.2.3",
|
|
43
|
+
readme="# My tool\n\nA Go command.",
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`build_wheels` returns a list of absolute wheel paths. `readme` accepts Markdown
|
|
48
|
+
as a `str` or a file as a `pathlib.Path`; relative paths resolve against the Go
|
|
49
|
+
module.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```console
|
|
54
|
+
$ uv sync --locked
|
|
55
|
+
$ uv run ruff check .
|
|
56
|
+
$ uv run ruff format --check .
|
|
57
|
+
$ uv run ty check
|
|
58
|
+
$ uv run pytest
|
|
59
|
+
$ uv build
|
|
60
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# radlermass
|
|
2
|
+
|
|
3
|
+
Go binaries in Python wheels.
|
|
4
|
+
|
|
5
|
+
Inspired by [go-to-wheel](https://github.com/simonw/go-to-wheel). The wheels
|
|
6
|
+
install native executables, with no Python wrapper.
|
|
7
|
+
|
|
8
|
+
Requires Python 3.14+ and Go. Cgo is not supported.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
From a checkout:
|
|
13
|
+
|
|
14
|
+
```console
|
|
15
|
+
$ uv run radlermass ./mytool --version 1.2.3
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This builds the root `main` package for Linux, macOS, and Windows (amd64 and
|
|
19
|
+
arm64), writing wheels to `./dist`.
|
|
20
|
+
|
|
21
|
+
Use `--package-path cmd/mytool` for a subdirectory. See `uv run radlermass --help`
|
|
22
|
+
for names, target selection, metadata, and linker flags.
|
|
23
|
+
|
|
24
|
+
## Python API
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from radlermass import build_wheels
|
|
28
|
+
|
|
29
|
+
wheels = build_wheels(
|
|
30
|
+
"./mytool",
|
|
31
|
+
package_path="cmd/mytool",
|
|
32
|
+
version="1.2.3",
|
|
33
|
+
readme="# My tool\n\nA Go command.",
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`build_wheels` returns a list of absolute wheel paths. `readme` accepts Markdown
|
|
38
|
+
as a `str` or a file as a `pathlib.Path`; relative paths resolve against the Go
|
|
39
|
+
module.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ uv sync --locked
|
|
45
|
+
$ uv run ruff check .
|
|
46
|
+
$ uv run ruff format --check .
|
|
47
|
+
$ uv run ty check
|
|
48
|
+
$ uv run pytest
|
|
49
|
+
$ uv build
|
|
50
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "radlermass"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Go binaries in Python wheels."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = ["packaging>=26.0"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "William Woodruff"
|
|
11
|
+
email = "william@yossarian.net"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
radlermass = "radlermass._cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.12.15,<0.13.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest",
|
|
23
|
+
"ruff",
|
|
24
|
+
"ty",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.uv.workspace]
|
|
28
|
+
members = []
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = [
|
|
32
|
+
"E",
|
|
33
|
+
"F",
|
|
34
|
+
"I",
|
|
35
|
+
"UP",
|
|
36
|
+
"B",
|
|
37
|
+
"SIM",
|
|
38
|
+
"PT",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "radlermass"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Go binaries in Python wheels."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "William Woodruff", email = "william@yossarian.net" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"packaging>=26.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
radlermass = "radlermass._cli:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.12.15,<0.13.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest",
|
|
24
|
+
"ruff",
|
|
25
|
+
"ty",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.uv.workspace]
|
|
29
|
+
members = []
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "PT"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Compile a Go command and package it for each requested platform."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from packaging.utils import canonicalize_name
|
|
9
|
+
from packaging.version import Version
|
|
10
|
+
|
|
11
|
+
from radlermass._go import Go
|
|
12
|
+
from radlermass._metadata import Metadata, validate_command
|
|
13
|
+
from radlermass._platforms import macos_tag, select_targets
|
|
14
|
+
from radlermass._wheel import wheel_timestamp, write_wheel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_package(go_dir: str | Path, package_path: str) -> tuple[Path, str]:
|
|
18
|
+
module = Path(go_dir).resolve()
|
|
19
|
+
if not module.is_dir():
|
|
20
|
+
raise ValueError(f"Go module directory not found: {go_dir}")
|
|
21
|
+
if not (module / "go.mod").is_file():
|
|
22
|
+
raise ValueError(f"Not a Go module (no go.mod): {module}")
|
|
23
|
+
|
|
24
|
+
if not package_path or Path(package_path).is_absolute():
|
|
25
|
+
raise ValueError("Package path must be a directory relative to the Go module")
|
|
26
|
+
|
|
27
|
+
package_dir = (module / package_path).resolve()
|
|
28
|
+
if not package_dir.is_relative_to(module):
|
|
29
|
+
raise ValueError("Package path must stay inside the Go module")
|
|
30
|
+
if not package_dir.is_dir():
|
|
31
|
+
raise ValueError(f"Go package directory not found: {package_path}")
|
|
32
|
+
|
|
33
|
+
return module, "./" + package_dir.relative_to(module).as_posix()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _linker_flags(
|
|
37
|
+
version: Version, ldflags: str | None, set_version_var: str | None
|
|
38
|
+
) -> str:
|
|
39
|
+
flags = ["-s", "-w"]
|
|
40
|
+
|
|
41
|
+
if set_version_var is not None:
|
|
42
|
+
if not re.fullmatch(r"[^\s='\"\x00]+\.[A-Za-z_][A-Za-z0-9_]*", set_version_var):
|
|
43
|
+
raise ValueError(f"Invalid Go version variable: {set_version_var!r}")
|
|
44
|
+
|
|
45
|
+
flags.append(f"-X {set_version_var}={version}")
|
|
46
|
+
|
|
47
|
+
if ldflags:
|
|
48
|
+
flags.append(ldflags)
|
|
49
|
+
|
|
50
|
+
result = " ".join(flags)
|
|
51
|
+
if "\x00" in result:
|
|
52
|
+
raise ValueError("Linker flags must not contain NUL characters")
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_wheels(
|
|
58
|
+
go_dir: str | Path,
|
|
59
|
+
*,
|
|
60
|
+
name: str | None = None,
|
|
61
|
+
version: str = "0.1.0",
|
|
62
|
+
output_dir: str | Path = "dist",
|
|
63
|
+
entry_point: str | None = None,
|
|
64
|
+
platforms: Sequence[str] | None = None,
|
|
65
|
+
go_binary: str = "go",
|
|
66
|
+
package_path: str = ".",
|
|
67
|
+
description: str | None = None,
|
|
68
|
+
author: str | None = None,
|
|
69
|
+
author_email: str | None = None,
|
|
70
|
+
license_: str | None = None,
|
|
71
|
+
url: str | None = None,
|
|
72
|
+
readme: str | Path | None = None,
|
|
73
|
+
ldflags: str | None = None,
|
|
74
|
+
set_version_var: str | None = None,
|
|
75
|
+
build_timeout: float = 300,
|
|
76
|
+
) -> list[Path]:
|
|
77
|
+
"""Build wheels from ``go_dir`` and return their absolute paths.
|
|
78
|
+
|
|
79
|
+
``package_path`` selects the main package, defaulting to the module root.
|
|
80
|
+
``readme`` is Markdown text or a Path to a UTF-8 file. Relative README paths
|
|
81
|
+
use ``go_dir``; relative ``output_dir`` paths use the working directory.
|
|
82
|
+
``description=None`` omits the package summary.
|
|
83
|
+
|
|
84
|
+
Raises ValueError for invalid inputs, RuntimeError for Go failures or
|
|
85
|
+
timeouts, and OSError for I/O or process errors.
|
|
86
|
+
|
|
87
|
+
Existing wheels are replaced only after all builds succeed. Each replacement
|
|
88
|
+
is atomic.
|
|
89
|
+
"""
|
|
90
|
+
module, package = _resolve_package(go_dir, package_path)
|
|
91
|
+
|
|
92
|
+
if name is None:
|
|
93
|
+
name = module.name
|
|
94
|
+
canonicalize_name(name, validate=True)
|
|
95
|
+
|
|
96
|
+
command = name if entry_point is None else entry_point
|
|
97
|
+
validate_command(command)
|
|
98
|
+
|
|
99
|
+
targets = select_targets(platforms)
|
|
100
|
+
|
|
101
|
+
if isinstance(readme, Path):
|
|
102
|
+
readme = (module / readme).read_text(encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
metadata = Metadata(
|
|
105
|
+
name=name,
|
|
106
|
+
version=Version(version),
|
|
107
|
+
description=description,
|
|
108
|
+
author=author,
|
|
109
|
+
author_email=author_email,
|
|
110
|
+
license=license_,
|
|
111
|
+
url=url,
|
|
112
|
+
readme=readme,
|
|
113
|
+
)
|
|
114
|
+
metadata.render() # Validate headers before invoking Go.
|
|
115
|
+
|
|
116
|
+
flags = _linker_flags(metadata.version, ldflags, set_version_var)
|
|
117
|
+
timestamp = wheel_timestamp()
|
|
118
|
+
|
|
119
|
+
go = Go(module, executable=go_binary, timeout=build_timeout)
|
|
120
|
+
|
|
121
|
+
wheel_tags = {}
|
|
122
|
+
for target in targets:
|
|
123
|
+
tag = target.tag
|
|
124
|
+
if target.goos == "darwin":
|
|
125
|
+
tag = macos_tag(go.version, tag)
|
|
126
|
+
|
|
127
|
+
wheel_tags[target] = tag
|
|
128
|
+
|
|
129
|
+
output = Path(output_dir).resolve()
|
|
130
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
# Keep staged wheels on the output filesystem so each rename is atomic.
|
|
133
|
+
with tempfile.TemporaryDirectory(prefix=".radlermass-", dir=output) as temporary:
|
|
134
|
+
staging = Path(temporary)
|
|
135
|
+
binaries = go.build(package, targets, staging, flags)
|
|
136
|
+
|
|
137
|
+
wheels = []
|
|
138
|
+
for target, tag in wheel_tags.items():
|
|
139
|
+
wheel = write_wheel(
|
|
140
|
+
binary=binaries[target.goos, target.goarch],
|
|
141
|
+
output_dir=staging,
|
|
142
|
+
metadata=metadata,
|
|
143
|
+
command=command,
|
|
144
|
+
tag=tag,
|
|
145
|
+
timestamp=timestamp,
|
|
146
|
+
)
|
|
147
|
+
wheels.append(wheel)
|
|
148
|
+
|
|
149
|
+
return [wheel.replace(output / wheel.name) for wheel in wheels]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Command-line interface for radlermass."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from radlermass._build import build_wheels
|
|
9
|
+
from radlermass._platforms import TARGETS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="radlermass", description="Compile Go commands into Python wheels."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
parser.add_argument("go_dir", help="Go module directory containing go.mod")
|
|
18
|
+
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--name", help="Distribution name (default: module directory name)"
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--version", default="0.1.0", help="Distribution version (PEP 440)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
parser.add_argument("--output-dir", default="dist", help="Wheel output directory")
|
|
27
|
+
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--entry-point", help="Installed command name (default: --name)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--package-path", default=".", help="Package directory within the module"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--platforms",
|
|
38
|
+
help=f"Comma-separated targets (default: all): {', '.join(TARGETS)}",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
parser.add_argument("--go-binary", default="go", help="Go executable path or name")
|
|
42
|
+
|
|
43
|
+
parser.add_argument("--description", help="Package summary")
|
|
44
|
+
parser.add_argument("--author", help="Package author")
|
|
45
|
+
parser.add_argument("--author-email", help="Package author email")
|
|
46
|
+
parser.add_argument("--license", dest="license_", help="Package license")
|
|
47
|
+
parser.add_argument("--url", help="Project URL")
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--readme", type=Path, help="Markdown README path, relative to the Go module"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
parser.add_argument("--ldflags", help="Additional Go linker flags (after -s -w)")
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--set-version-var", help="Go string variable to set to --version"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--build-timeout",
|
|
59
|
+
type=float,
|
|
60
|
+
default=300,
|
|
61
|
+
help="Seconds allowed per Go build (default: 300)",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
args = parser.parse_args(argv)
|
|
65
|
+
|
|
66
|
+
if args.platforms is not None:
|
|
67
|
+
args.platforms = [platform.strip() for platform in args.platforms.split(",")]
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
wheels = build_wheels(**vars(args))
|
|
71
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
72
|
+
print(f"radlermass: {error}", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
except KeyboardInterrupt:
|
|
75
|
+
print("radlermass: interrupted", file=sys.stderr)
|
|
76
|
+
return 130
|
|
77
|
+
|
|
78
|
+
print(f"Built {len(wheels)} wheel(s):")
|
|
79
|
+
for wheel in wheels:
|
|
80
|
+
print(f" {wheel}")
|
|
81
|
+
|
|
82
|
+
return 0
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Invoke the Go toolchain in a module directory."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from radlermass._platforms import Target
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Go:
|
|
15
|
+
def __init__(self, module: Path, executable: str, timeout: float) -> None:
|
|
16
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
17
|
+
raise ValueError("Build timeout must be a positive, finite number")
|
|
18
|
+
|
|
19
|
+
path = shutil.which(executable)
|
|
20
|
+
if path is None:
|
|
21
|
+
raise ValueError(f"Go executable not found: {executable!r}")
|
|
22
|
+
|
|
23
|
+
# Resolve before changing the subprocess's working directory to the module.
|
|
24
|
+
self.executable = str(Path(path).absolute())
|
|
25
|
+
self.module = module
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
|
|
28
|
+
def _run(
|
|
29
|
+
self,
|
|
30
|
+
args: list[str],
|
|
31
|
+
*,
|
|
32
|
+
label: str,
|
|
33
|
+
env: dict[str, str] | None = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
[self.executable, *args],
|
|
38
|
+
cwd=self.module,
|
|
39
|
+
env=env,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=self.timeout,
|
|
43
|
+
)
|
|
44
|
+
except subprocess.TimeoutExpired as error:
|
|
45
|
+
raise RuntimeError(f"{label} exceeded {self.timeout:g} seconds") from error
|
|
46
|
+
|
|
47
|
+
if result.returncode:
|
|
48
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
49
|
+
raise RuntimeError(f"{label} failed (exit {result.returncode}):\n{detail}")
|
|
50
|
+
|
|
51
|
+
return result.stdout
|
|
52
|
+
|
|
53
|
+
@cached_property
|
|
54
|
+
def version(self) -> str:
|
|
55
|
+
"""Return the toolchain version selected for this module."""
|
|
56
|
+
return self._run(["env", "GOVERSION"], label="Go version query").strip()
|
|
57
|
+
|
|
58
|
+
def build(
|
|
59
|
+
self,
|
|
60
|
+
package: str,
|
|
61
|
+
targets: Sequence[Target],
|
|
62
|
+
output_dir: Path,
|
|
63
|
+
ldflags: str,
|
|
64
|
+
) -> dict[tuple[str, str], Path]:
|
|
65
|
+
"""Compile once per GOOS/GOARCH pair, sharing Linux libc variants."""
|
|
66
|
+
binaries: dict[tuple[str, str], Path] = {}
|
|
67
|
+
for target in targets:
|
|
68
|
+
key = (target.goos, target.goarch)
|
|
69
|
+
if key in binaries:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
binary = output_dir / f"{target.goos}-{target.goarch}.exe"
|
|
73
|
+
env = os.environ | {
|
|
74
|
+
"GOOS": target.goos,
|
|
75
|
+
"GOARCH": target.goarch,
|
|
76
|
+
"CGO_ENABLED": "0",
|
|
77
|
+
# Wheel tags don't encode GOAMD64/GOARM64 feature levels.
|
|
78
|
+
"GOAMD64": "v1",
|
|
79
|
+
"GOARM64": "v8.0",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
self._run(
|
|
83
|
+
[
|
|
84
|
+
"build",
|
|
85
|
+
"-trimpath",
|
|
86
|
+
"-buildmode=exe",
|
|
87
|
+
f"-ldflags={ldflags}",
|
|
88
|
+
"-o",
|
|
89
|
+
str(binary),
|
|
90
|
+
package,
|
|
91
|
+
],
|
|
92
|
+
env=env,
|
|
93
|
+
label=f"Go build for {target.goos}/{target.goarch}",
|
|
94
|
+
)
|
|
95
|
+
binaries[key] = binary
|
|
96
|
+
|
|
97
|
+
return binaries
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Distribution names, versions, and core metadata."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from packaging.metadata import RFC822Message
|
|
7
|
+
from packaging.utils import canonicalize_name
|
|
8
|
+
from packaging.version import Version
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_command(name: str) -> None:
|
|
12
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) or name.endswith("."):
|
|
13
|
+
raise ValueError(f"Invalid command name: {name!r}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Metadata:
|
|
18
|
+
name: str
|
|
19
|
+
version: Version
|
|
20
|
+
description: str | None = None
|
|
21
|
+
author: str | None = None
|
|
22
|
+
author_email: str | None = None
|
|
23
|
+
license: str | None = None
|
|
24
|
+
url: str | None = None
|
|
25
|
+
readme: str | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def stem(self) -> str:
|
|
29
|
+
name = canonicalize_name(self.name, validate=True).replace("-", "_")
|
|
30
|
+
return f"{name}-{self.version}"
|
|
31
|
+
|
|
32
|
+
def render(self) -> bytes:
|
|
33
|
+
# Omit Requires-Python: the wheel installs a native executable directly.
|
|
34
|
+
headers = {
|
|
35
|
+
"Metadata-Version": "2.1",
|
|
36
|
+
"Name": self.name,
|
|
37
|
+
"Version": str(self.version),
|
|
38
|
+
"Summary": self.description,
|
|
39
|
+
"Author": self.author,
|
|
40
|
+
"Author-email": self.author_email,
|
|
41
|
+
"License": self.license,
|
|
42
|
+
"Home-page": self.url,
|
|
43
|
+
"Description-Content-Type": "text/markdown"
|
|
44
|
+
if self.readme is not None
|
|
45
|
+
else None,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
message = RFC822Message()
|
|
49
|
+
for key, value in headers.items():
|
|
50
|
+
if value is not None:
|
|
51
|
+
if any(ord(char) < 32 or ord(char) == 127 for char in value):
|
|
52
|
+
raise ValueError(f"{key} must not contain control characters")
|
|
53
|
+
|
|
54
|
+
message[key] = value
|
|
55
|
+
|
|
56
|
+
message.set_payload(self.readme or "")
|
|
57
|
+
return message.as_bytes()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Supported Go targets and wheel platform tags."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from packaging.tags import mac_platforms
|
|
7
|
+
from packaging.version import InvalidVersion, Version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Target:
|
|
12
|
+
goos: str
|
|
13
|
+
goarch: str
|
|
14
|
+
tag: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TARGETS = {
|
|
18
|
+
"linux-amd64": Target("linux", "amd64", "manylinux_2_17_x86_64"),
|
|
19
|
+
"linux-arm64": Target("linux", "arm64", "manylinux_2_17_aarch64"),
|
|
20
|
+
"linux-amd64-musl": Target("linux", "amd64", "musllinux_1_2_x86_64"),
|
|
21
|
+
"linux-arm64-musl": Target("linux", "arm64", "musllinux_1_2_aarch64"),
|
|
22
|
+
"darwin-amd64": Target("darwin", "amd64", "x86_64"),
|
|
23
|
+
"darwin-arm64": Target("darwin", "arm64", "arm64"),
|
|
24
|
+
"windows-amd64": Target("windows", "amd64", "win_amd64"),
|
|
25
|
+
"windows-arm64": Target("windows", "arm64", "win_arm64"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def select_targets(platforms: Sequence[str] | None) -> list[Target]:
|
|
30
|
+
"""Select targets in request order, defaulting to all supported platforms."""
|
|
31
|
+
selected = list(dict.fromkeys(TARGETS if platforms is None else platforms))
|
|
32
|
+
if not selected:
|
|
33
|
+
raise ValueError("Select at least one platform")
|
|
34
|
+
|
|
35
|
+
unknown = [platform for platform in selected if platform not in TARGETS]
|
|
36
|
+
if unknown:
|
|
37
|
+
raise ValueError(f"Unknown platform(s): {', '.join(unknown)}")
|
|
38
|
+
|
|
39
|
+
return [TARGETS[platform] for platform in selected]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# https://go.dev/wiki/MinimumRequirements and the Darwin release notes.
|
|
43
|
+
# Keep releases explicit: a new Go release may raise the macOS minimum.
|
|
44
|
+
MACOS_MINIMUMS = {
|
|
45
|
+
(1, 16): (10, 12),
|
|
46
|
+
(1, 17): (10, 13),
|
|
47
|
+
(1, 18): (10, 13),
|
|
48
|
+
(1, 19): (10, 13),
|
|
49
|
+
(1, 20): (10, 13),
|
|
50
|
+
(1, 21): (10, 15),
|
|
51
|
+
(1, 22): (10, 15),
|
|
52
|
+
(1, 23): (11, 0),
|
|
53
|
+
(1, 24): (11, 0),
|
|
54
|
+
(1, 25): (12, 0),
|
|
55
|
+
(1, 26): (12, 0),
|
|
56
|
+
(1, 27): (13, 0),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def macos_tag(go_version: str, arch: str) -> str:
|
|
61
|
+
"""Use the Go release's macOS minimum, with a floor of 11.0 on arm64."""
|
|
62
|
+
try:
|
|
63
|
+
version = Version(go_version.removeprefix("go").split("-", 1)[0])
|
|
64
|
+
minimum = MACOS_MINIMUMS[version.major, version.minor]
|
|
65
|
+
except InvalidVersion, KeyError:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Unknown macOS minimum for Go version {go_version!r}; "
|
|
68
|
+
"update radlermass's Go version map"
|
|
69
|
+
) from None
|
|
70
|
+
|
|
71
|
+
if arch == "arm64":
|
|
72
|
+
minimum = max(minimum, (11, 0))
|
|
73
|
+
|
|
74
|
+
return next(mac_platforms(version=minimum, arch=arch))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Write wheels containing native executables in the scripts install scheme."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import csv
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import BinaryIO
|
|
13
|
+
|
|
14
|
+
from packaging.tags import Tag
|
|
15
|
+
|
|
16
|
+
from radlermass._metadata import Metadata
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def wheel_timestamp() -> tuple[int, int, int, int, int, int]:
|
|
20
|
+
"""Return a ZIP timestamp from SOURCE_DATE_EPOCH, or 1980-01-01.
|
|
21
|
+
|
|
22
|
+
Clamp values before 1980 and reject values after 2107.
|
|
23
|
+
"""
|
|
24
|
+
epoch = int(os.environ.get("SOURCE_DATE_EPOCH", "315532800"))
|
|
25
|
+
if epoch > 4354819199: # ZIP timestamps end in 2107.
|
|
26
|
+
raise ValueError("SOURCE_DATE_EPOCH is too large for a ZIP timestamp")
|
|
27
|
+
|
|
28
|
+
return time.gmtime(max(epoch, 315532800))[:6]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_wheel(
|
|
32
|
+
binary: Path,
|
|
33
|
+
output_dir: Path,
|
|
34
|
+
metadata: Metadata,
|
|
35
|
+
command: str,
|
|
36
|
+
tag: str,
|
|
37
|
+
timestamp: tuple[int, int, int, int, int, int],
|
|
38
|
+
) -> Path:
|
|
39
|
+
if tag.startswith("win_") and not command.lower().endswith(".exe"):
|
|
40
|
+
command += ".exe"
|
|
41
|
+
|
|
42
|
+
stem = metadata.stem
|
|
43
|
+
dist_info = f"{stem}.dist-info"
|
|
44
|
+
wheel_tag = Tag("py3", "none", tag)
|
|
45
|
+
path = output_dir / f"{stem}-{wheel_tag}.whl"
|
|
46
|
+
rows: list[tuple[str, str, str]] = []
|
|
47
|
+
|
|
48
|
+
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as wheel:
|
|
49
|
+
|
|
50
|
+
def add(member: str, source: BinaryIO, mode: int) -> None:
|
|
51
|
+
info = zipfile.ZipInfo(member, date_time=timestamp)
|
|
52
|
+
info.create_system = 3 # Unix permissions, even when building on Windows.
|
|
53
|
+
info.external_attr = (stat.S_IFREG | mode) << 16
|
|
54
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
55
|
+
|
|
56
|
+
digest = hashlib.sha256()
|
|
57
|
+
size = 0
|
|
58
|
+
|
|
59
|
+
with wheel.open(info, "w", force_zip64=True) as destination:
|
|
60
|
+
while chunk := source.read(1024 * 1024):
|
|
61
|
+
destination.write(chunk)
|
|
62
|
+
digest.update(chunk)
|
|
63
|
+
size += len(chunk)
|
|
64
|
+
|
|
65
|
+
encoded = (
|
|
66
|
+
base64.urlsafe_b64encode(digest.digest()).rstrip(b"=").decode("ascii")
|
|
67
|
+
)
|
|
68
|
+
rows.append((member, f"sha256={encoded}", str(size)))
|
|
69
|
+
|
|
70
|
+
with binary.open("rb") as source:
|
|
71
|
+
add(f"{stem}.data/scripts/{command}", source, 0o755)
|
|
72
|
+
|
|
73
|
+
add(f"{dist_info}/METADATA", io.BytesIO(metadata.render()), 0o644)
|
|
74
|
+
|
|
75
|
+
wheel_metadata = (
|
|
76
|
+
"Wheel-Version: 1.0\n"
|
|
77
|
+
"Generator: radlermass\n"
|
|
78
|
+
"Root-Is-Purelib: false\n"
|
|
79
|
+
f"Tag: {wheel_tag}\n"
|
|
80
|
+
)
|
|
81
|
+
add(f"{dist_info}/WHEEL", io.BytesIO(wheel_metadata.encode("ascii")), 0o644)
|
|
82
|
+
|
|
83
|
+
record = f"{dist_info}/RECORD"
|
|
84
|
+
rows.append((record, "", ""))
|
|
85
|
+
|
|
86
|
+
contents = io.StringIO(newline="")
|
|
87
|
+
csv.writer(contents, lineterminator="\n").writerows(rows)
|
|
88
|
+
add(record, io.BytesIO(contents.getvalue().encode("utf-8")), 0o644)
|
|
89
|
+
|
|
90
|
+
return path
|