cyclopts icon indicating copy to clipboard operation
cyclopts copied to clipboard

More nested meta help page issues

Open JuneStepp opened this issue 3 months ago • 3 comments

I tried the latest release and found:

  • The help page for meta commands don't show the parameters from meta.meta.default.
  • meta.meta commands show the parameters from meta.default.
  • Non-meta commands except app.default don't show the parameters from meta.meta.default.

JuneStepp avatar Nov 11 '25 17:11 JuneStepp

Hey @JuneStepp!

Can you explain your use-case a little more? I'm sure there are some meta-meta consistency/bugs, but I'm also a bit hesitant if the fixes make the code significantly more complicated. Is there a way to restructure your app to avoid these structures?

The help page for meta commands don't show the parameters from meta.meta.default.

If i'm interpreting this correctly, this is intended. The relationship is that meta calls it's parent, so the parent doesn't care/concern itself about the meta. By extension, app.meta doesn't do anything with app.meta.meta.

BrianPugh avatar Nov 11 '25 19:11 BrianPugh

Can you explain your use-case a little more? I'm sure there are some meta-meta consistency/bugs, but I'm also a bit hesitant if the fixes make the code significantly more complicated. Is there a way to restructure your app to avoid these structures?

I explained this trouble in https://github.com/BrianPugh/cyclopts/issues/641#issuecomment-3489180169. Every level of a parameter's validation depending on another parameter requires another meta app. In my application that has various games registered to it, I first need to get where the program config directory is in meta.meta.default, then handle program options and selecting a game in meta.default, then finally handle game specific options in app.default. I attach commands that don't depend on the program config to meta.meta and ones that don't depend on a game being selected to meta.

The help page for meta commands don't show the parameters from meta.meta.default.

If i'm interpreting this correctly, this is intended. The relationship is that meta calls it's parent, so the parent doesn't care/concern itself about the meta. By extension, app.meta doesn't do anything with app.meta.meta.

When an app.meta command is called, app.meta.meta.default runs first, so the parameters for it should be in the help page, no?

JuneStepp avatar Nov 11 '25 20:11 JuneStepp

First things first, I merged in #689 as I ran into an ergonomics issue when typing up the example below. I'll release this soon, but as of right now please use main for playing around with the example below.

Every level of a parameter's validation depending on another parameter requires another meta app.

Have you looked into Groups? I suspect what you are trying to achieve is best accomplished through Group validators. I admit, the documentation there is not very great (and I'd appreciate some help there once we resolve this issue!).

To make the conversation a little more concrete, here's a relatively small minimum-working-example that probably has strong resemblance to what you want:

from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import cyclopts
from cyclopts import App, Group, Parameter
from cyclopts.validators._group import MutuallyExclusive

app = App(
    name="launcher",
    default_parameter=Parameter(negative=""),
)


def _credential_validator(argument_collection):
    # Filter to only arguments that have values (were provided by the user)
    populated = argument_collection.filter_by(value_set=True)

    # Check if both token and username/password were provided
    if "--token" in populated and ("--username" in populated or "--password" in populated):
        raise ValueError("Cannot supply both TOKEN and USERNAME/PASSWORD.")


_credential_group = Group(name="Credentials", validator=_credential_validator)
cheats = Group(name="Cheats")  # Just for organizing
mods = Group(name="Mods")  # Just for organizing


@Parameter(name="*", show_default=False)
@dataclass(kw_only=True)
class Credentials:
    username: Annotated[str, Parameter(alias="-u", group=_credential_group)] = ""
    """Username to login."""

    password: Annotated[str, Parameter(alias="-p", group=_credential_group)] = ""
    """Password for username."""

    token: Annotated[str, Parameter(alias="-t", group=_credential_group)] = ""
    """Authentication token (instead of username/password)."""


@app.command(group="Games")
def mario(
    *,
    credentials: Credentials | None = None,
    infinite_lives: Annotated[bool, Parameter(group=cheats)] = False,
):
    """An Italian plumber stomping on some Koopas.

    Parameters
    ----------
    infinite_lives: bool
        Give mario an infinite number of lives.
    """
    print(f"{credentials=}")
    print(f"{infinite_lives=}")


_mutually_exclusive_health = Group(validator=MutuallyExclusive())


@app.command(group="Games")
def zelda(
    *,
    credentials: Credentials | None = None,
    infinite_health: Annotated[bool, Parameter(group=(cheats, _mutually_exclusive_health))] = False,
    one_hit_ko: Annotated[bool, Parameter(group=(mods, _mutually_exclusive_health))],
):
    """The adventure awaits our hero of Hyrule.

    Parameters
    ----------
    infinite_health: bool
        Give the player infinite health.
    one_hit_ko: bool
        Taking damage causes instant death.
    """
    print(f"{credentials=}")
    print(f"{infinite_health=}")


@app.meta.default
def meta(
    *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
    config: Path = Path("config.yaml"),
):
    """Customized game launcher.

    Parameters
    ----------
    config: Path
        Path to YAML configuration file.
    """
    app.config = cyclopts.config.Yaml(config, search_parents=True)
    app(tokens)


if __name__ == "__main__":
    app.meta()

Please play around with this and let me know what you think! We can use this example as a concrete way to experiment with logic that we want to implement.

BrianPugh avatar Nov 12 '25 01:11 BrianPugh