MCPcopy Index your code
hub / github.com/explosion/radicli

github.com/explosion/radicli @v0.0.26

Chat with this repo
repository ↗ · DeepWiki ↗ · release v0.0.26 ↗ · + Follow
166 symbols 771 edges 12 files 48 documented · 29%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

radicli: Radically lightweight command-line interfaces

radicli is a small, zero-dependency Python package for creating command line interfaces, built on top of Python's argparse module. It introduces minimal overhead, preserves your original Python functions and uses type hints to parse values provided on the CLI. It supports all common types out-of-the-box, including complex ones like List[str], Literal and Enum, and allows registering custom types with custom converters, as well as custom CLI-only error handling, exporting a static representation for faster --help and errors and auto-generated Markdown documentation.

Important note: This package aims to be a simple option based on the requirements of our libraries. If you're looking for a more full-featured CLI toolkit, check out typer, click or plac.

GitHub Actions Current Release Version pypi Version

⏳ Installation

Note that radicli currently requires Python 3.8+.

pip install radicli

👩‍💻 Usage

The Radicli class sets up the CLI and provides decorators for commands and subcommands. The Arg dataclass can be used to describe how the arguments should be presented on the CLI. Types and defaults are read from the Python functions. You typically don't have to change anything about how you implement your Python functions to make them available as a CLI command.

# cli.py
from radicli import Radicli, Arg

cli = Radicli()

@cli.command(
    "hello",
    name=Arg(help="Your name"),
    age=Arg("--age", "-a", help="Your age"),
    greet=Arg("--greet", "-G", help="Whether to greet"),
)
def hello(name: str, age: int, greet: bool = False):
    """Description of the function for help text."""
    if greet:
        print(f"Hello {name} ({age})!")

if __name__ == "__main__":
    cli.run()
$ python cli.py hello Alex --age 35 --greet
Hello Alex (35)!

If a file only specifies a single command (with or without subcommands), you can optionally leave out the command name. So the above example script can also be called like this:

$ python cli.py Alex --age 35 --greet
Hello Alex (35)!

Alternatively, you can also use Radicli.call:

# cli.py
from radicli import Radicli, Arg

def hello(name: str, age: int):
     print(f"Hello {name} ({age})!")

if __name__ == "__main__":
    args = dict(name=Arg(help="Your name"), age=Arg("--age", "-a", help="Your age"))
    command = Command.from_function("hello", args, hello)
    Radicli().call(command)
$ python cli.py Alex --age 35
Hello Alex (35)!

Subcommands

radicli supports one level of nested subcommands. The parent command may exist independently, but it doesn't have to.

@cli.subcommand("parent", "child1", name=Arg("--name", help="Your name"))
def parent_child1(name: str):
    ...

@cli.subcommand("parent", "child2", name=Arg("--age", help="Your age"))
def parent_child2(age: int):
    ...
$ python cli.py parent child1 --name Alex
$ python cli.py parent child2 --age 35

Working with types

For built-in callable types like str, int or float, the string value received from the CLI is passed to the callable, e.g. int(value). More complex, nested types are resolved recursively. The library also provides several built-in custom types for handling things like file paths.

⚠️ Note that there's a limit to what can reasonably be supported by a CLI interface so it's recommended to avoid overly complex types. For a Union type, the first type of the union is used. Optional types are expected to be left unset to default to None. If a value is provided, the type marked as optional is used, e.g. str for Optional[str].

Lists

By default, list types are implemented by allowing the CLI argument to occur more than once. The value of each element is parsed using the type defined for list members.

@cli.command("hello", fruits=Arg("--fruits", help="One or more fruits"))
def hello(fruits: List[str]):
    print(fruits)
$ python cli.py hello --fruits apple --fruits banana --fruits cherry
['apple', 'banana', 'cherry']

If you don't like this syntax, you can also add a converter to the Arg definition that handles the value differently, e.g. by splitting a comma-separated string. This would let the user write --fruits apple,banana,cherry, while still passing a list to the Python function.

