Skip to content

Commit d2b2436

Browse files
committed
feat(cli): add --extra-yaml to cci task run
cci task run uses a custom RunTaskCommand MultiCommand. It must resolve --extra-yaml before get_task() runs. If it does not, any task defined only in the extra YAML is invisible to the runtime. RunTaskCommand.resolve_command reads --extra-yaml from the raw args list and calls CliRuntime.reload_project_config with the merged YAML. Both --extra-yaml PATH and --extra-yaml=PATH are accepted. The peek happens in resolve_command, not in get_command. Click's MultiCommand protocol calls resolve_command(ctx, args), which then calls get_command(ctx, cmd_name). By the time get_command runs, ctx.args is empty. The remaining args stay in the args parameter passed to resolve_command. We override resolve_command so that we can see --extra-yaml while the project config is still mutable. Adds "extra_yaml" to the specials list in core/tasks.py, alongside debug_before, debug_after, and no_prompt. Click passes extra_yaml as a kwarg on the task subcommand, which merges into task_config.config["options"]. Tasks that use the Pydantic Options form with extra='forbid' would reject it. The specials list already exists to handle exactly this case.
1 parent 220b230 commit d2b2436

3 files changed

Lines changed: 117 additions & 3 deletions

File tree

cumulusci/cli/task.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ def task_info(runtime, task_name, extra_yaml):
121121
click.echo(rst2ansi(doc))
122122

123123

124+
def _peek_extra_yaml(args):
125+
"""Extract --extra-yaml values from args before Click parses them.
126+
127+
Matches both ``--extra-yaml PATH`` and ``--extra-yaml=PATH`` forms.
128+
Returns a tuple suitable for resolve_extra_yaml().
129+
"""
130+
paths = []
131+
i = 0
132+
while i < len(args):
133+
arg = args[i]
134+
if arg == "--extra-yaml":
135+
if i + 1 >= len(args):
136+
raise CumulusCIUsageError("--extra-yaml requires a path argument")
137+
paths.append(args[i + 1])
138+
i += 2
139+
elif arg.startswith("--extra-yaml="):
140+
paths.append(arg.split("=", 1)[1])
141+
i += 1
142+
else:
143+
i += 1
144+
return tuple(paths)
145+
146+
124147
class RunTaskCommand(click.MultiCommand):
125148
# options that are not task specific
126149
global_options = {
@@ -140,13 +163,38 @@ class RunTaskCommand(click.MultiCommand):
140163
"help": "Drops into the Python debugger at task completion.",
141164
"is_flag": True,
142165
},
166+
"extra-yaml": {
167+
"help": (
168+
"Path to an additional YAML file to merge into the project "
169+
"config for this command only. Can be specified multiple times; "
170+
"later files override earlier ones. Also honors "
171+
"CUMULUSCI_EXTRA_YAML env var (colon-separated paths) as a "
172+
"fallback."
173+
),
174+
"is_flag": False,
175+
"multiple": True,
176+
},
143177
}
144178

145179
def list_commands(self, ctx):
146180
runtime = ctx.obj
147181
tasks = runtime.get_available_tasks()
148182
return sorted([t["name"] for t in tasks])
149183

184+
def resolve_command(self, ctx, args):
185+
# Peek at --extra-yaml / CUMULUSCI_EXTRA_YAML before Click resolves
186+
# the task. Click's MultiCommand protocol calls resolve_command ->
187+
# get_command; by the time get_command runs, ctx.args is empty. We
188+
# read from the raw `args` list here so --extra-yaml values are
189+
# available before get_task() runs.
190+
runtime = ctx.obj
191+
if runtime is not None and runtime.project_config is not None:
192+
extra_yaml_paths = _peek_extra_yaml(args)
193+
runtime.reload_project_config(
194+
additional_yaml=resolve_extra_yaml(extra_yaml_paths)
195+
)
196+
return super().resolve_command(ctx, args)
197+
150198
def get_command(self, ctx, task_name):
151199
runtime = ctx.obj
152200
if runtime.project_config is None:
@@ -285,6 +333,7 @@ def _get_default_command_options(self, is_salesforce_task):
285333
click.Option(
286334
param_decls=(f"--{opt_name}",),
287335
is_flag=config["is_flag"],
336+
multiple=config.get("multiple", False),
288337
help=config["help"],
289338
)
290339
)

cumulusci/cli/tests/test_task.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,58 @@ def test_task_run(runtime):
4949
DummyTask._run_task.assert_called_once()
5050

5151

