rhythmos-cli 26.8.2__tar.gz → 26.10.5__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.
@@ -1,8 +1,10 @@
1
1
  /.venv
2
2
  /.ipynb_checkpoints
3
3
 
4
- /.pytest_cache
5
- /.ruff_cache
4
+ # Generated by hatch-vcs on build / editable install
5
+ /src/rhythmos/_version.py
6
+
7
+ /.pytest_cache/.ruff_cache
6
8
  /.mypy_cache
7
9
  /.tox
8
10
  /.direnv
@@ -27,4 +29,6 @@
27
29
  data/**/*.yml
28
30
 
29
31
  .DS_Store
30
- environment/*.env
32
+ environment/*.env
33
+
34
+ .cursor/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: rhythmos-cli
3
- Version: 26.8.2
3
+ Version: 26.10.5
4
4
  Summary: A lightweight CLI framework with project-local auto-discovered commands
5
5
  Project-URL: Homepage, https://gitlab.com/JoanMbela/rhythmos-cli
6
6
  Project-URL: Repository, https://gitlab.com/JoanMbela/rhythmos-cli
@@ -20,6 +20,8 @@ Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Requires-Python: >=3.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
25
  Description-Content-Type: text/markdown
24
26
 
25
27
  # Rhythmos CLI
@@ -74,15 +76,20 @@ See `examples/commands/hello.py` for a ready example.
74
76
  ```bash
75
77
  python -m venv .venv
76
78
  source .venv/bin/activate
77
- pip install -e .
79
+ pip install -e ".[dev]"
78
80
  RHYTHMOS_COMMANDS=examples/commands rhythmos-cli hello World
81
+ pytest
79
82
  ```
80
83
 
81
84
  ## Release (GitLab CI)
82
85
 
83
- Publishes to PyPI when you push a version tag matching the version in `pyproject.toml`.
86
+ Version comes from **git tags** (no manual edits in `pyproject.toml`). Pushing a tag builds and publishes that version:
84
87
 
85
88
  ```bash
86
- git tag v0.1.1
87
- git push origin v0.1.1
89
+ git add -A && git commit -m "..."
90
+ git tag v26.10.2
91
+ git push origin v26.10.2
88
92
  ```
93
+
94
+ Requires CI variable `PYPI_TOKEN` (masked + protected) and protected tags `v*`.
95
+ If a tag pipeline fails, push a **new** tag — protected tags usually cannot be deleted via `git`.
@@ -50,15 +50,20 @@ See `examples/commands/hello.py` for a ready example.
50
50
  ```bash
51
51
  python -m venv .venv
52
52
  source .venv/bin/activate
53
- pip install -e .
53
+ pip install -e ".[dev]"
54
54
  RHYTHMOS_COMMANDS=examples/commands rhythmos-cli hello World
55
+ pytest
55
56
  ```
56
57
 
57
58
  ## Release (GitLab CI)
58
59
 
59
- Publishes to PyPI when you push a version tag matching the version in `pyproject.toml`.
60
+ Version comes from **git tags** (no manual edits in `pyproject.toml`). Pushing a tag builds and publishes that version:
60
61
 
61
62
  ```bash
62
- git tag v0.1.1
63
- git push origin v0.1.1
63
+ git add -A && git commit -m "..."
64
+ git tag v26.10.2
65
+ git push origin v26.10.2
64
66
  ```
67
+
68
+ Requires CI variable `PYPI_TOKEN` (masked + protected) and protected tags `v*`.
69
+ If a tag pipeline fails, push a **new** tag — protected tags usually cannot be deleted via `git`.
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling", "hatch-vcs"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rhythmos-cli"
7
- version = "26.8.2"
7
+ dynamic = ["version"]
8
8
  description = "A lightweight CLI framework with project-local auto-discovered commands"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -31,10 +31,22 @@ classifiers = [
31
31
  Homepage = "https://gitlab.com/JoanMbela/rhythmos-cli"
32
32
  Repository = "https://gitlab.com/JoanMbela/rhythmos-cli"
33
33
 
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=8.0"]
36
+
34
37
  [project.scripts]
35
38
  rhythmos-cli = "rhythmos.cli:main"
36
39
  rhythmos = "rhythmos.cli:main"
37
40
 
41
+ [tool.hatch.version]
42
+ source = "vcs"
43
+
44
+ [tool.hatch.version.raw-options]
45
+ local_scheme = "no-local-version"
46
+
47
+ [tool.hatch.build.hooks.vcs]
48
+ version-file = "src/rhythmos/_version.py"
49
+
38
50
  [tool.hatch.build.targets.wheel]
39
51
  packages = ["src/rhythmos"]
40
52
 
@@ -42,6 +54,11 @@ packages = ["src/rhythmos"]
42
54
  include = [
43
55
  "/src",
44
56
  "/examples",
57
+ "/tests",
45
58
  "/README.md",
46
59
  "/LICENCE",
47
60
  ]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
64
+ pythonpath = ["src"]
@@ -0,0 +1,16 @@
1
+ """Rhythmos — a lightweight project CLI framework."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from rhythmos.argument import Argument
6
+ from rhythmos.base_command import BaseCommand
7
+
8
+ try:
9
+ __version__ = version("rhythmos-cli")
10
+ except PackageNotFoundError: # pragma: no cover - editable / source tree
11
+ try:
12
+ from rhythmos._version import __version__
13
+ except ImportError:
14
+ __version__ = "0.0.0"
15
+
16
+ __all__ = ["Argument", "BaseCommand", "__version__"]
@@ -1,4 +1,4 @@
1
1
  from rhythmos.cli import main
2
2
 
3
3
  if __name__ == "__main__":
4
- main()
4
+ main(include_system_commands=True)
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '26.10.5'
22
+ __version_tuple__ = version_tuple = (26, 10, 5)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,36 @@
1
+ def _action_name(parser, action):
2
+ registry = parser._registries["action"]
3
+ for name, registered in registry.items():
4
+ if name is not None and type(action) is registered:
5
+ return name
6
+ return type(action)
7
+
8
+
9
+ class Argument:
10
+ """Declaration of one command argument.
11
+
12
+ Names and keywords match argparse's ``add_argument``.
13
+ """
14
+
15
+ def __init__(self, *names, **options):
16
+ self.names = tuple(names)
17
+ self._options = options
18
+
19
+ def apply(self, parser):
20
+ parser.add_argument(*self.names, **self._options)
21
+
22
+ @classmethod
23
+ def from_action(cls, action, parser):
24
+ argument = cls.__new__(cls)
25
+ argument.names = tuple(action.option_strings) or (action.dest,)
26
+ argument.dest = action.dest
27
+ argument.help = action.help
28
+ argument.type = action.type
29
+ argument.default = action.default
30
+ argument.action = _action_name(parser, action)
31
+ argument.nargs = action.nargs
32
+ argument.const = action.const
33
+ argument.choices = action.choices
34
+ argument.required = action.required
35
+ argument.metavar = action.metavar
36
+ return argument
@@ -1,5 +1,7 @@
1
1
  import argparse
2
2
 
3
+ from rhythmos.argument import Argument
4
+
3
5
 
4
6
  class BaseCommand:
5
7
  def __init__(self):
@@ -12,6 +14,17 @@ class BaseCommand:
12
14
  def add_arguments(self):
13
15
  pass
14
16
 
17
+ def add_args(self, *arguments: Argument) -> None:
18
+ for argument in arguments:
19
+ argument.apply(self.parser)
20
+
21
+ def get_arguments(self) -> list[Argument]:
22
+ return [
23
+ Argument.from_action(action, self.parser)
24
+ for action in self.parser._actions
25
+ if not isinstance(action, argparse._HelpAction)
26
+ ]
27
+
15
28
  def handle(self, *args, **options):
16
29
  raise NotImplementedError("You must implement the handle method.")
17
30
 
@@ -5,10 +5,18 @@ from rhythmos.base_command import BaseCommand
5
5
  from rhythmos.discovery import commands_dir, discover_commands, invoked_as
6
6
  from rhythmos.system_commands import SYSTEM_COMMANDS
7
7
 
8
+ OFFICIAL_CLIS = frozenset({"rhythmos", "rhythmos-cli"})
8
9
 
9
- def resolve_commands() -> dict[str, type[BaseCommand]]:
10
+
11
+ def resolve_commands(
12
+ include_system_commands: bool = True,
13
+ ) -> dict[str, type[BaseCommand]]:
10
14
  """Merge system + user commands. User commands win on name clashes."""
11
15
  user = discover_commands()
16
+
17
+ if not include_system_commands:
18
+ return {**user}
19
+
12
20
  for name in user:
13
21
  if name in SYSTEM_COMMANDS:
14
22
  print(
@@ -18,16 +26,23 @@ def resolve_commands() -> dict[str, type[BaseCommand]]:
18
26
  return {**SYSTEM_COMMANDS, **user}
19
27
 
20
28
 
21
- def main(argv: list[str] | None = None) -> None:
29
+ def main(
30
+ argv: list[str] | None = None,
31
+ *,
32
+ include_system_commands: bool | None = None,
33
+ ) -> None:
22
34
  argv = list(sys.argv[1:] if argv is None else argv)
23
35
  cli = invoked_as()
36
+ if include_system_commands is None:
37
+ # Official entry points expose make/list; project wrappers do not by default.
38
+ include_system_commands = cli in OFFICIAL_CLIS
24
39
 
25
40
  parser = argparse.ArgumentParser(prog=cli, description="Rhythmos CLI")
26
41
  subparsers = parser.add_subparsers(
27
42
  dest="command", help="Command to execute", required=True
28
43
  )
29
44
 
30
- commands = resolve_commands()
45
+ commands = resolve_commands(include_system_commands)
31
46
  command_instances = {}
32
47
 
33
48
  for command_name, command_class in commands.items():
@@ -0,0 +1,131 @@
1
+ import argparse
2
+
3
+ from rhythmos import Argument, BaseCommand
4
+
5
+
6
+ class CountCommand(BaseCommand):
7
+ def add_arguments(self):
8
+ self.add_args(
9
+ Argument("count", type=int),
10
+ Argument("--label", default="hi", help="label"),
11
+ )
12
+
13
+
14
+ def test_add_args_parses_positional_and_optional():
15
+ command = CountCommand()
16
+
17
+ parsed = command.parser.parse_args(["3"])
18
+ assert parsed.count == 3
19
+ assert parsed.label == "hi"
20
+
21
+ parsed = command.parser.parse_args(["3", "--label", "yo"])
22
+ assert parsed.count == 3
23
+ assert parsed.label == "yo"
24
+
25
+
26
+ def test_get_arguments_projects_the_batch():
27
+ arguments = CountCommand().get_arguments()
28
+
29
+ assert len(arguments) == 2
30
+ count, label = arguments
31
+
32
+ assert count.names == ("count",)
33
+ assert count.dest == "count"
34
+ assert count.help is None
35
+ assert count.type is int
36
+ assert count.default is None
37
+ assert count.action == "store"
38
+
39
+ assert label.names == ("--label",)
40
+ assert label.dest == "label"
41
+ assert label.help == "label"
42
+ assert label.type is None
43
+ assert label.default == "hi"
44
+ assert label.action == "store"
45
+
46
+ assert BaseCommand().get_arguments() == []
47
+
48
+
49
+ class CustomAction(argparse.Action):
50
+ def __call__(self, parser, namespace, values, option_string=None):
51
+ setattr(namespace, self.dest, values)
52
+
53
+
54
+ class MixedCommand(BaseCommand):
55
+ def add_arguments(self):
56
+ self.add_args(Argument("name", help="who"))
57
+ self.parser.add_argument("--flag", action="store_true")
58
+ self.add_args(Argument("--label", default="x", help="label"))
59
+ self.parser.add_argument("--custom", action=CustomAction)
60
+
61
+
62
+ def test_get_arguments_includes_both_registration_paths():
63
+ arguments = MixedCommand().get_arguments()
64
+ name, flag, label, custom = arguments
65
+
66
+ assert [argument.names for argument in arguments] == [
67
+ ("name",),
68
+ ("--flag",),
69
+ ("--label",),
70
+ ("--custom",),
71
+ ]
72
+ assert name.dest == "name"
73
+ assert name.help == "who"
74
+ assert name.action == "store"
75
+ assert name.default is None
76
+
77
+ assert flag.dest == "flag"
78
+ assert flag.help is None
79
+ assert flag.action == "store_true"
80
+ assert flag.default is False
81
+
82
+ assert label.dest == "label"
83
+ assert label.help == "label"
84
+ assert label.default == "x"
85
+ assert label.action == "store"
86
+
87
+ assert custom.dest == "custom"
88
+ assert custom.action is CustomAction
89
+ assert all(
90
+ name not in {"-h", "--help"}
91
+ for argument in arguments
92
+ for name in argument.names
93
+ )
94
+
95
+
96
+ class KeywordCommand(BaseCommand):
97
+ def add_arguments(self):
98
+ self.add_args(
99
+ Argument("name"),
100
+ Argument("--flag", action="store_true"),
101
+ )
102
+ self.parser.add_argument(
103
+ "--choice",
104
+ choices=("a", "b"),
105
+ metavar="X",
106
+ nargs="?",
107
+ const="a",
108
+ required=True,
109
+ )
110
+
111
+
112
+ def test_get_arguments_projects_resolved_keywords():
113
+ name, flag, choice = KeywordCommand().get_arguments()
114
+
115
+ assert name.nargs is None
116
+ assert name.const is None
117
+ assert name.choices is None
118
+ assert name.required is True
119
+ assert name.metavar is None
120
+
121
+ assert flag.nargs == 0
122
+ assert flag.const is True
123
+ assert flag.choices is None
124
+ assert flag.required is False
125
+ assert flag.metavar is None
126
+
127
+ assert choice.nargs == "?"
128
+ assert choice.const == "a"
129
+ assert choice.choices == ("a", "b")
130
+ assert choice.required is True
131
+ assert choice.metavar == "X"
@@ -0,0 +1,118 @@
1
+ import pytest
2
+
3
+ from rhythmos.base_command import BaseCommand
4
+ from rhythmos.cli import main, resolve_commands
5
+ from rhythmos.discovery import (
6
+ COMMANDS_DIR_ENV,
7
+ discover_commands,
8
+ scaffold_command,
9
+ to_class_name,
10
+ to_snake,
11
+ )
12
+ from rhythmos.system_commands import SYSTEM_COMMANDS
13
+ from rhythmos.system_commands.list import ListCommand
14
+ from rhythmos.system_commands.make import MakeCommand
15
+
16
+
17
+ HELLO_CMD = """\
18
+ from rhythmos import BaseCommand
19
+
20
+
21
+ class HelloCommand(BaseCommand):
22
+ def get_description(self):
23
+ return "Say hello"
24
+
25
+ def add_arguments(self):
26
+ self.parser.add_argument("name")
27
+
28
+ def handle(self, name):
29
+ print(f"Hello, {name}!")
30
+ """
31
+
32
+
33
+ @pytest.fixture
34
+ def commands_home(tmp_path, monkeypatch):
35
+ directory = tmp_path / "commands"
36
+ directory.mkdir()
37
+ monkeypatch.setenv(COMMANDS_DIR_ENV, str(directory))
38
+ return directory
39
+
40
+
41
+ def test_to_snake_and_class_name():
42
+ assert to_snake("HelloWorld") == "hello_world"
43
+ assert to_snake("foo-bar") == "foo_bar"
44
+ assert to_class_name("foo_bar") == "FooBar"
45
+
46
+
47
+ def test_discover_commands_loads_user_modules(commands_home):
48
+ (commands_home / "hello.py").write_text(HELLO_CMD, encoding="utf-8")
49
+ found = discover_commands()
50
+ assert set(found) == {"hello"}
51
+ assert issubclass(found["hello"], BaseCommand)
52
+
53
+
54
+ def test_resolve_commands_excludes_system_when_disabled(commands_home):
55
+ (commands_home / "hello.py").write_text(HELLO_CMD, encoding="utf-8")
56
+ resolved = resolve_commands(include_system_commands=False)
57
+ assert set(resolved) == {"hello"}
58
+ assert "list" not in resolved
59
+ assert "make" not in resolved
60
+
61
+
62
+ def test_resolve_commands_includes_system_when_enabled(commands_home):
63
+ (commands_home / "hello.py").write_text(HELLO_CMD, encoding="utf-8")
64
+ resolved = resolve_commands(include_system_commands=True)
65
+ assert set(resolved) == {"hello", "list", "make"}
66
+ assert resolved["list"] is ListCommand
67
+ assert resolved["make"] is MakeCommand
68
+
69
+
70
+ def test_resolve_commands_user_overrides_system(commands_home, capsys):
71
+ (commands_home / "list.py").write_text(
72
+ """\
73
+ from rhythmos import BaseCommand
74
+
75
+ class ListCommand(BaseCommand):
76
+ def get_description(self):
77
+ return "user list"
78
+
79
+ def handle(self):
80
+ print("user")
81
+ """,
82
+ encoding="utf-8",
83
+ )
84
+ resolved = resolve_commands(include_system_commands=True)
85
+ assert resolved["list"] is not ListCommand
86
+ assert resolved["list"] is not SYSTEM_COMMANDS["list"]
87
+ err = capsys.readouterr().err
88
+ assert "overrides system command 'list'" in err
89
+
90
+
91
+ def test_scaffold_command_writes_stub(commands_home):
92
+ path = scaffold_command("greet", "Greet someone")
93
+ assert path == commands_home / "greet.py"
94
+ text = path.read_text(encoding="utf-8")
95
+ assert "class GreetCommand" in text
96
+ assert "Greet someone" in text
97
+ assert "from rhythmos import BaseCommand" in text
98
+
99
+
100
+ def test_scaffold_command_rejects_duplicates(commands_home):
101
+ scaffold_command("greet")
102
+ with pytest.raises(FileExistsError):
103
+ scaffold_command("greet")
104
+
105
+
106
+ def test_main_with_system_commands_runs_list(commands_home, capsys):
107
+ (commands_home / "hello.py").write_text(HELLO_CMD, encoding="utf-8")
108
+ main(["list"], include_system_commands=True)
109
+ out = capsys.readouterr().out
110
+ assert "hello" in out
111
+ assert "[user" in out
112
+ assert "[system]" in out
113
+
114
+
115
+ def test_main_without_system_commands_rejects_list(commands_home):
116
+ (commands_home / "hello.py").write_text(HELLO_CMD, encoding="utf-8")
117
+ with pytest.raises(SystemExit):
118
+ main(["list"], include_system_commands=False)
@@ -1,6 +0,0 @@
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__"]
File without changes