Literals and Enums

Arguments that can only be one of a given set of values can be typed as a Literal. Any values not in the list will raise a CLI error.

@cli.command("hello", color=Arg("--color", help="Pick a color"))
def hello(color: Literal["red", "blue", "green"]):
    print(color)  # this will be a string

Enums are also supported and in this case, the enum key can be provided on the CLI and the function receives the selected enum member.

class ColorEnum(Enum):
    red = "the color red"
    blue = "the color blue"
    green = "the color green"

@cli.command("hello", color=Arg("--color", help="Pick a color"))
def hello(color: ColorEnum):
    print(color)  # this will be the enum, e.g. ColorEnum.red

Using custom types and converters

radicli supports defining custom converter functions to handle individual arguments, as well as all instances of a given type globally. Converters take the string value provided on the CLI and should return the value passed to the function, consistent with the type. They can also raise validation errors.

format_name = lambda value: value.upper()

@cli.command("hello", name=Arg("--name", converter=format_name))
def hello(name: str):
    print(f"Hello {name}"!)
$ python cli.py hello --name Alex
Hello ALEX!

Global converters for custom types

The converters argument lets you provide a dict of types mapped to converter functions when initializing Radicli. If an argument of that target type is encountered, the input string value is converted automatically. This ensures your Python functions remain composable and don't need additional logic only to satisfy the CLI usage.

The following example shows how to register a custom converter that loads a spaCy pipeline from a string name, while allowing the function itself to require the Language object itself:

import radicli
import spacy

def load_spacy_model(name: str) -> spacy.language.Language:
    return spacy.load(name)

converters = {spacy.language.Language: load_spacy_model}
cli = Radicli(converters=converters)

@cli.command(
    "process",
    nlp=Arg(help="The spaCy pipeline to use"),
    name=Arg("--text", help="The text to process")
)
def process_text(nlp: spacy.language.Language, text: str):
    doc = nlp(text)
    print(doc.text, [token.pos_ for token in doc])
$ python test.py process en_core_web_sm --text Hello world!
Hello world! ['INTJ', 'NOUN', 'PUNCT']

If you want to alias an existing type to add custom handling for it, you can create a NewType. This is also how the built-in Path converters are implemented. In help messages, the type it is based on will be displayed together with the custom name.

from typing import NewType
from pathlib import Path

ExistingPath = NewType("ExistingPath", Path)

def convert_existing_path(path_str: str) -> Path:
    path = Path(path_str)
    if not path.exists():
        raise ValueError(f"path does not exist: {path_str}")
    return path

converters = {ExistingPath: convert_existing_path}

For generic types that can have arguments, e.g. List and List[str], the converters are checked for both the exact type, as well as the origin. This means you can have multiple converters for different generics, as well as a fallback:

converters = {
    List[str]: convert_string_list,
    List[int]: convert_int_list,
    List: convert_other_lists,
}

Allowing extra arguments

If you want to capture and consume extra arguments not defined in the function and argument annotations, you can use the command_with_extra or subcommand_with_extra decorators. Extra arguments are passed to the function as a list of strings to an argument _extra (which you can change via the extra_key setting when initializing the CLI). spaCy uses this feature to pass settings to pip in its download command or to allow arbitrary configuration overrides during training.

@cli.command_with_extra("hello", name=Arg("--name", help="Your name"))
def hello(name: str, _extra: List[str] = []):
    print(f"Hello {name}!", _extra)
$ python cli.py hello --name Alex --age 35 --color blue
Hello Alex! ['--age', '35', '--color', 'blue']

Command aliases by stacking decorators

The command and subcommand decorators can be stacked to make the same function available via different command aliases. In this case, you just need to make sure that all decorators receive the same argument annotations, e.g. by moving them out to a variable.

args = dict(
    name=Arg(help="Your name"),
    age=Arg("--age", "-a", help="Your age")
)

