Skip to content

Dynamically Build Typer Commands

The build_command function of the typer-repyt library allows you to dynamically construct Typer commands based on a function template and a list of parameter definitions. This feature is particularly useful if you need to build out a command based on criteria that might not be completely available until run-time.

Overview

The build_command function takes a Typer app instance, a template function, and a series of parameter definitions to dynamically generate a Typer command. The template function serves as a blueprint, preserving its name and docstring, while the parameter definitions provide complete specifications for how to build the arguments and options for the command.

Usage

Here's an example of how to use the build_command feature:

from typing import Any
from collections.abc import Callable
from functools import wraps

import typer
from typer_repyt import build_command, OptDef, ArgDef, DecDef


def simple_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print("Start simple decorator")
        result = func(*args, **kwargs)
        print("End simple decorator")
        return result
    return wrapper


def complex_decorator(a: str, k: str = "hutt") -> Callable[..., Any]:
    def _decorate(func: Callable[..., Any]) -> Callable[..., Any]:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            print(f"Complex decorator args: {a=}, {k=}")
            print(f"Complex decorator before function call: {args=}, {kwargs=}")
            result = func(*args, **kwargs)
            print(f"Complex decorator after function call: {result=}")
            return result
        return wrapper
    return _decorate


cli = typer.Typer()


def dynamic(ctx: typer.Context, dyna1: str, dyna2: int, mite1: str, mite2: int | None):  # pyright: ignore[reportUnusedParameter]
    """
    Just prints values of passed params
    """
    print(f"{dyna1=}, {dyna2=}, {mite1=}, {mite2=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna1", param_type=str, help="This is dynamic option 1", default="default1"),
    OptDef(name="dyna2", param_type=int, help="This is dynamic option 2"),
    ArgDef(name="mite1", param_type=str, help="This is mighty argument 1"),
    ArgDef(name="mite2", param_type=int | None, help="This is mighty argument 2", default=None),
    decorators=[
        DecDef(simple_decorator),
        DecDef(complex_decorator, dec_args=["jawa"], dec_kwargs=dict(k="ewok"), is_simple=False),
    ],
    include_context=True,
)

if __name__ == "__main__":
    cli()

Try running this example with the --help flag to see that the command is dynamically constructed :

$ python examples/dynamic.py --help

 Usage: dynamic.py [OPTIONS] MITE1 [MITE2]

 Just prints values of passed params

╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────╮
│ *    mite1      TEXT     This is mighty argument 1 [default: None] [required]               │
│      mite2      [MITE2]  This is mighty argument 2 [default: None]                          │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ───────────────────────────────────────────────────────────────────────────────────╮
│ *  --dyna2                     INTEGER  This is dynamic option 2 [default: None] [required] │
│    --dyna1                     TEXT     This is dynamic option 1 [default: default1]        │
│    --install-completion                 Install completion for the current shell.           │
│    --show-completion                    Show completion for the current shell, to copy it   │
│                                         or customize the installation.                      │
│    --help                               Show this message and exit.                         │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯

The dynamically built function is equivalent to the static definition:

from collections.abc import Callable
from functools import wraps
from typing import Annotated, Any

import typer


def simple_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print("Start simple decorator")
        result = func(*args, **kwargs)
        print("End simple decorator")
        return result
    return wrapper


def complex_decorator(a: str, k: str = "hutt") -> Callable[..., Any]:
    def _decorate(func: Callable[..., Any]) -> Callable[..., Any]:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            print(f"Complex decorator args: {a=}, {k=}")
            print(f"Complex decorator before function call: {args=}, {kwargs=}")
            result = func(*args, **kwargs)
            print(f"Complex decorator after function call: {result=}")
            return result
        return wrapper
    return _decorate


cli = typer.Typer()

@cli.command()
@simple_decorator
@complex_decorator("jawa", k="ewok")
def static(
    ctx: typer.Context,  # pyright: ignore[reportUnusedParameter]
    mite1: Annotated[str, typer.Argument(help="This is mighty argument 1")],
    dyna2: Annotated[int, typer.Option(help="This is dynamic option 2")],
    dyna1: Annotated[str, typer.Option(help="This is dynamic option 1")] = "default1",
    mite2: Annotated[int | None, typer.Argument(help="This is mighty argument 2")] = None,
):
    """
    Just prints values of passed params
    """
    print(f"{dyna1=}, {dyna2=}, {mite1=}, {mite2=}")


