User Classes

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

Basic Example

As an example, let's consider using the builtin dataclass to make a CLI that manages a movie collection.

from cyclopts import App
from dataclasses import dataclass

app = App(name="movie-maintainer")

@dataclass
class Movie:
   title: str
   year: int

@app.command
def add(movie: Movie):
   print(f"Adding movie: {movie}")

app()
$ movie-maintainer add --help
Usage: movie-maintainer add [ARGS] [OPTIONS]

╭─ Parameters ────────────────────────────────────────────────╮
│ *  MOVIE.TITLE              [required]                      │
│      --movie.title                                          │
│ *  MOVIE.YEAR --movie.year  [required]                      │
╰─────────────────────────────────────────────────────────────╯

$ movie-maintainer add 'Mad Max: Fury Road' 2015
Adding movie: Movie(title='Mad Max: Fury Road', year=2015)

$ movie-maintainer add --movie.title 'Furiosa: A Mad Max Saga' --movie.year 2024
Adding movie: Movie(title='Furiosa: A Mad Max Saga', year=2024)

In most circumstances, Cyclopts will also parse a json-string for a dataclass-like parameter:

$ movie-maintainer add --movie='{"title": "Mad Max: Fury Road", "year": 2024}'
Adding movie: Movie(title='Mad Max: Fury Road', year=2024)

JSON Dict Parsing

JSON dict parsing will be performed when:

  1. The parameter is specified as a keyword option; e.g. --movie.

  2. The referenced parameter type has various sub-arguments (is dataclass-like).

  3. The referenced parameter is not union'd with a str.

  4. The first character is a {.

This behavior can be configured via Parameter.json_dict.

from cyclopts import App
from dataclasses import dataclass

app = App(name="movie-manager")

@dataclass
class Movie:
   title: str
   year: int
   rating: float = 8.0

@app.command
def add(movie: Movie):
   print(f"Adding: {movie}")

app()
$ movie-manager add --movie '{"title": "Mad Max: Fury Road", "year": 2015, "rating": 8.1}'
Adding: Movie(title='Mad Max: Fury Road', year=2015, rating=8.1)

$ movie-manager add --movie '{"title": "Furiosa", "year": 2024}'
Adding: Movie(title='Furiosa', year=2024, rating=8.0)

Note that JSON parsing only works when using the keyword option format (--movie). The traditional positional argument format still works with individual fields:

$ movie-manager add --movie.title "Dune" --movie.year 2021 --movie.rating 8.5
Adding: Movie(title='Dune', year=2021, rating=8.5)

JSON List Parsing

Cyclopts also supports JSON parsing for lists of dataclasses. This allows you to pass multiple structured objects via JSON:

from cyclopts import App
from dataclasses import dataclass

app = App(name="movie-collection")

@dataclass
class Movie:
   title: str
   year: int

@app.command
def add_batch(movies: list[Movie]):
   for movie in movies:
       print(f"Adding: {movie}")

app()

You can provide the list in several ways:

  1. JSON Array - Multiple objects in a single argument:

    $ movie-collection add-batch --movies '[{"title": "Mad Max", "year": 2015}, {"title": "Furiosa", "year": 2024}]'
    Adding: Movie(title='Mad Max', year=2015)
    Adding: Movie(title='Furiosa', year=2024)
    
  2. Individual JSON - Each object as a separate argument:

    $ movie-collection add-batch --movies '{"title": "Mad Max", "year": 2015}' --movies '{"title": "Furiosa", "year": 2024}'
    Adding: Movie(title='Mad Max', year=2015)
    Adding: Movie(title='Furiosa', year=2024)
    
  3. Mixed - Combining arrays and individual objects:

    $ movie-collection add-batch --movies '{"title": "Mad Max", "year": 2015}' --movies '[{"title": "Furiosa", "year": 2024}, {"title": "Dune", "year": 2021}]'
    Adding: Movie(title='Mad Max', year=2015)
    Adding: Movie(title='Furiosa', year=2024)
    Adding: Movie(title='Dune', year=2021)
    

JSON list parsing is automatically enabled for list types containing dataclasses. The same rules apply as for dict parsing:

  • The element type cannot be union'd with str

  • JSON objects must start with { or be arrays starting with [

This behavior can be configured via Parameter.json_list.

Namespace Flattening

It is likely that the actual movie class/object is not important to the CLI user, and the parameter names like --movie.title are unnecessarily verbose. We can remove movie from the name by giving the Movie type annotation the special name "*".

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

app = App(name="movie-maintainer")

@dataclass
class Movie:
   title: str
   year: int

@app.command
def add(movie: Annotated[Movie, Parameter(name="*")]):
   print(f"Adding movie: {movie}")

app()
$ movie-maintainer add --help
Usage: movie-maintainer add [ARGS] [OPTIONS]

╭─ Parameters ────────────────────────────────────────────────╮
│ *  TITLE --title  [required]                                │
│ *  YEAR --year    [required]                                │
╰─────────────────────────────────────────────────────────────╯

An alternative way of supplying the Parameter configuration is via a decorator. This way can be cleaner and terser in many scenarios. The Parameter configuration will also be inherited by subclasses.

from cyclopts import App, Parameter
from dataclasses import dataclass

app = App(name="movie-maintainer")

@Parameter(name="*")
@dataclass
class Movie:
   title: str
   year: int

@app.command
def add(movie: Movie):
   print(f"Adding movie: {movie}")

app()

Sharing Parameters

A flattened dataclass provides a natural way of easily sharing a set of parameters between commands.

from cyclopts import App, Parameter
from dataclasses import dataclass

app = App(name="movie-maintainer")

@Parameter(name="*")
@dataclass
class Config:
   user: str
   server: str = "media.sqlite"

@dataclass
class Movie:
   title: str
   year: int

@app.command
def add(movie: Movie, *, config: Config):
   print(f"Config: {config}")
   print(f"Adding movie: {movie}")

@app.command
def remove(movie: Movie, *, config: Config):
   print(f"Config: {config}")
   print(f"Removing movie: {movie}")

app()
$ movie-maintainer remove --help
Usage: movie-maintainer remove [ARGS] [OPTIONS]

╭─ Parameters ────────────────────────────────────────────────╮
│ *  MOVIE.TITLE              [required]                      │
│      --movie.title                                          │
│ *  MOVIE.YEAR --movie.year  [required]                      │
│ *  --user                   [required]                      │
│    --server                 [default: media.sqlite]         │
╰─────────────────────────────────────────────────────────────╯

$ movie-maintainer remove 'Mad Max: Fury Road' 2015 --user Guido
Config: Config(user='Guido', server='media.sqlite')
Removing movie: Movie(title='Mad Max: Fury Road', year=2015)

Config File

Having the user specify --user every single call is a bit cumbersome, especially if they're always going to provide the same value. We can have Cyclopts fallback to a toml configuration file.

Consider the following toml data saved to config.toml:

# config.toml
user = "Guido"

We can update our app to fill in missing CLI parameters from this file:

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

app = App(
   name="movie-maintainer",
   config=config.Toml("config.toml", use_commands_as_keys=False),
)

@Parameter(name="*")
@dataclass
class Config:
   user: str
   server: str = "media.sqlite"

@dataclass
class Movie:
   title: str
   year: int

@app.command
def add(movie: Movie, *, config: Config):
   print(f"Config: {config}")
   print(f"Adding movie: {movie}")

app()
$ movie-maintainer add 'Mad Max: Fury Road' 2015
Config: Config(user='Guido', server='media.sqlite')
Adding movie: Movie(title='Mad Max: Fury Road', year=2015)