Coder Social home page Coder Social logo

cyclopts's Introduction

Python compat PyPI ReadTheDocs codecov


Documentation: https://cyclopts.readthedocs.io

Source Code: https://github.com/BrianPugh/cyclopts


Cyclopts is a modern, easy-to-use command-line interface (CLI) framework. It offers a streamlined approach for building CLI applications with an emphasis on simplicity, extensibility, and robustness. Cyclopts aims to provide an intuitive and efficient developer experience, making python CLI development more accessible and enjoyable.

Why Cyclopts?

  • Intuitive API: Cyclopts features a straightforward and intuitive API, making it easy for developers to create complex CLI applications with minimal code.

  • Advanced Type Hinting: Cyclopts offers advanced type hinting features, allowing for more accurate and informative command-line interfaces.

  • Rich Help Generation: Automatically generates beautiful, user-friendly help messages, ensuring that users can easily understand and utilize your CLI application.

  • Extensible and Customizable: Designed with extensibility in mind, Cyclopts allows developers to easily add custom behaviors and integrate with other systems.

Installation

Cyclopts requires Python >=3.8; to install Cyclopts, run:

pip install cyclopts

Quick Start

  • Create an application using cyclopts.App.
  • Register commands with the command decorator.
  • Register a default function with the default decorator.
from cyclopts import App

app = App()


@app.command
def foo(loops: int):
    for i in range(loops):
        print(f"Looping! {i}")


@app.default
def default_action():
    print("Hello world! This runs when no command is specified.")


app()

Execute the script from the command line:

$ python demo.py
Hello world! This runs when no command is specified.

$ python demo.py foo 3
Looping! 0
Looping! 1
Looping! 2

With just a few additional lines of code, we have a full-featured CLI app. See the docs for more advanced usage.

Compared to Typer

Cyclopts is what you thought Typer was. Cyclopts's includes information from docstrings, support more complex types (even Unions and Literals!), and include proper validation support. See the documentation for a complete Typer comparison.

Consider the following short Cyclopts application:

import cyclopts
from typing import Literal

app = cyclopts.App()


@app.command
def deploy(
    env: Literal["dev", "staging", "prod"],
    replicas: int | Literal["default", "performance"] = "default",
):
    """Deploy code to an environment.

    Parameters
    ----------
    env
        Environment to deploy to.
    replicas
        Number of workers to spin up.
    """
    if replicas == "default":
        replicas = 10
    elif replicas == "performance":
        replicas = 20

    print(f"Deploying to {env} with {replicas} replicas.")


if __name__ == "__main__":
    app()
$ my-script deploy --help
Usage: my-script deploy [ARGS] [OPTIONS]

Deploy code to an environment.

╭─ Parameters ────────────────────────────────────────────────────────────────────────────────────────────╮
│ *  ENV,--env            Environment to deploy to. [choices: dev,staging,prod] [required]                │
│    REPLICAS,--replicas  Number of workers to spin up. [choices: default,performance] [default: default] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯

$ my-script deploy staging
Deploying to staging with 10 replicas.

$ my-script deploy staging 7
Deploying to staging with 7 replicas.

$ my-script deploy staging performance
Deploying to staging with 20 replicas.

$ my-script deploy nonexistent-env
╭─ Error ────────────────────────────────────────────────────────────────────────────────────────────╮
│ Error converting value "nonexistent-env" to typing.Literal['dev', 'staging', 'prod'] for "--env".  │
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯

In its current state, this application would be impossible to implement in Typer. However, lets see how close we can get with Typer:

from typer import Typer, Argument
from typing import Annotated, Literal
from enum import Enum

app = Typer()


class Environment(str, Enum):
    dev = "dev"
    staging = "staging"
    prod = "prod"


def replica_parser(value: str):
    if value == "default":
        return 10
    elif value == "performance":
        return 20
    else:
        return int(value)


@app.callback()
def dummy_callback():
    pass


@app.command(help="Deploy code to an environment.")
def deploy(
    env: Annotated[Environment, Argument(help="Environment to deploy to.")],
    replicas: Annotated[
        int,
        Argument(
            parser=replica_parser,
            help="Number of workers to spin up.",
        ),
    ] = replica_parser("default"),
):
    print(f"Deploying to {env.name} with {replicas} replicas.")


if __name__ == "__main__":
    app()
$ my-script deploy --help
Usage: my-script [OPTIONS] ENV:{dev|staging|prod} [REPLICAS]

 Deploy code to an environment.

╭─ Arguments ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *    env           ENV:{dev|staging|prod}  Environment to deploy to. [default: None] [required]                                       │
│      replicas      [REPLICAS]              Number of workers to spin up. [default: 10]                                                │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --install-completion        [bash|zsh|fish|powershell|pwsh]  Install completion for the specified shell. [default: None]              │
│ --show-completion           [bash|zsh|fish|powershell|pwsh]  Show completion for the specified shell, to copy it or customize the     │
│                                                              installation.                                                            │
│                                                              [default: None]                                                          │
│ --help                                                       Show this message and exit.                                              │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

$ my-script deploy staging
Deploying to staging with 10 replicas.