if __name__ == "__main__":
    cli()

Details

Let's take a closer look at how we can use the build_command() function.

Function signature

The function signature looks like this:

1
2
3
4
5
6
7
8
def build_command(
    cli: typer.Typer,
    func: Callable[..., None],
    /,
    *param_defs: ParamDef,
    decorators: list[DecDef] | None = None,
    include_context: bool | str = False,  # TODO: Think about setting this automatically if func's first arg is ctx
):

The cli argument is the Typer app that you want your command added to.

The func argument is a "template" function. The build_command() function builds a brand new function, but it borrows some parts from the template function. Most importantly, it uses the same code body. So, any logic included in the body of the template function will appear exactly the same in the built command function. The build_command() function will also preserve the name of the function and it's docstring. This is important, because the function name will become the name of the command that's added to the app just like it would in a static definition of a Typer command.

Template function parameters

The function parameters defined in the template function will not be preserved in any way in the generated function. They are completely stripped away and replaced with the parameters you pass in param_defs. However, it may be useful to supply parameters to your template function that match the local values that typer will provide when it runs the command. This will ensure that type checkers won't gripe about the function. Note that the dynamic example above matches the parameters in the template function with the param_defs that are dynamically injected.

The param_defs variadic arguments describe the Option and Argument parameters that will be injected into the constructed command function. Each of the attributes of ParamDef, OptDef, and ArgDef correspond directly to parameters that you can use to statically define Options and Arguments to your command.

The decorators keyword argument can be used to provide decorators that should be applied to the command. This option uses the DecDef class to describe each decorator that will be applied.

Finally, the include_context keyword argument instructs the build_command function whether a typer.Context argument should be included as the first positional argument to the constructed command. Note that in order to use a context, Typer requires that it be the first positional argument and that it is named "ctx".

ParamDef

ParamDef is a base class that contains parameters that are shared by both Option and Argument command parameters.

Here is the signature of ParamDef:

@dataclass
class ParamDef:
    """
    Define the necessary components to build a Typer `Option` or `Argument`.

    These elements are used by both `OptDef` and `ArgDef`.
    """

    name: str
    param_type: UnionType | type[Any]
    default: Any | None | Literal[Sentinel.NOT_GIVEN] = Sentinel.NOT_GIVEN
    help: str | None = None
    rich_help_panel: str | None = None
    show_default: bool | str = True

Let's dig into what each attribute is used for.

name

This will be the name of the parameter.

In this example, the two commands static and dynamic are equivalent:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[str, Option()]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(cli, dynamic, OptDef(name="dyna", param_type=str))


if __name__ == "__main__":
    cli()

Notice that the first parameter to static is dyna. When we build the command dynamically, the name attribute we pass to OptDef becomse the name of the option.

The help text from both commands is identical:

$ python examples/param_def/name.py static --help

 Usage: name.py static [OPTIONS]

╭─ Options ───────────────────────────────────────────────────────────────────────────────────╮
│ *  --dyna        TEXT  [default: None] [required]                                           │
│    --help              Show this message and exit.                                          │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯

$ python examples/param_def/name.py dynamic --help

 Usage: name.py dynamic [OPTIONS]

╭─ Options ───────────────────────────────────────────────────────────────────────────────────╮
│ *  --dyna        TEXT  [default: None] [required]                                           │
│    --help              Show this message and exit.                                          │
╰─────────────────────────────────────────────────────────────────────────────────────────────╯

param_type

This is the type hint for the parameter that Typer will use to cast the command input to the appropriate type. It will also validate the input with this type so that providing "thirteen", for example, to an int typed parameter will raise an error.

Again, in this example, the two commands static and dynamic are equivalent:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[int | None, Option()]):
    print(f"{dyna=}")


def dynamic(dyna: int | None):
    print(f"{dyna=}")


build_command(cli, dynamic, OptDef(name="dyna", param_type=int | None))


if __name__ == "__main__":
    cli()

The param_type can be any of the types supported by Typer. In this case, we are actually using a UnionType expressed as int | None to indicate that the parameter value will either be an integer or None.

Only basic unions

Typer does not currently support any UnionType. Instead, it can only use a UnionType that is composed of composed of two types: one NoneType and any other type that is not NoneType. See this issue on GitHub for more details.

Further reading

