Django-style BaseCommand framework for standalone Python CLI tools
Project description
python-base-command
A Django-style BaseCommand framework for standalone Python CLI tools โ no Django required.
If you've ever written a Django management command and wished you could use the same clean pattern anywhere in Python, this is for you.
๐ Features
- โ
Django-style API โ
handle(),add_arguments(),CommandError,LabelCommandโ the same pattern you already know - โ
Built-in logging โ
self.loggerpowered bycustom-python-logger, with colored output and custom levels (step,exception) - โ
Auto-discovery โ drop
.pyfiles into acommands/folder and they're automatically available, just like Django'smanage.py - โ
Manual registry โ register commands explicitly with the
@registry.register()decorator - โ
Built-in flags โ every command gets
--version,--verbosity,--tracebackfor free - โ
call_command()โ invoke commands programmatically, perfect for testing - โ
output_transactionโ wrap SQL output inBEGIN;/COMMIT;automatically - โ Zero Django dependency โ works in any Python project
- โ Python 3.12+
๐ฆ Installation
pip install python-base-command
Dependencies: custom-python-logger==2.0.13 โ installed automatically.
โก Quick Start
Add commands to a commands/ folder:
myapp/
โโโ pyproject.toml
โโโ commands/
โโโ __init__.py
โโโ greet.py
# commands/greet.py
from python_base_command import BaseCommand, CommandError
class Command(BaseCommand):
help = "Greet a user by name"
version = "1.0.0"
def add_arguments(self, parser):
parser.add_argument("name", type=str, help="Name to greet")
parser.add_argument("--shout", action="store_true", help="Print in uppercase")
def handle(self, **kwargs):
name = kwargs["name"].strip()
if not name:
raise CommandError("Name cannot be empty.")
msg = f"Hello, {name}!"
if kwargs["shout"]:
msg = msg.upper()
self.logger.info(msg)
Packaging as a CLI tool (recommended)
Register your entry point in pyproject.toml โ this is the preferred way to expose a CLI tool when distributing your project as a package.
Option A โ Single command (one BaseCommand subclass, no Runner):
# pyproject.toml
[project.scripts]
myapp = "myapp.commands.greet:main"
# myapp/commands/greet.py
import sys
from python_base_command import BaseCommand, CommandError
class Command(BaseCommand):
help = "Greet a user by name"
version = "1.0.0"
def add_arguments(self, parser):
parser.add_argument("name", type=str, help="Name to greet")
parser.add_argument("--shout", action="store_true", help="Print in uppercase")
def handle(self, **kwargs):
name = kwargs["name"].strip()
if not name:
raise CommandError("Name cannot be empty.")
msg = f"Hello, {name}!"
if kwargs["shout"]:
msg = msg.upper()
self.logger.info(msg)
def main():
Command().run_from_argv(sys.argv)
Option B โ Multiple commands (auto-discovery via Runner):
# pyproject.toml
[project.scripts]
myapp = "myapp.__main__:main"
# myapp/__main__.py
import sys
from python_base_command import Runner
def main():
Runner(commands_dir="myapp/commands").run(sys.argv)
Once installed (pip install myapp or uv add myapp), the command is available globally:
myapp --help
myapp greet Alice
myapp greet Alice --shout
myapp greet --version
myapp greet --verbosity 2
Local development (without installing)
For local development only, you can use a cli.py script as a quick entry point โ the equivalent of Django's manage.py:
# cli.py โ dev only, do not distribute
import sys
from python_base_command import Runner
Runner(commands_dir="commands").run(sys.argv)
python3 cli.py --help
python3 cli.py greet Alice
Note:
cli.pyis a development convenience only. For distributed packages, always use[project.scripts]inpyproject.toml.
๐ Manual Registry
Register commands explicitly using the @registry.register() decorator โ useful when you want multiple commands in a single file.
The registry style works in two ways:
Standalone โ run the registry directly as a script:
# my_commands.py
from python_base_command import BaseCommand, CommandError, CommandRegistry
registry = CommandRegistry()
@registry.register("greet")
class GreetCommand(BaseCommand):
help = "Greet a user"
version = "2.0.0"
def add_arguments(self, parser):
parser.add_argument("name", type=str)
def handle(self, **kwargs):
self.logger.info(f"Hello, {kwargs['name']}!")
@registry.register("export")
class ExportCommand(BaseCommand):
help = "Export data"
version = "3.0.0"
def add_arguments(self, parser):
parser.add_argument("--format", choices=["csv", "json"], default="csv")
parser.add_argument("--dry-run", action="store_true")
def handle(self, **kwargs):
if kwargs["dry_run"]:
self.logger.warning("Dry run โ no files written.")
return
self.logger.info(f"Exported as {kwargs['format']}.")
if __name__ == "__main__":
registry.run()
python3 my_commands.py greet Alice
python3 my_commands.py export --format json
python3 my_commands.py export --dry-run
Auto-discovered โ drop the registry file into your commands/ folder and Runner will discover it automatically alongside any classic Command files:
myapp/
โโโ cli.py
โโโ commands/
โโโ __init__.py
โโโ greet.py โ classic Command class
โโโ reg_cmd.py โ CommandRegistry with multiple commands
python3 cli.py --help # shows commands from both files
python3 cli.py greet Alice
python3 cli.py export --format json
๐งช Testing with call_command
Invoke commands programmatically โ ideal for unit tests.
from python_base_command import call_command, CommandError
import pytest
from commands.greet import Command as GreetCommand
def test_greet():
result = call_command(GreetCommand, name="Alice")
assert result is None # handle() logs, doesn't return
def test_greet_empty_name():
with pytest.raises(CommandError, match="cannot be empty"):
call_command(GreetCommand, name="")
CommandError propagates normally when using call_command() โ it is only caught and logged when invoked from the CLI.
๐ API Reference
BaseCommand
Base class for all commands. Inherit from it and implement handle().
Class attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
help |
str |
"" |
Description shown in --help |
version |
str |
"unknown" |
Version string exposed via --version. Set this per command. |
output_transaction |
bool |
False |
Wrap handle() return value in BEGIN; / COMMIT; |
suppressed_base_arguments |
set[str] |
set() |
Base flags to hide from --help |
stealth_options |
tuple[str] |
() |
Options used but not declared via add_arguments() |
missing_args_message |
str | None |
None |
Custom message when required positional args are missing |
Methods to override
| Method | Required | Description |
|---|---|---|
handle(**kwargs) |
โ | Command logic. May return a string. |
add_arguments(parser) |
โ | Add command-specific arguments to the parser. |
self.logger
A CustomLoggerAdapter from custom-python-logger, available inside every command:
self.logger.debug("...")
self.logger.info("...")
self.logger.step("...") # custom level for process steps
self.logger.warning("...")
self.logger.error("...")
self.logger.critical("...")
self.logger.exception("...") # logs with full traceback
Built-in flags โ available on every command automatically:
| Flag | Description |
|---|---|
--version |
Print the version and exit |
-v / --verbosity |
Verbosity level: 0=minimal, 1=normal, 2=verbose, 3=very verbose (default: 1) |
--traceback |
Re-raise CommandError with full traceback instead of logging cleanly |
CommandError
Raise this to signal that something went wrong. When raised inside handle() during CLI invocation, it is caught, logged as an error, and the process exits with returncode. When invoked via call_command(), it propagates normally.
raise CommandError("Something went wrong.")
raise CommandError("Fatal error.", returncode=2)
LabelCommand
For commands that accept one or more arbitrary string labels. Override handle_label() instead of handle().
from python_base_command import LabelCommand, CommandError
class Command(LabelCommand):
label = "filepath"
help = "Process one or more files"
def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument("--strict", action="store_true")
def handle_label(self, label, **kwargs):
if not label.endswith((".txt", ".csv", ".json")):
msg = f"Unsupported file type: '{label}'"
if kwargs["strict"]:
raise CommandError(msg)
self.logger.warning(f"Skipping โ {msg}")
return None
self.logger.info(f"Processed: {label}")
return f"ok:{label}"
python3 cli.py process report.csv notes.txt image.png
python3 cli.py process report.csv notes.txt image.png --strict
Runner
Auto-discovers commands from a directory. Two conventions are supported:
- Classic โ a
.pyfile that defines a class namedCommandsubclassingBaseCommand. The command name is the file stem. - Registry โ a
.pyfile that defines one or moreCommandRegistryinstances. Every command registered on those instances is merged in automatically; command names come from the registry, not the file name.
Files whose names start with _ are ignored.
from python_base_command import Runner
Runner(commands_dir="commands").run()
CommandRegistry
Manually register commands using a decorator or programmatically.
from python_base_command import BaseCommand, CommandRegistry
registry = CommandRegistry()
@registry.register("greet")
class GreetCommand(BaseCommand): ...
registry.add("export", ExportCommand) # programmatic alternative
registry.run() # uses sys.argv
registry.run(["myapp", "greet", "Alice"]) # explicit argv
call_command
Invoke a command from Python code. Accepts either a class or an instance.
from python_base_command import call_command
call_command(GreetCommand, name="Alice")
call_command(GreetCommand, name="Alice", verbosity=0)
call_command(GreetCommand())
๐ Comparison with Django
| Feature | Django BaseCommand |
python-base-command |
|---|---|---|
handle() / add_arguments() |
โ | โ |
self.logger (via custom-python-logger) |
โ | โ |
self.stdout / self.style |
โ | โ replaced by self.logger |
--version / --verbosity / --traceback |
โ | โ |
CommandError with returncode |
โ | โ |
LabelCommand |
โ | โ |
call_command() |
โ | โ |
output_transaction |
โ | โ |
| Auto-discovery from folder | โ | โ |
| Manual registry | โ | โ |
| Django dependency | โ required | โ none |
๐ค Contributing
If you have a helpful tool, pattern, or improvement to suggest:
Fork the repo
Create a new branch
Submit a pull request
I welcome additions that promote clean, productive, and maintainable development.
๐ Thanks
Thanks for exploring this repository!
Happy coding!
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file python_base_command-0.1.8.tar.gz.
File metadata
- Download URL: python_base_command-0.1.8.tar.gz
- Upload date:
- Size: 33.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
27e8a8f0c266bd7e706313a00f0526f50f0d8a9bceecf67eabf797061035c369
|
|
| MD5 |
c818735740df2a11b5382628f79e22a3
|
|
| BLAKE2b-256 |
51136acb5d59681fa545d9348a1619d8ed0ce6da0fb240376f491f35039cadd1
|
File details
Details for the file python_base_command-0.1.8-py3-none-any.whl.
File metadata
- Download URL: python_base_command-0.1.8-py3-none-any.whl
- Upload date:
- Size: 15.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ec3e5ec4cdf554dcb2b80530febc17c58bf5380144cc4611cedac1f2233c1f96
|
|
| MD5 |
134fb23ac729530bf91f0e4c157f304a
|
|
| BLAKE2b-256 |
88a03e614543e3b9e81fa4b330a349c9af2ac5bd4b195986c0714a7a347e9bd1
|