A Python package that enables you to create and manage custom management commands, similar to Django's management system, for any Python application — FastAPI, Flask, Sanic, Starlette, or plain Python. This package uses Python's click to define, register, and execute commands for your application dynamically. Both synchronous and asynchronous commands are supported.
- Dynamic Command Registration: Automatically discover and register commands located in specific directories (recursively, optionally).
- Class-Based Commands: Easily define reusable commands by subclassing
BaseCommand(sync) orAsyncBaseCommand(async). - Decorator API: Register plain functions (sync or
async) directly with@system.command(). - Custom Arguments: Commands can specify their own arguments and options via
Argument.positional(...)/Argument.option(...). - Lifecycle Hooks:
setup()andteardown(exc)run before and after every command, even on exceptions. - Aliases, Help & Hidden Commands: Class-level
aliases,help,short_help,hiddenattributes for ergonomic CLIs. - Plugin Discovery: Register commands advertised by installed packages through
cmd_manager.commandsentry points. - Framework-agnostic: Drop into any Python app — FastAPI, Flask, Sanic, Starlette, or a plain script — and thread your app/context into commands via constructor args.
Install the package via pip:
pip install cmd-manager# src/scripts/mycommand.py
from cmd_manager import Argument, BaseCommand
class Command(BaseCommand):
"""Print the arguments passed in."""
arguments = (
Argument.positional("arg1"),
Argument.option("--n", type=int, default=1),
)
def run(self, *args, **kwargs):
print(f"Running with args: {args}, kwargs: {kwargs}")Argument.positional wraps click.argument; Argument.option wraps click.option. Every click keyword (type, prompt, required, is_flag, multiple, ...) is forwarded verbatim.
# src/scripts/fetch.py
import click
from cmd_manager import Argument, AsyncBaseCommand
class Command(AsyncBaseCommand):
"""Fetch a URL asynchronously."""
arguments = (Argument.option("--url", required=True),)
async def setup(self):
self.session = await open_session()
async def run(self, *args, **kwargs):
body = await self.session.get(kwargs["url"])
click.echo(body)
async def teardown(self, exc=None):
await self.session.close()The system detects async commands automatically and runs them on an event loop — setup, run, and teardown are all awaited.
# cli_runner.py
from cmd_manager import ManagementCommandSystem
system = ManagementCommandSystem()
system.register(package="src.scripts")
cli = system.create_cli()
if __name__ == "__main__":
cli()Then:
python cli_runner.py mycommand arg1_value --n 3
python cli_runner.py fetch --url https://example.com
python cli_runner.py list # built-in: prints every registered commandsystem.register(package="src.scripts", recursive=True)src/scripts/users/create.py becomes the command users-create.
system.register(prefix="ext-", package="external_package.scripts")Per-package constructor args (useful when the host app and a plugin expect different DI payloads). app can be any framework instance (FastAPI, Flask, Sanic, Starlette, ...) or any arbitrary object — ManagementCommandSystem is framework-agnostic and just forwards constructor args to each command:
system = ManagementCommandSystem(app=app) # app is any object you want injected
system.register(package="external_package.scripts",
init_kwargs={"app": app, "config": plugin_cfg})from cmd_manager import Argument
@system.command("greet", arguments=[Argument.option("--name", default="world")])
def greet(name):
"""Say hello."""
click.echo(f"hello {name}")
@system.command() # name defaults to "fetch-once" (kebab-cased function name)
async def fetch_once(url):
...A plugin package declares the command in its pyproject.toml:
[project.entry-points."cmd_manager.commands"]
greet = "my_plugin.scripts.greet:Command"The host app then loads every installed plugin command with one call:
system.register_entry_points()Any BaseCommand / AsyncBaseCommand subclass can set class-level attributes:
class Command(BaseCommand):
"""Long help text comes from the docstring by default."""
name = "do-thing" # override the discovered name
short_help = "Do a thing." # one-liner shown in `--help`
aliases = ("dt",) # alternate names (registered as hidden subcommands)
hidden = False # hide from `--help` listingsSee the example/ folder; run it with:
python example_runner.py whats_my_name
python example_runner.py listContributions are always welcome! Please read CONTRIBUTING.md and adhere to the project's code of conduct.
Please open an issue and follow the template, so the community can help you.