$ my-script deploy staging 7
Deploying to staging with 7 replicas.

$ my-script deploy staging performance
Deploying to staging with 20 replicas.

$ my-script deploy nonexistent-env
Usage: my-script deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS]
Try 'my-script deploy --help' for help.
╭─ Error ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Invalid value for 'ENV:{dev|staging|prod}': 'nonexistent-env' is not one of 'dev', 'staging', 'prod'.                                  │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

The Typer implementation is 43 lines long, while the Cyclopts implementation is just 30, all while including a proper docstring. Since Typer doesn't support Unions, the choices for replica could not be displayed on the help page. We also had to include a dummy callback since our application currently only has a single command. Cyclopts is much more terse, much more readable, and much more intuitive to use.

cyclopts's People

Contributors

brianpugh avatar dependabot[bot] avatar kianmeng avatar nesb1 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

cyclopts's Issues

Path validator gives misleading error message

take this for an example

# Must exist, must be a file, must NOT be a directory
validator=validators.Path(exists=True, file_okay=True, dir_okay=False)
> python -m test --config "D:\this_dir_exists"
╭─ Error ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Invalid value for --config. D:\this_dir_exists is not a directory.                                                   │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

This is wrong, It is a directory, it's not supposed to be a directory but it is one. same with file_okay=False

The message should be something like Invalid value for --config. Only file input is allowed but D:\this_dir_exists is a directory.

Prompt for missing required parameters

How to implement Typer's CLI Option Prompt with cyclopts?
Is there a solution other than inspecting the tokens via Meta App?

In my specific use-case, I want to ask for a password if it's not passed as parameter (which usually shouldn't be passed in clear text via CLI, but I want to let the users decide).

Newlines should not be present in descriptions that span multiple lines

I have my docstrings formatted to have a max line length. However, this is just to make my linter (and my eyes) happy. When rendering the descriptions in the console, these line breaks should not be present.

I believe this is an issue with docstring_parser https://github.com/rr-/docstring_parser/issues/87 but I wanted to bring it to your attention in case there is a slick workaround, or it doesn't get fixed upstream 😉

Example:

    """
    short description
    
    Long description that can span multiple lines. This is test sentence 1. This is test sentence 2. I do not want a 
    line break in this sentence.

    Parameters:
        param_1: Description of param_1. This is test sentence 1. This is test sentence 2. This is test sentence 3. I
            do not want a line break in this sentence. 
    """

Ability to set global overrides for `cyclopts.Parameters`

Take this case as an example:
I do not want negatives or defaults to show up in --help for everything but one. Currently, I gotta do something like:

foo: Annotated[bool, Parameter(name="--foo", help="do foo", negative="", show_default=False)] = False,
bar: Annotated[bool, Parameter(name="--bar", help="do bar", negative="", show_default=False)] = False,
baz: Annotated[bool, Parameter(name="--baz", help="do baz")] = False,

Is it (or would it be) possible to set some global default overrides?
Example:

cyclopts.GlobalParameters(negative="", show_default=False)

foo: Annotated[bool, Parameter(name="--foo", help="do foo")] = False,
bar: Annotated[bool, Parameter(name="--bar", help="do bar")] = False,
# override show_default global for baz but negative continues to follow global default
baz: Annotated[bool, Parameter(name="--baz", help="do baz", show_default=True)] = False,

Handle parameters that don't exist

argparse:

> myapp --helo
Usage: myapp [-h] [--hello]
myapp: error: unrecognized arguments: --helo

cyclopts:

 python .\test.py --hello
Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 314, in _convert
    cparam.validator(type_, val)
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\validators\_path.py", line 33, in __call__
    raise ValueError(f"{path} does not exist.")
ValueError: --hello does not exist.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 411, in parse_args
    command, bound, unused_tokens = self.parse_known_args(tokens, console=console)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 328, in parse_known_args
    bound, unused_tokens = create_bound_arguments(command, unused_tokens)
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 357, in create_bound_arguments
    coerced = _convert(mapping)
              ^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 321, in _convert
    raise new_exception from e
cyclopts.exceptions.ValidationError: <exception str() failed>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\test.py", line 30, in <module>
    app()
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 453, in __call__
    command, bound = self.parse_args(tokens, **kwargs)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 423, in parse_args
    console.print(format_cyclopts_error(e))
                  ^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\exceptions.py", line 261, in format_cyclopts_error
    Text(str(e), "default"),
         ^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\exceptions.py", line 143, in __str__
    parameter_cli_name = ",".join(self.parameter2cli[self.parameter])
                                  ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
KeyError: <Parameter "path: Annotated[pathlib.Path, Parameter(name=(), converter=None, validator=Path(exists=True, file_okay=True, dir_okay=True), negative=None, token_count=None, parse=True, show=None, show_default=None, show_choices=None, help='directory or file', show_env_var=None, env_var=())] = WindowsPath('C:/Users/raven/Documents/GitHub/test')">

migration guides?

Howdy,

Saw the recent post on reddit about this and am checking it out.

Is there any plans to include some migration guides in the docs? E.g. from Typer to cyclopts?

There are a couple of things that aren't obvious to me from reading so far, e.g. how to implement the equivalent of nested subcommands.

