python-base-command 0.1.3__py3-none-any.whl → 0.1.5__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.
@@ -15,8 +15,6 @@ Public API
15
15
  - call_command — programmatic command invocation
16
16
  """
17
17
 
18
- from custom_python_logger import build_logger
19
-
20
18
  from .base import (
21
19
  BaseCommand,
22
20
  CommandError,
@@ -36,8 +34,3 @@ __all__ = [
36
34
  "Runner",
37
35
  "call_command",
38
36
  ]
39
-
40
- build_logger(
41
- project_name="python-base-command",
42
- log_format="%(asctime)s | %(levelname)s | %(message)s",
43
- )
@@ -6,14 +6,14 @@ 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
13
12
  from collections.abc import Sequence
13
+ from importlib.metadata import version
14
14
  from typing import Any, TextIO
15
15
 
16
- from custom_python_logger import CustomLoggerAdapter, get_logger
16
+ from custom_python_logger import CustomLoggerAdapter, build_logger, get_logger
17
17
 
18
18
  __all__ = [
19
19
  "BaseCommand",
@@ -22,6 +22,7 @@ __all__ = [
22
22
  "LabelCommand",
23
23
  ]
24
24
 
25
+ from python_base_toolkit.utils.path_utils import get_project_path_by_file
25
26
 
26
27
  # ---------------------------------------------------------------------------
27
28
  # Exceptions
@@ -142,6 +143,8 @@ class BaseCommand:
142
143
  ----------
143
144
  help : str
144
145
  Short description printed in --help output.
146
+ version : str
147
+ Version string exposed via --version. Set this per command.
145
148
  output_transaction : bool
146
149
  If True, wrap any string returned by handle() with BEGIN; / COMMIT;.
147
150
  suppressed_base_arguments : set[str]
@@ -153,6 +156,7 @@ class BaseCommand:
153
156
  """
154
157
 
155
158
  help: str = ""
159
+ version: str = "unknown"
156
160
  output_transaction: bool = False
157
161
  suppressed_base_arguments: set[str] = set()
158
162
  stealth_options: tuple[str, ...] = ()
@@ -169,22 +173,23 @@ class BaseCommand:
169
173
  ) -> None:
170
174
  _ = stdout, stderr # API compatibility with call_command(stdout=..., stderr=...)
171
175
  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"
176
+ build_logger(
177
+ project_name=self.__class__.__name__,
178
+ log_format="%(asctime)s | %(levelname)s | %(message)s",
179
+ log_file=os.getenv("PYTHON_BASE_COMMAND_LOG_FILE", "true").lower() == "true",
180
+ )
185
181
 
186
182
  # ------------------------------------------------------------------ parser
187
183
 
184
+ def set_project_version(self, project_name: str | None = None) -> None:
185
+ if not project_name:
186
+ try:
187
+ project_path = get_project_path_by_file()
188
+ project_name = project_path.name
189
+ except Exception:
190
+ self.logger.warning("Project name not provided and could not be inferred from file markers.")
191
+ self.version = version(project_name) if project_name else self.version
192
+
188
193
  def create_parser(self, prog_name: str, subcommand: str, **kwargs: Any) -> CommandParser:
189
194
  """Create and return the CommandParser used to parse arguments."""
190
195
  kwargs.setdefault("formatter_class", CommandHelpFormatter)
@@ -200,7 +205,7 @@ class BaseCommand:
200
205
  parser,
201
206
  "--version",
202
207
  action="version",
203
- version=self.get_version(),
208
+ version=self.version,
204
209
  help="Show program's version number and exit.",
205
210
  )