52+
def test_task_run__extra_yaml_applied(runtime, tmp_path):
53+
"""--extra-yaml resolves via resolve_command before get_task runs."""
54+
extra = tmp_path / "extra.yml"
55+
extra.write_text(
56+
"tasks:\n dummy-task:\n description: Overridden from --extra-yaml\n"
57+
)
58+
59+
DummyTask._run_task = Mock()
60+
runtime.reload_project_config = Mock()
61+
multi_cmd = task.RunTaskCommand()
62+
with click.Context(multi_cmd, obj=runtime) as ctx:
63+
multi_cmd.resolve_command(ctx, ["dummy-task", "--extra-yaml", str(extra)])
64+
65+
runtime.reload_project_config.assert_called_once()
66+
call_kwargs = runtime.reload_project_config.call_args.kwargs
67+
assert "Overridden from --extra-yaml" in call_kwargs["additional_yaml"]
68+
69+
70+
def test_task_run__extra_yaml_equals_syntax(runtime, tmp_path):
71+
"""--extra-yaml=<path> form is also accepted."""
72+
extra = tmp_path / "extra.yml"
73+
extra.write_text("tasks:\n dummy-task:\n description: eq-syntax\n")
74+
75+
DummyTask._run_task = Mock()
76+
runtime.reload_project_config = Mock()
77+
multi_cmd = task.RunTaskCommand()
78+
with click.Context(multi_cmd, obj=runtime) as ctx:
79+
multi_cmd.resolve_command(ctx, ["dummy-task", f"--extra-yaml={extra}"])
80+
81+
runtime.reload_project_config.assert_called_once()
82+
assert (
83+
"eq-syntax" in runtime.reload_project_config.call_args.kwargs["additional_yaml"]
84+
)
85+
86+
87+
def test_task_run__no_extra_yaml_still_calls_reload_with_none(runtime):
88+
DummyTask._run_task = Mock()
89+
runtime.reload_project_config = Mock()
90+
multi_cmd = task.RunTaskCommand()
91+
with click.Context(multi_cmd, obj=runtime) as ctx:
92+
multi_cmd.resolve_command(ctx, ["dummy-task"])
93+
94+
runtime.reload_project_config.assert_called_once_with(additional_yaml=None)
95+
96+
97+
def test_task_run__extra_yaml_missing_path_raises(runtime):
98+
multi_cmd = task.RunTaskCommand()
99+
with click.Context(multi_cmd, obj=runtime) as ctx:
100+
with pytest.raises(CumulusCIUsageError, match="requires a path"):
101+
multi_cmd.resolve_command(ctx, ["dummy-task", "--extra-yaml"])
102+
103+
52104
def test_task_run__no_project(runtime):
53105
runtime.project_config = None
54106
runtime.project_config_error = Exception("Broken")
@@ -110,6 +162,18 @@ def test_task_run__list_commands(runtime):
110162
assert commands == ["dummy-derived-task", "dummy-task"]
111163

112164

165+
def test_task_run__list_commands_with_extra_yaml_in_args(runtime, tmp_path):
166+
"""--help paths reach list_commands/format_help directly, not through
167+
resolve_command. --extra-yaml in the arg vector must not break them."""
168+
extra = tmp_path / "extra.yml"
169+
extra.write_text("tasks:\n dummy-task:\n description: from extra\n")
170+
multi_cmd = task.RunTaskCommand()
171+
with click.Context(multi_cmd, obj=runtime) as ctx:
172+
ctx.args = ["--extra-yaml", str(extra)]
173+
commands = multi_cmd.list_commands(ctx)
174+
assert "dummy-task" in commands
175+
176+
113177
def test_format_help(runtime):
114178
runtime.universal_config = Mock()
115179
multi_cmd = task.RunTaskCommand()
@@ -126,11 +190,12 @@ def test_format_help(runtime):
126190

127191
def test_get_default_command_options():
128192
opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=False)
129-
assert len(opts) == 4
193+
assert len(opts) == 5
130194

131195
opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=True)
132-
assert len(opts) == 5
196+
assert len(opts) == 6
133197
assert any([o.name == "org" for o in opts])
198+
assert any([o.name == "extra_yaml" for o in opts])
134199

135200

136201
def test_collect_task_options():

cumulusci/core/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def process_options(option):
158158

159159
if self.Options:
160160
try:
161-
specials = ["debug_before", "debug_after", "no_prompt"]
161+
specials = ["debug_before", "debug_after", "no_prompt", "extra_yaml"]
162162
options_without_specials = {
163163
opt: val for opt, val in self.options.items() if opt not in specials
164164
}

0 commit comments

Comments
 (0)