Skip to main content

Django-style BaseCommand framework for standalone Python CLI tools

Project description

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!

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

python_base_command-0.1.8.tar.gz (33.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

python_base_command-0.1.8-py3-none-any.whl (15.2 kB view details)

Uploaded Python 3

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

Hashes for python_base_command-0.1.8.tar.gz
Algorithm Hash digest
SHA256 27e8a8f0c266bd7e706313a00f0526f50f0d8a9bceecf67eabf797061035c369
MD5 c818735740df2a11b5382628f79e22a3
BLAKE2b-256 51136acb5d59681fa545d9348a1619d8ed0ce6da0fb240376f491f35039cadd1

See more details on using hashes here.

File details

Details for the file python_base_command-0.1.8-py3-none-any.whl.

File metadata

File hashes

Hashes for python_base_command-0.1.8-py3-none-any.whl
Algorithm Hash digest
SHA256 ec3e5ec4cdf554dcb2b80530febc17c58bf5380144cc4611cedac1f2233c1f96
MD5 134fb23ac729530bf91f0e4c157f304a
BLAKE2b-256 88a03e614543e3b9e81fa4b330a349c9af2ac5bd4b195986c0714a7a347e9bd1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page