Coercion Rules

This page intends to serve as a terse set of type coercion rules that Cyclopts follows.

Automatic coercion can always be overridden by the Parameter.converter field. Typically, the converter function will receive a single token, but it may receive multiple tokens if the annotated type is iterable (e.g. list, set).

No Hint

If no explicit type hint is provided:

  • If the parameter has a non-None default value, interpret the type as type(default_value).

    from cyclopts import App
    
    app = App()
    
    @app.default
    def default(value=5):
        print(f"{value=} {type(value)=}")
    
    app()
    
    $ my-program 3
    value=3 type(value)=<class 'int'>
    
  • Otherwise, interpret the type as string.

    from cyclopts import App
    
    app = App()
    
    @app.default
    def default(value):
        print(f"{value=} {type(value)=}")
    
    app()
    
    $ my-program foo
    value='foo' type(value)=<class 'str'>
    

Any

A standalone Any type hint is equivalent to No Hint

Str

No operation is performed, CLI tokens are natively strings.

from cyclopts import App

app = App()

@app.default
def default(value: str):
    print(f"{value=} {type(value)=}")

app()
$ my-program foo
value='foo' type(value)=<class 'str'>

Int

For convenience, Cyclopts provides a richer feature-set of parsing integers than just naively calling int.

  • Accepts vanilla decimal values (e.g. 123, 3.1415). Floating-point values will be rounded prior to casting to an int.

  • Accepts binary values (strings starting with 0b)

  • Accepts octal values (strings starting with 0o)

  • Accepts hexadecimal values (strings starting with 0x).

Float

Token gets cast as float(token). For example, float("3.14").

Complex

Token gets cast as complex(token). For example, complex("3+5j")

Bool

  1. If specified as a keyword, booleans are interpreted flags that take no parameter. The default false-like flag are --no-FLAG-NAME. See Parameter.negative for more about this feature.

    Example:

    from cyclopts import App
    
    app = App()
    
    @app.command
    def foo(my_flag: bool):
        print(my_flag)
    
    app()
    
    $ my-program foo --my-flag
    True
    
    $ my-program foo --no-my-flag
    False
    
  2. If specified as a positional argument, a case-insensitive lookup is performed:

    • If the token is a true-like value {"yes", "y", "1", "true", "t"}, then it is parsed as True.

    • If the token is a false-like value {"no", "n", "0", "false", "f"}, then it is parsed as False.

    • Otherwise, a CoercionError will be raised.

    $ my-program foo 1
    True
    
    $ my-program foo 0
    False
    
    $ my-program foo not-a-true-or-false-value
    ╭─ Error ─────────────────────────────────────────────────╮
    │ Invalid value for "--my-flag": unable to convert        │
    │ "not-a-true-or-false-value" into bool.                  │
    ╰─────────────────────────────────────────────────────────╯
    
  3. If specified as a keyword with a value attached with an =, then the provided value will be parsed according to positional argument rules above (2).

from cyclopts import App

app = App()

@app.command
def foo(my_flag: bool):
    print(my_flag)

 app()
$ my-program foo --my-flag=true
True

$ my-program foo --my-flag=false
False

$ my-program foo --no-my-flag=true
False

$ my-program foo --no-my-flag=false
True

List

Unlike more simple types like str and int, lists use different parsing rules depending on if the values are provided positionally or by keyword.

Positional