206
211
  self.add_base_argument(
@@ -273,7 +278,6 @@ class BaseCommand:
273
278
  If handle() returns a string and output_transaction is True,
274
279
  wraps it in BEGIN; / COMMIT;.
275
280
  """
276
- output: str | None = None
277
281
  if output := self.handle(**kwargs):
278
282
  if self.output_transaction:
279
283
  output = f"BEGIN;\n{output}\nCOMMIT;"
@@ -40,16 +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 . import CommandRegistry
52
- from .base import BaseCommand, CommandError
49
+ from python_base_command.base import BaseCommand
50
+ from python_base_command.registry import CommandRegistry
53
51
 
54
52
  logger = get_logger("python-base-command")
55
53
 
@@ -70,7 +68,7 @@ class Runner:
70
68
  def __init__(
71
69
  self,
72
70
  commands_dir: str | Path = "commands",
73
- ):
71
+ ) -> None:
74
72
  # Resolve relative to cwd — the directory the user runs the script from,
75
73
  # just like Django resolves manage.py commands from the project root.
76
74
  self._commands_dir = (Path.cwd() / commands_dir).resolve()
@@ -98,8 +96,7 @@ class Runner:
98
96
  if path.stem.startswith("_"):
99
97
  continue
100
98
 
101
- module = self._load_module(path)
102
- if module is None:
99
+ if (module := self._load_module(path)) is None:
103
100
  continue
104
101
 
105
102
  # --- 1. Classic: a top-level class named "Command" ---
@@ -112,14 +109,13 @@ class Runner:
112
109
  obj = getattr(module, attr_name)
113
110
  if isinstance(obj, CommandRegistry):
114
111
  for name in obj.list_commands():
115
- cls = obj.get(name)
116
- if cls is not None:
112
+ if (cls := obj.get(name)) is not None:
117
113
  commands[name] = cls
118
114
 
119
115
  return commands
120
116
 
121
117
  @staticmethod
122
- def _load_module(path: Path) -> Optional[ModuleType]:
118
+ def _load_module(path: Path) -> ModuleType | None:
123
119
  """Dynamically load a Python file as a module."""
124
120
  module_name = f"_base_command_discovered_.{path.stem}"
125
121
  spec = importlib.util.spec_from_file_location(module_name, path)
@@ -135,7 +131,7 @@ class Runner:
135
131
 
136
132
  # ------------------------------------------------------------------ running
137
133
 
138
- def run(self, argv: list[str] | None = None):
134
+ def run(self, argv: list[str] | None = None) -> None:
139
135
  """
140
136
  Parse *argv* (defaults to ``sys.argv``), discover commands, find the
141
137
  requested one, and run it.
@@ -144,14 +140,12 @@ class Runner:
144
140
  commands = self._discover()
145
141
 
146
142
  # Show top-level help if no subcommand is given.
147
- if len(argv) < 2 or argv[1] in ("-h", "--help"):
143
+ if len(argv) < 2 or argv[1] in {"-h", "--help"}:
148
144
  self._print_help(argv[0] if argv else "unknown", commands)
149
145
  sys.exit(0)
150
146
 
151
147
  subcommand = argv[1]
152
- command_class = commands.get(subcommand)
153
-
154
- if command_class is None:
148
+ if (command_class := commands.get(subcommand)) is None:
155
149
  prog = argv[0] if argv else "unknown"
156
150
  available = ", ".join(sorted(commands)) or "(none found)"
157
151
  logger.error(
@@ -165,7 +159,7 @@ class Runner:
165
159
  command_class().run_from_argv([argv[0]] + argv[2:])
166
160
 
167
161
  @staticmethod
168
- def _print_help(prog: str, commands: dict[str, type[BaseCommand]]):
162
+ def _print_help(prog: str, commands: dict[str, type[BaseCommand]]) -> None:
169
163
  print(f"Usage: {prog} <command> [options]\n")
170
164
  print("Available commands:")
171
165
  for name, cls in sorted(commands.items()):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-base-command
3
- Version: 0.1.3
3
+ Version: 0.1.5
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")
@@ -105,24 +107,48 @@ class Command(BaseCommand):
105
107
  self.logger.info(msg)
106
108
  ```
107
109
 
110
+ ```python
111
+ # commands/greet.py
112
+ from python_base_command import BaseCommand, CommandError
113
+
114
+
115
+ class Command(BaseCommand):
116
+ help = "Greet a user by name"
117
+
118
+ def __init__(self) -> None:
119
+ super().__init__()
120
+ self.set_project_version("python-base-command")
121
+
122
+ def add_arguments(self, parser):
123
+ pass
124
+
125
+ def handle(self, **kwargs):
126
+ pass
127
+ ```
128
+
108
129
  Run from anywhere inside the project:
109
130
 
110
131
  ```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
132
+ python3 cli.py --help # lists all available commands
133
+ python3 cli.py greet Alice
134
+ python3 cli.py greet Alice --shout
135
+ python3 cli.py greet --version
136
+ python3 cli.py greet --verbosity 2
116
137
  ```
117
138
 
118
139
  ---
119
140
 
120
141
  ## 📋 Manual Registry
121
142
 
122
- Register commands explicitly using the `@registry.register()` decorator — useful when commands live across different modules.
143
+ Register commands explicitly using the `@registry.register()` decorator — useful when you want multiple commands in a single file.
144
+
145
+ The registry style works in two ways:
146
+
147
+ **Standalone** — run the registry directly as a script:
123
148
 
124
149
  ```python
125
- from base_command import BaseCommand, CommandError, CommandRegistry
150
+ # my_commands.py
151
+ from python_base_command import BaseCommand, CommandError, CommandRegistry
126
152
 
127
153
  registry = CommandRegistry()
128
154
 
@@ -130,6 +156,7 @@ registry = CommandRegistry()
130
156
  @registry.register("greet")
131
157
  class GreetCommand(BaseCommand):
132
158
  help = "Greet a user"
159
+ version = "2.0.0"
133
160
 
134
161
  def add_arguments(self, parser):
135
162
  parser.add_argument("name", type=str)
@@ -141,6 +168,7 @@ class GreetCommand(BaseCommand):
141
168
  @registry.register("export")
142
169
  class ExportCommand(BaseCommand):
143
170
  help = "Export data"
171
+ version = "3.0.0"
144
172
 
145
173
  def add_arguments(self, parser):
146
174
  parser.add_argument("--format", choices=["csv", "json"], default="csv")
@@ -158,10 +186,26 @@ if __name__ == "__main__":
158
186
  ```
159
187
 
160
188
  ```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
189
+ python3 my_commands.py greet Alice
190
+ python3 my_commands.py export --format json
191
+ python3 my_commands.py export --dry-run
192
+ ```
193
+
194
+ **Auto-discovered** — drop the registry file into your `commands/` folder and `Runner` will discover it automatically alongside any classic `Command` files:
195
+
196
+ ```
197
+ myapp/
198
+ ├── cli.py
199
+ └── commands/
200
+ ├── __init__.py
201
+ ├── greet.py ← classic Command class
202
+ └── reg_cmd.py ← CommandRegistry with multiple commands
203
+ ```
204
+
205
+ ```bash
206
+ python3 cli.py --help # shows commands from both files
207
+ python3 cli.py greet Alice
208
+ python3 cli.py export --format json
165
209
  ```
166
210
 
167
211
  ---
@@ -171,7 +215,7 @@ python cli.py export --dry-run
171
215
  Invoke commands programmatically — ideal for unit tests.
172
216
 
173
217
  ```python
174
- from base_command import call_command, CommandError
218
+ from python_base_command import call_command, CommandError
175
219
  import pytest
176
220
 
177
221
  from commands.greet import Command as GreetCommand
@@ -202,6 +246,7 @@ Base class for all commands. Inherit from it and implement `handle()`.
202
246
  | Attribute | Type | Default | Description |
203
247
  |---|---|---|---|
204
248
  | `help` | `str` | `""` | Description shown in `--help` |
249
+ | `version` | `str` | `"unknown"` | Version string exposed via `--version`. Set this per command. |
205
250
  | `output_transaction` | `bool` | `False` | Wrap `handle()` return value in `BEGIN;` / `COMMIT;` |
206
251
  | `suppressed_base_arguments` | `set[str]` | `set()` | Base flags to hide from `--help` |
207
252
  | `stealth_options` | `tuple[str]` | `()` | Options used but not declared via `add_arguments()` |
@@ -213,7 +258,6 @@ Base class for all commands. Inherit from it and implement `handle()`.
213
258
  |---|---|---|
214
259
  | `handle(**kwargs)` | ✅ | Command logic. May return a string. |
215
260
  | `add_arguments(parser)` | ❌ | Add command-specific arguments to the parser. |
216
- | `get_version()` | ❌ | Override to expose your package version via `--version`. |
217
261
 
218
262
  **`self.logger`**
219
263
 
@@ -255,7 +299,7 @@ raise CommandError("Fatal error.", returncode=2)
255
299
  For commands that accept one or more arbitrary string labels. Override `handle_label()` instead of `handle()`.
256
300
 
257
301
  ```python
258
- from base_command import LabelCommand, CommandError
302
+ from python_base_command import LabelCommand, CommandError
259
303
 
260
304
 
261
305
  class Command(LabelCommand):
@@ -278,24 +322,27 @@ class Command(LabelCommand):
278
322
  ```
279
323
 
280
324
  ```bash
281
- python cli.py process report.csv notes.txt image.png
282
- python cli.py process report.csv notes.txt image.png --strict
325
+ python3 cli.py process report.csv notes.txt image.png
326
+ python3 cli.py process report.csv notes.txt image.png --strict
283
327
  ```
284
328
 
285
329
  ---
286
330
 
287
331
  ### `Runner`
288
332
 
289
- Auto-discovers commands from a directory. Every `.py` file in the folder that defines a `Command` class subclassing `BaseCommand` is automatically registered.
333
+ Auto-discovers commands from a directory. Two conventions are supported:
334
+
335
+ 1. **Classic** — a `.py` file that defines a class named `Command` subclassing `BaseCommand`. The command name is the file stem.
336
+ 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.
337
+
338
+ Files whose names start with `_` are ignored.
290
339
 
291
340
  ```python
292
- from base_command import Runner
341
+ from python_base_command import Runner
293
342
 
294
343
  Runner(commands_dir="commands").run()
295
344
  ```
296
345
 
297
- Files whose names start with `_` are ignored.
298
-
299
346
  ---
300
347
 
301
348
  ### `CommandRegistry`
@@ -303,17 +350,19 @@ Files whose names start with `_` are ignored.
303
350
  Manually register commands using a decorator or programmatically.
304
351
 
305
352
  ```python
306
- from base_command import CommandRegistry
353
+ from python_base_command import BaseCommand, CommandRegistry
307
354
 
308
355
  registry = CommandRegistry()
309
356
 
357
+
310
358
  @registry.register("greet")
311
359
  class GreetCommand(BaseCommand): ...
312
360
 
361
+
313
362
  registry.add("export", ExportCommand) # programmatic alternative
314
363
 
315
- registry.run() # uses sys.argv
316
- registry.run(["myapp", "greet", "Alice"]) # explicit argv
364
+ registry.run() # uses sys.argv
365
+ registry.run(["myapp", "greet", "Alice"]) # explicit argv
317
366
  ```
318
367
 
319
368
  ---
@@ -323,7 +372,7 @@ registry.run(["myapp", "greet", "Alice"]) # explicit argv
323
372
  Invoke a command from Python code. Accepts either a class or an instance.
324
373
 
325
374
  ```python
326
- from base_command import call_command
375
+ from python_base_command import call_command
327
376
 
328
377
  call_command(GreetCommand, name="Alice")
329
378
  call_command(GreetCommand, name="Alice", verbosity=0)
@@ -0,0 +1,9 @@
1
+ python_base_command/__init__.py,sha256=sPoDneqhe6JjRMetB3N8Kq8ohsUilTUvzo1QrgmSyy0,967
2
+ python_base_command/base.py,sha256=YPN5DpM6qVfOqOVJX3JwyaEdx0gD6Q2C-lyBNpDhGCM,11450
3
+ python_base_command/registry.py,sha256=WJl5IgjqOWRZuN36od1K-gm_RzDYCwivMkFSHqLbhe0,3883
4
+ python_base_command/runner.py,sha256=rhMUhrJBJJ6XaxbuXuC5DcGqBHVqErqGm5BL9cR0YqQ,5906
5
+ python_base_command/utils.py,sha256=Q0U8uU1YgMJdeP4IpCktsI8dSjqgcF9nUnMTxOvqL0o,1320
6
+ python_base_command-0.1.5.dist-info/METADATA,sha256=k7Frnj-4M5IPdfaHylUduURaGUfC3Y_Tw9vHMMNR52Q,12289
7
+ python_base_command-0.1.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ python_base_command-0.1.5.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
9
+ python_base_command-0.1.5.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.28.0
2
+ Generator: hatchling 1.29.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,9 +0,0 @@
1
- python_base_command/__init__.py,sha256=3szmLuo8nfQ3Mttzcx7-iRUi0U-2PEhQRg_v-daRo7Y,1131
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=iLD09RyLlnXDc6l_D_KcfVXK7TuXDUJ-jMbGWQQEEzw,5952
5
- python_base_command/utils.py,sha256=Q0U8uU1YgMJdeP4IpCktsI8dSjqgcF9nUnMTxOvqL0o,1320
6
- python_base_command-0.1.3.dist-info/METADATA,sha256=maTKmH5dbz1W97tAqUl9Bdcp9apdz3POBtTZS79eKy0,10820
7
- python_base_command-0.1.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
- python_base_command-0.1.3.dist-info/licenses/LICENSE,sha256=cSikHY6SZFsPZSBizCDAJ0-Bjjzxt-JtX6TVbKxwimo,1067
9
- python_base_command-0.1.3.dist-info/RECORD,,