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

Group Support #39

Closed
BrianPugh opened this issue Dec 22, 2023 · 19 comments
Closed

Group Support #39

BrianPugh opened this issue Dec 22, 2023 · 19 comments
Labels
enhancement New feature or request v2 Breaking change required.

Comments

@BrianPugh
Copy link
Owner

BrianPugh commented Dec 22, 2023

Design document for adding "groups" to Cyclopts v2.

Motivation

Groups are to achieve 2 purposes:

  • Add grouping on the help-page.
    • This includes the requested separation of "Arguments" from "Parameters".
  • Add additional validation logic.
    • E.g. mutually_exclusive parameters.

The implementation goals of this implementation are to:

  1. Favor composition over hard-coded functionality.
  2. Consistent interfaces across App, @app.command, Group, and Parameter.
    • Use same keywords whenever possible.
    • Functionality should be intuitive without documentation.
  3. Add (or even remove!) as little function-signature-bloat as possible.

Group Class (new)

There will be a new public class, Group, which can be used with Parameter and @app.command.

@define
class Group:
    name: str
    """
    Group name used for the help-panel and for group-referenced-by-string.
    """

    help: str = ""
    """
    Additional documentation show on help screen.
    """

    # All below parameters are keyword-only

    validator: Optional[Callable] = field(default=None, kw_only=True)
    """
    A callable where the CLI-provided group variables will be keyword-unpacked, regardless
    of their positional/keyword type in the command function signature.

    .. code-block:: python

        def validator(**kwargs):
            """Raise an exception if something is invalid."""

    *Not invoked on command groups.*
    """

    default_parameter: Optional[Parameter] = field(default=None, kw_only=True)
    """
    Default Parameter in the parameter-resolution-stack that goes
    between ``app.default_parameter`` and the function signature's Annotated Parameter.
    """

    default: Optional[Literal["Arguments", "Parameters", "Commands"]] = field(default=None, kw_only=True)
    """
    Only one group registered to an app can have each non-``None`` option.
    """

    # Private Internal Use
    _children List[Union[inspect.Parameter, App]]= field(factory=list, init=False)
    """
    List of ``inspect.Parameter`` or ``App`` (for commands) included in the group.
    Externally populated.
    """

    def __str__(self):
        return self.name