When arguments are provided positionally:

  • If Parameter.allow_leading_hyphen is False (default behavior), reaching an option-like token will stop parsing for this parameter. If the number of consumed tokens is not a multiple of the required number of tokens to create an element of the list, a MissingArgumentError will be raised.

    from cyclopts import App
    
    app = App()
    
    @app.command
    def foo(values: list[int]):  # 1 CLI token per element
       print(values)
    
    @app.command
    def bar(values: list[tuple[int, str]]):  # 2 CLI tokens per element
       print(values)
    
    app()
    
    $ my-program foo 1 2 3
    [1, 2, 3]
    
    $ my-program bar 1 one 2 two
    [(1, 'one'), (2, 'two')]
    
    $ my-program bar 1 one 2
    ╭─ Error ─────────────────────────────────────────────────────╮
    │ Command "bar" parameter "--values" requires 2 arguments.    │
    │ Only got 1.                                                 │
    ╰─────────────────────────────────────────────────────────────╯
    
  • If Parameter.allow_leading_hyphen is True, CLI tokens will be consumed unconditionally until exhausted.

    from cyclopts import App, Parameter
    from pathlib import Path
    from typing import Annotated
    
    app = App()
    
    @app.default
    def main(
       files: Annotated[list[Path], Parameter(allow_leading_hyphen=True)],
       some_flag: bool = False,
     ):
       print(f"{some_flag=}")
       print(f"Analyzing files {files}")
    
    app()
    
    $ my-program foo.bin bar.bin --fizz.bin buzz.bin --some-flag
    some_flag=True
    Analyzing files [PosixPath('foo.bin'), PosixPath('bar.bin'), PosixPath('--fizz.bin'), PosixPath('buzz.bin')]
    

    Known keyword arguments are parsed first (in this case, --some-flag). To unambiguously pass in values positionally, provide them after a bare --:

    $ my-program -- foo.bin bar.bin --fizz.bin buzz.bin --some-flag
    some_flag=False
    Analyzing files [PosixPath('foo.bin'), PosixPath('bar.bin'), PosixPath('--fizz.bin'), PosixPath('buzz.bin'), PosixPath('--some-flag')]
    

Keyword

When arguments are provided by keyword:

  • Tokens will be consumed until enough data is collected to form the type-hinted object.

  • The keyword can be specified multiple times.

  • If Parameter.allow_leading_hyphen is False (default behavior), reaching an option-like token will raise MissingArgumentError if insufficient tokens have been parsed.

    from cyclopts import App
    
    app = App()
    
    @app.command
    def foo(values: list[int]):  # 1 CLI token per element
       print(values)
    
    @app.command
    def bar(values: list[tuple[int, str]]):  # 2 CLI tokens per element
       print(values)
    
    app()
    
    $ my-program foo --values 1 --values 2 --values 3
    [1, 2, 3]
    
    $ my-program bar --values 1 one --values 2 two
    [(1, 'one'), (2, 'two')]
    
    $ my-program bar --values 1 --values 2
    ╭─ Error ─────────────────────────────────────────────────────╮
    │ Command "bar" parameter "--values" requires 2 arguments.    │
    │ Only got 1.                                                 │
    ╰─────────────────────────────────────────────────────────────╯
    
  • If Parameter.consume_multiple is True, all remaining tokens will be consumed (until an option-like token is reached if Parameter.allow_leading_hyphen is False)

    from cyclopts import App, Parameter
    from typing import Annotated
    
    app = App()
    
    @app.default
    def foo(values: Annotated[list[int], Parameter(consume_multiple=True)]):  # 1 CLI token per element
       print(values)
    
    app()
    
    $ my-program foo --values 1 2 3
    [1, 2, 3]
    

Empty List

Commonly, if we want a default list for a parameter in a function, we set the default value to None in the signature and then set it to the actual list in the function body:

def foo(extensions: Optional[list] = None):
   if extensions is None:
      extensions = [".png", ".jpg"]

We do this because mutable defaults is a common unexpected source of bugs in python.

However, sometimes we actually want to specify an empty list. To get an empty list pass in the flag --empty-MY-LIST-NAME.

from cyclopts import App

app = App()

@app.default
def main(extensions: list | None = None):
   if extensions is None:
      extensions = [".png", ".jpg"]
   print(f"{extensions=}")

app()
$ my-program
extensions=['.png', '.jpg']

$ my-program --empty-extensions
extensions=[]

See Parameter.negative for more about this feature.

Positional Only With Subsequent Parameters

