Skip to content

Commit 5e91741

Browse files
authored
Add mechanism for extending existing spin commands (#248)
Closes #242
2 parents 3760320 + 8a49e3b commit 5e91741

File tree

7 files changed

+216
-42
lines changed

7 files changed

+216
-42
lines changed

README.md

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -201,52 +201,39 @@ click options or function keywords:
201201

202202
### Advanced: adding arguments to built-in commands
203203

204-
Instead of rewriting a command from scratch, a project may want to add a flag to a built-in `spin` command, or perhaps do some pre- or post-processing.
205-
For this, we have to use an internal Click concept called a [context](https://click.palletsprojects.com/en/8.1.x/complex/#contexts).
206-
Fortunately, we don't need to know anything about contexts other than that they allow us to execute commands within commands.
204+
Instead of rewriting a command from scratch, a project may simply want to add a flag to an existing `spin` command, or perhaps do some pre- or post-processing.
205+
For this purpose, we provide the `spin.util.extend_cmd` decorator.
207206

208-
We proceed by duplicating the function header of the existing command, and adding our own flag:
207+
Here, we show how to add a `--extra` flag to the existing `build` function:
209208

210209
```python
211-
from spin.cmds import meson
210+
import spin
212211

213-
# Take this from the built-in implementation, in `spin.cmds.meson.build`:
214212

213+
@click.option("-e", "--extra", help="Extra test flag")
214+
@spin.util.extend_command(spin.cmds.meson.build)
215+
def build_extend(*, parent_callback, extra=None, **kwargs):
216+
"""
217+
This version of build also provides the EXTRA flag, that can be used
218+
to specify an extra integer argument.
219+
"""
220+
print(f"Preparing for build with {extra=}")
221+
parent_callback(**kwargs)
222+
print("Finalizing build...")
223+
```
215224

216-
@click.command()
217-
@click.argument("meson_args", nargs=-1)
218-
@click.option("-j", "--jobs", help="Number of parallel tasks to launch", type=int)
219-
@click.option("--clean", is_flag=True, help="Clean build directory before build")
220-
@click.option(
221-
"-v", "--verbose", is_flag=True, help="Print all build output, even installation"
222-
)
223-
224-
# This is our new option
225-
@click.option("--custom-arg/--no-custom-arg")
226-
227-
# This tells spin that we will need a context, which we
228-
# can use to invoke the built-in command
229-
@click.pass_context
230-
231-
# This is the original function signature, plus our new flag
232-
def build(ctx, meson_args, jobs=None, clean=False, verbose=False, custom_arg=False):
233-
"""Docstring goes here. You may want to copy and customize the original."""
225+
Note that `build_extend` receives the parent command callback (the function the `build` command would have executed) as its first argument.
234226

235-
# Do something with the new option
236-
print("The value of custom arg is:", custom_arg)
227+
The matching entry in `pyproject.toml` is:
237228

238-
# The spin `build` command doesn't know anything about `custom_arg`,
239-
# so don't send it on.
240-
del ctx.params["custom_arg"]
229+
```
230+
"Build" = [".spin/cmds.py:build_extend"]
231+
```
241232

242-
# Call the built-in `build` command, passing along
243-
# all arguments and options.
244-
ctx.forward(meson.build)
233+
The `extend_cmd` decorator also accepts a `doc` argument, for setting the new command's `--help` description.
234+
The function documentation ("This version of build...") is also appended.
245235

246-
# Also see:
247-
# - https://click.palletsprojects.com/en/8.1.x/api/#click.Context.forward
248-
# - https://click.palletsprojects.com/en/8.1.x/api/#click.Context.invoke
249-
```
236+
Finally, `remove_args` is a tuple of arguments that are not inherited from the original command.
250237

251238
### Advanced: override Meson CLI
252239

example_pkg/.spin/cmds.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import click
44

5+
import spin
56
from spin import util
67

78

@@ -33,3 +34,15 @@ def example(flag, test, default_kwd=None):
3334

3435
click.secho("\nTool config is:", fg="yellow")
3536
print(json.dumps(config["tool.spin"], indent=2))
37+
38+
39+
@click.option("-e", "--extra", help="Extra test flag", type=int)
40+
@util.extend_command(spin.cmds.meson.build, remove_args=("gcov",))
41+
def build_ext(*, parent_callback, extra=None, **kwargs):
42+
"""
43+
This version of build also provides the EXTRA flag, that can be used
44+
to specify an extra integer argument.
45+
"""
46+
print(f"Preparing for build with {extra=}")
47+
parent_callback(**kwargs)
48+
print("Finalizing build...")

example_pkg/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ package = 'example_pkg'
4343
"spin.cmds.meson.gdb",
4444
"spin.cmds.meson.lldb"
4545
]
46-
"Extensions" = [".spin/cmds.py:example"]
46+
"Extensions" = [".spin/cmds.py:example", ".spin/cmds.py:build_ext"]
4747
"Pip" = [
4848
"spin.cmds.pip.install"
4949
]

spin/__main__.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ def group(ctx):
131131
}
132132
cmd_default_kwargs = toml_config.get("tool.spin.kwargs", {})
133133

134+
custom_module_cache = {}
135+
134136
for section, cmds in config_cmds.items():
135137
for cmd in cmds:
136138
if cmd not in commands:
@@ -147,11 +149,17 @@ def group(ctx):
147149
else:
148150
try:
149151
path, func = cmd.split(":")
150-
spec = importlib.util.spec_from_file_location(
151-
"custom_mod", path
152-
)
153-
mod = importlib.util.module_from_spec(spec)
154-
spec.loader.exec_module(mod)
152+
153+
if path not in custom_module_cache:
154+
spec = importlib.util.spec_from_file_location(
155+
"custom_mod", path
156+
)
157+
mod = importlib.util.module_from_spec(spec)
158+
spec.loader.exec_module(mod)
159+
custom_module_cache[path] = mod
160+
else:
161+
mod = custom_module_cache[path]
162+
155163
except FileNotFoundError:
156164
print(
157165
f"!! Could not find file `{path}` to load custom command `{cmd}`.\n"

spin/cmds/util.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
annotations, # noqa: F401 # TODO: remove once only >3.8 is supported
33
)
44

5+
import copy
56
import os
67
import shlex
78
import subprocess
89
import sys
10+
from collections.abc import Callable
911

1012
import click
1113

@@ -98,3 +100,79 @@ def get_commands():
98100
``commands`` key.
99101
"""
100102
return click.get_current_context().meta["commands"]
103+
104+
105+
Decorator = Callable[[Callable], Callable]
106+
107+
108+
def extend_command(
109+
cmd: click.Command, doc: str | None = None, remove_args: tuple[str] | None = None
110+
) -> Decorator:
111+
"""This is a decorator factory.
112+
113+
The resulting decorator lets the user derive their own command from `cmd`.
114+
The new command can support arguments not supported by `cmd`.
115+
116+
Parameters
117+
----------
118+
cmd : click.Command
119+
Command to extend.
120+
doc : str
121+
Replacement docstring.
122+
The wrapped function's docstring is also appended.
123+
remove_args : tuple of str
124+
List of arguments to remove from the parent command.
125+
These arguments can still be set explicitly by calling
126+
``parent_callback(..., removed_flag=value)``.
127+
128+
Examples
129+
--------
130+
131+
@click.option("-e", "--extra", help="Extra test flag")
132+
@util.extend_cmd(
133+
spin.cmds.meson.build
134+
)
135+
@extend_cmd(spin.cmds.meson.build)
136+
def build(*, parent_callback, extra=None, **kwargs):
137+
'''
138+
Some extra documentation related to the constant flag.
139+
'''
140+
...
141+
parent_callback(**kwargs)
142+
...
143+
144+
"""
145+
my_cmd = copy.copy(cmd)
146+
147+
# This is necessary to ensure that added options do not leak
148+
# to the original command
149+
my_cmd.params = copy.deepcopy(cmd.params)
150+
151+
def decorator(user_func: Callable) -> click.Command:
152+
def callback_with_parent_callback(ctx, *args, **kwargs):
153+
"""Wrap the user callback to receive a
154+
`parent_callback` keyword argument, containing the
155+
callback from the originally wrapped command."""
156+
157+
def parent_cmd(*user_args, **user_kwargs):
158+
ctx.invoke(cmd.callback, *user_args, **user_kwargs)
159+
160+
return user_func(*args, parent_callback=parent_cmd, **kwargs)
161+
162+
my_cmd.callback = click.pass_context(callback_with_parent_callback)
163+
164+
if doc is not None:
165+
my_cmd.help = doc
166+
my_cmd.help = (my_cmd.help or "") + "\n\n" + (user_func.__doc__ or "")
167+
my_cmd.help = my_cmd.help.strip()
168+
169+
my_cmd.name = user_func.__name__.replace("_", "-")
170+
171+
if remove_args:
172+
my_cmd.params = [
173+
param for param in my_cmd.params if param.name not in remove_args
174+
]
175+
176+
return my_cmd
177+
178+
return decorator

spin/tests/test_extend_command.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import click
2+
import pytest
3+
4+
from spin import cmds
5+
from spin.cmds.util import extend_command
6+
7+
from .testutil import get_usage, spin
8+
9+
10+
def test_override_add_option():
11+
@click.option("-e", "--extra", help="Extra test flag")
12+
@extend_command(cmds.meson.build)
13+
def build_ext(*, parent_callback, extra=None, **kwargs):
14+
pass
15+
16+
assert "--extra" in get_usage(build_ext)
17+
assert "--extra" not in get_usage(cmds.meson.build)
18+
19+
20+
def test_doc_setter():
21+
@click.option("-e", "--extra", help="Extra test flag")
22+
@extend_command(cmds.meson.build)
23+
def build_ext(*, parent_callback, extra=None, **kwargs):
24+
"""
25+
Additional docstring
26+
"""
27+
pass
28+
29+
assert "Additional docstring" in get_usage(build_ext)
30+
assert "Additional docstring" not in get_usage(cmds.meson.build)
31+
32+
@extend_command(cmds.meson.build, doc="Hello world")
33+
def build_ext(*, parent_callback, extra=None, **kwargs):
34+
"""
35+
Additional docstring
36+
"""
37+
pass
38+
39+
doc = get_usage(build_ext)
40+
assert "Hello world\n" in doc
41+
assert "\n Additional docstring" in doc
42+
43+
44+
def test_ext_additional_args():
45+
@click.option("-e", "--extra", help="Extra test flag", type=int)
46+
@extend_command(cmds.meson.build)
47+
def build_ext(*, parent_callback, extra=None, **kwargs):
48+
"""
49+
Additional docstring
50+
"""
51+
assert extra == 5
52+
53+
ctx = build_ext.make_context(
54+
None,
55+
[
56+
"--extra=5",
57+
],
58+
)
59+
ctx.forward(build_ext)
60+
61+
# And ensure that option didn't leak into original command
62+
with pytest.raises(click.exceptions.NoSuchOption):
63+
cmds.meson.build.make_context(
64+
None,
65+
[
66+
"--extra=5",
67+
],
68+
)
69+
70+
71+
def test_ext_remove_arg():
72+
@extend_command(cmds.meson.build, remove_args=("gcov",))
73+
def build_ext(*, parent_callback, extra=None, **kwargs):
74+
pass
75+
76+
assert "gcov" in get_usage(cmds.meson.build)
77+
assert "gcov" not in get_usage(build_ext)
78+
79+
80+
def test_cli_additional_arg(example_pkg):
81+
p = spin("build-ext", "--extra=3")
82+
assert b"Preparing for build with extra=3" in p.stdout
83+
assert b"meson compile" in p.stdout

spin/tests/testutil.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ def stdout(p):
3838

3939
def stderr(p):
4040
return p.stderr.decode("utf-8").strip()
41+
42+
43+
def get_usage(cmd):
44+
ctx = cmd.make_context(None, [])
45+
return cmd.get_help(ctx)

0 commit comments

Comments
 (0)