python-base-command 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ """
2
+ python-base-command
3
+ ===================
4
+
5
+ A Django-style BaseCommand framework for standalone Python CLI tools.
6
+
7
+ Public API
8
+ ----------
9
+ - BaseCommand — base class for all commands (use self.logger for output)
10
+ - LabelCommand — base class for commands that accept one or more labels
11
+ - CommandError — exception to signal a clean error exit
12
+ - CommandParser — customized ArgumentParser used internally
13
+ - CommandRegistry — manual command registry (decorator-based)
14
+ - Runner — auto-discovery runner (folder-based, like manage.py)
15
+ - call_command — programmatic command invocation
16
+ """
17
+
18
+ from custom_python_logger import build_logger
19
+
20
+ from .base import (
21
+ BaseCommand,
22
+ CommandError,
23
+ CommandParser,
24
+ LabelCommand,
25
+ )
26
+ from .registry import CommandRegistry
27
+ from .runner import Runner
28
+ from .utils import call_command
29
+
30
+ __all__ = [
31
+ "BaseCommand",
32
+ "CommandError",
33
+ "CommandParser",
34
+ "CommandRegistry",
35
+ "LabelCommand",
36
+ "Runner",
37
+ "call_command",
38
+ ]
39
+
40
+ build_logger(project_name="python-base-command")
@@ -0,0 +1,325 @@
1
+ """
2
+ Base classes for writing CLI commands outside of Django.
3
+
4
+ Mirrors Django's django.core.management.base as closely as possible,
5
+ replacing self.stdout / self.style with self.logger from custom-python-logger.
6
+ """
7
+
8
+ import argparse
9
+ import importlib.metadata
10
+ import os
11
+ import sys
12
+ from argparse import Action, ArgumentParser, HelpFormatter
13
+ from collections.abc import Sequence
14
+ from typing import Any, TextIO
15
+
16
+ from custom_python_logger import CustomLoggerAdapter, get_logger
17
+
18
+ __all__ = [
19
+ "BaseCommand",
20
+ "CommandError",
21
+ "CommandParser",
22
+ "LabelCommand",
23
+ ]
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Exceptions
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ class CommandError(Exception):
32
+ """
33
+ Exception class indicating a problem while executing a command.
34
+
35
+ If raised during command execution it will be caught and logged as an error;
36
+ the process exits with ``returncode`` (default 1).
37
+
38
+ When invoked via ``call_command()``, it propagates normally.
39
+ """
40
+
41
+ def __init__(self, *args: Any, returncode: int = 1, **kwargs: Any) -> None:
42
+ self.returncode = returncode
43
+ super().__init__(*args, **kwargs)
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Argument parser
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class CommandParser(ArgumentParser):
52
+ """
53
+ Customized ArgumentParser that raises CommandError instead of calling
54
+ sys.exit() when the command is invoked programmatically.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ missing_args_message: str | None = None,
61
+ called_from_command_line: bool | None = None,
62
+ **kwargs: Any,
63
+ ) -> None:
64
+ self.missing_args_message = missing_args_message
65
+ self.called_from_command_line = called_from_command_line
66
+ super().__init__(**kwargs)
67
+
68
+ def parse_args(
69
+ self,
70
+ args: Sequence[str] | None = None,
71
+ namespace: argparse.Namespace | None = None,
72
+ ) -> argparse.Namespace:
73
+ if self.missing_args_message and not (args or any(not arg.startswith("-") for arg in (args or []))):
74
+ self.error(self.missing_args_message)
75
+ return super().parse_args(args, namespace)
76
+
77
+ def error(self, message: str) -> None:
78
+ if self.called_from_command_line:
79
+ super().error(message)
80
+ else:
81
+ raise CommandError(f"Error: {message}")
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Help formatter
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ class CommandHelpFormatter(HelpFormatter):
90
+ """
91
+ Pushes common base arguments to the bottom of --help output so that
92
+ command-specific arguments appear first.
93
+ """
94
+
95
+ show_last: set[str] = {
96
+ "--version",
97
+ "--verbosity",
98
+ "--traceback",
99
+ "--no-color",
100
+ "--force-color",
101
+ }
102
+
103
+ def _reordered_actions(self, actions: list[Action]) -> list[Action]:
104
+ return sorted(
105
+ actions,
106
+ key=lambda a: bool(set(a.option_strings) & self.show_last),
107
+ )
108
+
109
+ def add_usage(
110
+ self,
111
+ usage: str | None,
112
+ actions: list[Action],
113
+ *args: Any,
114
+ **kwargs: Any,
115
+ ) -> None:
116
+ super().add_usage(usage, self._reordered_actions(actions), *args, **kwargs)
117
+
118
+ def add_arguments(self, actions: list[Action]) -> None:
119
+ super().add_arguments(self._reordered_actions(actions))
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # BaseCommand
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ class BaseCommand:
128
+ """
129
+ The base class from which all commands derive.
130
+
131
+ Instead of Django's self.stdout / self.style, this class exposes
132
+ self.logger — a CustomLoggerAdapter from custom-python-logger.
133
+
134
+ Execution flow
135
+ --------------
136
+ 1. run_from_argv() parses sys.argv and calls execute().
137
+ 2. execute() calls handle() with the parsed options.
138
+ 3. Any CommandError raised in handle() is caught, logged, and the
139
+ process exits with the error's returncode.
140
+
141
+ Attributes
142
+ ----------
143
+ help : str
144
+ Short description printed in --help output.
145
+ output_transaction : bool
146
+ If True, wrap any string returned by handle() with BEGIN; / COMMIT;.
147
+ suppressed_base_arguments : set[str]
148
+ Option strings whose help text should be suppressed.
149
+ stealth_options : tuple[str, ...]
150
+ Options used by the command but not declared via add_arguments().
151
+ missing_args_message : str | None
152
+ Custom error message when required positional arguments are missing.
153
+ """
154
+
155
+ help: str = ""
156
+ output_transaction: bool = False
157
+ suppressed_base_arguments: set[str] = set()
158
+ stealth_options: tuple[str, ...] = ()
159
+ missing_args_message: str | None = None
160
+
161
+ _called_from_command_line: bool = False
162
+
163
+ # ------------------------------------------------------------------ init
164
+
165
+ def __init__(
166
+ self,
167
+ stdout: TextIO | None = None,
168
+ stderr: TextIO | None = None,
169
+ ) -> None:
170
+ _ = stdout, stderr # API compatibility with call_command(stdout=..., stderr=...)
171
+ self.logger: CustomLoggerAdapter = get_logger(name=self.__class__.__module__.split(".", maxsplit=1)[0])
172
+
173
+ # ------------------------------------------------------------------ version
174
+
175
+ def get_version(self) -> str:
176
+ """
177
+ Return the version string for this command.
178
+ Override to expose your own application version via --version.
179
+ """
180
+ try:
181
+ pkg = self.__module__.split(".", maxsplit=1)[0]
182
+ return importlib.metadata.version(pkg)
183
+ except Exception:
184
+ return "unknown"
185
+
186
+ # ------------------------------------------------------------------ parser
187
+
188
+ def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser:
189
+ """Create and return the CommandParser used to parse arguments."""
190
+ kwargs.setdefault("formatter_class", CommandHelpFormatter)
191
+ parser = CommandParser(
192
+ prog=f"{os.path.basename(prog_name)} {subcommand}",
193
+ description=self.help or None,
194
+ missing_args_message=self.missing_args_message,
195
+ called_from_command_line=self._called_from_command_line,
196
+ **kwargs,
197
+ )
198
+
199
+ self.add_base_argument(
200
+ parser,
201
+ "--version",
202
+ action="version",
203
+ version=self.get_version(),
204
+ help="Show program's version number and exit.",
205
+ )
206
+ self.add_base_argument(
207
+ parser,
208
+ "-v",
209
+ "--verbosity",
210
+ default=1,
211
+ type=int,
212
+ choices=[0, 1, 2, 3],
213
+ help="Verbosity level; 0=minimal, 1=normal, 2=verbose, 3=very verbose.",
214
+ )
215
+ self.add_base_argument(
216
+ parser,
217
+ "--traceback",
218
+ action="store_true",
219
+ help="Raise on CommandError instead of logging cleanly.",
220
+ )
221
+
222
+ self.add_arguments(parser)
223
+ return parser
224
+
225
+ def add_base_argument(self, parser: CommandParser, *args: Any, **kwargs: Any) -> None:
226
+ """Add a base argument, suppressing help if in suppressed_base_arguments."""
227
+ for arg in args:
228
+ if arg in self.suppressed_base_arguments:
229
+ kwargs["help"] = argparse.SUPPRESS
230
+ break
231
+ parser.add_argument(*args, **kwargs)
232
+
233
+ def add_arguments(self, parser: CommandParser) -> None:
234
+ """Override to add command-specific arguments."""
235
+
236
+ def print_help(self, prog_name: str, subcommand: str) -> None:
237
+ """Print the help message for this command."""
238
+ parser = self.create_parser(prog_name, subcommand)
239
+ parser.print_help()
240
+
241
+ # ------------------------------------------------------------------ execution
242
+
243
+ def run_from_argv(self, argv: list[str]) -> None:
244
+ """
245
+ Primary entry point when the command is invoked from the CLI.
246
+
247
+ argv[0] = prog name, argv[1:] = arguments (no subcommand slot).
248
+ """
249
+ self._called_from_command_line = True
250
+
251
+ prog = argv[0]
252
+ remaining = argv[1:]
253
+
254
+ parser = self.create_parser(prog, "")
255
+ options = parser.parse_args(remaining)
256
+ cmd_options = vars(options)
257
+ cmd_options.pop("args", ())
258
+
259
+ try:
260
+ self.execute(**cmd_options)
261
+ except CommandError as e:
262
+ if options.traceback:
263
+ raise
264
+ self.logger.error(f"{e.__class__.__name__}: {e}")
265
+ sys.exit(e.returncode)
266
+ except KeyboardInterrupt:
267
+ self.logger.warning("Aborted.")
268
+ sys.exit(1)
269
+
270
+ def execute(self, **kwargs: Any) -> str | None:
271
+ """
272
+ Try to execute the command, then delegate to handle().
273
+ If handle() returns a string and output_transaction is True,
274
+ wraps it in BEGIN; / COMMIT;.
275
+ """
276
+ output: str | None = None
277
+ if output := self.handle(**kwargs):
278
+ if self.output_transaction:
279
+ output = f"BEGIN;\n{output}\nCOMMIT;"
280
+ self.logger.info(output)
281
+
282
+ return output
283
+
284
+ def handle(self, **kwargs: Any) -> str | None:
285
+ """
286
+ The actual logic of the command. Subclasses must implement this.
287
+
288
+ Use self.logger.info() / .warning() / .error() / .step() etc.
289
+ May return a string (used with output_transaction).
290
+ """
291
+ raise NotImplementedError("Subclasses of BaseCommand must implement a handle() method.")
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # LabelCommand
296
+ # ---------------------------------------------------------------------------
297
+
298
+
299
+ class LabelCommand(BaseCommand):
300
+ """
301
+ A command that accepts one or more string labels and calls
302
+ handle_label() once per label. Override handle_label() instead of handle().
303
+ """
304
+
305
+ label: str = "label"
306
+ missing_args_message: str = "Enter at least one %s."
307
+
308
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
309
+ super().__init__(*args, **kwargs)
310
+ if self.missing_args_message == LabelCommand.missing_args_message:
311
+ self.missing_args_message = self.missing_args_message % self.label
312
+
313
+ def add_arguments(self, parser: CommandParser) -> None:
314
+ parser.add_argument("args", metavar=self.label, nargs="+")
315
+
316
+ def handle(self, *labels: str, **kwargs: Any) -> str | None:
317
+ output = []
318
+ for label in labels:
319
+ if result := self.handle_label(label, **kwargs):
320
+ output.append(result)
321
+ return "\n".join(output) if output else None
322
+
323
+ def handle_label(self, label: str, **kwargs: Any) -> str | None:
324
+ """Perform the command's actions for a single label. Subclasses must implement this."""
325
+ raise NotImplementedError("Subclasses of LabelCommand must implement handle_label().")
@@ -0,0 +1,123 @@
1
+ """
2
+ Manual command registry.
3
+
4
+ Allows registering commands by name and running them from a single entry point,
5
+ without relying on the auto-discovery folder convention.
6
+
7
+ Usage::
8
+
9
+ from python_base_command import BaseCommand, CommandRegistry
10
+
11
+ registry = CommandRegistry()
12
+
13
+ @registry.register("greet")
14
+ class GreetCommand(BaseCommand):
15
+ help = "Greet someone"
16
+
17
+ def add_arguments(self, parser):
18
+ parser.add_argument("name")
19
+
20
+ def handle(self, *args, **options):
21
+ self.stdout.write(self.style.SUCCESS(f"Hello, {options['name']}!"))
22
+
23
+ if __name__ == "__main__":
24
+ registry.run()
25
+ """
26
+
27
+ import sys
28
+ from collections.abc import Callable
29
+ from typing import TYPE_CHECKING
30
+
31
+ from custom_python_logger import get_logger
32
+
33
+ if TYPE_CHECKING:
34
+ from .base import BaseCommand as BaseCommandType
35
+
36
+ logger = get_logger(name="python-base-command")
37
+
38
+
39
+ class CommandRegistry:
40
+ """
41
+ A registry that maps command names to ``BaseCommand`` subclasses and
42
+ exposes a single ``run()`` entry point.
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ self._commands: dict[str, type[BaseCommandType]] = {}
47
+
48
+ # ------------------------------------------------------------------ registration
49
+
50
+ def register(self, name: str) -> Callable[[type["BaseCommandType"]], type["BaseCommandType"]]:
51
+ """
52
+ Class decorator that registers a ``BaseCommand`` subclass under *name*.
53
+
54
+ Usage::
55
+
56
+ @registry.register("greet")
57
+ class GreetCommand(BaseCommand):
58
+ ...
59
+ """
60
+
61
+ def decorator(cls: type["BaseCommandType"]) -> type["BaseCommandType"]:
62
+ self._commands[name] = cls
63
+ return cls
64
+
65
+ return decorator
66
+
67
+ def add(self, name: str, command_class: type["BaseCommandType"]) -> None:
68
+ """
69
+ Programmatically register *command_class* under *name*.
70
+ """
71
+ self._commands[name] = command_class
72
+
73
+ # ------------------------------------------------------------------ lookup
74
+
75
+ def get(self, name: str) -> type["BaseCommandType"] | None:
76
+ """Return the command class registered under *name*, or ``None``."""
77
+ return self._commands.get(name)
78
+
79
+ def list_commands(self) -> list[str]:
80
+ """Return a sorted list of registered command names."""
81
+ return sorted(self._commands)
82
+
83
+ # ------------------------------------------------------------------ running
84
+
85
+ def run(self, argv: list[str] | None = None) -> None:
86
+ """
87
+ Parse *argv* (defaults to ``sys.argv``), find the requested command,
88
+ and run it.
89
+
90
+ The expected argv format is::
91
+
92
+ [prog, subcommand, ...args...]
93
+
94
+ e.g. ``["myapp", "greet", "Alice", "--shout"]``
95
+ """
96
+
97
+ argv = argv or sys.argv[:]
98
+
99
+ # Show top-level help if no subcommand is given.
100
+ if len(argv) < 2 or argv[1] in {"-h", "--help"}:
101
+ self._print_help(argv[0] if argv else "unknown")
102
+ sys.exit(0)
103
+
104
+ subcommand = argv[1]
105
+ if (command_class := self._commands.get(subcommand)) is None:
106
+ prog = argv[0] if argv else "unknown"
107
+ available = ", ".join(self.list_commands()) or "(none registered)"
108
+ logger.error(
109
+ f"Unknown command: '{subcommand}'. "
110
+ f"Available commands: {available}. "
111
+ f"Type '{prog} --help' for usage."
112
+ )
113
+ sys.exit(1)
114
+ command_class().run_from_argv([argv[0]] + argv[2:])
115
+
116
+ def _print_help(self, prog: str) -> None:
117
+ print(f"Usage: {prog} <command> [options]\n")
118
+ print("Available commands:")
119
+ for name in self.list_commands():
120
+ cls = self._commands[name]
121
+ desc = cls.help or "(no description)"
122
+ print(f" {name:<20} {desc}")
123
+ print(f"\nRun '{prog} <command> --help' for command-specific help.")
@@ -0,0 +1,162 @@
1
+ """
2
+ Auto-discovery runner.
3
+
4
+ Discovers ``BaseCommand`` subclasses from a ``commands/`` directory
5
+ (or any directory you point it at) and exposes a single ``run()``
6
+ entry point — exactly like Django's ``manage.py``.
7
+
8
+ Convention
9
+ ----------
10
+ Each Python module inside the commands directory must define a class
11
+ named ``Command`` that extends ``BaseCommand``. Files whose names start
12
+ with an underscore are ignored.
13
+
14
+ Usage::
15
+
16
+ # myapp/cli.py
17
+ from base_command.runner import Runner
18
+
19
+ runner = Runner(commands_dir="commands") # relative to cwd
20
+
21
+ if __name__ == "__main__":
22
+ runner.run()
23
+
24
+ Then create ``commands/greet.py``::
25
+
26
+ from base_command import BaseCommand, CommandError
27
+
28
+ class Command(BaseCommand):
29
+ help = "Greet someone"
30
+
31
+ def add_arguments(self, parser):
32
+ parser.add_argument("name")
33
+
34
+ def handle(self, **kwargs):
35
+ self.stdout.write(self.style.SUCCESS(f"Hello, {kwargs['name']}!"))
36
+
37
+ And run::
38
+
39
+ python cli.py greet Alice
40
+ """
41
+
42
+ import importlib.util
43
+ import os
44
+ import sys
45
+ from pathlib import Path
46
+ from types import ModuleType
47
+ from typing import Optional
48
+
49
+ from custom_python_logger import get_logger
50
+
51
+ from .base import BaseCommand, CommandError
52
+
53
+ logger = get_logger("python-base-command")
54
+
55
+
56
+ class Runner:
57
+ """
58
+ Discovers and runs commands from a directory of Python modules.
59
+
60
+ Parameters
61
+ ----------
62
+ commands_dir:
63
+ Path to the directory containing command modules. Can be absolute
64
+ or relative to the current working directory (where the user runs
65
+ the script from) — just like Django resolves commands from the
66
+ project root.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ commands_dir: str | Path = "commands",
72
+ ):
73
+ # Resolve relative to cwd — the directory the user runs the script from,
74
+ # just like Django resolves manage.py commands from the project root.
75
+ self._commands_dir = (Path.cwd() / commands_dir).resolve()
76
+
77
+ # ------------------------------------------------------------------ discovery
78
+
79
+ def _discover(self) -> dict[str, type[BaseCommand]]:
80
+ """
81
+ Walk ``self._commands_dir`` and import every non-private module that
82
+ exposes a ``Command`` class inheriting from ``BaseCommand``.
83
+ """
84
+ commands: dict[str, type[BaseCommand]] = {}
85
+
86
+ if not self._commands_dir.is_dir():
87
+ return commands
88
+
89
+ for path in sorted(self._commands_dir.glob("*.py")):
90
+ if path.stem.startswith("_"):
91
+ continue
92
+
93
+ module = self._load_module(path)
94
+ if module is None:
95
+ continue
96
+
97
+ command_class = getattr(module, "Command", None)
98
+ if command_class is None:
99
+ continue
100
+ if not (
101
+ isinstance(command_class, type) and issubclass(command_class, BaseCommand)
102
+ ):
103
+ continue
104
+
105
+ commands[path.stem] = command_class
106
+
107
+ return commands
108
+
109
+ @staticmethod
110
+ def _load_module(path: Path) -> Optional[ModuleType]:
111
+ """Dynamically load a Python file as a module."""
112
+ module_name = f"_base_command_discovered_.{path.stem}"
113
+ spec = importlib.util.spec_from_file_location(module_name, path)
114
+ if spec is None or spec.loader is None:
115
+ return None
116
+ module = importlib.util.module_from_spec(spec)
117
+ try:
118
+ spec.loader.exec_module(module) # type: ignore[attr-defined]
119
+ except Exception as exc:
120
+ logger.error(f"Error loading command module '{path}': {exc}")
121
+ return None
122
+ return module
123
+
124
+ # ------------------------------------------------------------------ running
125
+
126
+ def run(self, argv: list[str] | None = None):
127
+ """
128
+ Parse *argv* (defaults to ``sys.argv``), discover commands, find the
129
+ requested one, and run it.
130
+ """
131
+ argv = argv or sys.argv[:]
132
+ commands = self._discover()
133
+
134
+ # Show top-level help if no subcommand is given.
135
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
136
+ self._print_help(argv[0] if argv else "unknown", commands)
137
+ sys.exit(0)
138
+
139
+ subcommand = argv[1]
140
+ command_class = commands.get(subcommand)
141
+
142
+ if command_class is None:
143
+ prog = argv[0] if argv else "unknown"
144
+ available = ", ".join(sorted(commands)) or "(none found)"
145
+ logger.error(
146
+ f"Unknown command: '{subcommand}'. "
147
+ f"Available commands: {available}. "
148
+ f"Type '{prog} --help' for usage."
149
+ )
150
+ sys.exit(1)
151
+
152
+ # Strip the subcommand so run_from_argv receives [prog, ...args]
153
+ command_class().run_from_argv([argv[0]] + argv[2:])
154
+
155
+ @staticmethod
156
+ def _print_help(prog: str, commands: dict[str, type[BaseCommand]]):
157
+ print(f"Usage: {prog} <command> [options]\n")
158
+ print("Available commands:")
159
+ for name, cls in sorted(commands.items()):
160
+ desc = cls.help or "(no description)"
161
+ print(f" {name:<20} {desc}")
162
+ print(f"\nRun '{prog} <command> --help' for command-specific help.")
@@ -0,0 +1,54 @@
1
+ """
2
+ call_command — invoke a command programmatically.
3
+
4
+ Mirrors Django's django.core.management.call_command.
5
+
6
+ Usage::
7
+
8
+ from python_base_command import call_command, CommandError
9
+
10
+ call_command(GreetCommand, name="Alice")
11
+ """
12
+
13
+ from typing import Any
14
+
15
+ from .base import BaseCommand
16
+
17
+
18
+ def call_command(
19
+ command: "type[BaseCommand] | BaseCommand",
20
+ *args: Any,
21
+ **options: Any,
22
+ ) -> Any:
23
+ """
24
+ Call the given BaseCommand subclass (or instance) programmatically.
25
+
26
+ Parameters
27
+ ----------
28
+ command:
29
+ A BaseCommand subclass or an already-instantiated command object.
30
+ *args:
31
+ Positional arguments forwarded to handle().
32
+ **options:
33
+ Keyword arguments forwarded to execute().
34
+
35
+ Returns
36
+ -------
37
+ Whatever handle() returns.
38
+
39
+ Raises
40
+ ------
41
+ CommandError
42
+ Propagated from handle() so callers can handle it themselves.
43
+ TypeError
44
+ If *command* is a type that is not a ``BaseCommand`` subclass.
45
+ """
46
+ if isinstance(command, type):
47
+ if not issubclass(command, BaseCommand):
48
+ raise TypeError(f"command must be a BaseCommand subclass, got {type(command)}")
49
+ command = command()
50
+
51
+ options.setdefault("verbosity", 1)
52
+ options.setdefault("traceback", False)
53
+
54
+ return command.execute(*args, **options)
@@ -0,0 +1,364 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-base-command
3
+ Version: 0.1.0
4
+ Summary: Django-style BaseCommand framework for standalone Python CLI tools
5
+ Project-URL: Homepage, https://github.com/yourusername/python-base-command
6
+ Project-URL: Repository, https://github.com/yourusername/python-base-command
7
+ Project-URL: Issues, https://github.com/yourusername/python-base-command/issues
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: argparse,cli,command,django,management
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: custom-python-logger==2.0.13
21
+ Description-Content-Type: text/markdown
22
+
23
+ ![PyPI version](https://img.shields.io/pypi/v/python-base-command)
24
+ ![Python](https://img.shields.io/badge/python->=3.12-blue)
25
+ ![Development Status](https://img.shields.io/badge/status-stable-green)
26
+ ![Maintenance](https://img.shields.io/maintenance/yes/2026)
27
+ ![PyPI](https://img.shields.io/pypi/dm/python-base-command)
28
+ ![License](https://img.shields.io/pypi/l/python-base-command)
29
+
30
+ ---
31
+
32
+ # python-base-command
33
+ A Django-style `BaseCommand` framework for **standalone** Python CLI tools — no Django required.
34
+
35
+ 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.
36
+
37
+ ---
38
+
39
+ ## 🚀 Features
40
+
41
+ - ✅ **Django-style API** — `handle()`, `add_arguments()`, `CommandError`, `LabelCommand` — the same pattern you already know
42
+ - ✅ **Built-in logging** — `self.logger` powered by [`custom-python-logger`](https://pypi.org/project/custom-python-logger/), with colored output and custom levels (`step`, `exception`)
43
+ - ✅ **Auto-discovery** — drop `.py` files into a `commands/` folder and they're automatically available, just like Django's `manage.py`
44
+ - ✅ **Manual registry** — register commands explicitly with the `@registry.register()` decorator
45
+ - ✅ **Built-in flags** — every command gets `--version`, `--verbosity`, `--traceback` for free
46
+ - ✅ **`call_command()`** — invoke commands programmatically, perfect for testing
47
+ - ✅ **`output_transaction`** — wrap SQL output in `BEGIN;` / `COMMIT;` automatically
48
+ - ✅ **Zero Django dependency** — works in any Python project
49
+ - ✅ **Python 3.12+**
50
+
51
+ ---
52
+
53
+ ## 📦 Installation
54
+
55
+ ```bash
56
+ pip install python-base-command
57
+ ```
58
+
59
+ Dependencies: [`custom-python-logger==2.0.13`](https://pypi.org/project/custom-python-logger/) — installed automatically.
60
+
61
+ ---
62
+
63
+ ## ⚡ Quick Start
64
+
65
+ Start by creating `cli.py` — your entry point, the equivalent of Django's `manage.py`. You only need this once:
66
+
67
+ ```python
68
+ # cli.py
69
+ from base_command import Runner
70
+
71
+ Runner(commands_dir="commands").run()
72
+ ```
73
+
74
+ Then add commands to a `commands/` folder:
75
+
76
+ ```
77
+ myapp/
78
+ ├── cli.py ← entry point (2 lines)
79
+ └── commands/
80
+ ├── __init__.py
81
+ └── greet.py
82
+ ```
83
+
84
+ ```python
85
+ # commands/greet.py
86
+ from base_command import BaseCommand, CommandError
87
+
88
+
89
+ class Command(BaseCommand):
90
+ help = "Greet a user by name"
91
+
92
+ def add_arguments(self, parser):
93
+ parser.add_argument("name", type=str, help="Name to greet")
94
+ parser.add_argument("--shout", action="store_true", help="Print in uppercase")
95
+
96
+ def handle(self, **kwargs):
97
+ name = kwargs["name"].strip()
98
+ if not name:
99
+ raise CommandError("Name cannot be empty.")
100
+
101
+ msg = f"Hello, {name}!"
102
+ if kwargs["shout"]:
103
+ msg = msg.upper()
104
+
105
+ self.logger.info(msg)
106
+ ```
107
+
108
+ Run from anywhere inside the project:
109
+
110
+ ```bash
111
+ python cli.py --help # lists all available commands
112
+ python cli.py greet Alice
113
+ python cli.py greet Alice --shout
114
+ python cli.py greet --version
115
+ python cli.py greet --verbosity 2
116
+ ```
117
+
118
+ ---
119
+
120
+ ## 📋 Manual Registry
121
+
122
+ Register commands explicitly using the `@registry.register()` decorator — useful when commands live across different modules.
123
+
124
+ ```python
125
+ from base_command import BaseCommand, CommandError, CommandRegistry
126
+
127
+ registry = CommandRegistry()
128
+
129
+
130
+ @registry.register("greet")
131
+ class GreetCommand(BaseCommand):
132
+ help = "Greet a user"
133
+
134
+ def add_arguments(self, parser):
135
+ parser.add_argument("name", type=str)
136
+
137
+ def handle(self, **kwargs):
138
+ self.logger.info(f"Hello, {kwargs['name']}!")
139
+
140
+
141
+ @registry.register("export")
142
+ class ExportCommand(BaseCommand):
143
+ help = "Export data"
144
+
145
+ def add_arguments(self, parser):
146
+ parser.add_argument("--format", choices=["csv", "json"], default="csv")
147
+ parser.add_argument("--dry-run", action="store_true")
148
+
149
+ def handle(self, **kwargs):
150
+ if kwargs["dry_run"]:
151
+ self.logger.warning("Dry run — no files written.")
152
+ return
153
+ self.logger.info(f"Exported as {kwargs['format']}.")
154
+
155
+
156
+ if __name__ == "__main__":
157
+ registry.run()
158
+ ```
159
+
160
+ ```bash
161
+ python cli.py --help
162
+ python cli.py greet Alice
163
+ python cli.py export --format json
164
+ python cli.py export --dry-run
165
+ ```
166
+
167
+ ---
168
+
169
+ ## 🧪 Testing with `call_command`
170
+
171
+ Invoke commands programmatically — ideal for unit tests.
172
+
173
+ ```python
174
+ from base_command import call_command, CommandError
175
+ import pytest
176
+
177
+ from commands.greet import Command as GreetCommand
178
+
179
+
180
+ def test_greet():
181
+ result = call_command(GreetCommand, name="Alice")
182
+ assert result is None # handle() logs, doesn't return
183
+
184
+
185
+ def test_greet_empty_name():
186
+ with pytest.raises(CommandError, match="cannot be empty"):
187
+ call_command(GreetCommand, name="")
188
+ ```
189
+
190
+ `CommandError` propagates normally when using `call_command()` — it is only caught and logged when invoked from the CLI.
191
+
192
+ ---
193
+
194
+ ## 📖 API Reference
195
+
196
+ ### `BaseCommand`
197
+
198
+ Base class for all commands. Inherit from it and implement `handle()`.
199
+
200
+ **Class attributes**
201
+
202
+ | Attribute | Type | Default | Description |
203
+ |---|---|---|---|
204
+ | `help` | `str` | `""` | Description shown in `--help` |
205
+ | `output_transaction` | `bool` | `False` | Wrap `handle()` return value in `BEGIN;` / `COMMIT;` |
206
+ | `suppressed_base_arguments` | `set[str]` | `set()` | Base flags to hide from `--help` |
207
+ | `stealth_options` | `tuple[str]` | `()` | Options used but not declared via `add_arguments()` |
208
+ | `missing_args_message` | `str \| None` | `None` | Custom message when required positional args are missing |
209
+
210
+ **Methods to override**
211
+
212
+ | Method | Required | Description |
213
+ |---|---|---|
214
+ | `handle(**kwargs)` | ✅ | Command logic. May return a string. |
215
+ | `add_arguments(parser)` | ❌ | Add command-specific arguments to the parser. |
216
+ | `get_version()` | ❌ | Override to expose your package version via `--version`. |
217
+
218
+ **`self.logger`**
219
+
220
+ A `CustomLoggerAdapter` from `custom-python-logger`, available inside every command:
221
+
222
+ ```python
223
+ self.logger.debug("...")
224
+ self.logger.info("...")
225
+ self.logger.step("...") # custom level for process steps
226
+ self.logger.warning("...")
227
+ self.logger.error("...")
228
+ self.logger.critical("...")
229
+ self.logger.exception("...") # logs with full traceback
230
+ ```
231
+
232
+ **Built-in flags** — available on every command automatically:
233
+
234
+ | Flag | Description |
235
+ |---|---|
236
+ | `--version` | Print the version and exit |
237
+ | `-v` / `--verbosity` | Verbosity level: 0=minimal, 1=normal, 2=verbose, 3=very verbose (default: 1) |
238
+ | `--traceback` | Re-raise `CommandError` with full traceback instead of logging cleanly |
239
+
240
+ ---
241
+
242
+ ### `CommandError`
243
+
244
+ 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.
245
+
246
+ ```python
247
+ raise CommandError("Something went wrong.")
248
+ raise CommandError("Fatal error.", returncode=2)
249
+ ```
250
+
251
+ ---
252
+
253
+ ### `LabelCommand`
254
+
255
+ For commands that accept one or more arbitrary string labels. Override `handle_label()` instead of `handle()`.
256
+
257
+ ```python
258
+ from base_command import LabelCommand, CommandError
259
+
260
+
261
+ class Command(LabelCommand):
262
+ label = "filepath"
263
+ help = "Process one or more files"
264
+
265
+ def add_arguments(self, parser):
266
+ super().add_arguments(parser)
267
+ parser.add_argument("--strict", action="store_true")
268
+
269
+ def handle_label(self, label, **kwargs):
270
+ if not label.endswith((".txt", ".csv", ".json")):
271
+ msg = f"Unsupported file type: '{label}'"
272
+ if kwargs["strict"]:
273
+ raise CommandError(msg)
274
+ self.logger.warning(f"Skipping — {msg}")
275
+ return None
276
+ self.logger.info(f"Processed: {label}")
277
+ return f"ok:{label}"
278
+ ```
279
+
280
+ ```bash
281
+ python cli.py process report.csv notes.txt image.png
282
+ python cli.py process report.csv notes.txt image.png --strict
283
+ ```
284
+
285
+ ---
286
+
287
+ ### `Runner`
288
+
289
+ Auto-discovers commands from a directory. Every `.py` file in the folder that defines a `Command` class subclassing `BaseCommand` is automatically registered.
290
+
291
+ ```python
292
+ from base_command import Runner
293
+
294
+ Runner(commands_dir="commands").run()
295
+ ```
296
+
297
+ Files whose names start with `_` are ignored.
298
+
299
+ ---
300
+
301
+ ### `CommandRegistry`
302
+
303
+ Manually register commands using a decorator or programmatically.
304
+
305
+ ```python
306
+ from base_command import CommandRegistry
307
+
308
+ registry = CommandRegistry()
309
+
310
+ @registry.register("greet")
311
+ class GreetCommand(BaseCommand): ...
312
+
313
+ registry.add("export", ExportCommand) # programmatic alternative
314
+
315
+ registry.run() # uses sys.argv
316
+ registry.run(["myapp", "greet", "Alice"]) # explicit argv
317
+ ```
318
+
319
+ ---
320
+
321
+ ### `call_command`
322
+
323
+ Invoke a command from Python code. Accepts either a class or an instance.
324
+
325
+ ```python
326
+ from base_command import call_command
327
+
328
+ call_command(GreetCommand, name="Alice")
329
+ call_command(GreetCommand, name="Alice", verbosity=0)
330
+ call_command(GreetCommand())
331
+ ```
332
+
333
+ ---
334
+
335
+ ## 🔄 Comparison with Django
336
+
337
+ | Feature | Django `BaseCommand` | `python-base-command` |
338
+ |---|---|---|
339
+ | `handle()` / `add_arguments()` | ✅ | ✅ |
340
+ | `self.logger` (via custom-python-logger) | ❌ | ✅ |
341
+ | `self.stdout` / `self.style` | ✅ | ❌ replaced by `self.logger` |
342
+ | `--version` / `--verbosity` / `--traceback` | ✅ | ✅ |
343
+ | `CommandError` with `returncode` | ✅ | ✅ |
344
+ | `LabelCommand` | ✅ | ✅ |
345
+ | `call_command()` | ✅ | ✅ |
346
+ | `output_transaction` | ✅ | ✅ |
347
+ | Auto-discovery from folder | ✅ | ✅ |
348
+ | Manual registry | ❌ | ✅ |
349
+ | Django dependency | ✅ required | ❌ none |
350
+
351
+ ---
352
+
353
+ ## 🤝 Contributing
354
+ If you have a helpful tool, pattern, or improvement to suggest:
355
+ Fork the repo <br>
356
+ Create a new branch <br>
357
+ Submit a pull request <br>
358
+ I welcome additions that promote clean, productive, and maintainable development. <br>
359
+
360
+ ---
361
+
362
+ ## 🙏 Thanks
363
+ Thanks for exploring this repository! <br>
364
+ Happy coding! <br>
@@ -0,0 +1,9 @@
1
+ python_base_command/__init__.py,sha256=UoAfs_pYL9cYszbrHv_E9f10WzRHxwSzQEMkzBY-YfI,1064
2
+ python_base_command/base.py,sha256=et8HITQhHCcE8oWoUv_10dNknWXNjeT9qD0CEL1EDr8,11028
3
+ python_base_command/registry.py,sha256=WJl5IgjqOWRZuN36od1K-gm_RzDYCwivMkFSHqLbhe0,3883
4
+ python_base_command/runner.py,sha256=_pRW_tn--DcLgCMkLxIAj4dI_sXAtIhNL9b5QOJpucg,5170
5
+ python_base_command/utils.py,sha256=Q0U8uU1YgMJdeP4IpCktsI8dSjqgcF9nUnMTxOvqL0o,1320
6
+ python_base_command-0.1.0.dist-info/METADATA,sha256=EMC54Kuo-069ausaubRpj-0yUTRjHqSF4WAtsZFadGg,10838
7
+ python_base_command-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ python_base_command-0.1.0.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
9
+ python_base_command-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Avi Zaguri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.