@cli.command("hello", **args)
@cli.command("hey", **args)
@cli.subcommand("greet", "person", **args)
def hello(name: str, age: int):
    print(f"Hello {name} ({age})!")
$ python cli.py hello --name Alex --age 35
$ python cli.py hey --name Alex --age 35
$ python cli.py greet person --name Alex --age 35

Error handling

One common problem when adding CLIs to a code base is error handling. When called in a CLI context, you typically want to pretty-print any errors and avoid long tracebacks. However, you don't want to use those errors and plain SystemExits with no traceback in helper functions that are used in other places, or when the CLI functions are called directly from Python or during testing.

To solve this, radicli lets you provide an error map via the errors argument on initialization. It maps Exception types like ValueError or fully custom error subclasses to handler functions. If an error of that type is raised, the handler is called and will receive the error. The handler can optionally return an exit code – in this case, radicli will perform a sys.exit using that code. If no error code is returned, no exit is performed and the handler can either take care of the exiting itself or choose to not exit.

from radicli import Radicli
from termcolor import colored

def pretty_print_error(error: Exception) -> int:
    print(colored(f"🚨 {error}", "red"))
    return 1

cli = Radicli(errors={ValueError: handle_error})

@cli.command("hello", name=Arg("--name"))
def hello(name: str):
    if name == "Alex":
        raise ValueError("Invalid name")
$ python cli.py hello --name Alex
🚨 Invalid name
>>> hello("Alex")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Invalid name

This approach is especially powerful with custom error subclasses. Here you can decide which arguments the error should take and how this information should be displayed on the CLI vs. in a regular non-CLI context.

class CustomError(Exception):
    def __init__(self, text: str, additional_info: Any = "") -> None:
        self.text = text
        self.additional_info
        self.message = f"{self.text} {self.additional_info}"
        super().__init__(self.message)

def handle_custom_error(error: CustomError) -> int:
    print(colored(error.text, "red"))
    print(error.additional_info)
    return 1

Using static data for faster help and errors

CLIs often require various other Python packages that need to be imported – for example, you might need to import pytorch and tensorflow, or load other resources in the global scope. This all adds to the CLI's load time, so even showing the --help message may take several seconds to run. That's all unnecessary and makes for a frustrating developer experience.

radicli lets you generate a static representation of your CLI as a JSON file, including everything needed to output help messages and to check that the command exists and the correct and required arguments are provided. If the static CLI doesn't perform a system exit via printing the help message or raising an error, you can import and run the "live" CLI to continue. This lets you defer the import until it's really needed, i.e. to convert the arguments to the expected types and executing the command function.

cli.to_static("./static.json")
from radicli import StaticRadicli

static = StaticRadicli.load("./static.json")

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

    # This only runs if the static CLI doesn't error or print help
    from .cli import cli
    cli.run()

If the CLI is part of a Python package, you can generate the static JSON file during your build process and ship the pre-generated JSON file with your package.

StaticRadicli also provides a disable argument to disable static parsing during development (or if a certain environment variable is set). Setting debug=True will print an a

Core symbols most depended-on inside this repo

run
called by 65
radicli/cli.py
get_arg
called by 39
radicli/util.py
parse
called by 12
radicli/cli.py
stringify_type
called by 10
radicli/util.py
to_static_json
called by 5
radicli/cli.py
from_static_json
called by 5
radicli/cli.py
pop
called by 5
radicli/util.py
join_strings
called by 5
radicli/util.py

Shape

Function 92
Method 49
Class 24
Route 1

Languages

Python100%

Modules by API surface

radicli/tests/test_cli.py59 symbols
radicli/util.py49 symbols
radicli/cli.py28 symbols
radicli/tests/test_parser.py6 symbols
radicli/tests/test_document.py6 symbols
radicli/parser.py6 symbols
radicli/tests/test_util.py5 symbols
radicli/static.py4 symbols
radicli/document.py3 symbols

For agents

$ claude mcp add radicli \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact