Skip to content

Type hints

Introduction

arguably uses type hints to convert CLI input from strings to the type needed by your function.

import arguably
from pathlib import Path

@arguably.command
def basic(name: str, age: int, percent: float):
    """all basic types like str, int, float, etc are supported"""
    print(f"{name=}", f"{age=}", f"{percent=}")
user@machine:~$ python3 type-hint.py basic Monty 42 33.3
name='Monty' age=42 percent=33.3
@arguably.command
def tuple_(value: tuple[str, int, float]):
    """tuples can contain any supported type that isn't a list or tuple"""
    print(f"{value=}")
user@machine:~$ python3 type-hint.py tuple foo,1,3.14
value=('foo', 1, 3.14)
class UserType:
    def __init__(self, val: str):
        self.val = int(val)
    def __repr__(self):
        return f"{type(self).__name__}(val={self.val})"

@arguably.command
def any_type(value: UserType, path: Path):
    """any type that can be initialized from a string is supported"""
    print(f"{value=}", f"{path=}")
user@machine:~$ python3 type-hint.py any-type 123 .
value=UserType(val=123) path=PosixPath('.')
@arguably.command
def list_(files: list[Path], *, nums: list[int]):
    """lists are supported. if they appear as an option
    (like `coord` does), they can be specified multiple times"""
    print(f"{files=}", f"{nums=}")
user@machine:~$ python3 type-hint.py list foo.txt,bar.exe --nums 1 --nums 2,3
files=[PosixPath('foo.txt'), PosixPath('bar.exe')] nums=[1, 2, 3]
if __name__ == "__main__":
    arguably.run()

Allowed types

"Normal" types

Any type that can be constructed by passing in a single string value is allowed. This includes:

  • Basic built-in types like str, int, float, bool
  • Other built-ins like pathlib.Path
  • Any user-defined classes that also have this kind of constructor
@dataclass
class GoodClass1:
    """Example of a user-defined class that can be used"""
    name: str

@dataclass
class BadClass1:
    """NOT USABLE: This class won't work, since it should take in an integer"""
    age: int

@dataclass
class BadClass2:
    """NOT USABLE: This class won't work, since it takes in multiple arguments"""
    first_name: str
    last_name: str

class GoodClass2:
    """Example of another user-defined class that can be used"""
    def __init__(self, value: str | int):
        if isinstance(value, str):
            value = int(str)
        self._int_value = value

Unions with None

Any union with None at the outermost level is ignored:

  • Optional[int] will be parsed as int
  • Tuple[str, int, float] | None will be parsed as Tuple[str, int, float]
  • Tuple[Optional[str], int, float] | None is not allowed - the first element can be either a str or None, which isn't possible to unambiguously parse.

Tuples

Tuples are handled as comma-separated values. If you need to put a comma in a value itself, you can wrap it in quotes.

  • tuple[int, int, int] would take in 1,2,3
  • tuple[int, float, str] would take in 1,3.14,etc
  • tuple[int, ...] - would not work, as flexible-length tuples are not allowed (though this may change in the future)
  • tuple[str, str] would take in 'abc,"d,e,f"', which would become ("abc", "d,e,f")

Quote double-wrapping

To escape a comma, the whole argument must be wrapped in quotes - this is necessary to prevent your shell from parsing away the inner pair of quotes. Please discuss in #7 if you have input on a better way of doing this.

Lists

Lists are comma-separated, like tuples. However, if a list appears as an --option, it can be specified multiple times.

  • list[int] would take in 1,2,3,4
  • def foo(*, bar: list[int]) would take in --bar 1 --bar 2 --bar 3,4

enum.Enum

Enums allow member names to be used as input. No other value is accepted.

Enum names are normalized the same way as function names:

  • Converted to lowercase
  • _leading and trailing__ underscores _ are stripped
  • Underscores between_words are converted to dashes: between-words
import arguably
import enum

class Direction(enum.Enum):
    UP = (0, 1)
    DOWN = (0, -1)
    LEFT = (-1, 0)
    RIGHT = (1, 0)

@arguably.command
def move(start: tuple[int, int], direction: Direction):
    end = start + direction.value
    print(f"{start=}", f"{direction}", f"{end=}")

if __name__ == "__main__":
    arguably.run()

user@machine:~$ python3 enum-1.py 100,100 diagonally
usage: enum-1.py [-h] start,start {up,down,left,right}
enum-1.py: error: argument direction: invalid choice: 'diagonally' (choose from 'up', 'down', 'left', 'right')
user@machine:~$ python3 enum-1.py 100,100 down
start=(100, 100) Direction.DOWN end=(100, 99)