Likewise is it possible to generate shell autocompletions and are there any patterns for incorporating telemetry?

Thanks!

allow keyword list to accept multiple tokens per single CLI option.

from pathlib import Path
from typing import Annotated

from cyclopts import App, Parameter, validators

app = App()

@app.default() # type: ignore
def cli(
    *,
    path: Annotated[
        Path, 
        Parameter(
            help="directory or file",
            validator=validators.Path(exists=True))
        ] = Path.cwd(),
    config: Annotated[
        Path, 
        Parameter(
            name=["--config"], 
            help="config file", 
            validator=validators.Path(exists=True))
        ] = Path.cwd() / "config.yaml",
    args: Annotated[
        list[str], 
        Parameter(
            name="--args", 
            help="args")
        ] = [],
) -> None:
    print(f"{path = }, type = {type(path)}")
    print(f"{config = }, type = {type(config)}")
    print(f"{args = }, type = {type(args)}")

if __name__ == "__main__":
    app()
> python .\test.py --args abc def geh
Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\test.py", line 36, in <module>
    app()
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 455, in __call__
    command, bound = self.parse_args(tokens, **kwargs)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 413, in parse_args
    command, bound, unused_tokens = self.parse_known_args(tokens, console=console)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 330, in parse_known_args
    bound, unused_tokens = create_bound_arguments(command, unused_tokens)
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 365, in create_bound_arguments
    p2c = parameter2cli(f)
          ^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 72, in parameter2cli
    p2c.setdefault(iparam, [])
  File "C:\Users\raven\AppData\Local\Programs\Python\Python312\Lib\inspect.py", line 2842, in __hash__
    return hash((self._name, self._kind, self._annotation, self._default))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'list'

[BUG] Weird behavior of list of tuples

there is difference between when command need a tuple and when it expect list of tuples
in unexpected way:
when you handle list of tuples unlike regular tuple if u pass the --item flag it assume that the list is empty instead of checking that you added at-least one. not sure that intended.

moreover if you pass items and the arity of the tuple is not divisdeable by the number of the params you dont get error it just discard the extra tokens which seems like a bug

was checked on v2.3.0 commit of main

import cyclopts
from cyclopts import Parameter
from typing import Annotated

app = cyclopts.App()


@app.command(name="create")
def create_dict(items: Annotated[list[tuple[str,int|str]],Parameter(name="--item")]):
    """
    item:
        key,value of items to store in the dict - can pass more then one pair or pass the flag multiple times
    """
    res=dict(items)
    print(res)


if __name__=="__main__":
    app()
orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create
╭─ Error ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Command "create" parameter "--item,--empty-item" requires 2 arguments. Only got 0.                                   │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create  --item
{}

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create  --item a
{}

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create  --item a b
{'a': 'b'}

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create  --item a b c
{'a': 'b'}

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create  --item a b c 2
{'a': 'b', 'c': 2}

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   create  --item a b c 2 d
{'a': 'b', 'c': 2}

[Discussion] First-class config overrides via pyproject.toml?

Hi! Thanks for building this awesome tool. I'm loving using it so far, and excited to show our users how awesome our CLIs can be. I did want to get your thoughts on how you might envision cyclopts evolving. There are some areas the story seems a little unclear with how configs loaded from pyproject.toml interact with those passed in. These aren't feature requests, but it would be good to get your reaction to these, and the general idea of first-class support for pyproject.toml:

Meta App Parameter Overrides

While there is a nice example showing how to override values for commands, I was playing around with this a bit and didn't see an immediately obvious way to infer defaults for parameters passed to the meta app. Take the following example, using verbose and quiet logging flags as an example:

log_params = Group(
    "Logging",
    default_parameter=Parameter(
        negative="",  # Disable "--no-" flags
        show_default=False
    ),
    validator=validators.LimitedChoice(),  # Mutually Exclusive Options
)

# This method requires these types to be optional even if they're not
VERBOSE = Annotated[bool | None, Parameter(help="Increase log output", group=log_params)]
QUIET = Annotated[bool | None, Parameter(help="Decrease log output", group=log_params)]

# Separate dict to track actual defaults since we have to have the CLI default to None
DEFAULTS = {
    'verbose': False,
    'quiet': False
}

def get_meta_param(name: str, value: Any, config: dict[str, Any]) -> Any:
    """Get parameter for meta app from config, accounting for pyproject.toml and command line overrides.

    All meta app parameters must be optional/nullable in order to sue this as a flag for whether or not
    they have been set via the command line.

    Args:
        name: Name of the parameter
        value: Value of parameter received from CLI
        config: Config dict from pyproject.toml

    Returns:
        Parameter value
    """
    # Flag was passed via CLI
    if value is not None:
        return value

    # Flag was specified in pyproject.toml
    if config.get(name, None) is not None:
        return config[name]
    
    # Flag was specified in neither, set true default
    return DEFAULTS[name]


