Language ecosystems (part B)
Language ecosystems (part B)
Goal: Package a second language ecosystem tool so the patterns transfer: FODs for language deps, helpers vs raw stdenv, and how overrides differ across builders.
Why it matters
At work you will not stay monoglot. The point is not fluency in five builders—it is recognizing the same skeleton:
src FOD → language-dep FOD → build/install hooks → $out/bin → meta
Part A was depth. This chapter is transfer with a deliberately smaller scope: one binary, clear version, known license.
Theory 1 — Pattern map across ecosystems
| Concern | Go | Rust | Python | Node |
|---|---|---|---|---|
| Source pin | src.hash |
src.hash |
src.hash / fetchPypi |
src.hash |
| Dep pin | vendorHash |
cargoHash / lock |
package set / frozen | npmDepsHash |
| Builder | buildGoModule |
buildRustPackage |
buildPython* |
buildNpmPackage |
| Skip tests | doCheck |
doCheck / cargo flags |
pytest hooks | often build-only |
| Binary name | module main | crate bin | meta.mainProgram |
meta.mainProgram |
| Cross | GOOS/GOARCH care |
target triples | often pure | often pure JS |
| Override flavor | overrideAttrs |
overrideAttrs |
overridePythonAttrs |
overrideAttrs |
When stuck, ask: which hash is wrong—source or language deps?
Theory 2 — Choose B deliberately
| If part A was… | Good part B pick | Why |
|---|---|---|
| Go | Python CLI or Rust | Different dep story |
| Rust | Go small tool | Faster vendor cycle |
| Python | Node or Go | Leaves interpreter wrapping |
| Node | Python or Go | Sees compiled vs interpreted |
Avoid starting a second multi-crate workspace. One binary, clear version, known license.
Transfer questions (answer in notes)
- What is the second FOD field named?
- Where does the binary land under
$out?
- What is the fastest hash-mismatch workflow for this builder?
- Would I pick this ecosystem for internal CLIs at work? Why?
Theory 3 — Wrapping and runtime deps
Interpreted ecosystems often need wrappers:
{ stdenv, lib, python3, makeWrapper }:
stdenv.mkDerivation {
pname = "tool";
version = "0.1.0";
dontUnpack = true;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cat > $out/bin/tool <<'EOF'
#!/bin/sh
echo tool
EOF
chmod +x $out/bin/tool
wrapProgram $out/bin/tool \
--prefix PATH : ${lib.makeBinPath [ python3 ]}
runHook postInstall
'';
meta = {
mainProgram = "tool";
license = lib.licenses.mit;
};
}| Situation | Prefer |
|---|---|
| Official language builder exists | Use it |
| Need one env var or PATH sibling | wrapProgram / makeWrapper |
| Need whole tree of modules | Builder’s dependency graph |
| Need different interpreter | Override package set / python version |
Python applications usually wrap PYTHONPATH via the builder—do not reimplement unless you must.
Theory 4 — Overrides preview
Deep dive lives in the patching chapter; know these exist:
# Change flags without forking the whole expression
helloGo.overrideAttrs (old: {
ldflags = (old.ldflags or []) ++ [ "-X main.extra=eco-b" ];
})# Python package override inside python3.pkgs
python3.pkgs.requests.overridePythonAttrs (old: {
doCheck = false;
})# Node / generic
helloNode.overrideAttrs (old: {
postPatch = (old.postPatch or "") + ''
substituteInPlace package.json --replace-fail '"private": true' '"private": false'
'';
})Composition rules matter later—for now, confirm your ecosystem B package can be lightly tweaked without a full fork.
Theory 5 — Cross-ecosystem FOD discipline
Whatever language B you chose, fetchers obey the same rules as the fetchers chapter:
| Rule | Why |
|---|---|
| Pin URL + hash | Reproducible |
| Vendor offline where possible | CI without network flakiness |
| Prefer language-native lock → Nix conversion carefully | Don’t invent hashes by hand when tooling exists |
Never commit fakeHash |
CI will fail or worse, someone “fixes” blindly |
# Always capture both hashes when documenting
echo "src.hash=…"
echo "langDep.hash=…" # vendorHash / cargoHash / npmDepsHashTheory 6 — Runtime vs build-time closure
nativeBuildInputs → tools for build (compilers, make)
buildInputs → linked/used at build; may propagate
propagated* → infect dependents (use sparingly)
A package that “works in nix develop” but fails as nix build often mixed shell conveniences with missing buildInputs.
| Ecosystem | Common closure weight |
|---|---|
Pure Go (CGO_ENABLED=0) |
Small |
| Rust release bin | Small–medium |
| Python app | Interpreter + site-packages |
| Node CLI | Node runtime + modules (or bundled) |
nix path-info -Sh ./result
nix path-info -rS ./result | sort -k2 -n | tail -n 20Theory 7 — Dual interface pattern
Make both A and B print something comparable so checks share structure:
eco-a: hello from ecosystem A
eco-b: hello from ecosystem B
Or both support --version with Nix-injected versions. Unified checks then look like:
both-run = pkgs.runCommand "both-run" {
nativeBuildInputs = [ a b ];
} ''
${a}/bin/${a.meta.mainProgram} --version
${b}/bin/${b.meta.mainProgram} --version
touch $out
'';Worked example — dual packages in one flake
{
description = "language ecosystems A+B lab";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
packages = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; }; in {
lang-a = pkgs.callPackage ./pkgs/lang-a.nix { };
lang-b = pkgs.callPackage ./pkgs/lang-b.nix { };
default = self.packages.${system}.lang-b;
});
checks = forAllSystems (system:
let
pkgs = import nixpkgs { inherit system; };
a = self.packages.${system}.lang-a;
b = self.packages.${system}.lang-b;
in {
both-run = pkgs.runCommand "both-run" {
nativeBuildInputs = [ a b ];
} ''
"${a}/bin/${a.meta.mainProgram}" --help >/dev/null 2>&1 \
|| "${a}/bin/${a.meta.mainProgram}" >/dev/null
"${b}/bin/${b.meta.mainProgram}" --help >/dev/null 2>&1 \
|| "${b}/bin/${b.meta.mainProgram}" >/dev/null
touch $out
'';
});
};
}Tiny Python app (if A was compiled)
{ lib, python3Packages }:
python3Packages.buildPythonApplication {
pname = "eco-b-greet";
version = "0.1.0";
pyproject = true;
src = ../src/eco-b-greet;
build-system = with python3Packages; [ setuptools ];
meta = {
description = "Ecosystem B greet";
license = lib.licenses.mit;
mainProgram = "eco-b-greet";
};
}# src/eco-b-greet/pyproject.toml
[project]
name = "eco-b-greet"
version = "0.1.0"
requires-python = ">=3.11"
[project.scripts]
eco-b-greet = "eco_b_greet:main"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"# src/eco-b-greet/eco_b_greet.py
def main():
print("hello from ecosystem B")
if __name__ == "__main__":
main()Tiny Node CLI (alternative)
{ lib, buildNpmPackage }:
buildNpmPackage {
pname = "eco-b-node";
version = "0.1.0";
src = ../src/eco-b-node;
npmDepsHash = "sha256-…";
meta = {
mainProgram = "eco-b-node";
license = lib.licenses.mit;
};
}Tiny Go (if A was Python)
{ lib, buildGoModule }:
buildGoModule {
pname = "eco-b-go";
version = "0.1.0";
src = ../src/eco-b-go;
vendorHash = null;
meta = {
mainProgram = "eco-b-go";
license = lib.licenses.mit;
};
}Lab — multi-step
Suggested workspace: ~/lab/nixos/packaging/eco-b
(or extend the ecosystem A flake)
Step 1 — Import or copy the part A package
Keep lang-a buildable so you compare logs side by side.
Step 2 — Scaffold smaller B project
Budget: under ~50 lines of application code. No microservices.
Step 3 — Package with the B builder
nix build .#lang-b -L
nix run .#lang-bRecord every hash you had to set (src + language deps).
Step 4 — Comparison table (required)
In NOTES.md:
| Question | Lang A | Lang B |
|---|---|---|
| Builder name | ||
| Source hash field | ||
| Dep hash field | ||
| Time to first success | ||
| Hardest error | ||
| Where binary lands | ||
| Closure (nar size) | ||
| Would I use this at work for CLIs? |
Step 5 — Unified check
Add a flake checks.* that smoke-tests both tools. nix flake check.
Step 6 — Optional same interface
Make both print a comparable line so a shell script can assert either.
Step 7 — Closure glance
nix path-info -rS ./result | sort -k2 -n | tail
nix path-info -Sh ./resultNote which ecosystem pulled a heavier closure and why (interpreter? compiler runtime? libc?).
Step 8 — Hash bump drill
- Intentionally change a src rev or use
lib.fakeHash
- Run build; capture the
got:hash
- Paste correct hash; rebuild green
- Write the exact error class name in notes
Step 9 — Light override
Apply overrideAttrs or overridePythonAttrs to flip doCheck or add an ldflags entry. Rebuild once.
Exercises
- Draw the FOD graph for A and B (boxes and arrows).
- Time cold build vs warm cache for each (rough wall clock).
- Document one override API difference between the two builders.
- Try
nix runwithoutmainProgramset—observe failure mode—then fix.
- Write a recommendation: “default internal CLI language for our team” with two bullets of rationale from your measurements.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Trying to “finish” a second huge app | Shrink scope; transfer patterns, not product |
| Mixing package sets (python311 vs python312) | Align interpreters |
Assuming vendorHash = null always works |
Only pure module-free trees |
npmDepsHash churn from lock format |
Commit lock; pin npm |
| Comparing wall times unfairly | Same machine, warm vs cold cache noted |
Forgetting meta.license |
Set it; habit for nixpkgs-shaped work |
| Unified check hardcodes wrong bin names | Use meta.mainProgram |
| Porting Go ldflags habits to Python | Use each ecosystem’s idioms |
| Skipping comparison table | Required—this chapter is about transfer |
Checkpoint
- Second ecosystem package builds and runs
- Comparison table filled honestly
- Both A and B reachable from one flake
- At least one combined check
- Closure notes captured
- Hash bump drill completed
- Light override demonstrated
Commit
git add .
git commit -m "packaging: language ecosystem B + comparison"Write three personal gotchas before patching and overrides.