Flakes, but DRYer.
This is a pure Nix library handling the worst parts of having to deal with Flakes:
The lack of builtins.currentSystem
or similar semantics.
It does so while respecting the intent behind the design.
It does so in different ways.
First, by collapsing and then expanding the systems-indexed attribute sets from inputs and outputs with a convenience function.
This enables a flake.nix
expression to implicitly use and export all systems that by default and by convention should.
Then, this also exposes some useful helpers to further help.
For example, using builtins.currentSystem
can be done from a “pure” Flake evaluation,
either from the injected builtins
,
or in imported expressions when using the injected import
function.
{
description = "A very simple dried-up hello example Flake";
inputs.nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz";
inputs.dried-nix-flakes.url = "github:cyberus-technology/dried-nix-flakes";
outputs =
inputs: # ➊
(inputs.dried-nix-flakes.for /* ➋ */ inputs).exportOutputs (# ➌
{ nixpkgs, ... }:
{
packages = {
inherit (nixpkgs.legacyPackages) # ➍
hello
;
};
}
)
;
}
This is making a dog's dinner out of parentheses, and might look harder to reason about, but it's simpler than it looks like.
The numbers in comments show:
- The actual Flake output's inputs are taken as a whole.
- They are fed into the
for
function of this Flake. - The
exportOutputs
function is called, more on it later. - The intended inputs can be used without tautologically referring to the system types.
The following example shows side-by-side the "translated equivalent" for a single system.
# A classic Flake. | # A dried-up Flake.
{ | {
/* inputs */ | /* inputs */
|
outputs = | outputs =
| { dried-nix-flakes, nixpkgs, ... }@inputs:
| /* ------- */
|
| (inputs.dried-nix-flakes.for inputs).exportOutputs (
| /* --------------------------------------------------- */
{ nixpkgs, ... }: | { nixpkgs, ... }:
|
{ | {
packages = { | packages = {
x86_64-linux = { |
/* ------------ */ |
inherit (nixpkgs.legacyPackages.x86_64-linux) | inherit (nixpkgs.legacyPackages)
hello /* ------------ */ | hello
; | ;
}; |
}; | };
} | }
| )
; | ;
} | }
Highlighted by dashes are the trade-off this utility Flake makes: exchanging repetition of a “scoped fact” (a system) for an intermediary function call.
Without using other additional helper libraries, the example on the left would need to explicitly list any platform that could work, or end-up listing only a subset of what actually does work.
The exportOutputs
function will skip expanding the systems on some outputs.
Check the source if in doubts, but the list should map to the usual well-known outputs.
For example:
lib
nixosModule
nixosModules
overlay
overlays
One major distinction from "classic" Flakes is that when producing these outputs with exportOutputs
,
access to any “system-indexed” output is forbidden.
If you (really) need to expose a lib
function that accesses a system,
use the merge
pattern to merge a simple attribute set.
Additional outputs can be configured to not be expanded.
{
/* inputs */
outputs =
inputs:
let
dried-nix-flakes =
(inputs.dried-nix-flakes.for inputs).override {
extraIndexedOutputs = [
"viruses"
];
}
;
in
{
packages = dried-nix-flakes.exportOutput (
{ shady-stuff, ... }:
{
inherit (shady-stuff.viruses)
hello
;
}
);
}
;
}
The override
function can take in either indexedOutputs
or extraIndexedOutputs
to configure additional outputs that should be collapsed down.
{
/* inputs */
outputs =
inputs:
let
dried-nix-flakes =
(inputs.dried-nix-flakes.for inputs).override {
extraIndexedOutputs = [
"viruses"
];
}
;
in
{
packages = dried-nix-flakes.exportOutput (
{ shady-stuff, ... }:
{
inherit (shady-stuff.viruses)
hello
;
}
);
}
;
}
The override
function can take in either systems
or extraSystems
to configure additional systems that should be handled.
The following example adds armv5tel-linux
to the systems collapsed and expanded by this Flakes support library.
{
/* inputs */
outputs =
inputs:
let
dried-nix-flakes =
(inputs.dried-nix-flakes.for inputs).override {
extraSystems = [
"armv5tel-linux"
];
}
;
in
dried-nix-flakes.exportOutput (
/* ... */
)
;
}
We can use conventions to do some of the inconvenient stuff.
Namely, due to how Flakes inputs work,
it is very likely that nixpkgs
refers to the main package set your Flake and your outputs use.
We then use that particular nixpkgs
input in the for
utility function to seed the (default) list of systems with lib.systems.flakeExposed
.
This means that the upstream package set is the one dictating which systems are plausible.
Not us (except for the fallback if nixpkgs
is not an input).
Then, armed with that knowledge, we expose a set of utility functions to handle the unergonomic conventions.
The exportOutputs
and exportOutput
functions will automatically “expand” the list of systems from the inputs
's outputs
, and pass them to your function.
The function mirrors the usual Flake structure for outputs, but without thinking about systems.
If you are interacting with non-Flake code, there's a bit more that can help you.
This Flake inserts the import
function into the inputs,
which can be used to import Nix expressions that refers “impurely”[sic] to builtins.currentSystem
without any changes.
Note
While opinions differ on whether builtins.currentSystem
should be considered an impurity, this is not introducing an impurity.
The builtins.currentSystem
function exposed within this import
function returns the system from the pure Nix evaluation from within the Flake.
It is a compatibility shim, and as pure as writing the string it would refer to.
Evaluation or build-time failures with an “unexpected” system name can be pure!
While it may seem like explicitly declaring the systems is better, it's really a liability. Explicitly declaring systems makes Flakes less composable.
Any system a Flake can support is limited by the lowest common subset of systems any Flake in the dependency chain exposes.
So if you are referring to a contoso/widget
Flake,
which only exposes x86_64-linux
in its outputs,
your outputs won't have any way to even try other systems.
Even though it may just as well work on other systems!
When considering composability of Flakes, it should be desirable to keep the plausible set of systems as wide as possible. I assume so, since other utility Flakes tried handling this problem with varying degrees of success.
This is why this is defaults to using inputs.nixpkgs.lib.systems.flakeExposed
when possible.
We're letting the likeliest most-upstream package set dictate which systems to expose.
This way, even if you did not test on all of them,
your downstream users can rely on your Flake outputs if it ends-up working.
All of this, without having to specify a long list of systems.
Ergonomics.
All other options I am aware of end-up very leaky about the abstractions. Either the platforms still need to be declared, the platforms are defined by some other authority, or end-up using very weird hacks.
The main goal when designing the library was that there should be no system or system variable in play for most use-cases.
The system
for all intents and purposes is an implementation detail that for most developers should just be inherited from ambient facts.
I want to write a packages.my-package
, not think about the implementation detail that allows supporting different systems in a Nix evaluation by the upstream package set.
The secondary goal is to reduce boilerplate, and write the Flake as “Flake-ish” as intended, especially for the simpler use-cases.
This means the semantics of outputs
has to be kept as close to the intended ones: a set-pattern function expression exposing the inputs to produce outputs.
No unexpected magic, no additional new semantics, no other shortcuts.
All of this was also designed with the goal of making it easier to produce a Flake stub for generic composable Nix expressions, including expressions relying on Nixpkgs.
With this, it's possible to keep writing standard Nix expressions taking a pkgs
as an input, and expose it in a form usable within Flakes.
This makes it less convenient to mix different systems in a single output. Though doing this may not be desirable: the systems that Flakes expose are build-time systems. In other words, the operating system and instruction set the builder uses. Mixing different systems in a single output from other Flake outputs would require multiple builders to build a given output.
This does not affect cross-compilation, for example like how it works in Nixpkgs.
Just as much as it is okay for Nix to encode a lot of Nixpkgs-isms into Flakes.
Since Nix assumes a lot about Nixpkgs or its present in Flakes, it is fine to assume that when Nixpkgs is available, we should follow its semantics.
The common usage of Flakes implies Nixpkgs is present, so this is the most ergonomic default. Usage without Nixpkgs is assumed to be an advanced user choice, and may imply additionally semantics around Flakes. The fallback default platforms should be sufficient in these situations.
Note
This section is a stub.
Merges attribute sets recursively, only merging attribute sets together.
This fails (errors) on derivations or any other type.
This function is exposed on the Flake as a convenience, as it allows mixing different Flakes conventions.
Tip
This function also has been exported on the return value of the for
function.
The following example produces a Flake with a lib
output, with two functions, lib.a
and lib.b
.
{
/* inputs */
outputs =
inputs:
let
inherit (inputs.dried-nix-flakes.for inputs)
exportOutputs
;
in
inputs.dried-nix-flakes.merge [
{
lib = {
a = a: a + "a";
};
}
(exportOutputs {
{ ... }:
{
lib = {
b = b: b + "b";
};
}
})
]
;
}
For now, know that lib
functions while accessible, have no SLA.
None are expected to have a stable API. None are expected to stay exported.
The for
output is a function intended to receive the inputs
from a Flake,
and returning a set of utility functions to make writing Flakes more ergonomic.
The public API functions returned currently are:
exportOutput
, which collapses systems for inputs, then expands systems with the given set-pattern function result.exportOutputs
, which collapses systems for inputs, then expands systems on all outputs, except foroutputsWithoutSystems
.override
, which allows configuring some options.merge
, which merges attribute sets recursively. (see:outputs.merge
)
As an extra convenience feature, it is possible the call the Flake itself as a function.
Doing so is equivalent to calling inputs: (dried-nix-flakes.for inputs).exportOutputs
.
{
/* inputs */
outputs =
inputs:
inputs.dried-nix-flakes inputs (
{ nixpkgs, ... }:
{
packages = {
inherit (nixpkgs.legacyPackages)
hello
;
};
}
)
;
}
The exportOutput
(s
) functions inject a couple of additional arguments in the arguments of the function they take as an argument.
currentSystem
, the string for the system being evaluated.import
, augmentedimport
replacement. Includesbuiltins
andimport
replacements in the scope.builtins
, withcurrentSystem
andimport
overridden with the previous two values.
The currentSystem
argument is the canonical value to use when referring to the system is required.
It shouldn't be needed most of the time,
as inputs collapsing and outputs expansion should already abstract this properly.
The import
function can be used to directly evaluate standard Nix expressions that would make use of builtins.currentSystem
.
Performance concerns
There may or may not be a noticeable hit in performance when using the injected
import
fromdried-nix-flakes
.The injected
import
serves firstly as a convenience feature, to make working with existing and established standard Nix expressions possible.Under the hood, it uses
scopedImport
, which uses different semantics regarding evaluation state with imports.
When using the injected import
replacement,
you most likely want to pass expensive evaluations (like a Nixpkgs evaluation) coming from the Flake's evaluation,
so they get evaluated with the built-in import
rather than the overridden import
.
Since the collapsed inputs will already have selected the proper system, there shouldn't be any issues regarding it trying to use builtins.currentSystem
.
Note
In this example, it is assumed that the standard Nix expression would return a (flat) attribute set of packages.
{
inputs.nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz";
inputs.dried-nix-flakes.url = "github:cyberus-technology/dried-nix-flakes";
outputs =
inputs:
(inputs.dried-nix-flakes.for inputs).exportOutputs (
{ nixpkgs, import /* ➊ */, ... }:
{
# Exposes the standard Nix evaluation for this project on `packages`.
packages =
import ./default.nix {
pkgs = nixpkgs.legacyPackages; /* ➋ */
}
;
}
)
;
}
- Brings the injected
import
into scope. - Passes the "standard" Nixpkgs package set to the evaluated expression.
The main thing to consider is how the Flake outputs from your inputs maps to the standard Nix evaluation conventions.
Here we can see that the conventional pkgs
(an evaluated Nixpkgs package set) is coming from legacyPackages
for Nixpkgs.