Skip to main content

Saihai

Saihai turns a plain function into a command line command. The decorator gives it a name and a summary, the type hints give it its arguments, and the parser turns argv into the call the host has to make.

Saihai parses, it never calls: parse_command hands back the function and the arguments it matched, and the host decides how to invoke it. That is what lets an injection framework fill the parameters saihai knows nothing about.

Saihai is used by the Bolinette project to build the blnt command line tool.

from typing import Annotated

from escondite import Cache
from saihai import CommandArg, CommandOption, CommandParser, command

cache = Cache()


@command("greet", "Greets someone", cache=cache)
def greet(name: Annotated[str, CommandArg()], loud: Annotated[bool, CommandOption("l")] = False) -> None:
    print(f"HELLO {name.upper()}" if loud else f"Hello {name}")


cmd = CommandParser(cache, prog="mytool").parse_command(["greet", "Bob", "-l"])

assert cmd.args == {"name": "Bob", "loud": True}
cmd.func.func(**cmd.args)  # HELLO BOB

Installation

$ pip install saihai  # or use your preferred package manager

Requirements

Saihai requires Python 3.13 (or newer), and depends on peritype to read the signatures it builds arguments from, on hafersack to tag the functions its decorator collects, and on escondite for the cache that decorator writes to.

Declaring a command

command(path, summary) registers a function, as a decorator or applied directly to one that already exists. It returns the function untouched, so a command stays an ordinary function anyone can call. Commands go into an escondite cache, the global one by default or the one given as cache=.

from escondite import Cache
from saihai import command

cache = Cache()


@command("hello", "Says hello", cache=cache)
def hello() -> None:
    print("Hello")


def goodbye() -> None:
    print("Goodbye")


command(goodbye, "goodbye", "Says goodbye", cache=cache)

A path with spaces builds a tree of sub-commands, to any depth. db migrate and db seed are two commands under the same db group, and db on its own asks for that group's help.

Any other keyword argument is kept verbatim in the command's extras, for the host to read back. Saihai never interprets them, which is where a framework puts the flags that only mean something to it.

from saihai import CommandMeta, meta


@command("db migrate", "Runs migrations", cache=cache, run_startup=True)
def migrate() -> None: ...


assert meta.get(migrate, CommandMeta.KEY).extras == {"run_startup": True}

Declaring arguments

A parameter becomes an argument when it is annotated with CommandArg() for a positional one, or CommandOption() for a flag. Both take a summary= shown in the help, and CommandOption takes a one-letter shorthand as its first argument. An option is required unless it has a default or accepts None.

Parameters carrying neither marker are left alone and never reach the command line. That is deliberate: it leaves room for the host to fill them with whatever it resolves services from.

@command("serve", "Runs the server", cache=cache)
def serve(
    host: Annotated[str, CommandArg(summary="Address to bind")],
    logger: Logger,  # not a command line argument
    port: Annotated[int, CommandOption("p")] = 8080,
    verbose: Annotated[bool, CommandOption("v")] = False,
) -> None: ...

The type hint decides how the value is read, and a type saihai cannot map raises when the parser is built, not when the command runs. A list[T] option is repeated one flag at a time, while a positional one takes every remaining value at once.

Hint Command line
str, int, float Converted from the string form
bytes The argument is encoded
bool A flag, --name, defaulting to False
bool = True An inverted flag: passing --name gives False
Literal["a", "b"] A choice, rejected by the parser when it is not one
Literal[1, 2] The same, converted to int
list[T] A repeatable option, or a positional taking several
T | None Optional, None when absent

Parsing

CommandParser(cache) reads every command in the cache and builds the whole argparse tree once, and prog= and description= name the tool in the help text. parse_command(argv) returns a ParsedCommand carrying the matched function, its meta, and the args the command line produced.

@command("db rollback", "Rolls the last migration back", cache=cache)
def rollback(steps: Annotated[int, CommandOption("n")] = 1) -> None: ...


cmd = CommandParser(cache).parse_command(["db", "rollback", "-n", "3"])

assert cmd.meta.path == "db rollback"
assert cmd.meta.summary == "Rolls the last migration back"
assert cmd.args == {"steps": 3}

The parser never writes to the console and never exits the process. Asking for help, or giving a command line it cannot accept, raises instead, and the error carries the text a command line tool would have printed.

Reference

Errors

All errors derive from saihai.errors.SaihaiError, and a CommandError carries the exit code a tool should end on.

Error Raised when
CommandDefinitionError A command is malformed: a path conflict, or an argument saihai cannot map
CommandHelpError Help was asked for, or no command was given; code 0 or 1
CommandUsageError The command line does not match the command; code 2

License

Saihai is released under the MIT license, see LICENSE.txt.

Metadata

Release files for saihai 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for saihai 0.1.0
File Size Uploaded
saihai-0.1.0.tar.gz 8.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for saihai 0.1.0
File Interpreter ABI Platform
saihai-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 17.8 kB

Release files / saihai-0.1.0.tar.gz

Download URL saihai-0.1.0.tar.gz
Size 8.2 kB
Tags Source
SHA-256 checksum
How to use checksums
56ba2302fa3e6ade58c55c207a7161da8ddbf21d8193baedba76a4e1bc3a06f3
BLAKE2b-256 checksum
How to use checksums
d0258c7133d83e294d764a8598d9b9918fa1f28e5f3b0808f1d8584b4d292c28
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / saihai-0.1.0-py3-none-any.whl

Download URL saihai-0.1.0-py3-none-any.whl
Size 9.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e35acddacafa5a44f6f2701de9e812a96ee9e3afa55f0371d2be38cd21f92334
BLAKE2b-256 checksum
How to use checksums
a1417fee804f14ad0eb6b33c6a476dca2b80e88d164558c58b8207cd4d9ad2af
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page