WIP: feat: Namespaced includes #3

Closed
austreelis wants to merge 6 commits from austreelis/feat/namespaced-includes into main AGit
2 changed files with 262 additions and 10 deletions

View file

@ -142,6 +142,18 @@ lib: {
invalid = builtins.removeAttrs module lib.modules.VALID_KEYS;
in
invalid == { };
## Check that an include object has the required attributes and otherwise
## only specifies supported attributes.
##
## @type (Attrs | String | Path | Function) -> Bool
include =
include:
let
invalidKeys = builtins.removeAttrs include lib.modules.VALID_INCLUDE_KEYS;
namespaceIsString = include ? namespace -> builtins.isString include.namespace;
in
builtins.isAttrs include -> (include ? module && namespaceIsString && invalidKeys == { });
};
## Modules only support certain keys at the root level. This list determines
@ -159,6 +171,15 @@ lib: {
"meta"
];
## Include objects only support certain keys. This list determines the
## valid attributes that users can supply.
##
## @type List String
VALID_INCLUDE_KEYS = [
"module"
"namespace"
austreelis marked this conversation as resolved
Review

There may be issues with this approach if we end up doing shorthand syntax for all modules. I don't think that we will, but submodules do support it for convenience. I wonder if we can have a different kind of object used for namespaces includes. What about something like:

includes = [
  { namespace = "my-namespace"; module = ./mod.nix }
];

At the very least we can switch on whether include ? namespace is a thing.

There may be issues with this approach if we end up doing shorthand syntax for all modules. I don't _think_ that we will, but submodules do support it for convenience. I wonder if we can have a different kind of object used for namespaces includes. What about something like: ```nix includes = [ { namespace = "my-namespace"; module = ./mod.nix } ]; ``` At the very least we can switch on whether `include ? namespace` is a thing.
];
## Normalize a module to a standard structure. All other information will be
## lost in the conversion.
##
@ -168,12 +189,62 @@ lib: {
let
invalid = builtins.removeAttrs module lib.modules.VALID_KEYS;
invalidKeys = builtins.concatStringsSep ", " (builtins.attrNames invalid);
throwError = msg: builtins.throw ("Module ${key} (${file}) " + msg);
__key__ = builtins.toString module.__key__ or key;
normalizeIncludes =
includes:
let
normalized = lib.lists.mapWithIndex1 (
n: include:
let
invalid = builtins.removeAttrs module lib.modules.VALID_INCLUDE_KEYS;
invalidKeys = builtins.concatStringsSep ", " (builtins.attrNames invalid);
hasInvalidKeys = invalid != { };
throwError' = msg: throwError ("has invalid include at position ${builtins.toString n}" + msg);
in
if lib.modules.validate.include include then
austreelis marked this conversation as resolved
Review

nit: maybe a name like createNamespacedModule would be informative here?

nit: maybe a name like `createNamespacedModule` would be informative here?
if builtins.isAttrs include then
{
inherit (include) module;
namespace = include.namespace or null;
}
else
{
module = include;
namespace = null;
}
else if !include ? module then
{
module = include;
namespace = null;
}
austreelis marked this conversation as resolved
Review

Is this comment needed?

Is this comment needed?
Review

No, I thought I left it out when cleaning up my patch.

No, I thought I left it out when cleaning up my patch.
else if hasInvalidKeys then
throwError' " with unsupported attribute(s): ${invalidKeys}"
else
austreelis marked this conversation as resolved
Review

Can we extract the builtins.map doNamespace flattened bit to a variable so that this value is easier to read? Something like builtins.seq throwOnConflict namespacedModules might make it easier to understand.

Can we extract the `builtins.map doNamespace flattened` bit to a variable so that this value is easier to read? Something like `builtins.seq throwOnConflict namespacedModules` might make it easier to understand.
throwError' ": namespace is not a string"
) includes;
throwOnConflict =
let
filter =
austreelis marked this conversation as resolved
Review

Can we wrap usage of or in parens here? It can be a bit confusing otherwise.

Can we wrap usage of `or` in parens here? It can be a bit confusing otherwise.
namespaces:
{ namespace, ... }:
if namespace != null && builtins.elem namespace namespaces then
throwError "declares several includes under the same namespace '${namespace}'"
else
namespaces ++ [ namespace ];
in
builtins.foldl' filter [ ] normalized;
in
builtins.seq throwOnConflict normalized;
in
if lib.modules.validate.keys module then
{
__file__ = builtins.toString module.__file__ or file;
__key__ = builtins.toString module.__key__ or key;
includes = module.includes or [ ];
inherit __key__;
__file__ = builtins.toString (module.__file__ or file);
includes = normalizeIncludes (module.includes or [ ]);
excludes = module.excludes or [ ];
options = module.options or { };
config =
@ -201,7 +272,7 @@ lib: {
withFreeform (withMeta base);
}
else
builtins.throw "Module `${key}` (${file}) has unsupported attribute(s): ${invalidKeys}";
throwError "has unsupported attribute(s): ${invalidKeys}";
## Convert a module that is either a function or an attribute set into
## a resolved attribute set. If the module was a function then it will
@ -497,19 +568,36 @@ lib: {
collect =
let
load =
args: file: key: module:
args: file: key: module':
let
namespace = module'.namespace or null;
module = module'.module or module';
moduleFromValue = lib.modules.normalize file key (lib.modules.resolve key module args);
moduleFromPath = lib.modules.normalize (builtins.toString module) (builtins.toString module) (
lib.modules.resolve (builtins.toString module) (import module) args
);
in
if builtins.isAttrs module || builtins.isFunction module then
moduleFromValue
else if builtins.isString module || builtins.isPath module then
moduleFromPath
if namespace == null then
if builtins.isAttrs module || builtins.isFunction module then
moduleFromValue
else if builtins.isString module || builtins.isPath module then
moduleFromPath
else
builtins.throw "The provided module must be either an attribute set, function, or path but got ${builtins.typeOf module}"
# We don't resolve the module yet if we're under a namespace
else
builtins.throw "The provided module must be either an attribute set, function, or path but got ${builtins.typeOf module}";
lib.modules.normalize file key {
__key__ = "${key}:include-${namespace}";
options.${namespace} = lib.options.create {
description = "options and configuration included from ${namespace}";
default.value = { };
type = lib.types.submodules.of {
modules = [ module ];
description = "include ${namespace}";
};
};
};
normalize =
parentFile: parentKey: modules: args:

View file

@ -200,6 +200,62 @@ in
in
!value;
};
"cannot mix include and module keys" =
let
# Simple backtracking algorithm to generate all combinations of a list's
# elements without ordering, in .
combinations =
set:
let
n = builtins.length set;
genIndices =
combinations':
let
prev = builtins.head combinations';
k = builtins.length prev;
first = builtins.head prev;
next' =
# Generate the next combination of length k
if k > 0 && first + 1 < n then
[ (first + 1) ] ++ (builtins.tail prev)
# We have generated all combinations of length k, generate the first
# (trivial) combination of length k+1
else
builtins.genList (i: i) (k + 1);
next = builtins.deepSeq next' next';
in
# Generate the very first (trivial) combination
if combinations' == [ ] then
genIndices [ [ ] ]
# We have generated all combinations of length 0 to n, return
else if k >= n then
combinations'
# Generate the next combination and continue
else
genIndices ([ next ] ++ combinations');
select = indices: builtins.map (builtins.elemAt set) indices;
in
if set == [ ] then [ ] else builtins.map select (genIndices [ ]);
keys = combinations (lib.modules.VALID_KEYS ++ lib.modules.VALID_INCLUDE_KEYS);
sets = builtins.map (
names:
builtins.listToAttrs (
builtins.map (name: {
value = null;
inherit name;
}) names
)
) keys;
validated = builtins.map (set: {
inherit set;
include = lib.modules.validate.include set;
module = lib.modules.validate.keys set;
}) sets;
bothValid = { include, module, ... }: include && module;
in
!builtins.any bothValid validated;
};
"normalize" = {
@ -236,6 +292,91 @@ in
};
in
actual == expected;
"includes" = {
"handles an empty list" =
let
expected = [ ];
actual = (lib.modules.normalize "/aux/example.nix" "example" { includes = [ ]; }).includes;
in
expected == actual;
"handles a mixed list" =
let
expected = [
{
module = { };
namespace = null;
}
{
module = { };
namespace = "a";
}
];
actual =
(lib.modules.normalize "/aux/example.nix" "example" {
includes = [
{ }
{
module = { };
namespace = "a";
}
];
}).includes;
in
expected == actual;
"rejects conflicting namespaces" =
let
normalized = lib.modules.normalize "/aux/example.nix" "example" {
includes = [
{
module = { };
namespace = "a";
}
{
module = { };
namespace = "a";
}
];
};
in
!(builtins.tryEval normalized.includes).success;
"allows multiple without namespace" =
let
normalized = lib.modules.normalize "/aux/example.nix" "example" {
includes = [
{ }
{ }
];
};
in
(builtins.tryEval normalized.includes).success;
"handles multiple without namespace" =
let
expected = [
{
namespace = null;
module = { };
}
{
namespace = null;
module = { };
}
];
actual =
(lib.modules.normalize "/aux/example.nix" "example" {
includes = [
{ }
{ }
];
}).includes;
in
expected == actual;
};
};
"resolve" = {
@ -699,5 +840,28 @@ in
};
in
(evaluated.config.aux.message == evaluated.config.aux.message2) && evaluated.config.aux.exists;
"namespaced includes" =
let
expected = {
msg = "hello";
myinclude.msg = "hello";
};
evaluated = lib.modules.run {
modules = [
{
includes = [
{
namespace = "myinclude";
module.options.msg = lib.options.create { default.value = "hello"; };
}
{ options.msg = lib.options.create { default.value = "hello"; }; }
];
}
];
};
actual = evaluated.config;
in
expected == actual;
};
}