Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib.types: init attrsWith #344216

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open

Conversation

hsjobeki
Copy link
Contributor

@hsjobeki hsjobeki commented Sep 24, 2024

Description of changes

Unify the code path between attrsOf and lazyAtrrsOf

  • Set lazy = true to receive set lazy type
  • Set name="name" to configure the <name> placeholder for the docs. This is particularly useful when "<name>" doesn't make sense or when dealing with nested attrsOf submodule. Which would yield "<name>.<name>"

Apart from that everything should behave the same.

Usage example

mkOption {
  type = types.attrsWith {
    elemType = types.str;
    name = "userName";
    lazy = true;
  };
  default = "root";
}

Context

Things done

  • Built on platform(s)
    • x86_64-linux
    • aarch64-linux
    • x86_64-darwin
    • aarch64-darwin
  • For non-Linux: Is sandboxing enabled in nix.conf? (See Nix manual)
    • sandbox = relaxed
    • sandbox = true
  • Tested, as applicable:
  • Tested compilation of all packages that depend on this change using nix-shell -p nixpkgs-review --run "nixpkgs-review rev HEAD". Note: all changes have to be committed, also see nixpkgs-review usage
  • Tested basic functionality of all binary files (usually in ./result/bin/)
  • 24.11 Release Notes (or backporting 23.11 and 24.05 Release notes)
    • (Package updates) Added a release notes entry if the change is major or breaking
    • (Module updates) Added a release notes entry if the change is significant
    • (Module addition) Added a release notes entry if adding a new NixOS module
  • Fits CONTRIBUTING.md.

Add a 👍 reaction to pull requests you find important.

@github-actions github-actions bot added 6.topic: module system About "NixOS" module system internals 6.topic: lib The Nixpkgs function library labels Sep 24, 2024
@DavHau
Copy link
Member

DavHau commented Sep 24, 2024

LGTM

@roberth
Copy link
Member

roberth commented Sep 24, 2024

Apart from that everything should behave the same.

Seems like a wasted opportunity actually. The type could accept a function from name to type so you really have a name, that you can even feed into the submodule specialArgs using a different name name, etc.

Anyway, I guess what I'm getting at is that the attrs types should be factored into a single more capable function, because this property is not mutually exclusive with the other property about laziness (attrsOf/lazyAttrsOf).

See also

I'll add that to the description as well, because this would close that.

@hsjobeki
Copy link
Contributor Author

hsjobeki commented Sep 25, 2024

Seems like a wasted opportunity actually. The type could accept a function from name to type so you really have a name, that you can even feed into the submodule specialArgs using a different name name, etc.

So before i start factoring this, and make sure the checks pass.

Do you mean something like this?

    # Interface
    # elemTypeFn :: String -> OptionType
    namedAttrsOf = attrName: elemTypeFn: mkOptionType rec {
     # ... 
    }
 # Simple Usage with submodule taking a function itself
 # I choose username as concrete name name here.
 # Couldn't make up a nice name for `namedAttr` but `name` is probably bad, since it already used in types.submodule in a different way.
   mkOption {
      type = namedAttrsOf "username" (attrName: submoduleWith {
          specialArgs = {
             inherit attrName;
          };
          modules = [
           # ... other modules receiving the name
          ];
        }
      );
   };

@roberth
Copy link
Member

roberth commented Sep 25, 2024

I was thinking something along the lines of

attrsWith {
  name = "username";
  itemType = submoduleWith { modules = [ <...> ]; };
  # or, perhaps later:
  # itemTypeFunction = name: submoduleWith { modules = f name; specialArgs = g name; };
  # and perhaps later:
  # lazy = true;
};

This is more extensible and will let us cover lazyAttrsOf as well.