@app.meta.default
def meta(
    *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
    verbose: VERBOSE = None,
    quiet: QUIET = None
) -> Any:
    """Meta App. Used to inject configs from pyproject.toml as defaults
    """
    config = load_pyproject_config()

    # The main Cyclopts parsing/conversion
    command, bound = app.parse_args(tokens)

    # Example of using these - the internals here don't matter
    configure_logging(
        verbose=get_meta_param('verbose', verbose, config),
        quiet=get_meta_param('quiet', quiet, config)
    )

    # Other config parsing for commands below here
    ...

[edit: in case it's not clear why I felt like I have to use None as a default, we otherwise couldn't tell the difference between a passed False config and the method default value of False]

It is entirely possible I am way off the prescribed path here, so if there is an easier way to do this, please let me know. If not, it should be hopefully a little clear how using meta app params ends up feeling a little complex and also locks you out of documenting default values via type hints.

Config Validation

Configs loaded via pyproject.toml seem to skip the standard validation for CLI parameters. E.g. I could have updated the defaults via pyprject to some invalid combination, and this would not be caught by e.g. validators.LimitedChoice(). You can test out the example above, by adding a section to your project.yaml with both verbose and quiet config set to true.

There's probably a number of ways to work around this, but I think you'd have to load in the pyproject.toml early to get these values set in advance of the current validator logic. Perhaps loading pyproject.toml on init and attaching default_config dict to the App class would be one way to go about this? There are probably some considerations for not accidentally changing the defaults as they show in the CLI help text.

Schema Validation

It would be really awesome to be able to generate schema validation json for any commands. By this, I mean some compatibility functionality to generate a schema.json file from the command definitions themselves. These schema definitions can be used by IDEs etc to provide inspection capability to users who are adding a section to configure a published tool. An example of ruff's schema.json is https://github.com/astral-sh/ruff/blob/main/ruff.schema.json.

Having a defined schema for pyproject.toml input would also allow auto-coercion for any types from pyproject.toml, so you wouldn't have to manually validate each config.

Thanks again for sharing this library, it really is great, and I'd love to hear more about your thoughts on this problem space.

[BUG] list of tuple of single item is parsed but take only the first character of each token get parsed

import cyclopts
from cyclopts import Parameter
from typing import Annotated

app = cyclopts.App(converter=cyclopts.convert)


@app.command(name="create")
def create_dict(  
    numbers: Annotated[list[tuple[str]],Parameter(name="--item-int")],
    strs: Annotated[list[tuple[str]],Parameter(name="--item")]):
    """
    item:
        key,value of items to store in the dict - can pass more then one pair or pass the flag multiple times
    """
    # res=dict(items)
    print(f"numbers={numbers}, strs={strs}",)


if __name__=="__main__":
    app()
> poetry run python3 ./test.py   create --item aa --item-int 16
numbers=[('1',)], strs=[('a',)]

Parameter / Command Panel Regression

Somewhere in the upgrade from 1.3 -> v2, there has been a regression that has caused parameters and commands to be displayed on the same panel, which can be seen through the following:

(.venv) PS C:\Users\Skyler\PyCharm Projects\NewCLI_Testing> python .\main.py --help
Usage: main.py COMMAND

╭─ Parameters ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --version      Display application version.                                                                                                                   │
│ --help     -h  Display this message and exit.                                                                                                                 │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ create-snapshot  Creates a new snapshot for a VM.                                                                                                             │
│ delete-snapshot                                                                                                                                               │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
(.venv) PS C:\Users\Skyler\PyCharm Projects\NewCLI_Testing> pip list
Package           Version
----------------- -------
attrs             23.2.0
cyclopts          1.3.0
docstring-parser  0.15
mdurl             0.1.2
pip               23.3.2
Pygments          2.17.2
rich              13.7.0
typing_extensions 4.9.0
(.venv) PS C:\Users\Skyler\PyCharm Projects\NewCLI_Testing> pip install -U cyclopts
Requirement already satisfied: cyclopts in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (1.3.0)
Collecting cyclopts
  Downloading cyclopts-2.1.0-py3-none-any.whl.metadata (11 kB)