not

default

This describes the default value that will be assigned to the parameter. The default parmeter may be any Typer supported type or None.

Here is another example with equivalent static and dynamic commands:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[int | None, Option()]):
    print(f"{dyna=}")


def dynamic(dyna: int | None):
    print(f"{dyna=}")


build_command(cli, dynamic, OptDef(name="dyna", param_type=int | None))


if __name__ == "__main__":
    cli()

You may be wondering about the Sentinel type that default can use. Sentinels are a bit of an advanced concept, but in the plainest terms it lets build_command tell the difference between None being explicitly passed as the default value and no default parameter being supplied. You can read more about Sentinel values in PEP 661.

help

This argument provides the text that will describe the parameter's purpose when you run the command with the --help flag. If it is not provided, Typer won't show any description of the parameter.

Here is yet another example with equivalent commands:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[str, Option(help="Dyna goes BOOM")]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(cli, dynamic, OptDef(name="dyna", param_type=str, help="Dyna goes BOOM"))


if __name__ == "__main__":
    cli()

Here is what the produced --help output looks like:

$ python examples/param_def/help.py dynamic --help

 Usage: help.py dynamic [OPTIONS]

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ *  --dyna        TEXT  Dyna goes BOOM [default: None] [required]                             │
│    --help              Show this message and exit.                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

rich_help_panel

Typer allows you to add more eye candy in the --help output by putting parameters inside of Rich panels. This doesn't add any functionality at all, it just changes the appearance of the --help output.

Can you believe it, another example of equivalent commands?

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[str, Option(rich_help_panel="Dyna goes BOOM")]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(cli, dynamic, OptDef(name="dyna", param_type=str, rich_help_panel="Dyna goes BOOM"))


if __name__ == "__main__":
    cli()

You can see how the --dyna option is now wrapped in a fancy Rich panel in the --help output:

$ python examples/param_def/rich_help_panel.py dynamic --help

 Usage: rich_help_panel.py dynamic [OPTIONS]

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Dyna goes BOOM ─────────────────────────────────────────────────────────────────────────────╮
│ *  --dyna        TEXT  [default: None] [required]                                            │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

show_default

This parameter controls whether the default value for a parameter is shown in the --help text. If it is set to False, no help will be shown as if the help parameter was not supplied. If it is set to a string value, then the default value is replaced with the supplied string (I'm not sure where this would be useful!).

Let's look at the equivalent commands:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(
    dyna1: Annotated[str, Option(show_default=False)] = "BOOM",
    dyna2: Annotated[str, Option(show_default="-hidden-")] = "BOOM",
):
    print(f"{dyna1=}, {dyna2=}")


def dynamic(dyna1: str, dyna2: str):
    print(f"{dyna1=}, {dyna2=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna1", param_type=str, default="BOOM", show_default=False),
    OptDef(name="dyna2", param_type=str, default="BOOM", show_default="-hidden-"),
)


if __name__ == "__main__":
    cli()

And, here is the --help output it produces:

$ python examples/param_def/show_default.py dynamic --help

 Usage: show_default.py dynamic [OPTIONS]

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --dyna1        TEXT                                                                          │
│ --dyna2        TEXT  [default: (-hidden-)]                                                   │
│ --help               Show this message and exit.                                             │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

OptDef

OptDef is a the derived class that contains all the remaining parameters that can be passed to a Typer Option parameter.

Here is the signature of OptDef:

@dataclass
class OptDef(ParamDef):
    """
    Define the additional components to build a Typer `Option`.
    """

    prompt: bool | str = False
    confirmation_prompt: bool = False
    hide_input: bool = False
    override_name: str | None = None
    short_name: str | None = None
    callback: Callable[..., Any] | None = None
    is_eager: bool = False

Let's explore how each of these attributes work.

prompt

Typer allows you to prompt the user for input when you run a command. This is accomplished with the prompt parameter. The value of this parameter can have two different types. If the type is bool, then Typer will just use the name of the parameter as the prompt. If the type is str, then the provided string will be used as the prompt.

Here are the equivalent commands:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(
    dyna1: Annotated[str, Option(prompt=True)],
    dyna2: Annotated[str, Option(prompt="Dyna2 goes")] = "POW",
):
    print(f"{dyna1=}, {dyna2=}")


def dynamic(dyna1: str, dyna2: str):
    print(f"{dyna1=}, {dyna2=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna1", param_type=str, prompt=True),
    OptDef(name="dyna2", param_type=str, prompt="Dyna2 goes", default="POW"),
)


if __name__ == "__main__":
    cli()

When we run the command, we are prompted to provide the values:

$ python examples/opt_def/prompt.py dynamic
Dyna1: BOOM
Dyna2 goes [POW]:
dyna1='BOOM', dyna2='POW'

Notice that since we provided a default value for dyna2, it is shown in the prompt and then used if the user doesn't enter their own value.

Further reading

confirmation_prompt

Sometimes, you want to make sure that the text that the user provided the first time is correct by asking them to enter the same entry again. To accomplish this, we can use a confirmation_prompt. After entering the first prompted value, the user will be prompted to enter it again. Only if the values match will the prompt input be accepted. If it does not match, the user will be asked to complete the prompt (and confirmation) over again.

Again, we have equivalent implementations:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[str, Option(prompt=True, confirmation_prompt=True)]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna", param_type=str, prompt=True, confirmation_prompt=True),
)


if __name__ == "__main__":
    cli()

Running the example produces input like this:

$ python examples/opt_def/confirmation_prompt.py dynamic
Dyna: BOOM
Repeat for confirmation: BOOM
dyna='BOOM'


$ python examples/opt_def/confirmation_prompt.py dynamic
Dyna: BOOM
Repeat for confirmation: POW
Error: The two entered values do not match.
Dyna:

hide_input

The confirmation_prompt parameter is most useful when you use it with the hide_input parameter. Such a combination can be used to request a password from a user and confirm their entry all while hiding what they are typing. This is a very familiar pattern on web apps and other CLIs, so it's very nice that it's available in Typer as well.

Here are our equivalent implementations:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[str, Option(prompt=True, confirmation_prompt=True, hide_input=True)]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna", param_type=str, prompt=True, confirmation_prompt=True, hide_input=True),
)


if __name__ == "__main__":
    cli()

When we run the example, the input provided to the prompt is completely invisible:

$ python examples/opt_def/hide_input.py dynamic
Dyna:
Repeat for confirmation:
dyna='BOOM'

override_name

Typer also provides a mechanism to override the name of the option. There are many situations in which this is helpful, but it's probably most helpful when you want the Option parameter to use a Python keyword that you can't use as a parameter name.

Consider these equivalent commands:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(class_name: Annotated[str, Option("--class")]):
    print(f"class={class_name}")


def dynamic(class_name: str):
    print(f"class={class_name}")


build_command(
    cli,
    dynamic,
    OptDef(name="class_name", param_type=str, override_name="class"),
)


if __name__ == "__main__":
    cli()

Here is the help that this produces:

$ python examples/opt_def/override_name.py dynamic --help

 Usage: override_name.py dynamic [OPTIONS]

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ *  --class        TEXT  [default: None] [required]                                           │
│    --help               Show this message and exit.                                          │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

Notice how using the override_name parameter allows us to have a --class option in our command even though the keyword class cannot be used as a parameter name in python.

It's also worth pointing out that unlike the Typer native way of providing an alternative name for the option's long form, the override_name parameter does not require you to include the leading dashes as in --class.

Further reading

short_name

We can also provide a short name for the Option using the short_name parameter. Like the override_name parameter, you don't need to provide the leading dash (as Typer requires).

Here we have the equivalent commands:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()

@cli.command()
def static(dyna: Annotated[str, Option("-d")]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna", param_type=str, short_name="d"),
)


if __name__ == "__main__":
    cli()

And this produces some friendly help including the option's short form:

$ python examples/opt_def/short_name.py dynamic --help

 Usage: short_name.py dynamic [OPTIONS]

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ *          -d      TEXT  [default: None] [required]                                          │
│    --help                Show this message and exit.                                         │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

Notice here that if you include short_name without an accompanying override_name, then the command will only use the short-form. This matches Typer's functionality where if you provide only a shot-form option, the long-form option will not be used.

callback

One of the more interesting abilities of Typer Option pareameters is the ability to register a callback. A callback is a function that:

  • is called with the value of the parameter that was provided on the command-line
  • operates on it
  • returns a value that will replace the value it was called with

Let's look at an example:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()


def back(dyna: str):
    print(f"Callback operating on {dyna=}")
    return dyna * 3


