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.
@@ -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,5 @@
1
+ """Package Go commands as Python wheels."""
2
+
3
+ from radlermass._build import build_wheels
4
+
5
+ __all__ = ["build_wheels"]
@@ -0,0 +1,3 @@
1
+ from radlermass._cli import main
2
+
3
+ raise SystemExit(main())
@@ -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