When a list is positional-only, it will consume tokens such that it leaves enough tokens for subsequent positional-only parameters.

from pathlib import Path
from cyclopts import App

app = App()

@app.default
def main(srcs: list[Path], dst: Path, /):  # "/" makes all prior parameters POSITIONAL_ONLY
    print(f"Processing files {srcs!r} to {dst!r}.")

app()
$ my-program foo.bin bar.bin output.bin
Processing files [PosixPath('foo.bin'), PosixPath('bar.bin')] to PosixPath('output.bin').

The console wildcard * is expanded by the console, so this example will naturally work with wildcards.

$ ls foo
buzz.bin fizz.bin

$ my-program foo/*.bin output.bin
Processing files [PosixPath('foo/buzz.bin'), PosixPath('foo/fizz.bin')] to PosixPath('output.bin').

Iterable

Follows the same rules as List. The passed in data will be a list.

Sequence

Follows the same rules as List. The passed in data will be a list.

Set

Follows the same rules as List, but the resulting datatype is a set.

Frozenset

Follows the same rules as Set, but the resulting datatype is a frozenset.

Tuple

  • The inner type hint(s) will be applied independently to each element. Enough CLI tokens will be consumed to populate the inner types.

  • Nested fixed-length tuples are allowed: E.g. tuple[tuple[int, str], str] will consume 3 CLI tokens.

  • Indeterminite-size tuples tuple[type, ...] are only supported at the root-annotation level and behave similarly to List.

from cyclopts import App

app = App()

@app.default
def default(coordinates: tuple[float, float, str]):
   print(f"{coordinates=}")

app()

And invoke our script:

$ my-program --coordinates 3.14 2.718 my-coord-name
coordinates=(3.14, 2.718, 'my-coord-name')

Dict

Cyclopts can populate dictionaries using keyword dot-notation:

from cyclopts import App

app = App()

@app.default
def default(message: str, *, mapping: dict[str, str] | None = None):
    if mapping:
        for find, replace in mapping.items():
            message = message.replace(find, replace)
    print(message)

app()
$ my_program 'Hello Cyclopts users!'
Hello Cyclopts users!

$ my_program 'Hello Cyclopts users!' --mapping.Hello Hey
Hey Cyclopts users!

$ my_program 'Hello Cyclopts users!' --mapping.Hello Hey --mapping.users developers
Hey Cyclopts developers!

Due to the way of specifying keys, it is recommended to make dict parameters keyword-only; dicts cannot be populated positionally. If you do not wish for the user to be able to specify arbitrary keys, see User-Defined Classes. For specifying arbitrary keywords at the root level, see kwargs.

Union

The unioned types will be iterated left-to-right until a successful coercion is performed. None type hints are ignored.

from cyclopts import App
from typing import Union

app = App()

@app.default
def default(a: Union[None, int, str]):
    print(type(a))

app()
$ my-program 10
<class 'int'>

$ my-program bar
<class 'str'>

Optional

Optional[...] is syntactic sugar for Union[..., None]. See Union rules.

Literal

The Literal type is a good option for limiting user input to a set of choices. Like Union, the Literal options will be iterated left-to-right until a successful coercion is performed. Cyclopts attempts to coerce the input token into the type of each Literal option.

from cyclopts import App
from typing import Literal

app = App()

@app.default
def default(value: Literal["foo", "bar", 3]):
    print(f"{value=} {type(value)=}")

app()
$ my-program foo
value='foo' type(value)=<class 'str'>

$ my-program bar
value='bar' type(value)=<class 'str'>

$ my-program 3
value=3 type(value)=<class 'int'>

$ my-program fizz
╭─ Error ─────────────────────────────────────────────────╮
│ Invalid value for "VALUE": unable to convert "fizz"     │
│ into one of {'foo', 'bar', 3}.                          │
╰─────────────────────────────────────────────────────────╯

Enum

While Literal is the recommended way of providing the user a set of choices, another method is using Enum.

The Parameter.name_transform gets applied to all Enum names, as well as the CLI provided token. By default,this means that a case-insensitive name lookup is performed. If an enum name contains an underscore, the CLI parameter may instead contain a hyphen, -. Leading/Trailing underscores will be stripped.

If coming from Typer, Cyclopts Enum handling is the reverse of Typer. Typer attempts to match the token to an Enum value; Cyclopts attempts to match the token to an Enum name. This is done because generally the name of the enum is meant to be human readable, while the value has some program/machine significance.

As a real-world example, the PNG image format supports 5 different color-types, which gets encoded into a 1-byte int in the image header.

from cyclopts import App
from enum import IntEnum

app = App()

class ColorType(IntEnum):
    GRAYSCALE = 0
    RGB = 2
    PALETTE = 3
    GRAYSCALE_ALPHA = 4
    RGBA = 6

@app.default
def default(color_type: ColorType = ColorType.RGB):
    print(f"Writing color-type value: {color_type} to the image header.")

app()
$ my-program
Writing color-type value: 2 to the image header.

$ my-program grayscale-alpha
Writing color-type value: 4 to the image header.

datetime

Cyclopts supports parsing timestamps into a datetime object. The supplied time must be in one of the following formats:

  • %Y-%m-%d (e.g. 1956-01-31)

  • %Y-%m-%dT%H:%M:%S (e.g. 1956-01-31T10:00:00)

  • %Y-%m-%d %H:%M:%S (e.g. 1956-01-31 10:00:00)

  • %Y-%m-%dT%H:%M:%S%z (e.g. 1956-01-31T10:00:00+0000)

  • %Y-%m-%dT%H:%M:%S.%f (e.g. 1956-01-31T10:00:00.123456)

  • %Y-%m-%dT%H:%M:%S.%f%z (e.g. 1956-01-31T10:00:00.123456+0000)

timedelta

Cyclopts supports parsing time durations into a timedelta object. The supplied time must be in one of the following formats:

  • 30s - 30 seconds

  • 5m - 5 minutes

  • 2h - 2 hours

  • 1d - 1 day

  • 3w - 3 weeks

  • 6M - 6 months (approximate)

  • 1y - 1 year (approximate)

Combining durations is also supported:

  • "1h30m" - 1 hour and 30 minutes

  • "1d12h" - 1 day and 12 hours

User-Defined Classes

Cyclopts supports classically defined user classes, as well as classes defined by the following dataclass-like libraries:

Note

For pydantic classes, Cyclopts will not internally perform type conversions and instead relies on pydantic's coercion engine.

Subkey parsing allows for assigning values positionally and by keyword with a dot-separator.

from cyclopts import App
from dataclasses import dataclass
from typing import Literal

app = App()

@dataclass
class User:
   name: str
   age: int
   region: Literal["us", "ca"] = "us"

@app.default
def main(user: User):
   print(user)

app()
$ my-program --help
Usage: main COMMAND [ARGS] [OPTIONS]

╭─ Commands ──────────────────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                                       │
│ --version  Display application version.                                         │
╰─────────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ────────────────────────────────────────────────────────────────────╮
│ *  USER.NAME --user.name      [required]                                        │
│ *  USER.AGE --user.age        [required]                                        │
│    USER.REGION --user.region  [choices: us, ca] [default: us]                   │
╰─────────────────────────────────────────────────────────────────────────────────╯

$ my-program 'Bob Smith' 30
User(name='Bob Smith', age=30, region='us')

$ my-program --user.name 'Bob Smith' --user.age 30
User(name='Bob Smith', age=30, region='us')

$ my-program --user.name 'Bob Smith' 30 --user.region=ca
User(name='Bob Smith', age=30, region='ca')

Cyclopts will recursively search for Parameter annotations and respect them:

from cyclopts import App, Parameter
from dataclasses import dataclass
from typing import Annotated

app = App()

@dataclass
class User:
   # Beginning with "--" will completely override the parenting parameter name.
   name: Annotated[str, Parameter(name="--nickname")]
   # Not beginning with "--" will tack it on to the parenting parameter name.
   age: Annotated[int, Parameter(name="years-young")]

@app.default
def main(user: Annotated[User, Parameter(name="player")]):
   print(user)

app()
$ my-program --help
Usage: main COMMAND [ARGS] [OPTIONS]

╭─ Commands ────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                 │
│ --version  Display application version.                   │
╰───────────────────────────────────────────────────────────╯
╭─ Parameters ──────────────────────────────────────────────╮
│ *  NICKNAME --nickname     [required]                     │
│ *  PLAYER.YEARS-YOUNG      [required]                     │
│      --player.years-young                                 │
╰───────────────────────────────────────────────────────────╯

Namespace Flattening

The special parameter name "*" will remove the immediate parameter's name from the dotted-hierarchal name:

from cyclopts import App, Parameter
from dataclasses import dataclass
from typing import Annotated

app = App()

@dataclass
class User:
   name: str
   age: int

@app.default
def main(user: Annotated[User, Parameter(name="*")]):
   print(user)

app()
$ my-program --help
Usage: main COMMAND [ARGS] [OPTIONS]

╭─ Commands ─────────────────────────────────────────────╮
│ --help -h  Display this message and exit.              │
│ --version  Display application version.                │
╰────────────────────────────────────────────────────────╯
╭─ Parameters ───────────────────────────────────────────╮
│ *  NAME --name  [required]                             │
│ *  AGE --age    [required]                             │
╰────────────────────────────────────────────────────────╯

This can be used to conveniently share parameters between commands, and to create a global config object. See Sharing Parameters.

Docstrings

Docstrings from the class are used for the help page. Docstrings from the command have priority over class docstrings, if supplied:

from cyclopts import App
from dataclasses import dataclass

app = App()

@dataclass
class User:
   name: str
   "First and last name of the user."

   age: int
   "Age in years of the user."

@app.default
def main(user: User):
   """A short summary of what this program does.

   Parameters
   ----------
   user.age: int
      User's age docstring from the command docstring.
   """
   print(user)

app()
$ my-program --help
Usage: main COMMAND [ARGS] [OPTIONS]

A short summary of what this program does.

╭─ Commands ──────────────────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                                       │
│ --version  Display application version.                                         │
╰─────────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ────────────────────────────────────────────────────────────────────╮
│ *  USER.NAME --user.name  First and last name of the user. [required]           │
│ *  USER.AGE --user.age    User's age docstring from the command docstring.      │
│                           [required]                                            │
╰─────────────────────────────────────────────────────────────────────────────────╯

Parameter(accepts_keys=False)

If the class is annotated with Parameter(accepts_keys=False), then no dot-notation subkeys are exported. The class parameter will consume enough tokens to populate the required positional arguments.

from cyclopts import App, Parameter
from dataclasses import dataclass
from typing import Annotated, Literal

app = App()

@dataclass
class User:
   name: str
   age: int
   region: Literal["us", "ca"] = "us"

@app.default
def main(user: Annotated[User, Parameter(accepts_keys=False)]):
   print(user)

app()
$ my-program --help
Usage: main COMMAND [ARGS] [OPTIONS]

╭─ Commands ─────────────────────────────────────────────────────────────────────╮
│ --help -h  Display this message and exit.                                      │
│ --version  Display application version.                                        │
╰────────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ───────────────────────────────────────────────────────────────────╮
│ *  USER --user  [required]                                                     │
╰────────────────────────────────────────────────────────────────────────────────╯

$ my-program 'Bob Smith' 27
User(name='Bob Smith', age=27, region='us')

$ my-program 'Bob Smith'
╭─ Error ────────────────────────────────────────────────────────────────────────╮
│ Parameter "--user" requires 2 arguments. Only got 1.                           │
╰────────────────────────────────────────────────────────────────────────────────╯

In this example, we are unable to change the region parameter of User from the CLI.