@cli.command()
def static(dyna: Annotated[str, Option(callback=back)]):
    print(f"{dyna=}")


def dynamic(dyna: str):
    print(f"{dyna=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna", param_type=str, callback=back),
)


if __name__ == "__main__":
    cli()

Now, let's see what happens when we run this command:

$ python examples/opt_def/callback.py dynamic --dyna=BOOM
Callback operating on dyna='BOOM'
dyna='BOOMBOOMBOOM'

Here, you can see how the callback mutated the value of dyna.

Callback functions are often used for validating the parameter. In those cases, the callback would raise an exception if the value didn't match some needed criteria. However, you can do anything you like with the value passed to a callback.

is_eager

The is_eager parameter simply makes a callback attached to an Option evaluate before other, non-eager callbacks. The use-cases for eager callbacks aren't obvious, but you may find the need for it at some point.

Here is the equivalent example for is_eager:

from typing import Annotated

from typer import Typer, Option
from typer_repyt import build_command, OptDef

cli = Typer()


def back1(val: str):
    print(f"Callback 1 operating on {val=}")
    return f"one: {val}"

def back2(val: str):
    print(f"Callback 2 operating on {val=}")
    return f"two: {val}"


@cli.command()
def static(
    dyna1: Annotated[str, Option(callback=back1)],
    dyna2: Annotated[str, Option(callback=back2, is_eager=True)],
):
    print(f"{dyna1=}, {dyna2=}")


def dynamic(dyna1: str, dyna2: str):
    print(f"{dyna1=}, {dyna2=}")


build_command(
    cli,
    dynamic,
    OptDef(name="dyna1", param_type=str, callback=back1),
    OptDef(name="dyna2", param_type=str, callback=back2, is_eager=True),
)


if __name__ == "__main__":
    cli()

And, running the command produces this:

$ python examples/opt_def/is_eager.py dynamic --dyna1=BOOM --dyna2=POW
Callback 2 operating on val='POW'
Callback 1 operating on val='BOOM'
dyna1='one: BOOM', dyna2='two: POW'

Here we see that indeed back2() that was designated with is_eager was called first.

ArgDef

Like OptDef, ArgDef is derived from the ParamDef class. It contains additional parameters that can only be passed to a Typer Argument parameter.

Here is the signature of ArgDef:

@dataclass
class ArgDef(ParamDef):
    """
    Define the additional components to build a Typer `Argument`.
    """

    metavar: str | None = None
    hidden: bool = False
    envvar: str | list[str] | None = None
    show_envvar: bool = True

Here are what each of the attributes do.

metavar

You may want to use some special text to be a placeholder in the --help text that describes the Argument. These are called "Meta Variables". They help you see where the argument parameter needs to be provided in the command.

Have a look at the equivalent implementations in this example:

from typing import Annotated

from typer import Typer, Argument
from typer_repyt import build_command, ArgDef

cli = Typer()


@cli.command()
def static(mite: Annotated[str, Argument(metavar="NITRO")]):
    print(f"{mite=}")


def dynamic(mite: str):
    print(f"{mite=}")


build_command(
    cli,
    dynamic,
    ArgDef(name="mite", param_type=str, metavar="NITRO"),
)


if __name__ == "__main__":
    cli()

To see where the metavar comes in, check out the --help output:

$ python examples/arg_def/metavar.py dynamic --help

 Usage: metavar.py dynamic [OPTIONS] NITRO

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────╮
│ *    mite      NITRO  [default: None] [required]                                             │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

You an see that it's used as a placeholder in the "Usage" string and again in the argument description.

hidden

Sometimes, the purpose of an Argument is so obvious that it's just redundant to include help text for it. In such a case, you can hide the help text using the hidden parameter.

Observe the example:

from typing import Annotated

from typer import Typer, Argument
from typer_repyt import build_command, ArgDef

cli = Typer()


@cli.command()
def static(mite: Annotated[str, Argument(hidden=True)]):
    print(f"{mite=}")


def dynamic(mite: str):
    print(f"{mite=}")


build_command(
    cli,
    dynamic,
    ArgDef(name="mite", param_type=str, hidden=True),
)


if __name__ == "__main__":
    cli()

Here you can see how the Argument does not have a dedicated help section:

$ python examples/arg_def/hidden.py dynamic --help

 Usage: hidden.py dynamic [OPTIONS] MITE

╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

