DEV Community

Cover image for Your Function Signature Is Already a CLI — Meet ArgSense
Likianta D
Likianta D

Posted on

Your Function Signature Is Already a CLI — Meet ArgSense

ArgSense: write a function, get a CLI

ArgSense is a small command-line toolkit for Python. Point it at a function, and it turns the function signature plus its docstring into a complete CLI: argument parsing, type conversion, and a styled help panel — with no parser object to build and no decorator to remember. And when you would rather fill in a command than type it, the very same function is also a form.

If you have ever written this:

import argparse

def hello(name: str = 'world'):
    print(f'hello {name}')

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('name', nargs='?', default='world', help='Who to greet.')
    args = parser.parse_args()
    hello(name=args.name)
Enter fullscreen mode Exit fullscreen mode

...you know the pattern: the parser, the type=, the help= strings and the real function all describe the same thing — and they slowly drift apart.

ArgSense removes that duplication.

Install

pip install argsense
Enter fullscreen mode Exit fullscreen mode

Requires Python 3.9 and above. The optional form (the TUI) pulls in Textual as well:

pip install argsense[tui]
Enter fullscreen mode Exit fullscreen mode

From a function to a CLI in 30 seconds

# main.py
from argsense import cli


def hello(name: str = 'world'):
    print(f'hello {name}')


if __name__ == '__main__':
    cli.run(hello)
Enter fullscreen mode Exit fullscreen mode
python main.py                # hello world
python main.py Alice          # hello Alice
python main.py -h             # a styled help panel
Enter fullscreen mode Exit fullscreen mode

the help panel for hello -h

No parser object, no add_argument, no help=. The function is the interface.

The same thing, in four libraries

the same command in argparse, Click, Typer and ArgSense

  • argparse builds a parser imperatively and then unpacks args.
  • Click adds @click.command() and @click.option(...) decorators.
  • Typer infers from type hints, but help text still lives in Annotated[...].
  • ArgSense infers from type hints — and the docstring is the help text.

What makes it click

The docstring is the help text

Write each parameter's description once, in the docstring:

def install(appid: str, version: str = '', use_exist: bool = True):
    """
    install an app from the cloud.

    params:
        appid: the app id (a normalized app name).
        version: the app version; the last installed one is used if omitted.
        use_exist: reuse the installed version when possible.
    """
Enter fullscreen mode Exit fullscreen mode

the help panel generated from the docstring

With Click or Typer you would pass those same strings into help= / Annotated. Here there is one source of truth, so the code and the help can never disagree.

Types — including the values you actually want

Annotations drive the conversion, so count: int and rate: float just work. On top of that, ArgSense understands a few sentinels that argparse and Typer don't have:

Input Becomes
:true / :false True / False
:none None
:empty ''

That matters more than it sounds: --tag :none gives you a real None, instead of the string ":none" that you would have to post-process yourself.

Several commands in one file

Define more functions and call cli.run() with no argument — every public function in the module becomes a command:

def foo(a: str):
    """download a file by name."""


def bar(b: int):
    """update the local cache."""


if __name__ == '__main__':
    cli.run()
Enter fullscreen mode Exit fullscreen mode

the command list

Click needs a @click.group() and Typer needs a Typer() app. Here every public function is simply a command.

Group them when they multiply

Past a handful of commands a flat list gets hard to scan. cli.run_group(...) puts them in named groups — the same functions, the same docstrings, just organized:

if __name__ == '__main__':
    cli.run_group({
        'app': (install, upgrade, run),
        'debug': (doctor, echo),
    })
Enter fullscreen mode Exit fullscreen mode

the command groups

The group name is a label in the help interface, not a word you have to type: it stays python main.py install my-app. (With Click or Typer, nested groups are real sub-applications, so the invocation becomes python main.py app install my-app.)

A form, not just a command line

Sometimes you do not want to remember the flags — you want to see them. Pass mode='tui', or type :tui on the command line, and the same commands are rendered as a form: a sidebar of groups, a tab per command, a widget per parameter, and a log panel for whatever the command prints.

if __name__ == '__main__':
    cli.run_group({'app': (install, upgrade, run)}, mode='tui')
Enter fullscreen mode Exit fullscreen mode

the same commands as a form

Nothing extra is declared for it: the form reads the same signature and docstring, so the parameters, the types, the help text and the defaults are the ones you already wrote. A text parameter becomes an input, a number gets a narrow box of its own, and a flag becomes a switch; consecutive small widgets are packed into a single row so the form stays compact. It does need the optional dependency:

pip install argsense[tui]
Enter fullscreen mode Exit fullscreen mode

Document a shared parameter once

When a few commands share a parameter, describe it once and reference it elsewhere:

def foo(name: str):
    """
    params:
        name: the leader's name.
    """


def bar(name: str):
    """
    params:
        name: see `@foo:name`
    """
Enter fullscreen mode Exit fullscreen mode

The reference is resolved lazily, so registration order doesn't matter (and references can even chain). The libraries compared here have no docstring-level equivalent — the usual workaround is extracting a shared decorator by hand.

the reused parameter description

Help that stays readable

Long descriptions are the classic failure mode of CLI help: the description column gets squeezed into a narrow strip. ArgSense notices when a description doesn't get enough room and reflows it onto a full-width line.

before and after

Colors that belong on a terminal

The styling adapts to what the terminal can actually show. On a truecolor terminal it draws the Catppuccin mocha palette with a subtle gradient; on a 256-color terminal the gradient drops but the palette stays; and on an old 16-color terminal it falls back to plain ANSI without ever looking broken.

the catppuccin palette, and the legacy fallback

You pick nothing and configure nothing — the palette is chosen from the terminal's own color system.

How it compares

Library Interface declared with Help text from Type conversion Multiple commands Help UI Form UI
argparse add_argument calls help= manual subparsers plain no
Click decorators help= + docstring manual @click.group pretty no
Typer type hints Annotated + docstring yes Typer app rich no
Python Fire function signature docstring yes dict / class plain no
ArgSense type hints + docstring docstring (params:) yes cli.run() / cli.run_group() rich TUI

Built on Rich, so the help panel is styled out of the box, and on Textual the very same commands can be filled in as a form — with a deliberately small surface area: annotate a function and you're done.

Try it

pip install argsense          # the CLI
pip install argsense[tui]     # the CLI, plus the optional TUI form
Enter fullscreen mode Exit fullscreen mode

install and try it

If you build command-line tools in Python, give it a look. And if it saves you some boilerplate, a star on GitHub goes a long way.

Top comments (0)