Merge pull request 'Refactor select with new maybe selector' (#3362) from better-select into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3362
This commit is contained in:
Michael Hoang
2025-04-25 07:02:22 +00:00
12 changed files with 699 additions and 406 deletions

16
flake.lock generated
View File

@@ -89,6 +89,21 @@
"type": "github" "type": "github"
} }
}, },
"nix-select": {
"locked": {
"lastModified": 1745005516,
"narHash": "sha256-IVaoOGDIvAa/8I0sdiiZuKptDldrkDWUNf/+ezIRhyc=",
"ref": "refs/heads/main",
"rev": "69d8bf596194c5c35a4e90dd02c52aa530caddf8",
"revCount": 40,
"type": "git",
"url": "https://git.clan.lol/clan/nix-select"
},
"original": {
"type": "git",
"url": "https://git.clan.lol/clan/nix-select"
}
},
"nixos-facter-modules": { "nixos-facter-modules": {
"locked": { "locked": {
"lastModified": 1743671943, "lastModified": 1743671943,
@@ -123,6 +138,7 @@
"disko": "disko", "disko": "disko",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nix-darwin": "nix-darwin", "nix-darwin": "nix-darwin",
"nix-select": "nix-select",
"nixos-facter-modules": "nixos-facter-modules", "nixos-facter-modules": "nixos-facter-modules",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"sops-nix": "sops-nix", "sops-nix": "sops-nix",

View File

@@ -23,6 +23,8 @@
treefmt-nix.url = "github:numtide/treefmt-nix"; treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
nix-select.url = "git+https://git.clan.lol/clan/nix-select";
data-mesher = { data-mesher = {
url = "git+https://git.clan.lol/clan/data-mesher"; url = "git+https://git.clan.lol/clan/data-mesher";
inputs = { inputs = {

View File

@@ -30,6 +30,6 @@ lib.fix (clanLib: {
# Plain imports. # Plain imports.
values = import ./introspection { inherit lib; }; values = import ./introspection { inherit lib; };
jsonschema = import ./jsonschema { inherit lib; }; jsonschema = import ./jsonschema { inherit lib; };
select = import select/default.nix; select = self.inputs.nix-select.lib;
facts = import ./facts.nix { inherit lib; }; facts = import ./facts.nix { inherit lib; };
}) })

View File

@@ -11,7 +11,6 @@ rec {
./introspection/flake-module.nix ./introspection/flake-module.nix
./inventory/flake-module.nix ./inventory/flake-module.nix
./jsonschema/flake-module.nix ./jsonschema/flake-module.nix
./select/flake-module.nix
]; ];
flake.clanLib = import ./default.nix { flake.clanLib = import ./default.nix {
inherit lib inputs self; inherit lib inputs self;

View File

@@ -1,68 +0,0 @@
let
recursiveSelect =
selectorIndex: selectorList: target:
let
selector = builtins.elemAt selectorList selectorIndex;
in
# selector is empty, we are done
if selectorIndex + 1 > builtins.length selectorList then
target
else if builtins.isList target then
# support bla.* for lists and recurse into all elements
if selector == "*" then
builtins.map (v: recursiveSelect (selectorIndex + 1) selectorList v) target
# support bla.3 for lists and recurse into the 4th element
else if (builtins.match "[[:digit:]]*" selector) == [ ] then
recursiveSelect (selectorIndex + 1) selectorList (
builtins.elemAt target (builtins.fromJSON selector)
)
else
throw "only * or a number is allowed in list selector"
else if builtins.isAttrs target then
# handle the case bla.x.*.z where x is an attrset and we recurse into all elements
if selector == "*" then
builtins.mapAttrs (_: v: recursiveSelect (selectorIndex + 1) selectorList v) target
# support bla.{x,y,z}.world where we get world from each of x, y and z
else if (builtins.match ''^\{([^}]*)}$'' selector) != null then
let
attrsAsList = (
builtins.filter (x: !builtins.isList x) (
builtins.split "," (builtins.head (builtins.match ''^\{([^}]*)}$'' selector))
)
);
dummyAttrSet = builtins.listToAttrs (
map (x: {
name = x;
value = null;
}) attrsAsList
);
filteredAttrs = builtins.intersectAttrs dummyAttrSet target;
in
builtins.mapAttrs (_: v: recursiveSelect (selectorIndex + 1) selectorList v) filteredAttrs
else
recursiveSelect (selectorIndex + 1) selectorList (builtins.getAttr selector target)
else
throw "Expected a list or an attrset";
parseSelector =
selector:
let
splitByQuote = x: builtins.filter (x: !builtins.isList x) (builtins.split ''"'' x);
splitByDot =
x:
builtins.filter (x: x != "") (
map (builtins.replaceStrings [ "." ] [ "" ]) (
builtins.filter (x: !builtins.isList x) (builtins.split ''\.'' x)
)
);
handleQuoted =
x: if x == [ ] then [ ] else [ (builtins.head x) ] ++ handleUnquoted (builtins.tail x);
handleUnquoted =
x: if x == [ ] then [ ] else splitByDot (builtins.head x) ++ handleQuoted (builtins.tail x);
in
handleUnquoted (splitByQuote selector);
in
selector: target: recursiveSelect 0 (parseSelector selector) target

View File

@@ -1,45 +0,0 @@
{ self, inputs, ... }:
let
inputOverrides = builtins.concatStringsSep " " (
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in
{
perSystem =
{
pkgs,
lib,
system,
...
}:
{
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.evalTests
legacyPackages.evalTests-select = import ./tests.nix {
inherit lib;
inherit (self) clanLib;
};
checks = {
lib-select-eval = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
export NIX_ABORT_ON_WARN=1
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
--show-trace \
${inputOverrides} \
--flake ${
self.filter {
include = [
"flakeModules"
"lib"
"clanModules/flake-module.nix"
"clanModules/borgbackup"
];
}
}#legacyPackages.${system}.evalTests-select
touch $out
'';
};
};
}

View File

@@ -1,10 +0,0 @@
{ clanLib, ... }:
let
inherit (clanLib) select;
in
{
test_simple_1 = {
expr = select "a" { a = 1; };
expected = 1;
};
}

View File

@@ -18,10 +18,7 @@ def restore_service(machine: Machine, name: str, provider: str, service: str) ->
msg = f"Service {service} not found in configuration. Available services are: {', '.join(backup_folders.keys())}" msg = f"Service {service} not found in configuration. Available services are: {', '.join(backup_folders.keys())}"
raise ClanError(msg) raise ClanError(msg)
folders = backup_folders[service]["folders"].values() folders = backup_folders[service]["folders"]
assert all(isinstance(f, str) for f in folders), (
f"folders must be a list of strings instead of {folders}"
)
env = {} env = {}
env["NAME"] = name env["NAME"] = name
# FIXME: If we have too many folder this might overflow the stack. # FIXME: If we have too many folder this might overflow the stack.

View File

@@ -1,11 +1,11 @@
import json import json
import logging import logging
import re from dataclasses import asdict, dataclass, field
from dataclasses import dataclass from enum import Enum
from hashlib import sha1 from hashlib import sha1
from pathlib import Path from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import Any, cast from typing import Any
from clan_cli.cmd import Log, RunOpts, run from clan_cli.cmd import Log, RunOpts, run
from clan_cli.dirs import user_cache_dir from clan_cli.dirs import user_cache_dir
@@ -21,42 +21,212 @@ from clan_cli.nix import (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class AllSelector: class SetSelectorType(str, Enum):
pass """
enum for the type of selector in a set.
For now this is either a string or a maybe selector.
"""
STR = "str"
MAYBE = "maybe"
Selector = str | int | AllSelector | set[int] | set[str] @dataclass
class SetSelector:
"""
This class represents a selector used in a set.
type: SetSelectorType = SetSelectorType.STR
value: str = ""
a set looks like this:
{key1,key2}
"""
type: SetSelectorType = SetSelectorType.STR
value: str = ""
def split_selector(selector: str) -> list[Selector]: class SelectorType(str, Enum):
"""
enum for the type of a selector
this can be all, string, set or maybe
"""
ALL = "all"
STR = "str"
SET = "set"
MAYBE = "maybe"
@dataclass
class Selector:
"""
A class to represent a selector, which selects nix elements one level down.
consists of a SelectorType and a value.
if the type is all, no value is needed, since it selects all elements.
if the type is str, the value is a string, which is the key in a dict.
if the type is maybe the value is a string, which is the key in a dict.
if the type is set, the value is a list of SetSelector objects.
"""
type: SelectorType = SelectorType.STR
value: str | list[SetSelector] | None = None
def as_dict(self) -> dict[str, Any]:
if self.type == SelectorType.SET:
assert isinstance(self.value, list)
return {
"type": self.type.value,
"value": [asdict(selector) for selector in self.value],
}
if self.type == SelectorType.ALL:
return {"type": self.type.value}
if self.type == SelectorType.STR:
assert isinstance(self.value, str)
return {"type": self.type.value, "value": self.value}
if self.type == SelectorType.MAYBE:
assert isinstance(self.value, str)
return {"type": self.type.value, "value": self.value}
msg = f"Invalid selector type: {self.type}"
raise ValueError(msg)
def selectors_as_dict(selectors: list[Selector]) -> list[dict[str, Any]]:
return [selector.as_dict() for selector in selectors]
def selectors_as_json(selectors: list[Selector]) -> str:
return json.dumps(selectors_as_dict(selectors))
def parse_selector(selector: str) -> list[Selector]:
""" """
takes a string and returns a list of selectors. takes a string and returns a list of selectors.
a selector can be: a selector can be:
- a string, which is a key in a dict - a string, which is a key in a dict
- an integer, which is an index in a list - an integer, which is an index in a list
- a set of strings, which are keys in a dict - a set of strings or integers, which are keys in a dict or indices in a list.
- a set of integers, which are indices in a list
- a quoted string, which is a key in a dict
- the string "*", which selects all elements in a list or dict - the string "*", which selects all elements in a list or dict
""" """
pattern = r'"[^"]*"|[^.]+' stack: list[str] = []
matches = re.findall(pattern, selector)
# Extract the matched groups (either quoted or unquoted parts)
selectors: list[Selector] = [] selectors: list[Selector] = []
for selector in matches: acc_str: str = ""
if selector == "*":
selectors.append(AllSelector()) # only used by set for now
elif selector.isdigit(): submode = ""
selectors.append({int(selector)}) acc_selectors: list[SetSelector] = []
elif selector.startswith("{") and selector.endswith("}"):
sub_selectors = set(selector[1:-1].split(",")) for i in range(len(selector)):
selectors.append(sub_selectors) c = selector[i]
elif selector.startswith('"') and selector.endswith('"'): if stack == []:
selectors.append(selector[1:-1]) mode = "start"
else: else:
selectors.append(selector) mode = stack[-1]
if mode == "end":
if c == ".":
stack.pop()
if stack != []:
msg = "expected empy stack, but got {stack}"
raise ValueError(msg)
else:
msg = "expected ., but got {c}"
raise ValueError(msg)
elif mode == "start":
if c == "*":
stack.append("end")
selectors.append(Selector(type=SelectorType.ALL))
elif c == "?":
stack.append("maybe")
elif c == '"':
stack += ["str", "quote"]
elif c == "{":
stack.append("set")
elif c == ".":
selectors.append(Selector(type=SelectorType.STR, value=acc_str))
else:
stack.append("str")
acc_str += c
elif mode == "set":
if submode == "" and c == "?":
submode = "maybe"
elif c == "\\":
stack.append("escape")
if submode == "":
submode = "str"
elif c == '"':
stack.append("quote")
if submode == "":
submode = "str"
elif c == ",":
if submode == "maybe":
set_select_type = SetSelectorType.MAYBE
else:
set_select_type = SetSelectorType.STR
acc_selectors.append(SetSelector(type=set_select_type, value=acc_str))
submode = ""
acc_str = ""
elif c == "}":
if submode == "maybe":
set_select_type = SetSelectorType.MAYBE
else:
set_select_type = SetSelectorType.STR
acc_selectors.append(SetSelector(type=set_select_type, value=acc_str))
selectors.append(Selector(type=SelectorType.SET, value=acc_selectors))
submode = ""
acc_selectors = []
acc_str = ""
stack.pop()
stack.append("end")
else:
acc_str += c
if submode == "":
submode = "str"
elif mode == "quote":
if c == '"':
stack.pop()
elif c == "\\":
stack.append("escape")
else:
acc_str += c
elif mode == "escape":
stack.pop()
acc_str += c
elif mode == "str" or mode == "maybe":
if c == ".":
stack.pop()
if mode == "maybe":
select_type = SelectorType.MAYBE
else:
select_type = SelectorType.STR
selectors.append(Selector(type=select_type, value=acc_str))
acc_str = ""
elif c == "\\":
stack.append("escape")
else:
acc_str += c
if stack != []:
if stack[-1] == "str" or stack[-1] == "maybe":
if stack[-1] == "maybe":
select_type = SelectorType.MAYBE
else:
select_type = SelectorType.STR
selectors.append(Selector(type=select_type, value=acc_str))
elif stack[-1] == "end":
pass
else:
msg = f"expected empty stack, but got {stack}"
raise ValueError(msg)
return selectors return selectors
@@ -64,94 +234,18 @@ def split_selector(selector: str) -> list[Selector]:
@dataclass @dataclass
class FlakeCacheEntry: class FlakeCacheEntry:
""" """
a recursive structure to store the cache, with a value and a selector a recursive structure to store the cache.
consists of a dict with the keys being the selectors and the values being FlakeCacheEntry objects.
is_list is used to check if the value is a list.
exists is used to check if the value exists, which can be false if it was selected via maybe.
fetched_all is used to check if we have all keys on the current level.
""" """
def __init__( value: str | float | dict[str, Any] | None = field(default_factory=dict)
self, is_list: bool = False
value: str | float | dict[str, Any] | list[Any] | None, exists: bool = True
selectors: list[Selector], fetched_all: bool = False
is_out_path: bool = False,
) -> None:
self.value: str | float | int | None | dict[str | int, FlakeCacheEntry]
self.selector: set[int] | set[str] | AllSelector
selector: Selector = AllSelector()
if selectors == []:
self.selector = AllSelector()
elif isinstance(selectors[0], set):
self.selector = selectors[0]
selector = selectors[0]
elif isinstance(selectors[0], int):
self.selector = {int(selectors[0])}
selector = int(selectors[0])
elif isinstance(selectors[0], str):
self.selector = {selectors[0]}
selector = selectors[0]
elif isinstance(selectors[0], AllSelector):
self.selector = AllSelector()
if is_out_path:
if selectors != []:
msg = "Cannot index outPath"
raise ValueError(msg)
if not isinstance(value, str):
msg = "outPath must be a string"
raise ValueError(msg)
self.value = value
elif isinstance(selector, str):
self.value = {selector: FlakeCacheEntry(value, selectors[1:])}
elif isinstance(value, dict):
if isinstance(self.selector, set):
if not all(isinstance(v, str) for v in self.selector):
msg = "Cannot index dict with non-str set"
raise ValueError(msg)
self.value = {}
for key, value_ in value.items():
if key == "outPath":
self.value[key] = FlakeCacheEntry(
value_, selectors[1:], is_out_path=True
)
else:
self.value[key] = FlakeCacheEntry(value_, selectors[1:])
elif isinstance(value, list):
if isinstance(selector, int):
if len(value) != 1:
msg = "Cannot index list with int selector when value is not singleton"
raise ValueError(msg)
self.value = {
int(selector): FlakeCacheEntry(value[0], selectors[1:]),
}
if isinstance(selector, set):
if all(isinstance(v, int) for v in selector):
self.value = {}
for i, v in enumerate([selector]):
assert isinstance(v, int)
self.value[int(v)] = FlakeCacheEntry(value[i], selectors[1:])
else:
msg = "Cannot index list with non-int set"
raise ValueError(msg)
elif isinstance(self.selector, AllSelector):
self.value = {}
for i, v in enumerate(value):
if isinstance(v, dict | list | str | float | int):
self.value[i] = FlakeCacheEntry(v, selectors[1:])
else:
msg = f"expected integer selector or all for type list, but got {type(selector)}"
raise TypeError(msg)
elif isinstance(value, str) and value.startswith("/nix/store/"):
self.value = {}
self.selector = self.selector = {"outPath"}
self.value["outPath"] = FlakeCacheEntry(
value, selectors[1:], is_out_path=True
)
elif isinstance(value, (str | float | int | None)):
self.value = value
def insert( def insert(
self, self,
@@ -159,175 +253,236 @@ class FlakeCacheEntry:
selectors: list[Selector], selectors: list[Selector],
) -> None: ) -> None:
selector: Selector selector: Selector
# if we have no more selectors, it means we select all keys from now one and futher down
if selectors == []: if selectors == []:
selector = AllSelector() selector = Selector(type=SelectorType.ALL)
else: else:
selector = selectors[0] selector = selectors[0]
if isinstance(selector, str): # first we find out if we have all subkeys already
if isinstance(self.value, dict):
if selector in self.value:
self.value[selector].insert(value, selectors[1:])
else:
self.value[selector] = FlakeCacheEntry(value, selectors[1:])
return
msg = f"Cannot insert {selector} into non dict value"
raise TypeError(msg)
if isinstance(selector, AllSelector): if self.fetched_all:
self.selector = AllSelector() pass
elif isinstance(self.selector, set) and isinstance(selector, set): elif selector.type == SelectorType.ALL:
if all(isinstance(v, str) for v in self.selector) and all( self.fetched_all = True
isinstance(v, str) for v in selector
):
selector = cast(set[str], selector)
self.selector = cast(set[str], self.selector)
self.selector = self.selector.union(selector)
elif all(isinstance(v, int) for v in self.selector) and all(
isinstance(v, int) for v in selector
):
selector = cast(set[int], selector)
self.selector = cast(set[int], self.selector)
self.selector = self.selector.union(selector)
else:
msg = "Cannot union set of different types"
raise ValueError(msg)
elif isinstance(self.selector, set) and isinstance(selector, int):
if all(isinstance(v, int) for v in self.selector):
self.selector = cast(set[int], self.selector)
self.selector.add(selector)
elif isinstance(self.selector, set) and isinstance(selector, str): # if we have a string selector, that means we are usually on a dict or a list, since we cannot walk down scalar values
if all(isinstance(v, str) for v in self.selector): # so we passthrough the value to the next level
self.selector = cast(set[str], self.selector) if selector.type == SelectorType.STR:
self.selector.add(selector) assert isinstance(selector.value, str)
assert isinstance(self.value, dict)
if selector.value not in self.value:
self.value[selector.value] = FlakeCacheEntry()
self.value[selector.value].insert(value, selectors[1:])
else: # if we get a MAYBE, check if the selector is in the output, if not we create a entry with exists = False
msg = f"Cannot insert {selector} into {self.selector}" # otherwise we just insert the value into the current dict
raise TypeError(msg) # we can skip creating the non existing entry if we already fetched all keys
elif selector.type == SelectorType.MAYBE:
assert isinstance(self.value, dict)
assert isinstance(value, dict)
assert isinstance(selector.value, str)
if selector.value in value:
if selector.value not in self.value:
self.value[selector.value] = FlakeCacheEntry()
self.value[selector.value].insert(value[selector.value], selectors[1:])
elif not self.fetched_all:
if selector.value not in self.value:
self.value[selector.value] = FlakeCacheEntry()
self.value[selector.value].exists = False
if isinstance(self.value, dict) and isinstance(value, dict): # insert a dict is pretty straight forward
elif isinstance(value, dict):
assert isinstance(self.value, dict)
for key, value_ in value.items(): for key, value_ in value.items():
if key in self.value: if key not in self.value:
self.value[key] = FlakeCacheEntry()
self.value[key].insert(value_, selectors[1:]) self.value[key].insert(value_, selectors[1:])
else:
self.value[key] = FlakeCacheEntry(value_, selectors[1:])
elif isinstance(self.value, dict) and isinstance(value, list): # to store a list we also use a dict, so we know which indices we have
if isinstance(selector, set): elif isinstance(value, list):
if not all(isinstance(v, int) for v in selector): self.is_list = True
msg = "Cannot list with non-int set" fetched_indices: list[str] = []
raise ValueError(msg) # if we are in a set, we take all the selectors
for realindex, requested_index in enumerate(selector): if selector.type == SelectorType.SET:
assert isinstance(requested_index, int) assert isinstance(selector.value, list)
if requested_index in self.value: for subselector in selector.value:
self.value[requested_index].insert( fetched_indices.append(subselector.value)
value[realindex], selectors[1:] # if it's just a str, that is the index
) elif selector.type == SelectorType.STR:
elif isinstance(selector, AllSelector): assert isinstance(selector.value, str)
for index, v in enumerate(value): fetched_indices = [selector.value]
if index in self.value: # otherwise we just take all the indices, which is the length of the list
self.value[index].insert(v, selectors[1:]) elif selector.type == SelectorType.ALL:
else: fetched_indices = list(map(str, range(len(value))))
self.value[index] = FlakeCacheEntry(v, selectors[1:])
elif isinstance(selector, int): # insert is the same is insert a dict
if selector in self.value: assert isinstance(self.value, dict)
self.value[selector].insert(value[0], selectors[1:]) for i, requested_index in enumerate(fetched_indices):
else: assert isinstance(requested_index, str)
self.value[selector] = FlakeCacheEntry(value[0], selectors[1:]) if requested_index not in self.value:
self.value[requested_index] = FlakeCacheEntry()
self.value[requested_index].insert(value[i], selectors[1:])
# strings need to be checked if they are store paths
# if they are, we store them as a dict with the outPath key
# this is to mirror nix behavior, where the outPath of an attrset is used if no further key is specified
elif isinstance(value, str) and value.startswith("/nix/store/"): elif isinstance(value, str) and value.startswith("/nix/store/"):
self.value = {} assert selectors == []
self.value["outPath"] = FlakeCacheEntry( if value.startswith("/nix/store/"):
value, selectors[1:], is_out_path=True self.value = {"outPath": FlakeCacheEntry(value)}
)
elif isinstance(value, (str | float | int)): # if we have a normal scalar, we check if it conflicts with a maybe already store value
if self.value: # since an empty attrset is the default value, we cannot check that, so we just set it to the value
if self.value != value: elif isinstance(value, float | int | str) or value is None:
msg = "value mismatch in cache, something is fishy" assert selectors == []
raise TypeError(msg) if self.value == {}:
self.value = value
elif value is None: elif self.value != value:
if self.value is not None: msg = f"Cannot insert {value} into cache, already have {self.value}"
msg = "value mismatch in cache, something is fishy"
raise TypeError(msg)
else:
msg = f"Cannot insert value of type {type(value)} into cache"
raise TypeError(msg) raise TypeError(msg)
def is_cached(self, selectors: list[Selector]) -> bool: def is_cached(self, selectors: list[Selector]) -> bool:
selector: Selector selector: Selector
if selectors == []: if selectors == []:
selector = AllSelector() return self.fetched_all
else:
selector = selectors[0] selector = selectors[0]
# for store paths we have to check if they still exist, otherwise they have to be rebuild and are thus not cached
if isinstance(self.value, str) and self.value.startswith("/nix/store/"): if isinstance(self.value, str) and self.value.startswith("/nix/store/"):
return Path(self.value).exists() return Path(self.value).exists()
# if self.value is not dict but we request more selectors, we assume we are cached and an error will be thrown in the select function
if isinstance(self.value, str | float | int | None): if isinstance(self.value, str | float | int | None):
return selectors == [] return True
if isinstance(selector, AllSelector):
if isinstance(self.selector, AllSelector): # we just fetch all subkeys, so we need to check of we inserted all keys at this level before
if selector.type == SelectorType.ALL:
assert isinstance(self.value, dict)
if self.fetched_all:
result = all( result = all(
self.value[sel].is_cached(selectors[1:]) for sel in self.value self.value[sel].is_cached(selectors[1:]) for sel in self.value
) )
return result return result
# TODO: check if we already have all the keys anyway?
return False return False
if ( if (
isinstance(selector, set) selector.type == SelectorType.SET
and isinstance(self.selector, set) and isinstance(selector.value, list)
and isinstance(self.value, dict) and isinstance(self.value, dict)
): ):
if not selector.issubset(self.selector): for requested_selector in selector.value:
val = requested_selector.value
if val not in self.value:
# if we fetched all keys and we are not in the dict, we can assume we are cached
return self.fetched_all
# if a key does not exist from a previous fetch, we can assume it is cached
if self.value[val].exists is False:
return True
if not self.value[val].is_cached(selectors[1:]):
return False return False
result = all( return True
self.value[sel].is_cached(selectors[1:]) if sel in self.value else True
for sel in selector
)
return result # string and maybe work the same for cache checking
if isinstance(selector, str | int) and isinstance(self.value, dict): if (
if selector in self.value: selector.type == SelectorType.STR or selector.type == SelectorType.MAYBE
result = self.value[selector].is_cached(selectors[1:]) ) and isinstance(self.value, dict):
return result assert isinstance(selector.value, str)
return False val = selector.value
if val not in self.value:
# if we fetched all keys and we are not in there, refetching won't help, so we can assume we are cached
return self.fetched_all
if self.value[val].exists is False:
return True
return self.value[val].is_cached(selectors[1:])
return False return False
def select(self, selectors: list[Selector]) -> Any: def select(self, selectors: list[Selector]) -> Any:
selector: Selector selector: Selector
if selectors == []: if selectors == []:
selector = AllSelector() selector = Selector(type=SelectorType.ALL)
else: else:
selector = selectors[0] selector = selectors[0]
# mirror nix behavior where we return outPath if no further selector is specified
if selectors == [] and isinstance(self.value, dict) and "outPath" in self.value: if selectors == [] and isinstance(self.value, dict) and "outPath" in self.value:
return self.value["outPath"].value return self.value["outPath"].value
if isinstance(self.value, str | float | int | None): # if we are at the end of the selector chain, we return the value
if selectors == [] and isinstance(self.value, str | float | int | None):
return self.value return self.value
if isinstance(self.value, dict):
if isinstance(selector, AllSelector): # if we fetch a specific key, we return the recurse into that value in the dict
return {k: v.select(selectors[1:]) for k, v in self.value.items()} if selector.type == SelectorType.STR and isinstance(self.value, dict):
if isinstance(selector, set): assert isinstance(selector.value, str)
return self.value[selector.value].select(selectors[1:])
# if we are a MAYBE selector, we check if the key exists in the dict
if selector.type == SelectorType.MAYBE and isinstance(self.value, dict):
assert isinstance(selector.value, str)
if selector.value in self.value:
if self.value[selector.value].exists:
return { return {
k: v.select(selectors[1:]) selector.value: self.value[selector.value].select(selectors[1:])
for k, v in self.value.items()
if k in selector
} }
if isinstance(selector, str | int): return {}
return self.value[selector].select(selectors[1:]) if self.fetched_all:
msg = f"Cannot select {selector} from type {type(self.value)}" return {}
raise TypeError(msg)
# otherwise we return a list or a dict
if isinstance(self.value, dict):
keys_to_select: list[str] = []
# if we want to select all keys, we take all existing sub elements
if selector.type == SelectorType.ALL:
for key in self.value:
if self.value[key].exists:
keys_to_select.append(key)
# if we want to select a set of keys, we take the keys from the selector
if selector.type == SelectorType.SET:
assert isinstance(selector.value, list)
for subselector in selector.value:
# make sure the keys actually exist if we have a maybe selector
if subselector.type == SetSelectorType.MAYBE:
if (
subselector.value in self.value
and self.value[subselector.value].exists
):
keys_to_select.append(subselector.value)
else:
keys_to_select.append(subselector.value)
# if we are a list, return a list
if self.is_list:
result = []
for index in keys_to_select:
result.append(self.value[index].select(selectors[1:]))
return result
# otherwise return a dict
return {k: self.value[k].select(selectors[1:]) for k in keys_to_select}
# return a KeyError if we cannot fetch the key
str_selector: str
if selector.type == SelectorType.ALL:
str_selector = "*"
elif selector.type == SelectorType.SET:
subselectors: list[str] = []
assert isinstance(selector.value, list)
for subselector in selector.value:
subselectors.append(subselector.value)
str_selector = "{" + ",".join(subselectors) + "}"
else:
assert isinstance(selector.value, str)
str_selector = selector.value
raise KeyError(str_selector)
def __getitem__(self, name: str) -> "FlakeCacheEntry": def __getitem__(self, name: str) -> "FlakeCacheEntry":
if isinstance(self.value, dict): if isinstance(self.value, dict):
return self.value[name] return self.value[name]
msg = f"value is a {type(self.value)}, so cannot subscribe" raise KeyError(name)
raise TypeError(msg)
def as_json(self) -> dict[str, Any]: def as_json(self) -> dict[str, Any]:
json_data: Any = {} json_data: Any = {}
@@ -338,21 +493,13 @@ class FlakeCacheEntry:
else: # == str | float | None else: # == str | float | None
json_data["value"] = self.value json_data["value"] = self.value
if isinstance(self.selector, AllSelector): json_data["is_list"] = self.is_list
json_data["selector"] = "all-selector" json_data["exists"] = self.exists
else: # == set[int] | set[str] json_data["fetched_all"] = self.fetched_all
json_data["selector"] = list(self.selector)
return json_data return json_data
@staticmethod @staticmethod
def from_json(json_data: dict[str, Any]) -> "FlakeCacheEntry": def from_json(json_data: dict[str, Any]) -> "FlakeCacheEntry":
raw_selector = json_data.get("selector")
if raw_selector == "all-selector":
selector: Any = AllSelector()
else: # == set[int] | set[str]
assert isinstance(raw_selector, list)
selector = set(raw_selector)
raw_value = json_data.get("value") raw_value = json_data.get("value")
if isinstance(raw_value, dict): if isinstance(raw_value, dict):
value: Any = {} value: Any = {}
@@ -361,9 +508,13 @@ class FlakeCacheEntry:
else: # == str | float | None else: # == str | float | None
value = raw_value value = raw_value
entry = FlakeCacheEntry(None, [], is_out_path=False) is_list = json_data.get("is_list", False)
entry.selector = selector exists = json_data.get("exists", True)
entry.value = value fetched_all = json_data.get("fetched_all", False)
entry = FlakeCacheEntry(
value=value, is_list=is_list, exists=exists, fetched_all=fetched_all
)
return entry return entry
def __repr__(self) -> str: def __repr__(self) -> str:
@@ -379,22 +530,22 @@ class FlakeCache:
""" """
def __init__(self) -> None: def __init__(self) -> None:
self.cache: FlakeCacheEntry = FlakeCacheEntry({}, []) self.cache: FlakeCacheEntry = FlakeCacheEntry()
def insert(self, data: dict[str, Any], selector_str: str) -> None: def insert(self, data: dict[str, Any], selector_str: str) -> None:
if selector_str: if selector_str:
selectors = split_selector(selector_str) selectors = parse_selector(selector_str)
else: else:
selectors = [] selectors = []
self.cache.insert(data, selectors) self.cache.insert(data, selectors)
def select(self, selector_str: str) -> Any: def select(self, selector_str: str) -> Any:
selectors = split_selector(selector_str) selectors = parse_selector(selector_str)
return self.cache.select(selectors) return self.cache.select(selectors)
def is_cached(self, selector_str: str) -> bool: def is_cached(self, selector_str: str) -> bool:
selectors = split_selector(selector_str) selectors = parse_selector(selector_str)
return self.cache.is_cached(selectors) return self.cache.is_cached(selectors)
def save_to_file(self, path: Path) -> None: def save_to_file(self, path: Path) -> None:
@@ -544,13 +695,17 @@ class Flake:
if nix_options is None: if nix_options is None:
nix_options = [] nix_options = []
str_selectors: list[str] = []
for selector in selectors:
str_selectors.append(selectors_as_json(parse_selector(selector)))
config = nix_config() config = nix_config()
nix_code = f""" nix_code = f"""
let let
flake = builtins.getFlake("path:{self.store_path}?narHash={self.hash}"); flake = builtins.getFlake("path:{self.store_path}?narHash={self.hash}");
in in
flake.inputs.nixpkgs.legacyPackages.{config["system"]}.writeText "clan-flake-select" ( flake.inputs.nixpkgs.legacyPackages.{config["system"]}.writeText "clan-flake-select" (
builtins.toJSON [ {" ".join([f"(flake.clanInternals.clanLib.select ''{attr}'' flake)" for attr in selectors])} ] builtins.toJSON [ {" ".join([f"(flake.clanInternals.clanLib.select.applySelectors (builtins.fromJSON ''{attr}'') flake)" for attr in str_selectors])} ]
) )
""" """
if tmp_store := nix_test_store(): if tmp_store := nix_test_store():

View File

@@ -95,10 +95,10 @@ def get_clan_nix_attrset(clan_dir: Flake | None = None) -> ClanExports:
raw_clan_exports: dict[str, Any] = {"self": {"clan": {}}, "inputs": {"clan": {}}} raw_clan_exports: dict[str, Any] = {"self": {"clan": {}}, "inputs": {"clan": {}}}
try: maybe_templates = clan_dir.select("?clan.?templates")
raw_clan_exports["self"] = clan_dir.select("clan.{templates}") if "clan" in maybe_templates:
except ClanCmdError as ex: raw_clan_exports["self"] = maybe_templates["clan"]
log.debug(ex) else:
log.info("Current flake does not export the 'clan' attribute") log.info("Current flake does not export the 'clan' attribute")
# FIXME: flake.select destroys lazy evaluation # FIXME: flake.select destroys lazy evaluation
@@ -112,7 +112,7 @@ def get_clan_nix_attrset(clan_dir: Flake | None = None) -> ClanExports:
# of import statements. # of import statements.
# This needs to be fixed in clan.select # This needs to be fixed in clan.select
# For now always define clan.templates or no clan attribute at all # For now always define clan.templates or no clan attribute at all
temp = clan_dir.select("inputs.*.{clan}.templates") temp = clan_dir.select("inputs.*.?clan.templates")
# FIXME: We need this because clan.select removes the templates attribute # FIXME: We need this because clan.select removes the templates attribute
# but not the clan and other attributes leading up to templates # but not the clan and other attributes leading up to templates

View File

@@ -1,35 +1,282 @@
import logging import logging
import pytest import pytest
from clan_cli.flake import Flake, FlakeCache, FlakeCacheEntry from clan_cli.flake import (
Flake,
FlakeCache,
FlakeCacheEntry,
parse_selector,
selectors_as_dict,
)
from clan_cli.tests.fixtures_flakes import ClanFlake from clan_cli.tests.fixtures_flakes import ClanFlake
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def test_parse_selector() -> None:
selectors = parse_selector("x")
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
]
selectors = parse_selector("?x")
assert selectors_as_dict(selectors) == [
{"type": "maybe", "value": "x"},
]
selectors = parse_selector('"x"')
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
]
selectors = parse_selector("*")
assert selectors_as_dict(selectors) == [
{"type": "all"},
]
selectors = parse_selector("{x}")
assert selectors_as_dict(selectors) == [
{
"type": "set",
"value": [
{"type": "str", "value": "x"},
],
},
]
selectors = parse_selector("{x}.y")
assert selectors_as_dict(selectors) == [
{
"type": "set",
"value": [
{"type": "str", "value": "x"},
],
},
{"type": "str", "value": "y"},
]
selectors = parse_selector("x.y.z")
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
{"type": "str", "value": "y"},
{"type": "str", "value": "z"},
]
selectors = parse_selector("x.*")
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
{"type": "all"},
]
selectors = parse_selector("*.x")
assert selectors_as_dict(selectors) == [
{"type": "all"},
{"type": "str", "value": "x"},
]
selectors = parse_selector("x.*.z")
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
{"type": "all"},
{"type": "str", "value": "z"},
]
selectors = parse_selector("x.{y,z}")
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
{
"type": "set",
"value": [
{"type": "str", "value": "y"},
{"type": "str", "value": "z"},
],
},
]
selectors = parse_selector("x.?zzz.{y,?z,x,*}")
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
{"type": "maybe", "value": "zzz"},
{
"type": "set",
"value": [
{"type": "str", "value": "y"},
{"type": "maybe", "value": "z"},
{"type": "str", "value": "x"},
{"type": "str", "value": "*"},
],
},
]
selectors = parse_selector('x."?zzz".?zzz\\.asd..{y,\\?z,"x,",*}')
assert selectors_as_dict(selectors) == [
{"type": "str", "value": "x"},
{"type": "str", "value": "?zzz"},
{"type": "maybe", "value": "zzz.asd"},
{"type": "str", "value": ""},
{
"type": "set",
"value": [
{"type": "str", "value": "y"},
{"type": "str", "value": "?z"},
{"type": "str", "value": "x,"},
{"type": "str", "value": "*"},
],
},
]
def test_insert_and_iscached() -> None:
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.y.z")
test_cache.insert("x", selectors)
assert test_cache["x"]["y"]["z"].value == "x"
assert test_cache.is_cached(selectors)
assert not test_cache.is_cached(parse_selector("x.y"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert not test_cache.is_cached(parse_selector("x.*.z"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert test_cache.is_cached(parse_selector("x.?y.z"))
assert not test_cache.is_cached(parse_selector("x.?z.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.*.z")
test_cache.insert({"y": "x"}, selectors)
assert test_cache["x"]["y"]["z"].value == "x"
assert test_cache.is_cached(selectors)
assert not test_cache.is_cached(parse_selector("x.y"))
assert not test_cache.is_cached(parse_selector("x.y.x"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert test_cache.is_cached(parse_selector("x.{y,z}.z"))
assert test_cache.is_cached(parse_selector("x.{y,?z}.z"))
assert test_cache.is_cached(parse_selector("x.?y.z"))
assert test_cache.is_cached(parse_selector("x.?z.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.{y}.z")
test_cache.insert({"y": "x"}, selectors)
assert test_cache["x"]["y"]["z"].value == "x"
assert test_cache.is_cached(selectors)
assert not test_cache.is_cached(parse_selector("x.y"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert not test_cache.is_cached(parse_selector("x.*.z"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert test_cache.is_cached(parse_selector("x.?y.z"))
assert not test_cache.is_cached(parse_selector("x.?z.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.?y.z")
test_cache.insert({"y": "x"}, selectors)
assert test_cache["x"]["y"]["z"].value == "x"
assert test_cache.is_cached(selectors)
assert not test_cache.is_cached(parse_selector("x.y"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert not test_cache.is_cached(parse_selector("x.*.z"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert test_cache.is_cached(parse_selector("x.?y.z"))
assert not test_cache.is_cached(parse_selector("x.?z.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.?y.z")
test_cache.insert({}, selectors)
assert test_cache["x"]["y"].exists is False
assert test_cache.is_cached(selectors)
assert test_cache.is_cached(parse_selector("x.y"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert test_cache.is_cached(parse_selector("x.?y.z.1"))
assert not test_cache.is_cached(parse_selector("x.*.z"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert test_cache.is_cached(parse_selector("x.?y.abc"))
assert not test_cache.is_cached(parse_selector("x.?z.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.{y,z}.z")
test_cache.insert({"y": 1, "z": 2}, selectors)
assert test_cache["x"]["y"]["z"].value == 1
assert test_cache["x"]["z"]["z"].value == 2
assert test_cache.is_cached(selectors)
assert not test_cache.is_cached(parse_selector("x.y"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert not test_cache.is_cached(parse_selector("x.*.z"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert not test_cache.is_cached(parse_selector("x.?y.abc"))
assert test_cache.is_cached(parse_selector("x.?z.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.y")
test_cache.insert(1, selectors)
selectors = parse_selector("x.z")
test_cache.insert(2, selectors)
assert test_cache["x"]["y"].value == 1
assert test_cache["x"]["z"].value == 2
assert test_cache.is_cached(parse_selector("x.y"))
assert test_cache.is_cached(parse_selector("x.y.z.1"))
assert not test_cache.is_cached(parse_selector("x.*.z"))
assert test_cache.is_cached(parse_selector("x.{y}.z"))
assert test_cache.is_cached(parse_selector("x.?y.abc"))
assert test_cache.is_cached(parse_selector("x.?z.z"))
assert not test_cache.is_cached(parse_selector("x.?x.z"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.y.z")
test_cache.insert({"a": {"b": {"c": 1}}}, selectors)
assert test_cache.is_cached(selectors)
assert test_cache.is_cached(parse_selector("x.y.z.a.b.c"))
assert test_cache.is_cached(parse_selector("x.y.z.a.b"))
assert test_cache.is_cached(parse_selector("x.y.z.a"))
assert test_cache.is_cached(parse_selector("x.y.z"))
assert not test_cache.is_cached(parse_selector("x.y"))
assert not test_cache.is_cached(parse_selector("x"))
assert test_cache.is_cached(parse_selector("x.y.z.xxx"))
test_cache = FlakeCacheEntry()
selectors = parse_selector("x.y")
test_cache.insert(1, selectors)
with pytest.raises(TypeError):
test_cache.insert(2, selectors)
assert test_cache["x"]["y"].value == 1
def test_select() -> None: def test_select() -> None:
test_cache = FlakeCacheEntry()
test_cache.insert("bla", parse_selector("a.b.c"))
assert test_cache.select(parse_selector("a.b.c")) == "bla"
assert test_cache.select(parse_selector("a.b")) == {"c": "bla"}
assert test_cache.select(parse_selector("a")) == {"b": {"c": "bla"}}
assert test_cache.select(parse_selector("a.b.?c")) == {"c": "bla"}
assert test_cache.select(parse_selector("a.?b.?c")) == {"b": {"c": "bla"}}
assert test_cache.select(parse_selector("a.?c")) == {}
assert test_cache.select(parse_selector("a.?x.c")) == {}
assert test_cache.select(parse_selector("a.*")) == {"b": {"c": "bla"}}
assert test_cache.select(parse_selector("a.*.*")) == {"b": {"c": "bla"}}
assert test_cache.select(parse_selector("a.*.c")) == {"b": "bla"}
assert test_cache.select(parse_selector("a.b.*")) == {"c": "bla"}
assert test_cache.select(parse_selector("a.{b}.c")) == {"b": "bla"}
assert test_cache.select(parse_selector("a.{b}.{c}")) == {"b": {"c": "bla"}}
assert test_cache.select(parse_selector("a.b.{c}")) == {"c": "bla"}
assert test_cache.select(parse_selector("a.{?b}.c")) == {"b": "bla"}
assert test_cache.select(parse_selector("a.{?b,?x}.c")) == {"b": "bla"}
with pytest.raises(KeyError):
test_cache.select(parse_selector("a.b.x"))
with pytest.raises(KeyError):
test_cache.select(parse_selector("a.b.c.x"))
with pytest.raises(KeyError):
test_cache.select(parse_selector("a.{b,x}.c"))
testdict = {"x": {"y": [123, 345, 456], "z": "bla"}} testdict = {"x": {"y": [123, 345, 456], "z": "bla"}}
test_cache = FlakeCacheEntry(testdict, []) test_cache.insert(testdict, parse_selector("testdict"))
assert test_cache["x"]["z"].value == "bla" assert test_cache["testdict"]["x"]["z"].value == "bla"
assert test_cache.is_cached(["x", "z"]) selectors = parse_selector("testdict.x.z")
assert not test_cache.is_cached(["x", "y", "z"]) assert test_cache.select(selectors) == "bla"
assert test_cache.select(["x", "y", 0]) == 123 selectors = parse_selector("testdict.x.z.z")
assert not test_cache.is_cached(["x", "z", 1]) with pytest.raises(KeyError):
test_cache.select(selectors)
selectors = parse_selector("testdict.x.y.0")
def test_insert() -> None: assert test_cache.select(selectors) == 123
test_cache = FlakeCacheEntry({}, []) selectors = parse_selector("testdict.x.z.1")
# Inserting the same thing twice should succeed with pytest.raises(KeyError):
test_cache.insert(None, ["nix"]) test_cache.select(selectors)
test_cache.insert(None, ["nix"])
assert test_cache.select(["nix"]) is None
def test_out_path() -> None: def test_out_path() -> None:
testdict = {"x": {"y": [123, 345, 456], "z": "/nix/store/bla"}} testdict = {"x": {"y": [123, 345, 456], "z": "/nix/store/bla"}}
test_cache = FlakeCacheEntry(testdict, []) test_cache = FlakeCacheEntry()
assert test_cache.select(["x", "z"]) == "/nix/store/bla" test_cache.insert(testdict, [])
assert test_cache.select(["x", "z", "outPath"]) == "/nix/store/bla" selectors = parse_selector("x.z")
assert test_cache.select(selectors) == "/nix/store/bla"
selectors = parse_selector("x.z.outPath")
assert test_cache.select(selectors) == "/nix/store/bla"
@pytest.mark.with_core @pytest.mark.with_core
@@ -85,10 +332,10 @@ def test_conditional_all_selector(flake: ClanFlake) -> None:
assert isinstance(flake1._cache, FlakeCache) # noqa: SLF001 assert isinstance(flake1._cache, FlakeCache) # noqa: SLF001
assert isinstance(flake2._cache, FlakeCache) # noqa: SLF001 assert isinstance(flake2._cache, FlakeCache) # noqa: SLF001
log.info("First select") log.info("First select")
res1 = flake1.select("inputs.*.{clan,missing}") res1 = flake1.select("inputs.*.{?clan,?missing}")
log.info("Second (cached) select") log.info("Second (cached) select")
res2 = flake1.select("inputs.*.{clan,missing}") res2 = flake1.select("inputs.*.{?clan,?missing}")
assert res1 == res2 assert res1 == res2
assert res1["clan-core"].get("clan") is not None assert res1["clan-core"].get("clan") is not None

View File

@@ -86,8 +86,8 @@
# only adding clanCoreWithVendoredDeps to the nix store is not enough # only adding clanCoreWithVendoredDeps to the nix store is not enough
templateDerivation = pkgs.closureInfo { templateDerivation = pkgs.closureInfo {
rootPaths = rootPaths =
builtins.attrValues (self.clanLib.select "clan.templates.clan.*.path" self) builtins.attrValues (self.clanLib.select.select "clan.templates.clan.*.path" self)
++ builtins.attrValues (self.clanLib.select "clan.templates.machine.*.path" self); ++ builtins.attrValues (self.clanLib.select.select "clan.templates.machine.*.path" self);
}; };
clanCoreWithVendoredDeps = clanCoreWithVendoredDeps =