envvar

One very cool feature of Typer is the ability to use an environment variable to provide the value for an Argument if one is not provided by the user. Enter the envvar parameter. This acts as a default for the argument if no value is provided by the user and if the enviornment variable is set.

Additionally, it's possible to provide more than one environment variable that can be used to set the value of the Argument. If more than one is provided, then the Argument value will be set by the first environment variable in the list that is defined.

Let's see it in action in an example:

from typing import Annotated

from typer import Typer, Argument
from typer_repyt import build_command, ArgDef

cli = Typer()


@cli.command()
def static(
    mite1: Annotated[str, Argument(envvar="MITE")],
    mite2: Annotated[str, Argument(envvar=["NITRO", "DYNA", "MITE"])],
):
    print(f"{mite1=}, {mite2=}")


def dynamic(mite1: str, mite2: str):
    print(f"{mite1=}, {mite2=}")


build_command(
    cli,
    dynamic,
    ArgDef(name="mite1", param_type=str, envvar="MITE"),
    ArgDef(name="mite2", param_type=str, envvar=["NITRO", "DYNA", "MITE"]),
)


if __name__ == "__main__":
    cli()

First, let's have a look at what the --help text looks like for this command:

$ python examples/arg_def/envvar.py dynamic --help

 Usage: envvar.py dynamic [OPTIONS] MITE1 MITE2

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────╮
│ *    mite1      TEXT  [env var: MITE] [default: None] [required]                             │
│ *    mite2      TEXT  [env var: NITRO, DYNA, MITE] [default: None] [required]                │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

The environment variables that will be used for the argument are displayed in the help text! Very nice.

Now, let's set some environment variables and run the command:

MITE=BOOM DYNA=POW python examples/arg_def/envvar.py dynamic
mite1='BOOM', mite2='POW'

You can see that for the second Argument, it got the value of the first defined environment variable in its list, which was the value bound to "DYNA".

show_envvar

You may not want to reflect the environment variables used by an Argument in the help text. If that's the case, just set the show_envvar parameter to False.

Here we have our equivalent implementations:

from typing import Annotated

from typer import Typer, Argument
from typer_repyt import build_command, ArgDef

cli = Typer()


@cli.command()
def static(mite: Annotated[str, Argument(envvar="MITE", show_envvar=False)]):
    print(f"{mite=}")


def dynamic(mite: str):
    print(f"{mite=}")


build_command(
    cli,
    dynamic,
    ArgDef(name="mite", param_type=str, envvar="MITE", show_envvar=False),
)


if __name__ == "__main__":
    cli()

This results in the environment variables not being shown in the --help text:

$ python examples/arg_def/show_envvar.py dynamic --help

 Usage: show_envvar.py dynamic [OPTIONS] MITE

╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────╮
│ *    mite      TEXT  [default: None] [required]                                              │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────╮
│ --help          Show this message and exit.                                                  │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯

DecDef

The DecDef class is used to define a decorator that should be added to the final built command. This allows you to use any of the available decorators that need to be applied to the dynamically constructed function but were not applied to the original "template" funciton.

Here is the signature of DecDef:

@dataclass
class DecDef:
    """
    Define a decorator function and it parameters.
    """

    dec_func: Callable[..., Any]
    dec_args: list[Any] = field(default_factory=list)
    dec_kwargs: dict[str, Any] = field(default_factory=dict)
    is_simple: bool = True

    def decorate(self, f: Callable[..., Any]) -> Callable[..., Any]:
        BuildCommandError.require_condition(
            not self.is_simple or (self.dec_args == [] and self.dec_kwargs == {}),
            "Decorator arguments are not allowed for simple decorators",
        )

        def wrap(*args: Any, **kwargs: Any) -> Any:
            if self.is_simple:
                return self.dec_func(f)(*args, **kwargs)
            else:
                return self.dec_func(*self.dec_args, **self.dec_kwargs)(f)(*args, **kwargs)

        update_wrapper(wrap, f)
        return wrap

About decorate()

The decorate() function is used by the build_command() function to apply the decorator. It wasn't intentded to be used directly, but it may work for other purposes. No gurantees are provided!

Here are what each of the attributes do.

dec_func

This is the decorator function that should be applied to the dynamically constructed command.

Have a look at the equivalent implementations in this example:

from typing import Any
from collections.abc import Callable
from functools import wraps

