python-base-command 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (42) hide show
  1. {python_base_command-0.1.2 → python_base_command-0.1.4}/PKG-INFO +57 -27
  2. {python_base_command-0.1.2 → python_base_command-0.1.4}/README.md +55 -26
  3. {python_base_command-0.1.2 → python_base_command-0.1.4}/pyproject.toml +2 -1
  4. {python_base_command-0.1.2 → python_base_command-0.1.4}/python_base_command/base.py +4 -15
  5. {python_base_command-0.1.2 → python_base_command-0.1.4}/python_base_command/runner.py +28 -22
  6. {python_base_command-0.1.2 → python_base_command-0.1.4}/usage_example/commands/greet.py +1 -0
  7. python_base_command-0.1.4/usage_example/commands/registry_cmd.py +35 -0
  8. {python_base_command-0.1.2 → python_base_command-0.1.4}/.config/README.md +0 -0
  9. {python_base_command-0.1.2 → python_base_command-0.1.4}/.config/black.toml +0 -0
  10. {python_base_command-0.1.2 → python_base_command-0.1.4}/.config/pylintrc +0 -0
  11. {python_base_command-0.1.2 → python_base_command-0.1.4}/.config/pylintrc_tests +0 -0
  12. {python_base_command-0.1.2 → python_base_command-0.1.4}/.config/ruff.toml +0 -0
  13. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/CODEOWNERS +0 -0
  14. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/chat-titles.md +0 -0
  16. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/claude-sonnet-4.md +0 -0
  17. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/gemini-2.5-pro.md +0 -0
  18. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/gpt-4.1.md +0 -0
  19. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/gpt-4o.md +0 -0
  20. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/gpt-5-mini.md +0 -0
  21. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/gpt-5.md +0 -0
  22. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/nes-tab-completion.md +0 -0
  23. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/instructions/IDE Agent/prompt.md +0 -0
  24. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/pull_request_template.md +0 -0
  25. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/workflows/lint.yml +0 -0
  26. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/workflows/publish_to_pypi.yml +0 -0
  27. {python_base_command-0.1.2 → python_base_command-0.1.4}/.github/workflows/tests.yml +0 -0
  28. {python_base_command-0.1.2 → python_base_command-0.1.4}/.gitignore +0 -0
  29. {python_base_command-0.1.2 → python_base_command-0.1.4}/.pre-commit-config.yaml +0 -0
  30. {python_base_command-0.1.2 → python_base_command-0.1.4}/CHANGELOG.md +0 -0
  31. {python_base_command-0.1.2 → python_base_command-0.1.4}/LICENSE +0 -0
  32. {python_base_command-0.1.2 → python_base_command-0.1.4}/MANIFEST.in +0 -0
  33. {python_base_command-0.1.2 → python_base_command-0.1.4}/Taskfile.yml +0 -0
  34. {python_base_command-0.1.2 → python_base_command-0.1.4}/cli.py +0 -0
  35. {python_base_command-0.1.2 → python_base_command-0.1.4}/pytest.ini +0 -0
  36. {python_base_command-0.1.2 → python_base_command-0.1.4}/python_base_command/__init__.py +0 -0
  37. {python_base_command-0.1.2 → python_base_command-0.1.4}/python_base_command/registry.py +0 -0
  38. {python_base_command-0.1.2 → python_base_command-0.1.4}/python_base_command/utils.py +0 -0
  39. {python_base_command-0.1.2 → python_base_command-0.1.4}/tests/__init__.py +0 -0
  40. {python_base_command-0.1.2 → python_base_command-0.1.4}/tests/test_base_command.py +0 -0
  41. {python_base_command-0.1.2 → python_base_command-0.1.4}/usage_example/__init__.py +0 -0
  42. {python_base_command-0.1.2 → python_base_command-0.1.4}/usage_example/commands/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-base-command
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Django-style BaseCommand framework for standalone Python CLI tools
5
5
  Project-URL: Homepage, https://github.com/aviz92/python-base-command
6
6
  Project-URL: Repository, https://github.com/aviz92/python-base-command
@@ -18,6 +18,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Topic :: Utilities
19
19
  Requires-Python: >=3.12
20
20
  Requires-Dist: custom-python-logger>=2.0.13