Also, instead of attrsWith we could do dict, because a submodule value is also an attrset but very different. (See also the issue and the defintiion of dictionary in https://nix.dev/manual/nix/2.24/development/json-guideline)

@hsjobeki hsjobeki changed the title lib.types: init namedAttrsOf lib.types: init attrsWith Sep 27, 2024
@hsjobeki hsjobeki force-pushed the lib/namedAttrsOf branch 3 times, most recently from d8d8643 to 6432843 Compare September 27, 2024 07:43
@hsjobeki
Copy link
Contributor Author

hsjobeki commented Sep 27, 2024

@roberth okay. I refactored everthing accordingly. Lets see if all checks pass.
I also added some smoke eval tests.

Also when looking at the usage. It might be more consistent if we name elemType -> just type. Unsure because the parent attribute as also called type.

options = {
  foo = mkOption {
    type = attrsWith {
      elemType = submodule {
      ...

Copy link
Member

@roberth roberth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In summary

  • This would be a good opportunity to fix showOption properly
  • Some microoptimization

# If the part is a named placeholder of the form "<...>" don't escape it.
# Required for compatibility with: namedAttrsOf
# Can lead to misleading escaping if somebody uses literally "<...>" in their option names.
# This is the trade-off to allow for named placeholders in option names.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, we actually have two types of option paths: abstract ones containing placeholders, and real ones that are just data.
Treating them the same isn't quite correct, and merging a workaround could complicate a real fix.
Also note that some of these path items are not module options but attrsOf attributes, and "*" will occur as an attribute name in certain configurations to represent a "pattern" that matches everything; for example when an HTTP server doesn't responds for an unknown virtual host, etc.

We could solve this in at least two ways

a. Leave the "option path" type as is, but use two functions

  • leave showOption as is, suitable for concrete option paths
  • add showAbstractOption, which implements these new rules
    b. Only improve the representation
  • represent abstract path items with a value like { _type = "optionPathPlaceholder"; metaVariable = "name"; __toString = this: "<${this.metaVariable}>"; }

I think (b) may have good backward compatibility and it solves the problem for module options; not just attrsOf keys.
__toString is mostly for compatibility with existing code that kind of works when a placeholder string is passed. This happens when generating option docs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing tech debt, so while this is a good opportunity to fix it before making it slightly worse, perhaps this should be done in a follow-up PR instead.

Copy link
Contributor Author

@hsjobeki hsjobeki Sep 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to do this in a follow up PR if you are okay with it. Should i remove the changes from this one?

lib/tests/misc.nix Show resolved Hide resolved
lib/tests/misc.nix Show resolved Hide resolved
lib/types.nix Outdated Show resolved Hide resolved
Copy link
Member

@infinisil infinisil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great, nice work!

In addition to the issue I'm pointing out below, this should also have docs in https://nixos.org/manual/nixos/stable/#sec-option-types-composed. Otherwise I think this is good :)

lib/types.nix Outdated
substSubModules = m: lazyAttrsOf (elemType.substSubModules m);
functor = (defaultFunctor name) // { wrapped = elemType; };
substSubModules = m: attrsWith { elemType = elemType.substSubModules m; inherit name lazy; };
functor = (defaultFunctor typeName) // { wrapped = elemType; };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type merging is not well behaved right now. Here's the start of a test suite for that:

(import ./lib).evalModules {
  modules = [

    {
      imports = [
        ({ lib, ... }: {
          options.mergedLazy = lib.mkOption {
            type = lib.types.attrsWith {
              lazy = true;
              elemType = lib.types.int;
            };
          };
        })

        ({ lib, ... }: {
          options.mergedLazy = lib.mkOption {
            type = lib.types.attrsWith {
              lazy = true;
              elemType = lib.types.int;
            };
          };
        })

        ({ config, ... }: {
          # Can only evaluate if lazy
          mergedLazy.bar = config.mergedLazy.baz + 1;
          mergedLazy.baz = 10;
        })
      ];
    }

    {
      imports = [
        ({ lib, ... }: {
          options.mergedName = lib.mkOption {
            type = lib.types.attrsWith {
              name = "id";
              elemType = lib.types.submodule {
                options.nested = lib.mkOption {};
              };
            };
          };
        })
        ({ lib, ... }: {
          options.mergedName = lib.mkOption {
            type = lib.types.attrsOf (lib.types.submodule {});
          };
        })
        ({ lib, options, ... }: {
          options.nameWhenMerged = lib.mkOption {
            default = (options.mergedName.type.getSubOptions options.mergedName.loc).nested.loc;
          };
        })
      ];
    }
  ];
}

Merging of lazy = true doesn't work:

$ nix-instantiate --eval test.nix -A config.mergedLazy --strict
error: infinite recursion encountered

lazy should only be merged successfully if the values of both types are the same.

Merging of name doesn't work:

$ nix-instantiate --eval test.nix -A config.nameWhenMerged --strict
[ "mergedName" "<name>" "nested" ]

This should return <id> instead. name should be merged by prioritising non-default values.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Didnt think that the merging is done by <name> literally. Thanks for the test suite, i'll try to fix the merging.

Copy link
Contributor Author

@hsjobeki hsjobeki Oct 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@infinisil I am unsure how to solve the option name merging. Are we sure this is a valid module definition?

I think to fix this we need to change how modules.mergeOptionDecls works?

# Option definition 1
({ lib, ... }: {
          options.mergedName = lib.mkOption {
            type = lib.types.attrsWith {
              name = "id";
              elemType = lib.types.submodule {
                options.nested = lib.mkOption {};
              };
            };
          };
})
# Option definition 2 (same option, same type, different <name> placeholder)
({ lib, ... }: {
          options.mergedName = lib.mkOption {
            type = lib.types.attrsOf (lib.types.submodule {});
          };
})

And we might also need to make sure merging nested paths and conflicting placeholders

e.g. [<bar> <name> <name>] [<name> <foo> <baz>] [<foo> <name> <name>] -> [<?> <foo> <baz>]

So basically we need to decide which of the two functions we will take, when merging two options declarations?

getSubOptions = prefix: elemType.getSubOptions (prefix ++ ["<${name}>"]); (name = "id")
getSubOptions = prefix: elemType.getSubOptions (prefix ++ ["<${name}>"]); (name = "name")

I couldnt find a way yet. But if you have a solution i'd be very thankfull.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went ahead and pushed a commit with how I think it should be implemented, I hope you don't mind!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Big thanks for doing the name merging.

I was confused if i was supposed to add additional attributes to the functor.

@hsjobeki
Copy link
Contributor Author

hsjobeki commented Oct 8, 2024

@infinisil Just added some little documentation and fixed the missing functor attributes (wrapped, type), that got removed from my previous commit. Nixos manual should be able to build.

@infinisil
Copy link
Member

infinisil commented Oct 8, 2024

#344216 (comment) really needs to be added as tests, because those wrapped and type attributes aren't needed and actually break it 😅. lib/tests/modules.sh would be a good entry-point for testing that.

@nix-owners nix-owners bot requested review from infinisil and roberth October 9, 2024 07:18
@hsjobeki
Copy link
Contributor Author

hsjobeki commented Oct 9, 2024

#344216 (comment) really needs to be added as tests, because those wrapped and type attributes aren't needed and actually break it 😅. lib/tests/modules.sh would be a good entry-point for testing that.

Is it breaking? I ran those tests that you gave me and they where all fine. I'll run the other ones lib/tests/modules.sh as well. Shouldn't those run with CI because all jobs passed as well?

EDIT: Just ran nix-build lib/tests/release.nix which seems to run the lib/tests/modules.sh in turn. Just exited with tests ok

@infinisil
Copy link
Member

I mean that in the comment I linked, I showed you two additional test cases that should be added to modules.sh in this PR

@hsjobeki
Copy link
Contributor Author

hsjobeki commented Oct 17, 2024

@infinisil Was somehow still stuck with the name merging.
Previously attrsOf used:

functor = (defaultFunctor name) // { wrapped = elemType; };
Note that
wrapped = elemType;

Then in defaultTypeMerge we got this pice of code:

...
    else if (f.wrapped != null && f'.wrapped != null) && (wrapped != null)
       then f.type wrapped
    # value types
    else if (f.payload != null && f'.payload != null) && (payload != null)
       then f.type payload

This means if we omit wrapped then f.type is called with payload and f.binOp is executed to merge the name.
But if wrapped is omited, the manual wont build. (Not sure why and if this is a bug in the manual, since all other tests pass)

I found a possible solution: To switch the order of payload and wrapped
not sure if this affects lazyness. All tests where fine, the manual could be built and name merging worked.

    # value types
    else if (f.payload != null && f'.payload != null) && (payload != null)
       then f.type payload
    # composed types
    else if (f.wrapped != null && f'.wrapped != null) && (wrapped != null)
       then f.type wrapped

I also noticed that maybe both wrapped and payload should return null if they are null instead of continuing to the next condition branch.

    # value types
    else if (f.payload != null && f'.payload != null)
       # both f and f' have payload, If merged payload is null we should return null and not try f.type wrapped instead because the merge of payload failed already.
       then 
         if payload != null 
         then f.type payload 
         else null
       
    # composed types
    else if (f.wrapped != null && f'.wrapped != null)
      # same with wrapped
       then          
         if wrapped != null
         then f.type wrapped 
         else null

@hsjobeki
Copy link
Contributor Author

hsjobeki commented Oct 17, 2024

@infinisil @roberth what do you think about this. I have this questions still in mind:

  • Should <name> be configured via parameter name ? Because in the linked Issue everyone seemed convince that it shouldn't but it seems fine if i look at this implementation? (some alternatives: label,keyName s, keys, descriptor ....
  • Should defaultTypeMerge return early? (see lib.types: init attrsWith #344216 (comment))
  • Is it required that the functor still needs wrapped? (Because that means we need both payload and wrapped, which seems is a new case that never happened before?)

@roberth
Copy link
Member

roberth commented Oct 18, 2024

Should <name> be configured via parameter name ?

This should be considered in the context of also having e.g. https://github.com/NixOS/nixpkgs/pull/218812/files, which is a laterally related but independent parameter. This is somewhat of a red herring though, because we have good reasons to prefer a different name name for other reasons.

Picking good names makes a big difference in the UX, as they're used over and over.
"Name" is not great because it can refer to any of:

  • the default presentation of the placeholder in docs and diagnostics, <name>
  • the module argument by the name of name
  • now also: the setting that controls the placeholder
  • potentially: the setting that controls the attribute name of the module argument

This means we have to often qualify the name of name, which requires more thinking than just having separate terms.
It'd be easier to just say label or placeholder

Compare:

  • "name" is the default name
  • "name" is the default label
  • "name" is the default placeholder

Compare:

  • an attribute set type constructor has a name for documentation
    • carries too much weight, implying that they are unique in some imaginary namespace, which is not the case
  • an attribute set type constructor has a label for documentation
    • similar to "name" but weaker implication
  • an attribute set type constructor has a placeholder for documentation

I think placeholder is the best out of these three.

As for the other two points, I think @infinisil has more experience with type merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

<name> as prefix in types.attrsOf types.submodule is often unhelpful
4 participants