Only advanced Cyclopts users will need to know/use this class.
Implicitly created groups are scoped to the command.
Externally created groups should be scope to the command (i.e. don't use the same Group object for multiple commands).

Built-in Validators

A new LimitedChoice validator that can be used with groups.

class LimitedChoice:
    def __init__(self, min: int = 0, max: Optional[int] = None)
        self.min = min
        if self.max is None:
            self.max = self.min or 1

    def __call__(self, **kwargs):
        assert self.min <= len(kwargs) <= self.max

LimitedChoice is a superset of mutually-exclusive functionality.

To make a mutually exclusive group:

group = Group("A Mutually Exclusive Group", validator=validators.LimitedChoice())

Parameter Class (changes)

The Parameter class will take a new optional argument, group.

@frozen(kw_only=True)
class Parameter:  # Existing public class
    # NEW PUBLIC FIELD
    group: Optional[None, str, Group] = None
    """
    Assigns this parameter to a group within the command-context.
    * If ``None``, defaults to the appropriate argument or parameter group:
        * If ``POSITIONAL_ONLY``, add to ``default_argument`` group.
        * Otherwise, add to ``default_parameter`` group.
    * If ``str``, use an existing Group with name, or create a Group with provided
      name if it does not exist.
    * If ``Group``, directly use it.
    """

The allowing of a string identifier to implicitly/lazily create/reference a group cuts down on the API verbosity.

App Class (changes)

The App class will be gaining a group and groups attribute.
There will also be a get_group helper method.
The will also be a new validator field, which is similar to Group.validator, but applies to the entire default_command.
It will be losing help_title_commands and help_title_parameters because that functionality is now
encompassed by groups.

class App:
    # NEW FIELDS
    group: Union[None, str, Group]
    """
    The group that ``default_command`` belongs to.
    Used by the *parenting* app.
    * If ``None``, defaults to the ``Commands`` group.
    * If ``str``, use an existing Group (from app-parent ``groups``) with name,
      or create a Group with provided name if it does not exist.
    * If ``Group``, directly use it.
    """

    groups: List[Group] = field(factory=list)
    """
    List of groups used by ``default_command`` parameters, and ``commands``.
    The order of groups dictates the order displayed in the help page.
    This is initially populated *on decoration*.
    On command decoration, if default groups (Parameters, Arguments, Commands) are not found in the list, they
    will be **prepended** to this list (in that order).
    A copy of the list is NOT created.
    """

    validator: Optional[Callable] = field(default=None)
    """
    Same functionality as ``Group.validator``, but applies to the whole ``default_command``
    mapping (not just a single Group).
    """


    # REMOVE fields
    #    * ``help_title_commands``   - This data is now contained in ``_groups``
    #    * ``help_title_parameters`` - This data is now contained in ``_groups``
    #                                  or ``groups["Arguments"]``

    def get_group(self, name: str) -> Group:
        """Lookup a group-by-name used by ``default_command`` parameters and registered commands."""
        try:
            return next(group for group in self.groups if group.name == name)
        except StopIteration:
            raise KeyError(name)

Example Usage

Examples on how to use the new features.

Explicit Group Creation

Explicitly creating a Group for maximum control:

env_group = Group(
    "Environment",
    """Cloud environment to execute command in. Must choose 1.""",
    validator=validators.LimitedChoice(1),  # 1 means the user MUST select one.
    default_parameter=Parameter(negative="", show_default=False),
)

@app.command
def foo(
    bar,
    *,
    dev: Annotated[bool, Parameter(group=env_group)] = False,  # Alternatively, env_group could have been created here.
    prod: Annotated[bool, Parameter(group="Environment")] = False,  # Alternativelty, look up group by name
    baz: str = "some other parameter",
):
    """Foo the environment.

    Parameters
    ----------
    bar
        Bar's docstring.
    dev
        Dev's docstring.
    prod
        Prod's docstring.
    baz
        Baz's docstring.
    """
    pass

The lookup-by-name only works because a previous Parameter (left-to-right) used (registered) the group with name "Environment".

$ my-script foo --help
╭─ Arguments ────────────────────────────────────────────────────────────╮
│ BAR  Bar's docstring.                                                  │
╰────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ───────────────────────────────────────────────────────────╮
│ --baz  Baz's docstring.                                                │
╰────────────────────────────────────────────────────────────────────────╯
╭─ Environment ──────────────────────────────────────────────────────────╮
│ Cloud environment to execute command in. Must choose 1.                │
│                                                                        │
│ --dev   Dev's Docstring.                                               │
│ --prod  Prod's Docstring.                                              │
╰────────────────────────────────────────────────────────────────────────╯

Implicit Group Creation

This example shows implicit creation of a group purely for help-organtization purposes.

@app.command(default_parameter=Parameter(show_default=False))
def ice_cream(
    flavor: Literal["vanilla", "chocolate"],
    *,
    sprinkles: Annotated[bool, Parameter(group="Toppings")] = False,
    cherry: Annotated[bool, Parameter(group="Toppings")] = False,
    fudge: Annotated[bool, Parameter(group="Toppings")] = False,
):
    pass
$ my-script ice-cream --help
╭─ Arguments ────────────────────────────────────────────────────────────╮
│ flavor  [choices: vanilla,chocolate]                                   │
╰────────────────────────────────────────────────────────────────────────╯
╭─ Toppings ─────────────────────────────────────────────────────────────╮
│ --sprinkles                                                            │
│ --cherry                                                               │
│ --fudge                                                                │
╰────────────────────────────────────────────────────────────────────────╯

Command groups

This examples shows organizing commands into groups.

@app.command(group="File Management")
def mkdir(dst: Path):
    pass

@app.command(group="File Management")
def rmdir(dst: Path):
    pass

@app.command(group="System")
def info():
    pass

app["--help"].group = "System"
app["--version"].group = "System"
$ my-script --help
╭─ File Management ──────────────────────────────────────────────────────╮
│ mkdir                                                                  │
│ rmdir                                                                  │
╰────────────────────────────────────────────────────────────────────────╯
╭─ System ───────────────────────────────────────────────────────────────╮
│ info                                                                   │
│ --help     show this help message and exit.                            │
│ --version  Print the application version.                              │
╰────────────────────────────────────────────────────────────────────────╯

Other Thoughts

  • Because the default groups (Arguments, Parameters, Commands) are determined by Group.default, not by name,
    their help-page panel title can be changed without impacting functionality.
    • The Group.default options have their first letter capitalized to be consistent
      with how typical group naming.
  • It is up to the programmer to responsibly/reasonably use Groups of POSITIONAL_ONLY arguments.
  • Empty groups (groups with no _children) will not be displayed on the help-page.
  • Special flags (e.g. --help) must become proper commands instead of specially handled.
    • In some situations, this may seem a little funky, but its a more intuitive, consistent experience.

Related Work

@BrianPugh BrianPugh added v2 Breaking change required. enhancement New feature or request labels Dec 22, 2023
@Ravencentric
Copy link

Oh wow, this looks like it's gonna be great 😃

@BrianPugh
Copy link
Owner Author

This feature has now been implemented in the develop-v2 branch. It's not EXACTLY the above design spec, but it's very close.

Docs are available at: https://cyclopts.readthedocs.io/en/develop-v2/groups.html

I will leave this issue open until v2 is released to further discuss this feature.

@Ravencentric
Copy link

from pathlib import Path
from typing import Annotated

from cyclopts import App, Group, Parameter, validators

app = App(
    name="myapp",
    help="my cli app",
    default_parameter=Parameter(negative=(), show_default=False)
)

exclusive = Group(
    name="Flags",
    validator=validators.LimitedChoice(),  # Mutually Exclusive Options
)

# fairly minor issue (maybe not even an issue) but mypy type checker doesn't like this
# Incompatible types in assignment (expression has type "Group", variable has type "tuple[Group | str, ...]")
app["--help"].group = exclusive # type: ignore
app["--version"].group = "Flags" # type: ignore

# app["--help"].group = (exclusive,) # happy mypy


@app.default # type: ignore
def cli(
    path: Annotated[
        Path,
        Parameter(
            help="File or directory.",
            show_default=True,
            validator=validators.Path(exists=True),
            group="Input"
        )
    ] = Path.cwd(),
    /,
    *,
    no_resume: Annotated[
        bool,
        Parameter(
            help="Ignore existing resume data.",
            group=exclusive
        )
    ] = False,
    clear_resume: Annotated[
        bool,
        Parameter(
            help="Delete existing resume data.",
            group=exclusive
        ),
    ] = False,
) -> None:

    print(path)
    print(no_resume)
    print(clear_resume)

if __name__ == "__main__":
    app()

help:

python .\test.py --help
Usage: myapp COMMAND [ARGS] [OPTIONS]

my cli app

╭─ Input ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ PATH  File or directory. [default: C:\Users\raven\Documents\GitHub\juicenet-cli]                                                                                                         │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Flags ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --no-resume     Ignore existing resume data.                                                                                                                                             │
│ --clear-resume  Delete existing resume data.                                                                                                                                             │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Flags ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --help,-h  Display this message and exit.                                                                                                                                                │
│ --version  Display application version.                                                                                                                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

This ends up with two Flags instead, what am I doing wrong?

@BrianPugh
Copy link
Owner Author

BrianPugh commented Jan 12, 2024

Incompatible types in assignment (expression has type "Group", variable has type "tuple[Group | str, ...]")

I'll see what I can do about this; i've been type checking with pyright, but I'll give mypy a go as well. It seems like the attrs converter typing isn't being interpreted correctly.

As for your 2 groups, this is kind of expected. Basically, even though the groups have a unified interface, they're still separated between "commands" and "arguments/parameters."

In this example, --help and --version are commands, and so are in the "Flags" command group. Meanwhile, --no-resume and --clear-resume are in the "Flags" parameter group.

I understand this is probably not 100% desireable, but --help and --version were upgraded to be more proper commands rather than parameters.

I have an idea on how I could fix this (if we deem this worth fixing), let me play with it a bit.

@BrianPugh
Copy link
Owner Author

the mypy issue basically comes down to this:

python/mypy#3004

I think I can ruin the type internally a bit (e.g. say that it can be a str even though it never will be) to try and make it happier for the users of the library.

@BrianPugh
Copy link
Owner Author

Panels with the same name across groups/parameters are now combined now that #61 has been merged into develop-v2. Weirdly enough, it actually made the code a little cleaner 😄 .

Panels are now displayed in alphabetical order, which is better in someways and worse in some ways than previously. It's better that there's a logical order. It's worse that originally meta-app panels were at the top. We can revisit this if we desire in the future.

There is some undocumented ambiguous merging logic when it happens (particularly for group descriptions) for display elements. But this situation is so rare (and obvious what happens) that I think it's not really an issue (until someone says it is 😄 ).

@BrianPugh
Copy link
Owner Author

the mypy issue basically comes down to this:

python/mypy#3004

I think I can ruin the type internally a bit (e.g. say that it can be a str even though it never will be) to try and make it happier for the users of the library.

This has been mitigated a little bit by #62.

Thanks for the feedback so far! Please keep them coming!

@Ravencentric
Copy link

Ravencentric commented Jan 12, 2024

Both #61 and #62 work wonderfully

Panels are now displayed in alphabetical order, which is better in someways and worse in some ways than previously. It's better that there's a logical order. It's worse that originally meta-app panels were at the top. We can revisit this if we desire in the future.

Perhaps allow the user to define hierarchy? Otherwise maybe first sort by Positional and then keyword and then sort by alphabetical within each?

@Ravencentric
Copy link

also another question, can i have groups that apply their affect but are not seperated in help?
Say I want two mutually exclusive args, but I don't want them to be seperated in the help page?

@BrianPugh
Copy link
Owner Author

BrianPugh commented Jan 12, 2024

Thoughts:

  1. I don't want to rely on group-declaration-order is frequently ambiguous, especially for commands (if commands are in different files, we then rely on import order, which is probably not a good option).

  2. For listing parameters within a panel, I don't perform a sort. These I leave in declaration-order because it's not ambiguous.

  3. For organizing the panels amongst themselves, currently it's just alphabetical order.

  4. I'm tempted to offer a new field Group.sort_key: Any that would help provide additional control over the panel-display-order, but I think the resulting API would have too many foot-guns.

also another question, can i have groups that apply their affect but are not separated in help?
Say I want two mutually exclusive args, but I don't want them to be separated in the help page?

If you create a group with an empty name, or with show=False, it won't show up in the help-page. A parameter can have multiple groups. Note that you will need to include the default group (assuming you want to have it show up there on the help-page). So probably what you want:

# Neither of these would show up on the help-page.
exclusive = Group(
    name="",
    validator=validators.LimitedChoice(),  # Mutually Exclusive Options
)
exclusive = Group(
    name="Flags",
    show=False,
    validator=validators.LimitedChoice(),  # Mutually Exclusive Options
)

@app.command
def foo(bar: Annotated[bool, group=(app.group_parameter, exclusive)]):
    pass

@BrianPugh
Copy link
Owner Author

I'm going to think through sort_key a little more, i think I could make it work...

@BrianPugh
Copy link
Owner Author

Here's an API that could work:

New field to Group:

sort_key: Any = None

When performing the help-page panel sort, we can make the data structure look something like this:

sortable_panels : List[Tuple[Any, HelpPanel]]

where this can be sorted by the first element of the tuple.

Proposed logic:

  1. If sort_key: Callable, then invoke it sort_key(group), apply the returned value to (2) if None, (3) otherwise.
  2. Split up sorted_panels into 2 lists: those with sort_key==None, and those with a value.
    Sort sort_key==None list by HelpPanel.title (current develop-v2 behavior).
    These sorted groups will be displayed after sort_key != None list.
  3. In all other cases, the resulting sort value is (sort_key, HelpPanel.title).
    It is the user's responsibility that sort_keys are comparable.

Ad Hoc Example:

@app.command(group=Group("Admin", sort_key=5))
def foo():
    pass


@app.command(group=Group("Zebra", sort_key=lambda x: 1))
def bar():
    pass


@app.command(group="Other")
def baz():
    pass
╭─ Zebra ────────────────────────────────────────────────────────────╮
│ bar  Docstring for foo.                                            │
╰────────────────────────────────────────────────────────────────────╯
╭─ Admin ────────────────────────────────────────────────────────────╮
│ foo  Docstring for foo.                                            │
╰────────────────────────────────────────────────────────────────────╯
╭─ Other ────────────────────────────────────────────────────────────╮
│ baz  Docstring for foo.                                            │
╰────────────────────────────────────────────────────────────────────╯

Centralized example (would have same response):

from functools import partial

group_order = {
   "Admin": 5,
   "Zebra": 1,
}

OrderGroup = partial(Group, sort_key=lambda x: group_order.get(x.name))

@app.command(group=OrderGroup("Admin"))
def foo():
    pass


@app.command(group=OrderGroup("Zebra"))
def bar():
    pass


@app.command(group=OrderGroup("Other"))
def baz():
    pass

This brings up a separate, but related topic. Maybe I should add a App.add_group method to further centralize this a bit.

from functools import partial

group_order = {
   "Admin": 5,
   "Zebra": 1,
}

OrderGroup = partial(Group, sort_key=lambda x: group_order.get(x.name))

app.add_group(OrderGroup("Admin"))
app.add_group(OrderGroup("Zebra"))
app.add_group(OrderGroup("Other"))

@app.command(group="Admin")
def foo():
    pass


@app.command(group="Zebra")
def bar():
    pass


@app.command(group="Other")
def baz():
    pass

and then if you want "in order added" functionality:

import itertools
counter = itertools.count()

def OrderGroup(*args, **kwargs):
   if 'sort_key' not in kwargs:
      kwargs['sort_key'] = next(counter)
   return Group(*args, **kwargs)

app.add_group(OrderGroup("Zebra"))
app.add_group(OrderGroup("Admin"))
app.add_group(OrderGroup("Other"))

Thoughts are a bit scattered here, but what do you think?

@BrianPugh
Copy link
Owner Author

The sort_key feature is available in #64. @Ravencentric can you look at it before I merge it into develop-v2? While docs aren't built for that branch, the usage should be clear from the docs/api.rst changes.

I'm still on the fence about adding/implementing the above mentioned add_group, what are your thoughts?

@BrianPugh
Copy link
Owner Author

I've updated #64 to also include a convenience Group.create_ordered classmethod that performs the actions of OrderGroup described above. I also updated the Group docs page to be more useful.

I'm pretty happy with #64 now and will probably merge it to develop-v2 in the morning with the aims of releasing v2 by the end of the weekend.

I have decided against adding App.add_group, I don't think it provides enough value to warrant an additional method. The user can create all their command groups in bulk in one location if they would like, and then providing the group object to the command decorators is similar effort as providing a title-string.

@Ravencentric
Copy link

I think I'm pretty happy that you didn't go with App.add_group, the concept was confusing to me and I think the previous suggestion is intuitive enough. I'll hopefully get to test it tonight.

@Ravencentric
Copy link

Played with it a bit, found no issues. Although I was wondering, what would be the easiest way to sort the default groups (args, params, commands)?

@BrianPugh
Copy link
Owner Author

Played with it a bit, found no issues.

Thanks for giving it a try! I'm gonna tweak a few minor things (I think there might be some edge-cases).

what would be the easiest way to sort the default groups (args, params, commands)?

You can explicitly set the sort_key:

app.group_commands.sort_key = 100
app.group_arguments.sort_key = 200
app.group_arguments.sort_key = 300

Currently, these values don't propagate as far as you might expect. For example, it won't propagate to commands/subcommands. I'll try and improve this in another PR.

Expanding on above, whenever you do @app.command, this actually creates a new App object with your decorated function as the default command, and registers this new app object to the command-string. I'm thinking it should maybe copy certain attributes, like the above mentioned attributes.

@Ravencentric
Copy link

Ravencentric commented Jan 13, 2024

You can explicitly set the sort_key:

perfect!

I'm thinking it should maybe copy certain attributes, like the above mentioned attributes.

That does sound like something an end user would expect

@BrianPugh
Copy link
Owner Author

closing with the release of v2. We can reopen this issue, or open a new one, should the need arise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request v2 Breaking change required.
Projects
None yet
Development

No branches or pull requests

2 participants