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.
- python_base_command/__init__.py +40 -0
- python_base_command/base.py +325 -0
- python_base_command/registry.py +123 -0
- python_base_command/runner.py +162 -0
- python_base_command/utils.py +54 -0
- python_base_command-0.1.0.dist-info/METADATA +364 -0
- python_base_command-0.1.0.dist-info/RECORD +9 -0
- python_base_command-0.1.0.dist-info/WHEEL +4 -0
- python_base_command-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+

|
|
24
|
+

|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+

|
|
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,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.
|