from typer import Typer
from typer_repyt import build_command, DecDef

def simple_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print("Start simple decorator")
        result = func(*args, **kwargs)
        print("End simple decorator")
        return result
    return wrapper

cli = Typer()

@cli.command()
@simple_decorator
def static():
    print("In command")


def dynamic():
    print("In command")

build_command(
    cli,
    dynamic,
    decorators=[DecDef(dec_func=simple_decorator)],
)


if __name__ == "__main__":
    cli()

Let's run both the static and dynamic command from the example and see that the decorator is applied as expected:

$ python examples/dec_def/dec_func.py static
Start simple decorator
In command
End simple decorator

$ python examples/dec_def/dec_func.py dynamic
Start simple decorator
In command
End simple decorator

dec_args

The dec_args keyword argument provides a list of positional arguments that should be provided to the decorator.

Complex decorators only!

The dec_args keyword argument can only be used with a "complex" decorator.

A "simple" decorator is provided without parentheses or any arguments.

A "complex" decorator is provided with parentheses and may recieve positional and keyword arguments.

Here are two equivalent implementations with positional arguments:

from collections.abc import Callable
from functools import wraps
from typing import Any

import typer

from typer_repyt import build_command, DecDef


def complex_decorator(a: str, b: int) -> Callable[..., Any]:
    def _decorate(func: Callable[..., Any]) -> Callable[..., Any]:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            print(f"Complex decorator args: {a=}, {b=}")
            print("Complex decorator before function call")
            result = func(*args, **kwargs)
            print("Complex decorator after function call")
            return result
        return wrapper
    return _decorate


cli = typer.Typer()


@cli.command()
@complex_decorator("jawa", 13)
def static():
    print("In command")


def dynamic():
    print("In command")

build_command(
    cli,
    dynamic,
    decorators=[DecDef(dec_func=complex_decorator, dec_args=["jawa", 13], is_simple=False)],
)


if __name__ == "__main__":
    cli()

Let's check to make sure that the static and dynamic commands produce the same output:

$ python examples/dec_def/dec_args.py static
Complex decorator args: a='jawa', b=13
Complex decorator before function call
In command
Complex decorator after function call

$ uv run python examples/dec_def/dec_args.py dynamic
Complex decorator args: a='jawa', b=13
Complex decorator before function call
In command
Complex decorator after function call

dec_kwargs

The dec_kwargs keyword argument provides a dictionary of keyword arguments that should be provided to the decorator.

Complex decorators only!

The dec_kwargs keyword argument can only be used with a "complex" decorator.

Once again, we have equivalent implementations in an example:

from collections.abc import Callable
from functools import wraps
from typing import Any

import typer

from typer_repyt import build_command, DecDef


def complex_decorator(a: str = "jawa", b: int = 13) -> Callable[..., Any]:
    def _decorate(func: Callable[..., Any]) -> Callable[..., Any]:
        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            print(f"Complex decorator keyword args: {a=}, {b=}")
            print("Complex decorator before function call")
            result = func(*args, **kwargs)
            print("Complex decorator after function call")
            return result
        return wrapper
    return _decorate


cli = typer.Typer()


@cli.command()
@complex_decorator(a="ewok", b=21)
def static():
    print("In command")


def dynamic():
    print("In command")

build_command(
    cli,
    dynamic,
    decorators=[DecDef(dec_func=complex_decorator, dec_kwargs=dict(a="ewok", b=21), is_simple=False)],
)


if __name__ == "__main__":
    cli()

Let's check to make sure that the static and dynamic commands produce the same output:

$ python examples/dec_def/dec_kwargs.py static
Complex decorator kewyord args: a='ewok', b=21
Complex decorator before function call
In command
Complex decorator after function call

$ python examples/dec_def/dec_kwargs.py dynamic
Complex decorator kewyord args: a='ewok', b=21
Complex decorator before function call
In command
Complex decorator after function call

is_simple

The is_simple keyword argument is a flag that indicates whether the provided decorator is "simple" or "complex". Basically, if the decorator is used without parentheses it is a "simple" decorator. If the decorator must include parentheses and possibly takes positional and keyword arguments, then it is "complex".

Not official

The adjectives "simple" and "complex" are not used in official Python documentation. However, it's useful in the context of the typer-repyt package to clearly explain the difference.

See previous examples to see how is_simple can be applied for decorators.