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).
The number of tokens can be explicitly controlled with n_tokens, which is useful when the
type signature doesn't match the desired CLI token consumption.
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 anint.Accepts binary values (strings starting with
0b)Accepts octal values (strings starting with
0o)Accepts hexadecimal values (strings starting with
0x).
Counting Flags
For parameters that need to track the number of times a flag appears (e.g., verbosity levels like -vvv), use Parameter.count with an int type hint.
from cyclopts import App, Parameter
from typing import Annotated
app = App()
@app.default
def main(verbose: Annotated[int, Parameter(alias="-v", count=True)] = 0):
print(f"Verbosity level: {verbose}")
app()
$ my-program
Verbosity level: 0
$ my-program -v
Verbosity level: 1
$ my-program -vvv
Verbosity level: 3
$ my-program --verbose --verbose
Verbosity level: 2
$ my-program -v --verbose -vv
Verbosity level: 4
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
If specified as a keyword, booleans are interpreted flags that take no parameter. The default false-like flag are
--no-FLAG-NAME. SeeParameter.negativefor 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
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 asTrue.If the token is a false-like value
{"no", "n", "0", "false", "f"}, then it is parsed asFalse.Otherwise, a
CoercionErrorwill be raised.
Cyclopts is stricter than traditional
boolcasting; the provided value must be one of the above. For example,2is not considered a true-like value and will raise an error.$ my-program foo 1 True $ my-program foo 0 False $ my-program foo 2 ╭─ Error ───────────────────────────────────────╮ │ Invalid value for "--my-flag": unable to │ │ convert "2" into bool. │ ╰───────────────────────────────────────────────╯ $ 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. │ ╰─────────────────────────────────────────────────────────╯
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 whether the values are provided positionally or by keyword.
Positional
When arguments are provided positionally:
If
Parameter.allow_leading_hyphenisFalse(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, aMissingArgumentErrorwill 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_hyphenisTrue, 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_hyphenisFalse(default behavior), reaching an option-like token will raiseMissingArgumentErrorif 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_multipleisTrue, all remaining tokens will be consumed (until an option-like token is reached ifParameter.allow_leading_hyphenisFalse).Parameter.consume_multiplealso accepts anint(minimum element count) or atuple[int, int]for(min, max)bounds. See theParameter.consume_multipleAPI docs for details.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]
If
Parameter.allow_repeatingisFalse, a keyword option cannot be specified more than once. This is especially useful in combination withParameter.consume_multipleto allow--foo a b cbut reject--foo a --foo b. IfParameter.allow_repeatingisTrue, scalar types use "last wins" semantics instead of raising an error.from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def foo(values: Annotated[list[int], Parameter(consume_multiple=True, allow_repeating=False)]): print(values) app()
$ my-program --values 1 2 3 [1, 2, 3] $ my-program --values 1 --values 2 ╭─ Error ──────────────────────────────────────────────────╮ │ Parameter "--values" was specified multiple times. │ ╰─────────────────────────────────────────────────────────╯
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.
Flag
Flag enums (and by extension, IntFlag) are treated as a collection of boolean flags.
The Parameter.name_transform gets applied to all Flag 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.
from cyclopts import App
from enum import Flag, auto
app = App()
class Permission(Flag):
READ = auto()
WRITE = auto()
EXECUTE = auto()
@app.default
def default(permissions: Permission = Permission.READ):
print(f"Permissions: {permissions}")
app()
$ my-program
Permissions: Permission.READ
$ my-program write
Permissions: Permission.WRITE
$ my-program read write
Permissions: Permission.READ|WRITE
$ my-program --permissions.write
Permissions: Permission.WRITE
$ my-program --permissions.write --permissions.read
Permissions: Permission.READ|WRITE
Note
If you want to directly expose the flags as booleans (e.g. --read), then see Namespace Flattening.
date
Cyclopts supports parsing dates into a date object. It uses fromisoformat() under the hood, so the only supported format is %Y-%m-%d (e.g. 1956-01-31).
However, if you use newer Python (>= 3.11), it also supports other formats such as %Y%m%d (e.g., 20191204), 2021-W01-1, etc, defined by ISO 8601.
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 seconds5m- 5 minutes2h- 2 hours1d- 1 day3w- 3 weeks6M- 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.