Skip to content

Commit 6fdacd3

Browse files
committed
Add mechanism for extending existing spin commands
Closes #242
1 parent 3760320 commit 6fdacd3

File tree

7 files changed

+194
-42
lines changed

7 files changed

+194
-42
lines changed

README.md

+21-36
Original file line numberDiff line numberDiff line change
@@ -201,52 +201,37 @@ 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
211210
from spin.cmds import meson
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+
@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."""
234-
235-
# Do something with the new option
236-
print("The value of custom arg is:", custom_arg)
237-
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"]
225+
Note that `build_extend` receives the parent command callback (the function the `build` command would have executed) as its first argument.
241226

242-
# Call the built-in `build` command, passing along
243-
# all arguments and options.
244-
ctx.forward(meson.build)
227+
The matching entry in `pyproject.toml` is:
245228

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
249229
```
230+
"Build" = [".spin/cmds.py:build_extend"]
231+
```
232+
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.
250235

251236
### Advanced: override Meson CLI
252237

example_pkg/.spin/cmds.py

+13
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)
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

+1-1
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

+13-5
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

+67
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,68 @@ 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(cmd: click.Command, doc: str | None = None) -> Decorator:
109+
"""This is a decorator factory.
110+
111+
The resulting decorator lets the user derive their own command from `cmd`.
112+
The new command can support arguments not supported by `cmd`.
113+
114+
Parameters
115+
----------
116+
cmd : click.Command
117+
Command to extend.
118+
doc : str
119+
Replacement docstring.
120+
The wrapped function's docstring is also appended.
121+
122+
Examples
123+
--------
124+
125+
@click.option("-e", "--extra", help="Extra test flag")
126+
@util.extend_cmd(
127+
spin.cmds.meson.build
128+
)
129+
@extend_cmd(spin.cmds.meson.build)
130+
def build(*args, constant=None, **kwargs):
131+
'''
132+
Some extra documentation related to the constant flag.
133+
'''
134+
...
135+
ctx.forward(spin.cmds.meson.build, *args, **kwargs)
136+
...
137+
138+
"""
139+
my_cmd = copy.copy(cmd)
140+
141+
# This is necessary to ensure that added options do not leak
142+
# to the original command
143+
my_cmd.params = copy.deepcopy(cmd.params)
144+
145+
def decorator(user_func: Callable) -> click.Command:
146+
def callback_with_parent_callback(ctx, *args, **kwargs):
147+
"""Wrap the user callback to receive a
148+
`parent_callback` keyword argument, containing the
149+
callback from the originally wrapped command."""
150+
151+
def parent_cmd(*user_args, **user_kwargs):
152+
ctx.invoke(cmd.callback, *user_args, **user_kwargs)
153+
154+
return user_func(*args, parent_callback=parent_cmd, **kwargs)
155+
156+
my_cmd.callback = click.pass_context(callback_with_parent_callback)
157+
158+
if doc is not None:
159+
my_cmd.help = doc
160+
my_cmd.help = (my_cmd.help or "") + "\n\n" + (user_func.__doc__ or "")
161+
my_cmd.help = my_cmd.help.strip()
162+
163+
my_cmd.name = user_func.__name__.replace("_", "-")
164+
165+
return my_cmd
166+
167+
return decorator

spin/tests/test_extend_command.py

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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_cli_additional_arg(example_pkg):
72+
p = spin("build-ext", "--extra=3")
73+
assert b"Preparing for build with extra=3" in p.stdout
74+
assert b"meson compile" in p.stdout

spin/tests/testutil.py

+5
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)