Requirement already satisfied: attrs>=23.1.0 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from cyclopts) (23.2.0)
Requirement already satisfied: docstring-parser<0.16,>=0.15 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from cyclopts) (0.15)
Requirement already satisfied: rich>=13.6.0 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from cyclopts) (13.7.0)
Requirement already satisfied: typing-extensions>=4.8.0 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from cyclopts) (4.9.0)
Requirement already satisfied: markdown-it-py>=2.2.0 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from rich>=13.6.0->cyclopts) (3.0.0)
Requirement already satisfied: pygments<3.0.0,>=2.13.0 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from rich>=13.6.0->cyclopts) (2.17.2)
Requirement already satisfied: mdurl~=0.1 in c:\users\skyler\pycharm projects\newcli_testing\.venv\lib\site-packages (from markdown-it-py>=2.2.0->rich>=13.6.0->cyclopts) (0.1.2)
Downloading cyclopts-2.1.0-py3-none-any.whl (40 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40.4/40.4 kB 2.0 MB/s eta 0:00:00
Installing collected packages: cyclopts
  Attempting uninstall: cyclopts
    Found existing installation: cyclopts 1.3.0
    Uninstalling cyclopts-1.3.0:
      Successfully uninstalled cyclopts-1.3.0
Successfully installed cyclopts-2.1.0
(.venv) PS C:\Users\Skyler\PyCharm Projects\NewCLI_Testing> python .\main.py --help
Usage: main.py COMMAND

╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ create-snapshot  Creates a new snapshot for a VM.                                                                                                                                                      │
│ delete-snapshot                                                                                                                                                                                        │
│ --help,-h        Display this message and exit.                                                                                                                                                        │
│ --version        Display application version.                                                                                                                                                          │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
(.venv) PS C:\Users\Skyler\PyCharm Projects\NewCLI_Testing>

The actual python code used to generate this was this:

from cyclopts import App
from typing import Optional

app = App()


@app.command
def create_snapshot(name: Optional[str]):
    """
    Creates a new snapshot for a VM.

    Args:
        name: The name of the snapshot to create.

    """
    print(name)


@app.command
def delete_snapshot(name: str):
    print(name)


if __name__ == "__main__":
    app()

Use of the meta application results in a double help display

Hi,

I am moving from the Typer and have some strange behavior. Please, consider this test application:

from typing import Annotated

from cyclopts import App, Group
from cyclopts import Parameter

__app_name__ = "Test app"
__version__ = "0.0.1"

app = App(version=f"{__app_name__} v{__version__}", version_flags=["--version"], help_flags=["--help"])
app.meta.group_parameters = Group("Debug Output")


@app.command
def foo(h_param: Annotated[str, Parameter(name=["-h", "--hparam"], allow_leading_hyphen=True)] = "some_value"):
    print("h_param", h_param)


@app.meta.default()
def main(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
         verbose: Annotated[bool, Parameter(name="--verbose", show_default=False, negative="")] = False):
    if verbose:
        print("verbose mode enabled")
    else:
        print("verbose mode disabled")

    app(tokens)


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

Then the following happens:

python cli2.py

verbose mode disabled
Usage: cli2.py COMMAND

╭─ Commands ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ foo                                                                                                                                                                                       │
│ --help,-h  Display this message and exit.                                                                                                                                                 │
│ --help     Display this message and exit.                                                                                                                                                 │
│ --version  Display application version.                                                                                                                                                   │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Debug Output ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --verbose                                                                                                                                                                                 │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

And this:

python cli2.py --verbose foo -h 111

Usage: cli2.py COMMAND

╭─ Commands ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ foo                                                                                                                                                                                       │
│ --help,-h  Display this message and exit.                                                                                                                                                 │
│ --help     Display this message and exit.                                                                                                                                                 │
│ --version  Display application version.                                                                                                                                                   │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Debug Output ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --verbose                                                                                                                                                                                 │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

But if I change -h to --hparam it works as expected:

python cli2.py --verbose foo --hparam 222

verbose mode enabled
h_param 222

Please help me to remove double --help from the output, and -h should also work as a short option.

Thank you!

[Feature request]: Nested pydantic validation

Adding support for nested pydantic model.

For instance:

from pydantic import BaseModel

class FuncArgs(BaseModel):
   a: int
   b: str

def f(input: FuncArgs):
   ...

class MainInputs(BaseModel):
   f_arg: FuncArgs
   name: str

@app.default
def main(inputs: MainInputs):
     f(inputs.f_args)
     ...

Not sure how the nested config would look like from the cli perspective but here is a proposition

python cli.py --f-arg--a 1 --f-args--b 1
python cli.py --f-arg.a 1 --f-args.b 1

This is a feature that is available in hydra/OmegaCong that would be really useful to have here !

Thanks in advance

Group Support

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

  • Builin argparse uses groups for the help-page and mutual exclusion.
  • #19 - option groups/aliases
  • #30 - Seperate positional only and keyword only arguments in --help similar to argparse and typer

Misleading error message when flag name has an underscore

Example code:

from cyclopts import App
app = App()

@app.command
def foo(test_flag: bool):
  print("x" if test_flag else "y")

app()

Executing with --test-flag:

$python test.py foo --test_flag
╭─ Error ─────────────────────────────────────────────────────────────────╮
│ Unknown option: "--test-flag".                                          │
╰─────────────────────────────────────────────────────────────────────────╯

The error message seems incorrect, because --test-flag is right and is a known option. Ideally cyclopts should say Unknown option: --test_flag, or maybe test-flag and test_flag should be synonyms.

Version info: cyclopts 2.3.1, python 3.9.18.

App() name parameter type hint adjustment

While doing some development testing, PyCharm has flagged the following type error:

image

Per the examples in cyclopts' docs, this should work without errors. Inspecting the code inside App yields a type hint that is expecting a Tuple from the get go which is not what would initially be passed into that parameter (but would be accurate after the conversion function has run).

Current code:
_name: Optional[Tuple[str, ...]] = field(default=None, alias="name", converter=optional_to_tuple_converter)

Possible fix:
_name: Optional[Union[str, Tuple[str, ...]]] = field(default=None, alias="name", converter=optional_to_tuple_converter)

Docstring hyperlink

I'd like to include a hyperlink in my long_description. If I include a link, my TE allows me to CTRL+click it, which is great, but I'd rather not have the full link print if I can avoid it. I see that some TEs have support for hyperlinks, and it seems like rich supports this (bottom of section). Is there a way I can achieve this with cyclopts?

My hunch is "not currently" as I see that long_description is added as a single Text component with the style "info". It'd be cool if it could be parsed as markdown, but I understand why that'd be a bad default.

Also, really nice library. I've used click in the past. I was going to try typer, but I saw this project mentioned in an issue about Unions. Very impressed.

the help format parsing is not consistent

the rst parser parse only some parse of the pydoc
also it accept rich tags in some parts and no

import cyclopts
app=cyclopts.App(help_format="rst")

@app.command
def div(x:int,y:int)->int:
    """My application **[red]summary[/red]**.

    We can do RST things like have bold text**.
    More words in this paragraph.

    This is a new paragraph with some bulletpoints below:

    * bullet point 1.
    * bullet point 2.
    
    Args:
        x (int): 
        y (int): number [red bold]must to not be 0[/red bold]

    Returns:
        int: x/y

    """    
    print(x/y)

if __name__=="__main__":
    app()

seems like this is not consistent on some parts of the help u can use md/rst parser and some of them can be modified with rich tags
and the usage sometimes can be modified with rich tags and sometimes not.
i am not sure what is the best fix for this issue would be

for example

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.12
 > python3 rst.py  -h
Usage: rst.py COMMAND

╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ div        My application **summary**.                                                                                                                                 │
│ --help,-h  Display this message and exit.                                                                                                                              │
│ --version  Display application version.                                                                                                                                │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.12
 > clear

orhayat:~/cyclopts (main) is 📦 v0.0.0 via 🐍 v3.10.12
 > python3 rst.py div -h
Usage: rst.py div [ARGS] [OPTIONS]

My application [red]summary[/red].

We can do RST things like have bold text**. More words in this paragraph.

This is a new paragraph with some bulletpoints below:

 • bullet point 1.
 • bullet point 2.

╭─ Parameters ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *  X,--x  [required]                                                                                                                                                   │
│ *  Y,--y  number must to not be 0 [required]                                                                                                                           │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Doc string formatting behavior for multi-line comments

Hi! Just learned about this library and love it so far - this is some beautiful command line tooling. I'm seeing some interesting behavior with docstrings. Let's take the sample app:

import cyclopts

app = cyclopts.App()


@app.command
def foo(name: str) -> None:
    """
    Do something. this is a multi-line
    comment wrapping over. words words words
    more words on more lines


    Args:
        name: something
    """
    print(f"Hello {name}!")


def main() -> None:
    app()


if __name__ == "__main__":
    main()

Running my_app foo -h, I see

$ my_app foo -h
Usage: my_app foo [ARGS] [OPTIONS]

Do something. this is a multi-line

comment wrapping over. words words words
more words on more lines

First, I have a slightly different docstring format than yours, but cool to see it just works for the most part. I am noticing that it looks like it generates a space after the first line. Is this intended behavior/is there any way to avoid injecting this space?

Or maybe this is just telling me I should be more concise :). Why waste time say lot word when few word do trick.