21
+ Requires-Dist: python-base-toolkit>=1.0.2
21
22
  Description-Content-Type: text/markdown
22
23
 
23
24
  ![PyPI version](https://img.shields.io/pypi/v/python-base-command)
@@ -66,7 +67,7 @@ Start by creating `cli.py` — your entry point, the equivalent of Django's `man
66
67
 
67
68
  ```python
68
69
  # cli.py
69
- from base_command import Runner
70
+ from python_base_command import Runner
70
71
 
71
72
  Runner(commands_dir="commands").run()
72
73
  ```
@@ -83,11 +84,12 @@ myapp/
83
84
 
84
85
  ```python
85
86
  # commands/greet.py
86
- from base_command import BaseCommand, CommandError
87
+ from python_base_command import BaseCommand, CommandError
87
88
 
88
89
 
89
90
  class Command(BaseCommand):
90
91
  help = "Greet a user by name"
92
+ version = "1.0.0"
91
93
 
92
94
  def add_arguments(self, parser):
93
95
  parser.add_argument("name", type=str, help="Name to greet")
@@ -108,21 +110,26 @@ class Command(BaseCommand):
108
110
  Run from anywhere inside the project:
109
111
 
110
112
  ```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
113
+ python3 cli.py --help # lists all available commands
114
+ python3 cli.py greet Alice
115
+ python3 cli.py greet Alice --shout
116
+ python3 cli.py greet --version
117
+ python3 cli.py greet --verbosity 2
116
118
  ```
117
119
 
118
120
  ---
119
121
 
120
122
  ## 📋 Manual Registry
121
123
 
122
- Register commands explicitly using the `@registry.register()` decorator — useful when commands live across different modules.
124
+ Register commands explicitly using the `@registry.register()` decorator — useful when you want multiple commands in a single file.
125
+
126
+ The registry style works in two ways:
127
+
128
+ **Standalone** — run the registry directly as a script:
123
129
 
124
130
  ```python
125
- from base_command import BaseCommand, CommandError, CommandRegistry
131
+ # my_commands.py
132
+ from python_base_command import BaseCommand, CommandError, CommandRegistry
126
133
 
127
134
  registry = CommandRegistry()
128
135
 
@@ -130,6 +137,7 @@ registry = CommandRegistry()
130
137
  @registry.register("greet")
131
138
  class GreetCommand(BaseCommand):
132
139
  help = "Greet a user"
140
+ version = "2.0.0"
133
141
 
134
142
  def add_arguments(self, parser):
135
143
  parser.add_argument("name", type=str)
@@ -141,6 +149,7 @@ class GreetCommand(BaseCommand):
141
149
  @registry.register("export")
142
150
  class ExportCommand(BaseCommand):
143
151
  help = "Export data"
152
+ version = "3.0.0"
144
153
 
145
154
  def add_arguments(self, parser):
146
155
  parser.add_argument("--format", choices=["csv", "json"], default="csv")
@@ -158,10 +167,26 @@ if __name__ == "__main__":
158
167
  ```
159
168
 
160
169
  ```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
170
+ python3 my_commands.py greet Alice
171
+ python3 my_commands.py export --format json
172
+ python3 my_commands.py export --dry-run
173
+ ```
174
+
175
+ **Auto-discovered** — drop the registry file into your `commands/` folder and `Runner` will discover it automatically alongside any classic `Command` files:
176
+
177
+ ```
178
+ myapp/
179
+ ├── cli.py
180
+ └── commands/
181
+ ├── __init__.py
182
+ ├── greet.py ← classic Command class
183
+ └── reg_cmd.py ← CommandRegistry with multiple commands
184
+ ```
185
+
186
+ ```bash
187
+ python3 cli.py --help # shows commands from both files
188
+ python3 cli.py greet Alice
189
+ python3 cli.py export --format json
165
190
  ```
166
191
 
167
192
  ---
@@ -171,7 +196,7 @@ python cli.py export --dry-run
171
196
  Invoke commands programmatically — ideal for unit tests.
172
197
 
173
198
  ```python
174
- from base_command import call_command, CommandError
199
+ from python_base_command import call_command, CommandError
175
200
  import pytest
176
201
 
177
202
  from commands.greet import Command as GreetCommand
@@ -202,6 +227,7 @@ Base class for all commands. Inherit from it and implement `handle()`.
202
227
  | Attribute | Type | Default | Description |
203
228
  |---|---|---|---|
204
229
  | `help` | `str` | `""` | Description shown in `--help` |
230
+ | `version` | `str` | `"unknown"` | Version string exposed via `--version`. Set this per command. |
205
231
  | `output_transaction` | `bool` | `False` | Wrap `handle()` return value in `BEGIN;` / `COMMIT;` |
206
232
  | `suppressed_base_arguments` | `set[str]` | `set()` | Base flags to hide from `--help` |
207
233
  | `stealth_options` | `tuple[str]` | `()` | Options used but not declared via `add_arguments()` |
@@ -213,7 +239,6 @@ Base class for all commands. Inherit from it and implement `handle()`.
213
239
  |---|---|---|
214
240
  | `handle(**kwargs)` | ✅ | Command logic. May return a string. |
215
241
  | `add_arguments(parser)` | ❌ | Add command-specific arguments to the parser. |
216
- | `get_version()` | ❌ | Override to expose your package version via `--version`. |
217
242
 
218
243
  **`self.logger`**
219
244
 
@@ -255,7 +280,7 @@ raise CommandError("Fatal error.", returncode=2)
255
280
  For commands that accept one or more arbitrary string labels. Override `handle_label()` instead of `handle()`.
256
281
 
257
282
  ```python
258
- from base_command import LabelCommand, CommandError
283
+ from python_base_command import LabelCommand, CommandError
259
284
 
260
285
 
261
286
  class Command(LabelCommand):
@@ -278,24 +303,27 @@ class Command(LabelCommand):
278
303
  ```
279
304
 
280
305
  ```bash
281
- python cli.py process report.csv notes.txt image.png
282
- python cli.py process report.csv notes.txt image.png --strict
306
+ python3 cli.py process report.csv notes.txt image.png
307
+ python3 cli.py process report.csv notes.txt image.png --strict
283
308
  ```
284
309
 
285
310
  ---
286
311
 
287
312
  ### `Runner`
288
313
 
289
- Auto-discovers commands from a directory. Every `.py` file in the folder that defines a `Command` class subclassing `BaseCommand` is automatically registered.
314
+ Auto-discovers commands from a directory. Two conventions are supported:
315
+
316
+ 1. **Classic** — a `.py` file that defines a class named `Command` subclassing `BaseCommand`. The command name is the file stem.
317
+ 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.
318
+
319
+ Files whose names start with `_` are ignored.
290
320
 
291
321
  ```python
292
- from base_command import Runner
322
+ from python_base_command import Runner
293
323
 
294
324
  Runner(commands_dir="commands").run()
295
325
  ```
296
326
 
297
- Files whose names start with `_` are ignored.
298
-
299
327
  ---
300
328
 
301
329
  ### `CommandRegistry`
@@ -303,17 +331,19 @@ Files whose names start with `_` are ignored.
303
331
  Manually register commands using a decorator or programmatically.
304
332
 
305
333
  ```python
306
- from base_command import CommandRegistry
334
+ from python_base_command import BaseCommand, CommandRegistry
307
335
 
308
336
  registry = CommandRegistry()
309
337
 
338
+
310
339
  @registry.register("greet")
311
340
  class GreetCommand(BaseCommand): ...
312
341
 
342
+
313
343
  registry.add("export", ExportCommand) # programmatic alternative
314
344
 
315
- registry.run() # uses sys.argv
316
- registry.run(["myapp", "greet", "Alice"]) # explicit argv
345
+ registry.run() # uses sys.argv
346
+ registry.run(["myapp", "greet", "Alice"]) # explicit argv
317
347
  ```
318
348
 
319
349
  ---
@@ -323,7 +353,7 @@ registry.run(["myapp", "greet", "Alice"]) # explicit argv
323
353
  Invoke a command from Python code. Accepts either a class or an instance.
324
354
 
325
355
  ```python
326
- from base_command import call_command
356
+ from python_base_command import call_command
327
357
 
328
358
  call_command(GreetCommand, name="Alice")
329
359
  call_command(GreetCommand, name="Alice", verbosity=0)
@@ -44,7 +44,7 @@ Start by creating `cli.py` — your entry point, the equivalent of Django's `man
44
44
 
45
45
  ```python
46
46
  # cli.py
47
- from base_command import Runner
47
+ from python_base_command import Runner
48
48
 
49
49
  Runner(commands_dir="commands").run()
50
50
  ```
@@ -61,11 +61,12 @@ myapp/
61
61
 
62
62
  ```python
63
63
  # commands/greet.py
64
- from base_command import BaseCommand, CommandError
64
+ from python_base_command import BaseCommand, CommandError
65
65
 
66
66
 
67
67
  class Command(BaseCommand):
68
68
  help = "Greet a user by name"
69
+ version = "1.0.0"
69
70
 
70
71
  def add_arguments(self, parser):
71
72
  parser.add_argument("name", type=str, help="Name to greet")
@@ -86,21 +87,26 @@ class Command(BaseCommand):
86
87
  Run from anywhere inside the project:
87
88
 
88
89
  ```bash
89
- python cli.py --help # lists all available commands
90
- python cli.py greet Alice
91
- python cli.py greet Alice --shout
92
- python cli.py greet --version
93
- python cli.py greet --verbosity 2
90
+ python3 cli.py --help # lists all available commands
91
+ python3 cli.py greet Alice
92
+ python3 cli.py greet Alice --shout
93
+ python3 cli.py greet --version
94
+ python3 cli.py greet --verbosity 2
94
95
  ```
95
96
 
96
97
  ---
97
98
 
98
99
  ## 📋 Manual Registry
99
100
 
100
- Register commands explicitly using the `@registry.register()` decorator — useful when commands live across different modules.
101
+ Register commands explicitly using the `@registry.register()` decorator — useful when you want multiple commands in a single file.
102
+
103
+ The registry style works in two ways:
104
+
105
+ **Standalone** — run the registry directly as a script:
101
106
 
102
107
  ```python
103
- from base_command import BaseCommand, CommandError, CommandRegistry
108
+ # my_commands.py
109
+ from python_base_command import BaseCommand, CommandError, CommandRegistry
104
110
 
105
111
  registry = CommandRegistry()
106
112
 
@@ -108,6 +114,7 @@ registry = CommandRegistry()
108
114
  @registry.register("greet")
109
115
  class GreetCommand(BaseCommand):
110
116
  help = "Greet a user"
117
+ version = "2.0.0"
111
118
 
112
119
  def add_arguments(self, parser):
113
120
  parser.add_argument("name", type=str)
@@ -119,6 +126,7 @@ class GreetCommand(BaseCommand):
119
126
  @registry.register("export")
120
127
  class ExportCommand(BaseCommand):
121
128
  help = "Export data"
129
+ version = "3.0.0"
122
130
 
123
131
  def add_arguments(self, parser):
124
132
  parser.add_argument("--format", choices=["csv", "json"], default="csv")
@@ -136,10 +144,26 @@ if __name__ == "__main__":
136
144
  ```
137
145
 
138
146
  ```bash
139
- python cli.py --help
140
- python cli.py greet Alice
141
- python cli.py export --format json
142
- python cli.py export --dry-run
147
+ python3 my_commands.py greet Alice
148
+ python3 my_commands.py export --format json
149
+ python3 my_commands.py export --dry-run
150
+ ```
151
+
152
+ **Auto-discovered** — drop the registry file into your `commands/` folder and `Runner` will discover it automatically alongside any classic `Command` files:
153
+
154
+ ```
155
+ myapp/
156
+ ├── cli.py
157
+ └── commands/
158
+ ├── __init__.py
159
+ ├── greet.py ← classic Command class
160
+ └── reg_cmd.py ← CommandRegistry with multiple commands
161
+ ```
162
+
163
+ ```bash
164
+ python3 cli.py --help # shows commands from both files
165
+ python3 cli.py greet Alice
166
+ python3 cli.py export --format json
143
167
  ```
144
168
 
145
169
  ---
@@ -149,7 +173,7 @@ python cli.py export --dry-run
149
173
  Invoke commands programmatically — ideal for unit tests.
150
174
 
151
175
  ```python
152
- from base_command import call_command, CommandError
176
+ from python_base_command import call_command, CommandError
153
177
  import pytest
154
178
 
155
179
  from commands.greet import Command as GreetCommand
@@ -180,6 +204,7 @@ Base class for all commands. Inherit from it and implement `handle()`.
180
204
  | Attribute | Type | Default | Description |
181
205
  |---|---|---|---|
182
206
  | `help` | `str` | `""` | Description shown in `--help` |
207
+ | `version` | `str` | `"unknown"` | Version string exposed via `--version`. Set this per command. |
183
208
  | `output_transaction` | `bool` | `False` | Wrap `handle()` return value in `BEGIN;` / `COMMIT;` |
184
209
  | `suppressed_base_arguments` | `set[str]` | `set()` | Base flags to hide from `--help` |
185
210
  | `stealth_options` | `tuple[str]` | `()` | Options used but not declared via `add_arguments()` |
@@ -191,7 +216,6 @@ Base class for all commands. Inherit from it and implement `handle()`.
191
216
  |---|---|---|
192
217
  | `handle(**kwargs)` | ✅ | Command logic. May return a string. |
193
218
  | `add_arguments(parser)` | ❌ | Add command-specific arguments to the parser. |
194
- | `get_version()` | ❌ | Override to expose your package version via `--version`. |
195
219
 
196
220
  **`self.logger`**
197
221
 
@@ -233,7 +257,7 @@ raise CommandError("Fatal error.", returncode=2)
233
257
  For commands that accept one or more arbitrary string labels. Override `handle_label()` instead of `handle()`.
234
258
 
235
259
  ```python
236
- from base_command import LabelCommand, CommandError
260
+ from python_base_command import LabelCommand, CommandError
237
261
 
238
262
 
239
263
  class Command(LabelCommand):
@@ -256,24 +280,27 @@ class Command(LabelCommand):
256
280
  ```
257
281
 
258
282
  ```bash
259
- python cli.py process report.csv notes.txt image.png
260
- python cli.py process report.csv notes.txt image.png --strict
283
+ python3 cli.py process report.csv notes.txt image.png
284
+ python3 cli.py process report.csv notes.txt image.png --strict
261
285
  ```
262
286
 
263
287
  ---
264
288
 
265
289
  ### `Runner`
266
290
 
267
- Auto-discovers commands from a directory. Every `.py` file in the folder that defines a `Command` class subclassing `BaseCommand` is automatically registered.
291
+ Auto-discovers commands from a directory. Two conventions are supported:
292
+
293
+ 1. **Classic** — a `.py` file that defines a class named `Command` subclassing `BaseCommand`. The command name is the file stem.
294
+ 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.
295
+
296
+ Files whose names start with `_` are ignored.
268
297
 
269
298
  ```python
270
- from base_command import Runner
299
+ from python_base_command import Runner
271
300
 
272
301
  Runner(commands_dir="commands").run()
273
302
  ```
274
303
 
275
- Files whose names start with `_` are ignored.
276
-
277
304
  ---
278
305
 
279
306
  ### `CommandRegistry`
@@ -281,17 +308,19 @@ Files whose names start with `_` are ignored.
281
308
  Manually register commands using a decorator or programmatically.
282
309
 
283
310
  ```python
284
- from base_command import CommandRegistry
311
+ from python_base_command import BaseCommand, CommandRegistry
285
312
 
286
313
  registry = CommandRegistry()
287
314
 
315
+
288
316
  @registry.register("greet")
289
317
  class GreetCommand(BaseCommand): ...
290
318
 
319
+
291
320
  registry.add("export", ExportCommand) # programmatic alternative
292
321
 
293
- registry.run() # uses sys.argv
294
- registry.run(["myapp", "greet", "Alice"]) # explicit argv
322
+ registry.run() # uses sys.argv
323
+ registry.run(["myapp", "greet", "Alice"]) # explicit argv
295
324
  ```
296
325
 
297
326
  ---
@@ -301,7 +330,7 @@ registry.run(["myapp", "greet", "Alice"]) # explicit argv
301
330
  Invoke a command from Python code. Accepts either a class or an instance.
302
331
 
303
332
  ```python
304
- from base_command import call_command
333
+ from python_base_command import call_command
305
334
 
306
335
  call_command(GreetCommand, name="Alice")
307
336
  call_command(GreetCommand, name="Alice", verbosity=0)
@@ -4,13 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-base-command"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Django-style BaseCommand framework for standalone Python CLI tools"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.12"
12
12
  dependencies = [
13
13
  "custom-python-logger>=2.0.13",
14
+ "python-base-toolkit>=1.0.2",
14
15
  ]
15
16
  keywords = ["cli", "command", "argparse", "django", "management"]
16
17
  classifiers = [
@@ -6,7 +6,6 @@ replacing self.stdout / self.style with self.logger from custom-python-logger.
6
6
  """
7
7
 
8
8
  import argparse
9
- import importlib.metadata
10
9
  import os
11
10
  import sys
12
11
  from argparse import Action, ArgumentParser, HelpFormatter
@@ -142,6 +141,8 @@ class BaseCommand:
142
141
  ----------
143
142
  help : str
144
143
  Short description printed in --help output.
144
+ version : str
145
+ Version string exposed via --version. Set this per command.
145
146
  output_transaction : bool
146
147
  If True, wrap any string returned by handle() with BEGIN; / COMMIT;.
147
148
  suppressed_base_arguments : set[str]
@@ -153,6 +154,7 @@ class BaseCommand:
153
154
  """
154
155
 
155
156
  help: str = ""
157
+ version: str = "unknown"
156
158
  output_transaction: bool = False
157
159
  suppressed_base_arguments: set[str] = set()
158
160
  stealth_options: tuple[str, ...] = ()
@@ -170,19 +172,6 @@ class BaseCommand:
170
172
  _ = stdout, stderr # API compatibility with call_command(stdout=..., stderr=...)
171
173
  self.logger: CustomLoggerAdapter = get_logger(name=self.__class__.__module__.split(".", maxsplit=1)[0])
172
174
 
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
175
  # ------------------------------------------------------------------ parser
187
176
 
188
177
  def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser:
@@ -200,7 +189,7 @@ class BaseCommand:
200
189
  parser,
201
190
  "--version",
202
191
  action="version",
203
- version=self.get_version(),
192
+ version=self.version,
204
193
  help="Show program's version number and exit.",
205
194
  )
206
195
  self.add_base_argument(
@@ -40,15 +40,14 @@ And run::
40
40
  """
41
41
 
42
42
  import importlib.util
43
- import os
44
43
  import sys
45
44
  from pathlib import Path
46
45
  from types import ModuleType
47
- from typing import Optional
48
46
 
49
47
  from custom_python_logger import get_logger
50
48
 
51
- from .base import BaseCommand, CommandError
49
+ from python_base_command.base import BaseCommand
50
+ from python_base_command.registry import CommandRegistry
52
51
 
53
52
  logger = get_logger("python-base-command")
54
53
 
@@ -69,7 +68,7 @@ class Runner:
69
68
  def __init__(
70
69
  self,
71
70
  commands_dir: str | Path = "commands",
72
- ):
71
+ ) -> None:
73
72
  # Resolve relative to cwd — the directory the user runs the script from,
74
73
  # just like Django resolves manage.py commands from the project root.
75
74
  self._commands_dir = (Path.cwd() / commands_dir).resolve()
@@ -78,8 +77,15 @@ class Runner:
78
77
 
79
78
  def _discover(self) -> dict[str, type[BaseCommand]]:
80
79
  """
81
- Walk ``self._commands_dir`` and import every non-private module that
82
- exposes a ``Command`` class inheriting from ``BaseCommand``.
80
+ Walk ``self._commands_dir`` and import every non-private module.
81
+
82
+ Two conventions are supported per module:
83
+
84
+ 1. **Classic** — a class literally named ``Command`` that subclasses
85
+ ``BaseCommand``. The command name is the module's file stem.
86
+ 2. **Registry** — one or more ``CommandRegistry`` instances defined at
87
+ module level. Every command registered on those instances is merged
88
+ in; the names come from the registry (not the file stem).
83
89
  """
84
90
  commands: dict[str, type[BaseCommand]] = {}
85
91
 
@@ -90,24 +96,26 @@ class Runner:
90
96
  if path.stem.startswith("_"):
91
97
  continue
92
98
 
93
- module = self._load_module(path)
94
- if module is None:
99
+ if (module := self._load_module(path)) is None:
95
100
  continue
96
101
 
102
+ # --- 1. Classic: a top-level class named "Command" ---
97
103
  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
+ if command_class is not None and isinstance(command_class, type) and issubclass(command_class, BaseCommand):
105
+ commands[path.stem] = command_class
104
106
 
105
- commands[path.stem] = command_class
107
+ # --- 2. Registry: any CommandRegistry instances in the module ---
108
+ for attr_name in dir(module):
109
+ obj = getattr(module, attr_name)
110
+ if isinstance(obj, CommandRegistry):
111
+ for name in obj.list_commands():
112
+ if (cls := obj.get(name)) is not None:
113
+ commands[name] = cls
106
114
 
107
115
  return commands
108
116
 
109
117
  @staticmethod
110
- def _load_module(path: Path) -> Optional[ModuleType]:
118
+ def _load_module(path: Path) -> ModuleType | None:
111
119
  """Dynamically load a Python file as a module."""
112
120
  module_name = f"_base_command_discovered_.{path.stem}"
113
121
  spec = importlib.util.spec_from_file_location(module_name, path)
@@ -123,7 +131,7 @@ class Runner:
123
131
 
124
132
  # ------------------------------------------------------------------ running
125
133
 
126
- def run(self, argv: list[str] | None = None):
134
+ def run(self, argv: list[str] | None = None) -> None:
127
135
  """
128
136
  Parse *argv* (defaults to ``sys.argv``), discover commands, find the
129
137
  requested one, and run it.
@@ -132,14 +140,12 @@ class Runner:
132
140
  commands = self._discover()
133
141
 
134
142
  # Show top-level help if no subcommand is given.
135
- if len(argv) < 2 or argv[1] in ("-h", "--help"):
143
+ if len(argv) < 2 or argv[1] in {"-h", "--help"}:
136
144
  self._print_help(argv[0] if argv else "unknown", commands)
137
145
  sys.exit(0)
138
146
 
139
147
  subcommand = argv[1]
140
- command_class = commands.get(subcommand)
141
-
142
- if command_class is None:
148
+ if (command_class := commands.get(subcommand)) is None:
143
149
  prog = argv[0] if argv else "unknown"
144
150
  available = ", ".join(sorted(commands)) or "(none found)"
145
151
  logger.error(
@@ -153,7 +159,7 @@ class Runner:
153
159
  command_class().run_from_argv([argv[0]] + argv[2:])
154
160
 
155
161
  @staticmethod
156
- def _print_help(prog: str, commands: dict[str, type[BaseCommand]]):
162
+ def _print_help(prog: str, commands: dict[str, type[BaseCommand]]) -> None:
157
163
  print(f"Usage: {prog} <command> [options]\n")
158
164
  print("Available commands:")
159
165
  for name, cls in sorted(commands.items()):
@@ -7,6 +7,7 @@ from python_base_command.base import CommandParser
7
7
 
8
8
  class Command(BaseCommand):
9
9
  help = "Greet a user by name"
10
+ version = "1.0.0"
10
11
 
11
12
  def add_arguments(self, parser: CommandParser) -> None:
12
13
  parser.add_argument("name", type=str, help="Name to greet")
@@ -0,0 +1,35 @@
1
+ from typing import Any
2
+
3
+ from python_base_command import BaseCommand, CommandParser, CommandRegistry
4
+
5
+ registry = CommandRegistry()
6
+
7
+
8
+ @registry.register("greet2")
9
+ class Greet2Command(BaseCommand):
10
+ help = "Greet a user"
11
+
12
+ def add_arguments(self, parser: CommandParser) -> None:
13
+ parser.add_argument("name", type=str)
14
+
15
+ def handle(self, **kwargs: Any) -> None:
16
+ self.logger.info(f"Hello, {kwargs['name']}!")
17
+
18
+
19
+ @registry.register("export")
20
+ class ExportCommand(BaseCommand):
21
+ help = "Export data"
22
+
23
+ def add_arguments(self, parser: CommandParser) -> None:
24
+ parser.add_argument("--format", choices=["csv", "json"], default="csv")
25
+ parser.add_argument("--dry-run", action="store_true")
26
+
27
+ def handle(self, **kwargs: Any) -> None:
28
+ if kwargs["dry_run"]:
29
+ self.logger.warning("Dry run — no files written.")
30
+ return
31
+ self.logger.info(f"Exported as {kwargs['format']}.")
32
+
33
+
34
+ if __name__ == "__main__":
35
+ registry.run()