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.
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/.gitignore +7 -3
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/PKG-INFO +13 -6
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/README.md +9 -4
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/pyproject.toml +19 -2
- rhythmos_cli-26.10.5/src/rhythmos/__init__.py +16 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/__main__.py +1 -1
- rhythmos_cli-26.10.5/src/rhythmos/_version.py +24 -0
- rhythmos_cli-26.10.5/src/rhythmos/argument.py +36 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/base_command.py +13 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/cli.py +18 -3
- rhythmos_cli-26.10.5/tests/test_arguments.py +131 -0
- rhythmos_cli-26.10.5/tests/test_cli.py +118 -0
- rhythmos_cli-26.8.2/src/rhythmos/__init__.py +0 -6
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/LICENCE +0 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/examples/commands/hello.py +0 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/discovery.py +0 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/stubs/command.py.stub +0 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/system_commands/__init__.py +0 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/system_commands/list.py +0 -0
- {rhythmos_cli-26.8.2 → rhythmos_cli-26.10.5}/src/rhythmos/system_commands/make.py +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/.venv
|
|
2
2
|
/.ipynb_checkpoints
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: rhythmos-cli
|
|
3
|
-
Version: 26.
|
|
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
|
-
|
|
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
|
|
87
|
-
git
|
|
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
|
-
|
|
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
|
|
63
|
-
git
|
|
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
|
-
|
|
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__"]
|
|
@@ -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
|
-
|
|
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(
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|