enum.Flag

Flag values never appear directly. Instead, each member always appears as an --option. The docstring for enum.Flag values is parsed as well, meaning you can create help messages for each entry and specify a shorthand through [-x].

Flag names are processed the same way as enum.Enum names.

import arguably
import enum
from pathlib import Path

class Permissions(enum.Flag):
    """
    Permission flags

    Attributes:
        READ: [-r] allows for reads
        WRITE: [-w] allows for writes
        EXECUTE: [-x] allows for execution
    """

    READ = 4
    WRITE = 2
    EXECUTE = 1

class PermissionsAlt(enum.Flag):
    """Annotations can also appear like this"""

    READ = 4
    """[-r] allows for reads"""
    WRITE = 2
    """[-w] allows for writes"""
    EXECUTE = 1
    """[-x] allows for execution"""

@arguably.command
def chmod(file: Path, *, flags: Permissions = Permissions(0)):
    """
    change file permissions

    Args:
        file: the file to modify
        flags: permission flags
    """
    print(f"{file=}", f"{flags=}")

if __name__ == "__main__":
    arguably.run()
user@machine:~$ python3 flag.py -h
usage: flag.py [-h] [-r] [-w] [-x] file

change file permissions

positional arguments:
  file           the file to modify (type: Path)

options:
  -h, --help     show this help message and exit
  -r, --read     allows for reads
  -w, --write    allows for writes
  -x, --execute  allows for execution
user@machine:~$ python3 flag.py foo.txt -rwx
file=PosixPath('foo.txt') flags=<Permissions.READ|WRITE|EXECUTE: 7>

Special behaviors

There are a number of special behaviors you can attach to a parameter. These utilize the ability to attach metadata to a type using typing.Annotated[]:

def foo(
    param: Annotated[<param_type>, arguably.arg.*()]
):

Example

Here's an example of each being used. This is all the same script, but results are shown after each example.

arguably.arg.required

from pathlib import Path

import arguably
from dataclasses import dataclass
from typing import Annotated

@arguably.command
def email(
    from_: str,
    *to: Annotated[str, arguably.arg.required()]
):
    print(f"{from_=}", f"{to=}")

user@machine:~$ python3 annotated.py email example@google.com
usage: annotated.py email [-h] from to [to ...]
annotated.py email: error: the following arguments are required: to
user@machine:~$ python3 annotated.py email foo@example.com monty@python.org shrubbery-interest@example.com
from_='foo@example.com' to=('monty@python.org', 'shrubbery-interest@example.com')

arguably.arg.count

@arguably.command
def process(
    *,
    verbose: Annotated[int, arguably.arg.count()],
):
    """
    :param verbose: [-v] verbosity
    """
    print(f"{verbose=}")
user@machine:~$ python3 annotated.py process -vvvv
verbose=4

arguably.arg.choices

@arguably.command
def move(
    direction: Annotated[str, arguably.arg.choices("left", "right", "up", "down")]
):
    """An enum is usually recommended for cases like this"""
    print(f"{direction=}")
user@machine:~$ python3 annotated.py move diagonally
usage: annotated.py move [-h] {left,right,up,down}
annotated.py move: error: argument direction: invalid choice: 'diagonally' (choose from 'left', 'right', 'up', 'down')

arguably.arg.missing

@arguably.command
def do_something(
    *,
    log: Annotated[Path | None, arguably.arg.missing("~/.log.txt")] = None
):
    print(f"{log=}")

user@machine:~$ python3 annotated.py do-something
log=None
user@machine:~$ python3 annotated.py do-something --log
log=PosixPath('~/.log.txt')
user@machine:~$ python3 annotated.py do-something --log here.log
log=PosixPath('here.log')

arguably.arg.handler

@arguably.command
def handle_it(
    version: Annotated[int, arguably.arg.handler(lambda s: int(s.split("-")[-1]))] = None
):
    print(f"{version=}")
user@machine:~$ python3 annotated.py handle-it python-3
version=3

arguably.arg.builder

class Nic: ...

@arguably.subtype(alias="tap")
@dataclass
class TapNic(Nic):
    model: str

@arguably.subtype(alias="user")
@dataclass
class UserNic(Nic):
    hostfwd: str

@arguably.command
def builder(
    *,
    nic: Annotated[list[Nic], arguably.arg.builder()]
):
    print(f"{nic=}")
user@machine:~$ python3 annotated.py builder --nic tap,model=e1000 --nic user,hostfwd=tcp::10022-:22
nic=[TapNic(model='e1000'), UserNic(hostfwd='tcp::10022-:22')]
if __name__ == "__main__":
    arguably.run()