support for union of None like Optional

Hey, thanks for the great lib.

I think that currently Optional[int] is supported but not int | None which should be equivalent.

Would it be possible to support it ?

thanks in advance

Misc likely (rare) typing bugs

Double checking some things I noticed from reviewing #123. The following need to be investigated:

  • The implicit type mapping tuple: Tuple[str, ...] should probably be added here:

    set: Set[str],

    • I think the type currently being operated on in #123 needs to use this mapping; @OrHayat I think it might be best to make a function like
    def resolve_unparameterized_type(type_: Type) -> Type:
        return _implicit_iterable_type_mapping.get(type_, type_)
    
  • This Any -> str type coercion should probably be moved into either resolve or _convert:

    if type_ is Any:

  • converter not properly passed along here:

    return pconvert(_implicit_iterable_type_mapping[type_], element)

  • This Union check probably needs a similar fix as in #80:

    if get_origin(type_) is Union:

App() console parameter

should App() have a console parameter to pass?

from rich.console import Console
from cyclopts import App, Parameter

console = Console()
app = App(console=console)

[BUG]: bad error report because of assetion error when there is UnusedCliTokensError

code that reproduce the issue - checkd it on existing main branch (v2.3.0)

import cyclopts
from cyclopts import Parameter
from typing import Annotated

app = cyclopts.App()
#same issue happen even if u setup console on app
# from rich.console import Console
# app=cyclopts.App(console=Console())

@app.command(name="store")
def store_item(item: Annotated[tuple[str,str],Parameter(name="--item")]):
    """
    kv:
        key value of item to store
    """
    print(item)

if __name__=="__main__":
    app()

input that reproduce the error

