Usage#

Crafting your cli is identical to composing a pydantic class. Together with some assumptions this is enough to create a fully working cli tool.

Positional arguments#

All fields in your pydantic model without a default value are converted to cli positional arguments.

from clipstick import parse
from pydantic import BaseModel


class MyModel(BaseModel):
    """My model with a required value."""

    my_value: int
    """My required value."""


print(repr(parse(MyModel)))

A positional argument is required. If you don’t provide it an error will be raised. So running this without any arguments will result in the following error message:

missing argument

Help output:#

positional help

Keyword arguments#

All fields in your model with a default value are converted to cli optional arguments.

from typing import Annotated

from clipstick import parse, short
from pydantic import BaseModel


class MyModel(BaseModel):
    """A model with keyworded optional values."""

    my_value: int = 22
    """My value with a default."""

    other_value: int | None = None
    """Value with None as default."""

    with_short: Annotated[str, short("w")] = "some_value"
    """With a shorthand key."""


print(repr(parse(MyModel)))

Help output:#

positional with short

Choices#

A contraint set of values for a certain argument is defined by using the Literal annotation.

from typing import Literal

from clipstick import parse
from pydantic import BaseModel


class MyModel(BaseModel):
    """My model with choice values."""

    required_choice: Literal["option1", "option2"]
    """Required restricted values."""

    optional_choice: Literal[1, 2] = 2
    """Optional with a literal default."""

    optional_with_none: Literal[4, 5] | None = None
    """Optional with None as default."""


print(repr(parse(MyModel)))

Failing to provide a valid value gives you the error:

wrong choice

Help output:#

choice argument

Booleans/Flags#

A flag (true/false) is defined by the bool annotation.

from typing import Annotated

from clipstick import parse, short
from pydantic import BaseModel


class MyModel(BaseModel):
    """A model with flags."""

    required: bool
    """Some required thingy."""

    with_short: Annotated[bool, short("w")]
    """required flag with short."""

    an_optional: bool = True
    """An optional."""

    optional_with_short: Annotated[bool, short("o")] = False
    """Optional with short."""


print(repr(parse(MyModel)))

Help output:#

boolean help

Collections#

A collection (a list of ints or string) is defined providing a keyworded argument multiple times.

from typing import Annotated

from clipstick import parse, short
from pydantic import BaseModel


class MyModel(BaseModel):
    """My model with with a collection."""

    required_collection: list[str]
    """A required collection."""

    optional_short: Annotated[list[int], short("o")] = [1]
    """Optional collection."""


print(repr(parse(MyModel)))

example#

collection usage

collection usage

help output#

collection help

Subcommands#

Bigger cli applications will need the use of subcommands. A probably well known example of this is the git cli which has git clone ..., git merge ... etc. A subcommand is implemented by using pydantic models annotated with a Union:

from clipstick import parse
from pydantic import BaseModel


class Clone(BaseModel):
    """Clone a repo.

    This is a subcommand.
    """

    repo: str
    """Clone this repo."""

    def main(self):
        """Run when this subcommand is choosen."""
        print(f"Cloning repo {self.repo}")


class Merge(BaseModel):
    """Merge a branch.

    Provide a branch and merge it into your active branch.
    """

    branch: str
    """Branch name."""

    def main(self):
        """Run when this subcommand is choosen."""
        print(f"Merging {self.branch} into current branch.")


class MyGit(BaseModel):
    """My git tool."""

    sub_command: Clone | Merge  # <-- a subcommand of clone and merge

    def main(self):
        """Main entrypoint for this cli."""
        self.sub_command.main()


model = parse(MyGit)
model.main()

Help output#

Clipstick assumes you are using google docstring (or any other docstring) convention which follows these rules for class docstrings:

  • The first line is used as a summary description what the class is all about.

  • The rest of the lines contain a more detailed description.

With the above docstring of the Merge class in mind see only the first line of the docstring being used for the merge subcommand: subcommand help

And observe the full docstring when printing out help for the merge subcommand: subcommand sub help

Points of attention#

When using subcommands, be aware of the following:

  • Only one subcommand per model is allowed. (If you need more (and want to follow the more object-composition path), have a look at tyro)

  • A subcommand cannot have a default: It needs to always be provided by the user.

  • sub_command as a name is not required. Any name will do.

  • Nesting of subcommands is possible.

Help#

As with all cli applications providing -h or --help as an argument gives you printed help output. Clipstick (any possibly others) allow this argument to be provided at any point during entry.

For example consider the help output of a git cli:

Usage: my-cli-app [Options] [Subcommands]

Clone a git repository.

Options:
    --url                Url of the git repo. [str] [default = https://mysuperrepo]

Subcommands:
    clone                Clone a repo.
    info                 Show information about this repo

Halfway through command entry -you have provided the --url flag- you have forgotten about the rest. In this case which subcommands are available.

You can now add the -h flag (> m-cli-app --url my_url -h) and help output will be printed. Assuming your shell supports this, you can press up, remove the -h flag and continue your entry without losing the arguments you already provided.

Tab completion might be a solution to this problem too, but I have never found it really working: For example, positional arguments are not supported or mess with Tab completion for paths.

Validators#

Pydantic provides many field validators which can be used in clipstick too.

For example a cli which requires you to provide your age which can (obviously) not be negative:

from clipstick import parse
from pydantic import BaseModel, PositiveInt


class MyModel(BaseModel):
    """Model with a pydantic validator."""

    my_age: PositiveInt = 10
    """Your age."""


print(repr(parse(MyModel)))

When you do provide a negative value, Pydantic raises an error which is picked up by clipstick and presented to the user.

invalid negative

Another example would be a cli which needs a reference to an existing file location.

from clipstick import parse
from pydantic import BaseModel, FilePath


class MyModel(BaseModel):
    """Model with a pydantic validator."""

    my_path: FilePath
    """provide an existing file location."""


print(repr(parse(MyModel)))

Failing to provide a valid file location gives you:

invalid file

There are many more out-of-the-box validators available. Have a look here It is also pretty easy to write your own validators.

Restrictions#

While most of the model definitions will just work, some don’t and some work better than others:

  • A nesting of Models (a field inside a pydantic object which has a type of a pydantic object) is not allowed unless it is used in a Union as described in Subcommands.

  • Restrict the usage of Unions to using a type and a NoneType where None is the default value of a field:

    class MyModel(BaseModel)
        my_value: int | None = None # <-- Union with a None. Omitted in help output
    
    class MyModel(BaseModel)
        my_value: int | str # <-- Not allowed. Doesn't make sense of having a model like this?