Skip to main content

PyPI version Python Development Status Maintenance PyPI License


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.logger powered by custom-python-logger, with colored output and custom levels (step, exception)
  • ✅ Auto-discovery — drop .py files into a commands/ folder and they're automatically available, just like Django's manage.py
  • ✅ Manual registry — register commands explicitly with the @registry.register() decorator
  • ✅ Built-in flags — every command gets --version, --verbosity, --traceback for free
  • ✅ call_command() — invoke commands programmatically, perfect for testing
  • ✅ output_transaction — wrap SQL output in BEGIN; / 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.py is a development convenience only. For distributed packages, always use [project.scripts] in pyproject.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:

  1. Classic — a .py file that defines a class named Command subclassing BaseCommand. The command name is the file stem.
  2. Registry — a .py file that defines one or more CommandRegistry instances. 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!

Release files for python-base-command 0.1.8

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

Source distribution (sdist)

Source distribution for python-base-command 0.1.8
File Size Uploaded
python_base_command-0.1.8.tar.gz 33.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for python-base-command 0.1.8
File Interpreter ABI Platform
python_base_command-0.1.8-py3-none-any.whl Python 3 none any Details

Total release size: 48.7 kB

Release files / python_base_command-0.1.8.tar.gz

Download URL python_base_command-0.1.8.tar.gz
Size 33.4 kB
Tags Source
SHA-256 checksum
How to use checksums
27e8a8f0c266bd7e706313a00f0526f50f0d8a9bceecf67eabf797061035c369
BLAKE2b-256 checksum
How to use checksums
51136acb5d59681fa545d9348a1619d8ed0ce6da0fb240376f491f35039cadd1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.7

Release files / python_base_command-0.1.8-py3-none-any.whl

Download URL python_base_command-0.1.8-py3-none-any.whl
Size 15.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ec3e5ec4cdf554dcb2b80530febc17c58bf5380144cc4611cedac1f2233c1f96
BLAKE2b-256 checksum
How to use checksums
88a03e614543e3b9e81fa4b330a349c9af2ac5bd4b195986c0714a7a347e9bd1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.7

Release history Release notifications | RSS feed

This release

0.1.8 This release

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

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