poetry run python3 ./test.py   store --item a  g v
Traceback (most recent call last):
  File "/home/orhayat/cyclopts/cyclopts/core.py", line 622, in parse_args
    raise UnusedCliTokensError(
cyclopts.exceptions.UnusedCliTokensError: Unused Tokens: ['v'].

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/orhayat/cyclopts/./test.py", line 20, in <module>
    app()
  File "/home/orhayat/cyclopts/cyclopts/core.py", line 674, in __call__
    command, bound = self.parse_args(
  File "/home/orhayat/cyclopts/cyclopts/core.py", line 630, in parse_args
    assert e.console
AssertionError

it worked as expected in v2.2.0

:~/cyclopts (366a036 | v2.2.0) is 📦 v0.0.0 via 🐍 v3.10.6
> poetry run python3 ./test.py   store  --item a b c
╭─ Error ──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Unused Tokens: ['c'].                                                                                                │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Interactive shell created according to docs also includes shell command

When creating the interactive shell, you probably don't really want to expose the command that, well, creates the shell. Currently, you can enter a shell from a shell etc., endlessly. There is no way to prevent that since all the commands get exposed in the shell.

Maybe the simplest solution would be to add an argument allowing you to exclude commands when creating the interactive shell? Backwards compatible and not expanding the API unnecessarily (my another idea was a special decorator for the command calling the shell, but that's probably not ideal).

If that sounds like a sensible solution, I could try to add that, and then we should probably document it too.

Ability to modify default Usage string

There should be usage argument in App() that I could use to modify the default usage string in --help

> python -m juicenet --help
Usage: juicenet COMMAND [ARGS] [OPTIONS] <---- Change this

CLI tool designed to simplify the process of uploading files to Usenet

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ PATH  file or directory. [default: C:\Users\raven\Documents\GitHub\juicenet-cli]                                     │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --config            path to your juicenet config file [env var: JUICENET_CONFIG]                                     │
│ --public            use your public/secondary nyuu config                                                            │
│ --nyuu              only run nyuu                                                                                    │
│ --parpar            only run parPar                                                                                  │
│ --raw               only repost raw articles                                                                         │
│ --skip-raw          skip raw article reposting                                                                       │
│ --clear-raw         delete existing raw articles                                                                     │
│ --exts              file extensions to be matched, overrides config                                                  │
│ --glob              glob pattern(s) to be matched instead of extensions                                              │
│ --bdmv              search for BDMVs in path, can be used with --glob                                                │
│ --debug             show debug logs                                                                                  │
│ --move              move foobar.ext to foobar/foobar.ext                                                             │
│ --only-move         move foobar.ext to foobar/foobar.ext and exit                                                    │
│ --no-resume         ignore existing resume data                                                                      │
│ --clear-resume      delete existing resume data                                                                      │
│ --help          -h  display this message and exit                                                                    │
│ --version           display application version                                                                      │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Positional parameter doesn't handle validation error

Take this cli app for example:

from pathlib import Path
from typing import Annotated

from cyclopts import App, Parameter, validators

app = App()

@app.default() # type: ignore
def cli(
    path: Annotated[
        Path, 
        Parameter(
            help="directory or file",
            validator=validators.Path(exists=True))
        ] = Path.cwd(),
    /,
    *,
    config: Annotated[
        Path, 
        Parameter(
            name=["--config"], 
            help="config file", 
            validator=validators.Path(exists=True))
        ] = Path.cwd() / "config.yaml",
) -> None:
    print(path)
    print(config)
    exit()

if __name__ == "__main__":
    app()

If I pass an invalid path to --config, it handles it fine

> python .\test.py --config "doesntexist.yml"
╭─ Error ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Invalid value for --config. doesntexist.yml does not exist.                                                     │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

However, if i pass an invalid path to the positional parameter path then:

> python .\test.py "doesntexist"
Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 314, in _convert
    cparam.validator(type_, val)
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\validators\_path.py", line 33, in __call__
    raise ValueError(f"{path} does not exist.")
ValueError: doesntexist does not exist.

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 411, in parse_args
    command, bound, unused_tokens = self.parse_known_args(tokens, console=console)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 328, in parse_known_args
    bound, unused_tokens = create_bound_arguments(command, unused_tokens)
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 357, in create_bound_arguments
    coerced = _convert(mapping)
              ^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\bind.py", line 321, in _convert
    raise new_exception from e
cyclopts.exceptions.ValidationError: <exception str() failed>

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\raven\Documents\GitHub\test\test.py", line 30, in <module>
    app()
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 453, in __call__
    command, bound = self.parse_args(tokens, **kwargs)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\core.py", line 423, in parse_args
    console.print(format_cyclopts_error(e))
                  ^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\exceptions.py", line 261, in format_cyclopts_error
    Text(str(e), "default"),
         ^^^^^^
  File "C:\Users\raven\Documents\GitHub\test\.venv\Lib\site-packages\cyclopts\exceptions.py", line 143, in __str__
    parameter_cli_name = ",".join(self.parameter2cli[self.parameter])
                                  ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
KeyError: <Parameter "path: Annotated[pathlib.Path, Parameter(name=(), converter=None, validator=Path(exists=True, file_okay=True, dir_okay=True), negative=None, token_count=None, parse=True, show=None, show_default=None, show_choices=None, help='directory or file', show_env_var=None, env_var=())] = WindowsPath('C:/Users/raven/Documents/GitHub/test')">

Support in Trogon

Recently I discovered Trogon (https://github.com/Textualize/trogon) which seems pretty incredible (look at their video demo, this is some great stuff). In short, it turns a well-defined CLI app into a discoverable TUI which shows you all the options, defaults, types etc, lets you choose from available choices etc.

Especially useful for large apps, less so for trivial ones. Still, I think it kind of shows why standardizing your argument parsing etc makes sense, and I am fully on board with cyclopts' ambition to do that using plain Python.

Trogon wraps around click, yet there is some success people have reported with Typer, and an ongoing effort towards enabling mainline support + docs. Trogon+Typer does not fully work for me yet (it crashes unexpectedly when trying to execute the resulting command), and I suspect this has to do with some minor differences between vanilla Click and Typer. In my disenchantment with Typer recently and having decided I was switching over to Cyclopts, I will most likely not get involved there.

But Trogon remains a cool tool and there are efforts to make it work with other stuff including e.g. argparse - BTW this is from the author of https://www.fresh2.dev/r/yapx/ which builds on top of Argparse and seems like a good tool to make a comparison against. That effort in Trogon has stalled a bit, and the yapx developer seems to have forked Trogon to provide TUI support for his argparse-based lib, but I'd potentially be happy to help resurrect the effort to make Trogon more generic and support adding Cyclopts.

As far as comparisons are concerned, besides yapx, it might also make sense to look at clize, but I digress...

I guess the main purpose of the issue is to ask you, do you think having support for Cyclopts in Trogon would be useful? I wouldn't want to push for it if you a) found this to be useless and/or b) thought it was fundamentally impossible to do due to the way Cyclopts is implemented as compared to Click/argparse (which I doubt). It most likely won't really require any changes to Cyclopts itself.

And a bit of nostalgia, the first time when I realized how awesome building CLIs in Python can be some years ago was while reading the plac docs for advanced usage and all the nifty tricks described there. I was going to ask what your thoughts were on adding an interactive prompt (which I think is the niftiest of the nifty tricks)... until I found you'd already added it. Awesome!

Shorter validator syntax

Hi @BrianPugh - first of all, hats off for the nice library, I laughed when I read the "what you thought Typer was" line. I am a heavy Typer user (and of course credit where it's due, it's a great lib) but the issues you mention in your comparison bugged me, so I'm glad I stumbled upon cyclopts, and I think I will be switching to it.

I am now playing around with some Path type parameters (which are common with my scripts given that I manipulate files a lot) - and wanted to use validators, but admittedly they blow up the function signature a lot. Did you think about some alternative way to specify it? Like additional annotations for the validated parameters, or perhaps docstrings? Or some other way to reduce the amount of typing involved (pun intended)? So that we could bring the

def foo(path: Annotated[Path, Parameter(validator=validators.Path(exists=True))]):

example closer to

def foo(path: Path):

(the latter being kinda "the original promise of Typer")

I know I know, you don't have to use validators if you do not want to, but they make so much sense instead of implementing your own logic for this every time! If only we could find a way to make them less verbose.

Seperate positional only and keyword only arguments in `--help` similar to argparse and typer

This request is simply an aesthetic change regarding the help screen
argparse and typer seperate the postional and keyword arguments in the help screen. I personally find the seperation to be more readable.

argparse:

juicenet --help
Usage: myapp [-h] 

myapp for foobar

Positional Arguments:
  <path>                file or directory

Options:
  -h, --help            show this help message and exit

typer:

python .\test2.py --help

 Usage: test2.py [OPTIONS] [PATH]

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│   path      [PATH]  The path to use [default: .]                                                                     │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --flag    --no-flag      The flag to use [default: no-flag]                                                          │
│ --help                   Show this message and exit.                                                                 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

cyclopts:

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

╭─ Parameters ─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ PATH           directory or file [default: C:\Users\raven\Documents\GitHub\testtesttest]                             │
│ --config       config file [default: C:\Users\raven\Documents\GitHub\testtesttest\config.yaml]                       │
│ --version      Display application version.                                                                          │
│ --help     -h  Display this message and exit.                                                                        │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

I have explicitly defined path as positional only and config as keyword only

@app.default() # type: ignore
def cli(
    path: Annotated[
        Path, 
        Parameter(
            help="directory or file",
            validator=validators.Path(exists=True))
        ] = Path.cwd(),
    /, # path is positional only
    *, # all arguments after this are keyword only
    config: Annotated[
        Path, 
        Parameter(
            name=["--config"], 
            help="config file", 
            validator=validators.Path(exists=True))
        ] = Path.cwd() / "config.yaml",
) -> None:
    print(f"{path = }")
    print(f"{config = }")

Apologies if im bombarding you with issues! I've just started to play with cyclopts (really liking it)

[Feature Request] Completion?

Curious if this project currently has completion (couldn't find it in the docs or code) or if there are aspirations to provide completion in the future??

Automatic conversion from underscore to hyphen

This is not really a bug report but a "behavior note". As requested by Brian, I created an issue here for further reference. You may want to mention it in the README section.

Original discussion tiangolo/typer#233 (comment)

This behavior comes from Click

The workaround is using a lambda function as mentioned here tiangolo/typer#341 (comment)

I think if there're many requests from user, please consider adding an option to keep the opt name as-is. Thank you.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.