rhythmos-cli 0.1.0__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.
@@ -0,0 +1,30 @@
1
+ /.venv
2
+ /.ipynb_checkpoints
3
+
4
+ /.pytest_cache
5
+ /.ruff_cache
6
+ /.mypy_cache
7
+ /.tox
8
+ /.direnv
9
+ /.eggs
10
+ /*.egg-info
11
+ /src/*.egg-info
12
+ **/*.egg-info/
13
+
14
+ /dist
15
+ /build
16
+
17
+ /__pycache__
18
+ /*/__pycache__
19
+ **/__pycache__/
20
+
21
+ !/.gitignore
22
+
23
+ /.vscode
24
+ /tests/temp
25
+
26
+ .env
27
+ data/**/*.yml
28
+
29
+ .DS_Store
30
+ environment/*.env
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 JoanMbela
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: rhythmos-cli
3
+ Version: 0.1.0
4
+ Summary: A lightweight CLI framework with project-local auto-discovered commands
5
+ Project-URL: Homepage, https://gitlab.com/JoanMbela/rhythmos-cli
6
+ Project-URL: Repository, https://gitlab.com/JoanMbela/rhythmos-cli
7
+ Author-email: mbela imm <mbela.immongault@globalnext.rocks>
8
+ License-Expression: MIT
9
+ License-File: LICENCE
10
+ Keywords: argparse,cli,commands
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+
25
+ # Rhythmos CLI
26
+
27
+ A lightweight CLI framework. Install once, put commands in each project's `commands/` folder.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install rhythmos-cli
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ In any project:
38
+
39
+ ```bash
40
+ rhythmos-cli make hello -d "Say hello to someone"
41
+ # edit commands/hello.py
42
+ rhythmos-cli hello Alice
43
+ ```
44
+
45
+ `rhythmos` is an optional shorthand for the same CLI.
46
+
47
+ `make` and `list` are built-in system commands. If you define a user command with the same name, yours wins and a warning is printed.
48
+
49
+ Commands are loaded from `./commands` (cwd), or from `RHYTHMOS_COMMANDS` if set.
50
+
51
+ ## Write a command
52
+
53
+ ```python
54
+ from rhythmos import BaseCommand
55
+
56
+
57
+ class HelloCommand(BaseCommand):
58
+ def get_description(self):
59
+ return "Say hello to someone"
60
+
61
+ def add_arguments(self):
62
+ self.parser.add_argument("name", help="Name of the person to greet")
63
+
64
+ def handle(self, name):
65
+ print(f"Hello, {name}!")
66
+ ```
67
+
68
+ File `commands/foo_bar.py` → `rhythmos-cli foo-bar`.
69
+
70
+ See `examples/commands/hello.py` for a ready example.
71
+
72
+ ## Develop this package
73
+
74
+ ```bash
75
+ python -m venv .venv
76
+ source .venv/bin/activate
77
+ pip install -e .
78
+ RHYTHMOS_COMMANDS=examples/commands rhythmos-cli hello World
79
+ ```
@@ -0,0 +1,55 @@
1
+ # Rhythmos CLI
2
+
3
+ A lightweight CLI framework. Install once, put commands in each project's `commands/` folder.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install rhythmos-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ In any project:
14
+
15
+ ```bash
16
+ rhythmos-cli make hello -d "Say hello to someone"
17
+ # edit commands/hello.py
18
+ rhythmos-cli hello Alice
19
+ ```
20
+
21
+ `rhythmos` is an optional shorthand for the same CLI.
22
+
23
+ `make` and `list` are built-in system commands. If you define a user command with the same name, yours wins and a warning is printed.
24
+
25
+ Commands are loaded from `./commands` (cwd), or from `RHYTHMOS_COMMANDS` if set.
26
+
27
+ ## Write a command
28
+
29
+ ```python
30
+ from rhythmos import BaseCommand
31
+
32
+
33
+ class HelloCommand(BaseCommand):
34
+ def get_description(self):
35
+ return "Say hello to someone"
36
+
37
+ def add_arguments(self):
38
+ self.parser.add_argument("name", help="Name of the person to greet")
39
+
40
+ def handle(self, name):
41
+ print(f"Hello, {name}!")
42
+ ```
43
+
44
+ File `commands/foo_bar.py` → `rhythmos-cli foo-bar`.
45
+
46
+ See `examples/commands/hello.py` for a ready example.
47
+
48
+ ## Develop this package
49
+
50
+ ```bash
51
+ python -m venv .venv
52
+ source .venv/bin/activate
53
+ pip install -e .
54
+ RHYTHMOS_COMMANDS=examples/commands rhythmos-cli hello World
55
+ ```
@@ -0,0 +1,12 @@
1
+ from rhythmos import BaseCommand
2
+
3
+
4
+ class HelloCommand(BaseCommand):
5
+ def get_description(self):
6
+ return "Say hello to someone"
7
+
8
+ def add_arguments(self):
9
+ self.parser.add_argument("name", help="Name of the person to greet")
10
+
11
+ def handle(self, name):
12
+ print(f"Hello, {name}! Welcome to your custom CLI.")
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rhythmos-cli"
7
+ version = "0.1.0"
8
+ description = "A lightweight CLI framework with project-local auto-discovered commands"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "mbela imm", email = "mbela.immongault@globalnext.rocks" },
14
+ ]
15
+ keywords = ["cli", "argparse", "commands"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://gitlab.com/JoanMbela/rhythmos-cli"
32
+ Repository = "https://gitlab.com/JoanMbela/rhythmos-cli"
33
+
34
+ [project.scripts]
35
+ rhythmos-cli = "rhythmos.cli:main"
36
+ rhythmos = "rhythmos.cli:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/rhythmos"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = [
43
+ "/src",
44
+ "/examples",
45
+ "/README.md",
46
+ "/LICENCE",
47
+ ]
@@ -0,0 +1,6 @@
1
+ """Rhythmos — a lightweight project CLI framework."""
2
+
3
+ from rhythmos.base_command import BaseCommand
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["BaseCommand", "__version__"]
@@ -0,0 +1,4 @@
1
+ from rhythmos.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,19 @@
1
+ import argparse
2
+
3
+
4
+ class BaseCommand:
5
+ def __init__(self):
6
+ self.parser = argparse.ArgumentParser(description=self.get_description())
7
+ self.add_arguments()
8
+
9
+ def get_description(self):
10
+ return "A command for the CLI"
11
+
12
+ def add_arguments(self):
13
+ pass
14
+
15
+ def handle(self, *args, **options):
16
+ raise NotImplementedError("You must implement the handle method.")
17
+
18
+ def run(self, namespace):
19
+ self.handle(**vars(namespace))
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from rhythmos.base_command import BaseCommand
5
+ from rhythmos.discovery import commands_dir, discover_commands, invoked_as
6
+ from rhythmos.system_commands import SYSTEM_COMMANDS
7
+
8
+
9
+ def resolve_commands() -> dict[str, type[BaseCommand]]:
10
+ """Merge system + user commands. User commands win on name clashes."""
11
+ user = discover_commands()
12
+ for name in user:
13
+ if name in SYSTEM_COMMANDS:
14
+ print(
15
+ f"Warning: user command '{name}' overrides system command '{name}'",
16
+ file=sys.stderr,
17
+ )
18
+ return {**SYSTEM_COMMANDS, **user}
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> None:
22
+ argv = list(sys.argv[1:] if argv is None else argv)
23
+ cli = invoked_as()
24
+
25
+ parser = argparse.ArgumentParser(prog=cli, description="Rhythmos CLI")
26
+ subparsers = parser.add_subparsers(
27
+ dest="command", help="Command to execute", required=True
28
+ )
29
+
30
+ commands = resolve_commands()
31
+ command_instances = {}
32
+
33
+ for command_name, command_class in commands.items():
34
+ command_instance = command_class()
35
+ command_instances[command_name] = command_instance
36
+ subparsers.add_parser(
37
+ command_name,
38
+ help=command_instance.get_description(),
39
+ parents=[command_instance.parser],
40
+ conflict_handler="resolve",
41
+ )
42
+
43
+ try:
44
+ args = parser.parse_args(argv)
45
+ command_name = args.command
46
+
47
+ if command_name not in command_instances:
48
+ print(f"Command '{command_name}' not found.")
49
+ print(f"Looking in: {commands_dir()}")
50
+ print(f"Create one with: {cli} make <name>")
51
+ raise SystemExit(1)
52
+
53
+ args_dict = vars(args)
54
+ args_dict.pop("command", None)
55
+ command_instances[command_name].run(argparse.Namespace(**args_dict))
56
+
57
+ except KeyboardInterrupt:
58
+ print("\nOPERATION CANCELLED.\n")
59
+ raise SystemExit(130) from None
60
+
61
+ except SystemExit:
62
+ raise
63
+
64
+ except Exception as e:
65
+ print(f"\nERROR: {str(e)}\n")
66
+ raise SystemExit(1) from e
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,94 @@
1
+ import importlib.util
2
+ import inspect
3
+ import os
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from rhythmos.base_command import BaseCommand
9
+
10
+ COMMANDS_DIR_ENV = "RHYTHMOS_COMMANDS"
11
+ DEFAULT_COMMANDS_DIR = "commands"
12
+ SKIP_STEMS = {"__init__", "base_command"}
13
+
14
+
15
+ def commands_dir() -> Path:
16
+ override = os.environ.get(COMMANDS_DIR_ENV)
17
+ if override:
18
+ return Path(override).expanduser().resolve()
19
+ return (Path.cwd() / DEFAULT_COMMANDS_DIR).resolve()
20
+
21
+
22
+ def invoked_as() -> str:
23
+ name = Path(sys.argv[0]).name
24
+ return name if name else "rhythmos-cli"
25
+
26
+
27
+ def _load_module(path: Path):
28
+ module_name = f"rhythmos_user_commands.{path.stem}"
29
+ spec = importlib.util.spec_from_file_location(module_name, path)
30
+ if spec is None or spec.loader is None:
31
+ raise ImportError(f"Could not load {path}")
32
+ module = importlib.util.module_from_spec(spec)
33
+ sys.modules[module_name] = module
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ def discover_commands(directory: Path | None = None) -> dict[str, type[BaseCommand]]:
39
+ directory = directory or commands_dir()
40
+ found: dict[str, type[BaseCommand]] = {}
41
+
42
+ if not directory.is_dir():
43
+ return found
44
+
45
+ for file in sorted(directory.glob("*.py")):
46
+ if file.stem in SKIP_STEMS or file.stem.startswith("_"):
47
+ continue
48
+
49
+ try:
50
+ module = _load_module(file)
51
+ except Exception as e:
52
+ print(f"Warning: Could not load command from {file}: {e}", file=sys.stderr)
53
+ continue
54
+
55
+ for _, obj in inspect.getmembers(module):
56
+ if (
57
+ inspect.isclass(obj)
58
+ and issubclass(obj, BaseCommand)
59
+ and obj is not BaseCommand
60
+ ):
61
+ found[file.stem.replace("_", "-")] = obj
62
+ break
63
+
64
+ return found
65
+
66
+
67
+ def to_snake(name: str) -> str:
68
+ name = name.replace("-", "_")
69
+ name = re.sub(r"(?<!^)(?=[A-Z])", "_", name)
70
+ return re.sub(r"[^a-zA-Z0-9_]", "_", name).lower().strip("_")
71
+
72
+
73
+ def to_class_name(snake: str) -> str:
74
+ return "".join(part.capitalize() for part in snake.split("_") if part)
75
+
76
+
77
+ def scaffold_command(name: str, description: str = "A command for the CLI") -> Path:
78
+ directory = commands_dir()
79
+ directory.mkdir(parents=True, exist_ok=True)
80
+
81
+ snake = to_snake(name)
82
+ if not snake:
83
+ raise ValueError("Command name is empty")
84
+
85
+ target = directory / f"{snake}.py"
86
+ if target.exists():
87
+ raise FileExistsError(f"Command already exists: {target}")
88
+
89
+ stub = Path(__file__).parent / "stubs" / "command.py.stub"
90
+ contents = stub.read_text(encoding="utf-8")
91
+ contents = contents.replace("{{ClassName}}", to_class_name(snake))
92
+ contents = contents.replace("{{description}}", description)
93
+ target.write_text(contents, encoding="utf-8")
94
+ return target
@@ -0,0 +1,13 @@
1
+ from rhythmos import BaseCommand
2
+
3
+
4
+ class {{ClassName}}Command(BaseCommand):
5
+ def get_description(self):
6
+ return "{{description}}"
7
+
8
+ def add_arguments(self):
9
+ # self.parser.add_argument("name", help="...")
10
+ pass
11
+
12
+ def handle(self, **options):
13
+ raise NotImplementedError("Implement this command.")
@@ -0,0 +1,10 @@
1
+ from rhythmos.base_command import BaseCommand
2
+ from rhythmos.system_commands.list import ListCommand
3
+ from rhythmos.system_commands.make import MakeCommand
4
+
5
+ SYSTEM_COMMANDS: dict[str, type[BaseCommand]] = {
6
+ "make": MakeCommand,
7
+ "list": ListCommand,
8
+ }
9
+
10
+ __all__ = ["SYSTEM_COMMANDS", "MakeCommand", "ListCommand"]
@@ -0,0 +1,21 @@
1
+ from rhythmos.base_command import BaseCommand
2
+ from rhythmos.discovery import commands_dir, discover_commands
3
+
4
+
5
+ class ListCommand(BaseCommand):
6
+ """System command: list system and user commands."""
7
+
8
+ def get_description(self):
9
+ return "List all available commands"
10
+
11
+ def handle(self):
12
+ from rhythmos.system_commands import SYSTEM_COMMANDS
13
+
14
+ user = discover_commands()
15
+ merged = {**SYSTEM_COMMANDS, **user}
16
+ print(f"Commands directory: {commands_dir()}")
17
+ print()
18
+ for name in sorted(merged):
19
+ origin = "user" if name in user else "system"
20
+ description = merged[name]().get_description()
21
+ print(f" {name:<16} [{origin:<6}] {description}")
@@ -0,0 +1,25 @@
1
+ from rhythmos.base_command import BaseCommand
2
+ from rhythmos.discovery import invoked_as, scaffold_command
3
+
4
+
5
+ class MakeCommand(BaseCommand):
6
+ """System command: scaffold a user command in the project."""
7
+
8
+ def get_description(self):
9
+ return "Create a command in this project's commands/"
10
+
11
+ def add_arguments(self):
12
+ self.parser.add_argument("name", help="Command name (snake or kebab case)")
13
+ self.parser.add_argument(
14
+ "-d",
15
+ "--description",
16
+ default="A command for the CLI",
17
+ help="Short help text for the command",
18
+ )
19
+
20
+ def handle(self, name, description="A command for the CLI"):
21
+ cli = invoked_as()
22
+ path = scaffold_command(name, description)
23
+ kebab = path.stem.replace("_", "-")
24
+ print(f"Created {path}")
25
+ print(f"Run it with: {cli} {kebab}")