Making SPy installable without its repository
Today, SPy can only be installed as an editable install of a git checkout, followed by a
manual make -C spy/libspy. The goal is that users run uv tool install spylang, and
that developers run uv sync and nothing else. Everything compiled is built by uv sync
(developers) or when the package is built (users). Anything still missing is built
automatically the first time it is needed.
What has to be built¶
SPy needs a small C runtime library, called libspy. It has about 6000 lines of C and no external dependency.
libspy exists in several variants, each described by:
a target:
native,wasioremscripten;a build type:
debugorrelease;a kind:
exeortestlib;optionally a
--marchvalue (the CPU instruction set to compile for).
The standard variants are the ten that make -C spy/libspy builds today, all without
--march: native and native-static (release and debug, exe), and wasi and
emscripten (release and debug exe, debug testlib). emscripten needs emcc, and
native-static does not exist on macOS: they are skipped when unavailable.
Who uses which variant¶
Each variant is a directory of spy/libspy/build/ today (a cache entry later).
| Variant | Used by |
|---|---|
wasi/debug-testlib | The interpreter: every VM loads its libspy.wasm (vm.ll), so every spy command uses it, spy build included (for the interpreted part: imports and redshift). Out-of-tree modules: its libspy.a is linked with their archives into one wasm. Tests: the [interp], [doppler] and [C] backends. The [C] backend also compiles the generated C to a wasm module linked with this libspy.a. |
native/debug | spy build (the default is --target native, debug) for the compiled part; the [native] and [py-cffi] test backends. |
native/release | spy build --release. |
native-static/{debug,release} | spy build --static, with or without --release. |
wasi/{debug,release} | spy build --target wasi, with or without --release. |
emscripten/{debug,release} | spy build --target emscripten; the [emscripten] test backend and the playground (debug). |
emscripten/debug-testlib | SPy running on Pyodide under node: the VM loads its libspy.mjs. |
So pytest with the default backends needs only wasi/debug-testlib, which is also the
only variant shipped in the wheel. Everything else is needed by spy build or by the
other backends, and is built from the sources, on first use if it was not already built.
The garbage collector libgc is not part of libspy. It is only linked into native executables, and it is found as a system or conda library. This does not change.
Two kinds of variants¶
Portable variants (the wasm ones) give the same result on every machine. They are built once, when the package is built, and shipped inside it.
Host-dependent variants (native, and anything with
--march) depend on the user’s compiler and CPU. With-flto(link-time optimization), the archive even contains compiler-specific data. They are never shipped: they are built from the shipped sources on first use, and kept in a cache.
The wheel contains:
the libspy sources, the headers and the vendored C code (needed by every
spy build, since the generated C includesspy.h);the prebuilt
wasi/debug-testlibvariant, aslibspy.aandlibspy.wasm(the.ais needed too, to link out-of-tree modules, see below);the SPy standard library (
stdlib/*.spy), moved tospy/stdlibso that it does not install as a top-levelstdlibpackage.
Finding libspy at run time¶
One function, ensure_libspy, replaces the fixed spy/libspy/build/... paths. It
returns the directory containing libspy.a for a variant, building it first if it is not
in the cache.
The key hashes the libspy sources and headers, the variant, the compiler flags and the
compiler version. Hashing the whole of libspy takes about 1 ms. The build directory is
stored in the user cache directory
(~/.cache/spy/libspy/<target>/<build type>-march-<march>-<key> on Linux), apart from
the directories made by make. SPY_CACHE_DIR overrides the location of the cache; the
tests use it.
The module spy.libspy.builder also defines the helpers variant_name,
get_cflags_for_variant, get_ninja and _gen_build_ninja, described below.
CompilerConfig._ensure_libspy_built is the only caller. Without --march it returns
the directory built by make -C spy/libspy; with --march it calls ensure_libspy.
import hashlib
import os
import re
import shutil
import subprocess
import tempfile
from typing import Optional
import platformdirs
import py.path
import spy.libspy
from spy.build.build_info import BuildType, OutputKind
from spy.build.flags import get_build_dirname, get_cc
from spy.errors import WIP
def cache_key(
target: str, build_type: BuildType, kind: OutputKind, march: Optional[str]
) -> str:
"""
Name of a build, computed from everything which influences it: if one of
the inputs changes, the key changes, so that a stale libspy.a can never be
reused by mistake.
The inputs are the libspy sources and headers, the variant (target, build
type, kind, march), the compiler version and the flags.
"""
libspy_dir = spy.libspy.BUILD.dirpath()
h = hashlib.sha256()
for subdir in ("src", "include", "vendored"):
for p in sorted(libspy_dir.join(subdir).visit(rec=True)):
if p.check(file=True) and p.ext in (".c", ".h"):
h.update(p.relto(libspy_dir).encode())
h.update(p.read_binary())
h.update(variant_name(target, build_type, kind, march).encode())
h.update(" ".join(get_cflags_for_variant(target, build_type, kind, march)).encode())
cc = get_cc(target).split()
res = subprocess.run([*cc, "--version"], capture_output=True)
h.update(res.stdout + res.stderr)
return h.hexdigest()[:16]
# how many builds are kept for each variant, see prune_cache()
KEEP_PER_VARIANT = 3
# <build type>-march-<march>-<key>, see ensure_libspy()
_BUILD_DIRNAME = re.compile(r"(?P<variant>.+)-[0-9a-f]{16}")
def cache_root() -> py.path.local:
"""
The user cache directory of SPy: $SPY_CACHE_DIR if it is set, else the
standard one of the platform (~/.cache/spy on Linux, honoring
$XDG_CACHE_HOME).
"""
env = os.environ.get("SPY_CACHE_DIR")
if env:
return py.path.local(env)
return py.path.local(platformdirs.user_cache_dir("spy"))
def cache_dir() -> py.path.local:
"""
Where the variants built by ensure_libspy() are stored, as
<cache_dir>/<target>/<name>-<key>. The libspy built by `make` is not here.
"""
return cache_root().join("libspy")
def _mtime(path: py.path.local) -> float:
try:
return path.mtime()
except OSError: # removed by another process
return 0.0
def prune_cache(keep: int = KEEP_PER_VARIANT) -> int:
"""
For each variant (same target, build type, kind and march, whatever the
key), keep only the `keep` most recently used builds and delete the others.
Return how many were deleted.
Each edit of the libspy sources gives a new key, so without this the cache
would grow for ever. Only the directories named like a build are
considered, so a temporary directory of a build in progress is never
deleted. The date of last use is the mtime of the directory, which
ensure_libspy() updates at each use.
"""
removed = 0
root = cache_dir()
if not root.check(dir=True):
return 0
for target in root.listdir():
variants: dict[str, list[py.path.local]] = {}
for d in target.listdir():
m = _BUILD_DIRNAME.fullmatch(d.basename)
if m and d.check(dir=True):
variants.setdefault(m["variant"], []).append(d)
for builds in variants.values():
builds.sort(key=_mtime, reverse=True)
for old in builds[keep:]:
shutil.rmtree(old, ignore_errors=True)
removed += 1
return removed
def _touch(path: py.path.local) -> None:
"""Record that this build has just been used."""
try:
os.utime(path)
except OSError:
pass # e.g. read-only cache: not a reason to fail
def ensure_libspy(
target: str, build_type: BuildType, kind: OutputKind, march: Optional[str]
) -> py.path.local:
"""
Return the directory containing libspy.a for this variant, building it
first if it is not in the cache.
"""
assert march is not None, "only the --march variants are built here for now"
key = cache_key(target, build_type, kind, march)
name = f"{get_build_dirname(build_type, kind)}-march-{march}-{key}"
final = cache_dir().join(target, name)
if final.join("libspy.a").check(file=True):
_touch(final)
return final
# Build in a temporary directory, then rename it: a rename is atomic, so
# nobody ever sees a half-built library, and several processes (e.g.
# pytest-xdist workers) can start together without corrupting the cache.
final.dirpath().ensure(dir=True)
tmp = py.path.local(tempfile.mkdtemp(dir=str(final.dirpath())))
try:
cflags = get_cflags_for_variant(target, build_type, kind, march)
tmp.join("build.ninja").write(_gen_build_ninja(target, cflags))
subprocess.run([*get_ninja(), "-C", str(tmp)], check=True)
if not tmp.join("libspy.a").check(file=True):
raise WIP(f"ninja did not produce {final}/libspy.a")
try:
tmp.rename(final)
except OSError:
pass # another process finished first
finally:
shutil.rmtree(tmp, ignore_errors=True)
prune_cache()
return finalExplanations:
The build happens in a temporary directory, then the directory is renamed. A rename is atomic: a process never sees a half-built library, and several test workers can start together without corrupting the cache. If the rename fails, another process finished first and its result is used.
Each edit of the libspy sources gives a new key. After each build,
prune_cachekeeps the 3 most recently used builds of each variant (same target, build type, kind and march) and deletes the others. The date of last use is the mtime of the directory, which a cache hit updates. A temporary directory is never deleted, because it is not named like a build.spy cache clearremoves the whole cache directory, and nothing else (the libspy built bymakeis not in it).ninja is started as
[sys.executable, "-m", "ninja"], see the section on zig and ninja.
A later step extends ensure_libspy to every variant: it first looks for a prebuilt
variant shipped in the wheel (spy/libspy/prebuilt/).
spy.libspy currently loads the wasm at import time. It becomes lazy, so that importing
spy never triggers a build by surprise, and so that the build hook can import
spy.libspy.builder without wasmtime.
Building libspy¶
spy.libspy.builder contains the list of C files, the Ryu flags and a small
build.ninja generator with two rules: cc and ar (which creates the .a archive).
All the other flags come from spy.build.flags. It does not use NinjaWriter, because
NinjaWriter needs a CompilerConfig, which itself calls ensure_libspy. Both share
fmt_flags.
This module also handles jsffi.c (emscripten only) and the final wasm link, and builds
the standard variants:
STANDARD_VARIANTS = [
("native", "release", "exe"),
("native", "debug", "exe"),
("native-static", "release", "exe"),
("native-static", "debug", "exe"),
("wasi", "release", "exe"),
("wasi", "debug", "exe"),
("wasi", "debug", "testlib"),
("emscripten", "release", "exe"),
("emscripten", "debug", "exe"),
("emscripten", "debug", "testlib"),
]
def build_standard_variants() -> None:
for target, build_type, kind in STANDARD_VARIANTS:
if target == "emscripten" and shutil.which("emcc") is None:
continue
if target == "native-static" and sys.platform == "darwin":
continue
ensure_libspy(target, build_type, kind, march=None)
if __name__ == "__main__":
build_standard_variants()The Makefile is kept as a wrapper, so that make -C spy/libspy still rebuilds
everything, into the cache:
all:
python -m spy.libspy.builderThen spy/libspy/build/ is no longer used, and python -m spy.build.flags --libdir
returns the directory given by ensure_libspy.
spy.libspy.builder imports spy.build.ninja, which imports spy.build.config, which
imports spy.libspy, which loads wasmtime. Today config.py imports
spy.libspy.builder lazily, inside _ensure_libspy_built. This chain is cut, so that
the build step can use the writer without needing the runtime.
Wheel and sdist¶
The build backend becomes hatchling, with a build hook. For a wheel it produces the prebuilt variant. For an editable install it fills the cache.
pyproject.toml:
[build-system]
requires = ["hatchling", "ziglang==0.13.0", "ninja==1.13.2", "py==1.11.0", "platformdirs"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["spy"]
# hatchling skips files matched by .gitignore, which lists libspy.a and
# libspy.wasm: the built files must be listed explicitly.
artifacts = ["spy/libspy/prebuilt/**"]
[tool.hatch.build.hooks.custom]
path = "hatch_build.py"
[tool.uv]
# uv rebuilds an editable install, hence reruns the hook, when one of these changes
cache-keys = [
{ file = "pyproject.toml" },
{ file = "spy/libspy/**/*.c" },
{ file = "spy/libspy/**/*.h" },
]hatch_build.py, at the root of the repository:
from pathlib import Path
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
from spy.libspy.builder import build_prebuilt, build_standard_variants
if version == "editable":
# nothing is written in the package: the variants go to the cache
build_standard_variants()
else:
build_prebuilt(Path(self.root) / "spy" / "libspy" / "prebuilt")build_prebuilt builds the wasi/debug-testlib variant and links libspy.wasm. The
requires list contains ziglang (the compiler), ninja, platformdirs and py
(imported by spy/__init__.py).
For an editable install, the hook fills the cache with all the standard variants and
writes nothing in the package. A developer who runs uv sync, then pytest or spy,
finds everything already built: nothing is compiled by these commands. Two points:
A prebuilt variant is looked up before the cache and has no key, so in a checkout it would silently become stale when the libspy sources are edited. The cache key cannot. That is why a wheel has
prebuilt/and an editable install does not.The compiler version is part of the key.
ziglang==0.13.0inrequiresmust be the version in the dependencies of the project, otherwise the first run rebuilds.
If the C sources change without uv sync rerunning the hook, ensure_libspy builds the
changed variants the first time they are needed: the hook only fills the cache in
advance.
Zig and ninja¶
ziglang stays a normal runtime dependency (a wheel of about 81 MB, against about 9 MB
for wasmtime and less than 1 MB for everything else). Zig is needed to compile to wasi,
for --static builds, for any variant built from sources into the cache, and to link
out-of-tree wasm modules into the interpreter.
Both tools are currently started as python -m ziglang or as a bare ninja. Under
uv tool install, python is not the interpreter of the tool’s environment, and the
executables of dependencies are not on PATH. Both are started through the interpreter
that runs SPy:
import sys
def get_zig() -> list[str]:
try:
import ziglang # noqa: F401
except ImportError:
raise RuntimeError(
"zig is required for this operation: pip install ziglang"
) from None
return [sys.executable, "-m", "ziglang"]
def get_ninja() -> list[str]:
return [sys.executable, "-m", "ninja"]Every call site goes through these two functions (get_ninja is first defined in
spy.libspy.builder, and moves next to get_zig). Making ziglang an optional extra
later only means editing pyproject.toml: the error message is already in place.
Out-of-tree modules¶
An out-of-tree module is a Python package, outside the SPy source tree, that wraps a C
library. Its build_info() function returns the include directories, the archives and
the linker flags to use. Today its archives are built beforehand by a per-module Makefile
that calls python -m spy.build.flags.
The
spy.build.flagscommand line is public and stays as it is, so existing Makefiles keep working. It has no--marchoption:--marchvariants are built byensure_libspyandbuild_archive, not by Makefiles.ensure_libspyis generalized tobuild_archive(name, sources, include_dirs, cflags, variant). libspy is one user of it, and out-of-tree modules can call it frombuild_info()so that their archives are built on demand. The cache key includes the hash of libspy’s headers, because these modules include them.The interpreter links libspy and the module archives into one wasm file, cached under the same cache root instead of
site-packages/build.
Steps¶
Each step is a separate pull request.
☐
--marchforspy build(PR #745):spy.libspy.builderwithensure_libspyand the cache key, the user cache (pruned,spy cache clear), used for the--marchvariants only. TheMakefileis not modified.☐ Start zig and ninja through
get_zig()andget_ninja().☐ Move
stdlib/tospy/stdlib/.☐ Cut the import chain
ninja.py→config.py→libspy→ wasmtime.☐ Extend
spy.libspy.builderto all the variants (wasm link, emscripten) withbuild_standard_variants, add the lazy loading of the wasm, and reduce the libspyMakefileto its one-line wrapper.☐ Switch to hatchling with the build hook (prebuilt variant for wheels, cache for editable installs) and the
uvcache keys; check in CI that a wheel installed in a clean environment runsspy hello.spy, and that afteruv syncin a clean checkoutpytestcompiles nothing.☐ Add
build_archiveand use it for the out-of-tree examples.☐ Replace the
--staticlibgc build (curland./configurethroughmake) by ninja rules.