-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsetup.py
More file actions
373 lines (318 loc) · 15.1 KB
/
Copy pathsetup.py
File metadata and controls
373 lines (318 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
"""Local source-install build driver for fbuild.
`pip install ~/dev/fbuild` (or any `pip install .` from the repo root) goes
through this file because `pyproject.toml` declares the setuptools build
backend. The plain backend would ship only the `python/fbuild` Python
package — no working `fbuild` command — because the actual CLI is a Rust
crate (`fbuild-cli`) that lives in the cargo workspace under `crates/`.
This file wires the install path through `soldr cargo build --release -p
fbuild-cli`, copies the resulting binary to `ci/bin/fbuild[.exe]`, and
hands that path to setuptools as a raw wheel script (the `scripts=`
argument to `setup()` below). Pip drops raw scripts straight into the
venv's `Scripts/` (Windows) or `bin/` (POSIX) directory as-is — `.exe`
files are NOT wrapped, so `fbuild` on PATH is the literal cargo-built
binary with no Python shim in front of it (see #746 for why the previous
`[project.scripts] fbuild = "ci.bin_launcher:main"` approach broke stdout
ordering on Windows).
This is the LOCAL DEV install path. The RELEASE path lives entirely in
the Autonomous Release GitHub Action (`.github/workflows/release-auto.yml`):
the action builds per-platform binaries on its own runners, calls
`ci/publish.py::build_all_wheels` to assemble platform-tagged wheels, and
uploads to PyPI via trusted publishing (OIDC). See `docs/RELEASING.md`.
Why soldr (and not bare cargo)?
- soldr resolves the toolchain via `rustup which`, respecting
`rust-toolchain.toml` without requiring PATH to be pre-shaped.
- soldr auto-sets `RUSTC_WRAPPER` to zccache, so rebuilds across `pip
install .` invocations are incremental + dep-cached.
Why not `setuptools-rust` or `maturin`? Both are reasonable but heavier:
they introduce another tool with its own toolchain assumptions, while
soldr is already the canonical build driver across this repo's dev,
trampoline, and CI paths (see `ci/trampoline.py`). Keeping the single
soldr-cargo invocation means there's only one place to look when iteration
is slow.
Locating the built binary
-------------------------
Cargo writes the `fbuild` executable to either
<target>/release/fbuild[.exe]
(when no host triple is configured) or
<target>/<host-triple>/release/fbuild[.exe]
(when soldr / zccache sets `CARGO_BUILD_TARGET=<host-triple>` to isolate
its caches by host — which is what happens in this repo by default). The
previous version of this file only checked the first path; on Windows
where soldr was configured for a host triple, that path was empty even
on a green build, so every `pip install .` failed with
ERROR: cargo build succeeded but binary not at target\release\fbuild.exe.
To handle both layouts (and any future per-feature/per-profile target
directory) we drive cargo with `--message-format=json-render-diagnostics`
and pull the real artifact path out of cargo's structured output. That's
how `cargo install` and most Rust packaging tools find their binaries.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
# Import setuptools FIRST so its distutils shim is installed before we
# pull `distutils.command.build_scripts` off the shim. Importing the
# distutils module without setuptools loaded first either misses the
# shim or (depending on Python/setuptools version) yields the stdlib
# distutils, which is deprecated and gone in Python 3.12+.
from setuptools import setup
from setuptools.command.build_py import build_py
from setuptools.dist import Distribution
from distutils.command.build_scripts import ( # type: ignore[import-untyped]
build_scripts,
)
REPO_ROOT = Path(__file__).resolve().parent
TARGET_BINARY_NAME = "fbuild.exe" if sys.platform == "win32" else "fbuild"
STAGED_BIN_DIR = REPO_ROOT / "ci" / "bin"
STAGED_BINARY_PATH = STAGED_BIN_DIR / TARGET_BINARY_NAME
# Pin cargo's target directory to a stable absolute path so PEP 517
# isolated builds (pip copies the source tree to a temp dir, so
# `<cwd>/target/` lives in that temp dir and is discarded after the
# build) reuse cargo's incremental fingerprint cache across invocations.
# Without this, every isolated `pip install .` runs cargo cold — 25-30s
# wall-clock per invocation.
#
# We deliberately do NOT share `<repo>/target/` with the dev CLI: the
# dev CLI often runs `cargo check` or different `--features`/`--profile`
# combos, and sharing the target dir means each `pip install` invalidates
# whatever the dev CLI just compiled (and vice versa). A separate
# wheel-build target dir gives both paths a stable, hot cache.
WHEEL_BUILD_TARGET_DIR = Path.home() / ".fbuild" / "cargo-target" / "wheel-build"
os.environ.setdefault("CARGO_TARGET_DIR", str(WHEEL_BUILD_TARGET_DIR))
def _iter_cargo_inputs() -> "list[Path]":
"""Files that, if newer than the staged binary, invalidate the cached build."""
patterns = (
"Cargo.toml",
"Cargo.lock",
"rust-toolchain.toml",
"crates/**/Cargo.toml",
"crates/**/*.rs",
)
paths: list[Path] = []
for pat in patterns:
paths.extend(REPO_ROOT.glob(pat))
return paths
def _staged_binary_is_up_to_date() -> bool:
"""True if the staged binary exists and is newer than every cargo input."""
if not STAGED_BINARY_PATH.is_file():
return False
staged_mtime = STAGED_BINARY_PATH.stat().st_mtime
for path in _iter_cargo_inputs():
try:
if path.stat().st_mtime > staged_mtime:
return False
except FileNotFoundError:
# File disappeared between glob and stat — treat as changed.
return False
return True
def _require_soldr() -> None:
if shutil.which("soldr") is None:
sys.stderr.write(
"\n"
"ERROR: `soldr` is required to build fbuild from source.\n"
"Install one of:\n"
" uv tool install soldr\n"
" curl -fsSL https://raw.githubusercontent.com/zackees/soldr/main/install.sh | bash\n"
"Then re-run `pip install .`.\n"
"\n"
"If you only want the Python helpers (no `fbuild` CLI), install\n"
"the `fbuild-dev-tools` subpackage instead: `uv sync` from this\n"
"repo root.\n"
"\n"
)
sys.exit(1)
def _find_fbuild_executable_from_json(stdout: str) -> Optional[Path]:
"""Walk cargo's structured artifact stream and return the path to the
`fbuild` binary, or `None` if no compiler-artifact line for it appeared.
cargo emits one JSON object per line; the artifact we want has
`reason == "compiler-artifact"`, `target.name == "fbuild"`, and a non-
null `executable` field. We keep the *last* match because cargo emits
one artifact per crate target kind and the bin artifact is what we
want (matches `cargo install`'s selection rule).
"""
binary_path: Optional[Path] = None
for line in stdout.splitlines():
line = line.strip()
if not line or not line.startswith("{"):
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
# Non-JSON noise (rare; cargo's renderer can inline human-
# readable progress on stdout when there's no compatible TTY).
continue
if msg.get("reason") != "compiler-artifact":
continue
target = msg.get("target") or {}
if target.get("name") != "fbuild":
continue
executable = msg.get("executable")
if executable:
binary_path = Path(executable)
return binary_path
def _use_release_profile() -> bool:
"""True when this build should produce a release-optimized binary.
Default is `False` — pip/uv-driven builds use the dev profile so the
iteration loop is fast (workspace's third-party deps stay at opt-level
3 via `[profile.dev.package."*"]`, only our own crates compile
unoptimized). Set `FBUILD_BUILD_RELEASE=1` to opt into a release
build when you actually want a fast binary (CI, packaging, perf
tests).
"""
return os.environ.get("FBUILD_BUILD_RELEASE", "").lower() in ("1", "true", "yes")
def _profile_subdir() -> str:
return "release" if _use_release_profile() else "debug"
def _find_fbuild_executable_by_search() -> Optional[Path]:
"""Fallback when cargo didn't emit a usable artifact line (e.g. a fully
cached build that reports `Fresh` and skips compiler-artifact). Probe
the canonical `target/<profile>` path and every per-host-triple subdir.
"""
profile_dir = _profile_subdir()
target_root = Path(os.environ.get("CARGO_TARGET_DIR", REPO_ROOT / "target"))
candidates = [target_root / profile_dir / TARGET_BINARY_NAME]
if target_root.is_dir():
for child in target_root.iterdir():
candidate = child / profile_dir / TARGET_BINARY_NAME
if candidate.is_file():
candidates.append(candidate)
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def _build_fbuild_cli() -> Path:
"""Run `soldr cargo build` and return the path to the built executable."""
cmd = [
"soldr",
"cargo",
"build",
"-p",
"fbuild-cli",
"--message-format=json-render-diagnostics",
]
if _use_release_profile():
cmd.insert(3, "--release")
sys.stderr.write(f" $ {' '.join(cmd)}\n")
# stderr passes through so soldr's session summary stays visible; stdout
# is captured because that's where cargo writes its JSON artifact stream.
proc = subprocess.run(
cmd,
cwd=str(REPO_ROOT),
stdout=subprocess.PIPE,
stderr=None,
check=False,
text=True,
encoding="utf-8",
)
if proc.returncode != 0:
sys.stderr.write(
f"ERROR: `soldr cargo build` exited with code {proc.returncode}.\n"
)
sys.exit(proc.returncode)
binary_path = _find_fbuild_executable_from_json(proc.stdout)
if binary_path is None or not binary_path.is_file():
binary_path = _find_fbuild_executable_by_search()
if binary_path is None or not binary_path.is_file():
sys.stderr.write(
"ERROR: cargo build succeeded but no `fbuild` binary was found.\n"
"Searched:\n"
" - cargo's structured JSON artifact stream\n"
" (--message-format=json-render-diagnostics)\n"
f" - {REPO_ROOT / 'target' / 'release' / TARGET_BINARY_NAME}\n"
f" - {REPO_ROOT / 'target'}/<host-triple>/release/{TARGET_BINARY_NAME}\n"
"If you suspect cargo wrote the binary somewhere else, please\n"
"file an issue at https://github.com/FastLED/fbuild/issues and\n"
"attach the output of `soldr cargo build --release -p fbuild-cli -v`.\n"
)
sys.exit(1)
return binary_path
class BuildWithCargo(build_py):
"""Run `soldr cargo build --release -p fbuild-cli` before packaging."""
def run(self) -> None: # noqa: D401 — setuptools API name
# Fast path: if the staged binary is newer than every cargo input,
# skip the cargo invocation entirely. Even cargo's "Fresh" pass walks
# the workspace and takes wall-clock seconds; this short-circuits it.
# Triggered when uv/pip reinstall fbuild without any actual source
# change (e.g. version bump, lockfile churn, --reinstall-package).
if _staged_binary_is_up_to_date():
sys.stderr.write(
f" staged binary up-to-date ({STAGED_BINARY_PATH}); skipping cargo\n"
)
super().run()
return
_require_soldr()
binary_path = _build_fbuild_cli()
STAGED_BIN_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(binary_path, STAGED_BINARY_PATH)
sys.stderr.write(f" staged binary -> {STAGED_BINARY_PATH}\n")
super().run()
class BinaryDistribution(Distribution):
"""Force a platform-tagged wheel because we ship a native binary."""
def has_ext_modules(self) -> bool: # noqa: D401 — setuptools API name
return True
class BuildBinaryScripts(build_scripts):
"""Byte-copy variant of `build_scripts` for raw native binaries.
Stock `build_scripts.copy_scripts` calls `tokenize.open(script)` on
each entry to detect a coding cookie and patch a shebang for source
scripts. That's right for `.py` files but wrong for a Rust-built
`.exe` / ELF binary — `tokenize.open` raises `SyntaxError: invalid or
missing encoding declaration` on the very first read. We override
`copy_scripts` to do a plain byte-level `shutil.copy2`, preserving
the executable bit on POSIX (cargo already sets it). The file lands
in `<name>-<version>.data/scripts/` in the wheel; pip then copies it
straight into the install's `Scripts/` (Windows) or `bin/` (POSIX)
directory verbatim — no shebang, no Python wrapper. See #746.
"""
def copy_scripts(self): # noqa: D401 — distutils API name
self.mkpath(self.build_dir)
outfiles: list[str] = []
updated_files: list[str] = []
for script in self.scripts:
outfile = os.path.join(self.build_dir, os.path.basename(script))
# `dep_util.newer` returns True if `script` is newer than
# `outfile`, mirroring stock build_scripts' "update or skip"
# behavior — avoids spurious rebuilds breaking caching.
try:
from distutils import dep_util # type: ignore[import-untyped]
up_to_date = (
os.path.exists(outfile) and not dep_util.newer(script, outfile)
)
except ImportError:
# Python 3.12+ removed distutils.dep_util; fall back to
# an mtime compare.
up_to_date = os.path.exists(outfile) and (
os.path.getmtime(script) <= os.path.getmtime(outfile)
)
if up_to_date and not self.force:
outfiles.append(outfile)
continue
shutil.copy2(script, outfile)
outfiles.append(outfile)
updated_files.append(outfile)
return outfiles, updated_files
# `scripts=` is the legacy setuptools mechanism for shipping raw files
# (no shebang/no entry-point wrapping) into the install's Scripts/bin
# directory. Files land in `<name>-<version>.data/scripts/` inside the
# wheel; `pip install` then copies them straight into the venv as-is —
# on Windows pip does NOT generate a Python wrapper for `.exe` files
# (only for shebang-style script text). This is the same mechanism
# maturin's "bin" mode and cargo-dist use to ship native binaries via
# PyPI without a Python shim. See #746.
#
# Stock `build_scripts` parses each script as Python source to find a
# coding cookie / shebang — we override with `BuildBinaryScripts` to do
# a plain byte-copy instead. `STAGED_BINARY_PATH` doesn't exist until
# `BuildWithCargo` runs, which happens during the `build_py` phase.
# Setuptools' build pipeline runs `build_py` before `build_scripts`, so
# by the time `build_scripts` reads this list, the file is on disk.
setup(
cmdclass={
"build_py": BuildWithCargo,
"build_scripts": BuildBinaryScripts,
},
distclass=BinaryDistribution,
scripts=[str(STAGED_BINARY_PATH)],
)