From a83697664b57688e3a4069d9f43ed55b339b958a Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:35:45 -0700 Subject: [PATCH 01/13] chore: unblock pyright for pre-commit hook simple_salesforce has tightened its __all__ exports, so reading Salesforce/OrderedDict via the top-level namespace now trips pyright's reportPrivateImportUsage. Switch to the canonical submodule imports (simple_salesforce.api) and a setattr() monkey-patch that pyright doesn't flag. Also pin the pyright version in the pre-commit hook so node-side version drift can't silently reintroduce the same class of failure. --- .pre-commit-config.yaml | 2 +- cumulusci/__init__.py | 4 ++-- cumulusci/utils/salesforce/count_sobjects.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71833dd993..9c9b16a321 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,4 +35,4 @@ repos: types: [python] pass_filenames: false additional_dependencies: - - pyright + - pyright@1.1.408 diff --git a/cumulusci/__init__.py b/cumulusci/__init__.py index 2db01717a4..4e526f08c0 100644 --- a/cumulusci/__init__.py +++ b/cumulusci/__init__.py @@ -14,5 +14,5 @@ if sys.version_info < (3, 8): # pragma: no cover raise Exception("CumulusCI requires Python 3.8+.") -api.OrderedDict = dict -bulk.OrderedDict = dict +setattr(api, "OrderedDict", dict) +setattr(bulk, "OrderedDict", dict) diff --git a/cumulusci/utils/salesforce/count_sobjects.py b/cumulusci/utils/salesforce/count_sobjects.py index 216702ae4f..bccd518e05 100644 --- a/cumulusci/utils/salesforce/count_sobjects.py +++ b/cumulusci/utils/salesforce/count_sobjects.py @@ -1,6 +1,6 @@ import typing as T -from simple_salesforce import Salesforce +from simple_salesforce.api import Salesforce from cumulusci.utils.http.multi_request import CompositeParallelSalesforce from cumulusci.utils.iterators import partition From 9d650ace225137aecb8285a06e5cf65283985e51 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:36:58 -0700 Subject: [PATCH 02/13] feat(cli): add resolve_extra_yaml helper for --extra-yaml flag Introduces a pure function that reads one or more paths (from the --extra-yaml flag or CUMULUSCI_EXTRA_YAML env var) and returns the concatenated YAML content as a single string, suitable for passing as BaseProjectConfig's additional_yaml kwarg. Implements the motivating use case from #3725 (thanks @jlantz) with a different API: flag name, multi-file support, env var fallback, and proper Click wiring are all different. --- cumulusci/cli/extra_yaml.py | 57 ++++++++++++++++++++++++++ cumulusci/cli/tests/test_extra_yaml.py | 16 ++++++++ 2 files changed, 73 insertions(+) create mode 100644 cumulusci/cli/extra_yaml.py create mode 100644 cumulusci/cli/tests/test_extra_yaml.py diff --git a/cumulusci/cli/extra_yaml.py b/cumulusci/cli/extra_yaml.py new file mode 100644 index 0000000000..145c10331e --- /dev/null +++ b/cumulusci/cli/extra_yaml.py @@ -0,0 +1,57 @@ +"""Resolve ``--extra-yaml`` CLI flag and ``CUMULUSCI_EXTRA_YAML`` env var. + +The returned string is passed as ``BaseProjectConfig``'s ``additional_yaml`` +kwarg, which already merges into the project config via the existing YAML +merge stack. +""" +import os +from typing import Optional, Tuple + +import click + +from cumulusci.core.exceptions import CumulusCIUsageError + +ENV_VAR = "CUMULUSCI_EXTRA_YAML" + + +def resolve_extra_yaml(paths: Tuple[str, ...]) -> Optional[str]: + """Read extra-yaml paths from the CLI flag (preferred) or env var (fallback). + + Args: + paths: Tuple of paths from Click's ``multiple=True`` option. Empty + means the flag was not supplied; fall back to + ``CUMULUSCI_EXTRA_YAML`` (colon-separated paths). + + Returns: + Concatenated YAML content with ``\\n---\\n`` separators between files, + or ``None`` if no paths were resolved. + + Raises: + CumulusCIUsageError: If any listed path does not exist or is unreadable. + """ + effective_paths = paths + if not effective_paths: + env_value = os.environ.get(ENV_VAR) + if env_value: + effective_paths = tuple(p for p in env_value.split(":") if p) + + if not effective_paths: + return None + + click.echo( + f"Loading extra YAML from: {', '.join(effective_paths)}. " + "Extra YAML can redefine task class_path entries and run arbitrary " + "Python code; only load files you trust.", + err=True, + ) + + contents = [] + for path in effective_paths: + if not os.path.isfile(path): + raise CumulusCIUsageError(f"--extra-yaml file not found: {path}") + try: + with open(path, "r", encoding="utf-8") as f: + contents.append(f.read()) + except OSError as e: + raise CumulusCIUsageError(f"--extra-yaml could not read {path}: {e}") + return "\n---\n".join(contents) diff --git a/cumulusci/cli/tests/test_extra_yaml.py b/cumulusci/cli/tests/test_extra_yaml.py new file mode 100644 index 0000000000..504c72d49a --- /dev/null +++ b/cumulusci/cli/tests/test_extra_yaml.py @@ -0,0 +1,16 @@ +from cumulusci.cli.extra_yaml import resolve_extra_yaml + + +def test_resolve_extra_yaml__none_when_no_input(monkeypatch): + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + assert resolve_extra_yaml(()) is None + + +def test_resolve_extra_yaml__single_file(tmp_path, monkeypatch): + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + p = tmp_path / "extra.yml" + p.write_text("tasks:\n foo:\n description: from file\n") + result = resolve_extra_yaml((str(p),)) + assert result is not None + assert "from file" in result + assert result.startswith("tasks:") From 470c1e957274f9da969492b5dbeccfd3baecf0bf Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:42:37 -0700 Subject: [PATCH 03/13] test(cli): cover resolve_extra_yaml env var and error paths --- cumulusci/cli/extra_yaml.py | 23 ++++++-- cumulusci/cli/tests/test_extra_yaml.py | 73 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/cumulusci/cli/extra_yaml.py b/cumulusci/cli/extra_yaml.py index 145c10331e..926a35eff3 100644 --- a/cumulusci/cli/extra_yaml.py +++ b/cumulusci/cli/extra_yaml.py @@ -8,8 +8,10 @@ from typing import Optional, Tuple import click +import yaml from cumulusci.core.exceptions import CumulusCIUsageError +from cumulusci.core.utils import dictmerge ENV_VAR = "CUMULUSCI_EXTRA_YAML" @@ -23,8 +25,10 @@ def resolve_extra_yaml(paths: Tuple[str, ...]) -> Optional[str]: ``CUMULUSCI_EXTRA_YAML`` (colon-separated paths). Returns: - Concatenated YAML content with ``\\n---\\n`` separators between files, - or ``None`` if no paths were resolved. + A single YAML document representing the deep-merge of all input files + (later files override earlier files), or ``None`` if no paths were + resolved. The returned string is a valid single-document YAML stream + suitable for ``BaseProjectConfig(additional_yaml=...)``. Raises: CumulusCIUsageError: If any listed path does not exist or is unreadable. @@ -45,13 +49,22 @@ def resolve_extra_yaml(paths: Tuple[str, ...]) -> Optional[str]: err=True, ) - contents = [] + merged: dict = {} for path in effective_paths: if not os.path.isfile(path): raise CumulusCIUsageError(f"--extra-yaml file not found: {path}") try: with open(path, "r", encoding="utf-8") as f: - contents.append(f.read()) + raw = f.read() except OSError as e: raise CumulusCIUsageError(f"--extra-yaml could not read {path}: {e}") - return "\n---\n".join(contents) + try: + parsed = yaml.safe_load(raw) or {} + except yaml.YAMLError as e: + raise CumulusCIUsageError(f"--extra-yaml could not parse {path}: {e}") + if not isinstance(parsed, dict): + raise CumulusCIUsageError( + f"--extra-yaml expects a YAML mapping at the top level in {path}" + ) + merged = dictmerge(merged, parsed) + return yaml.safe_dump(merged, default_flow_style=False) diff --git a/cumulusci/cli/tests/test_extra_yaml.py b/cumulusci/cli/tests/test_extra_yaml.py index 504c72d49a..1c42275cbd 100644 --- a/cumulusci/cli/tests/test_extra_yaml.py +++ b/cumulusci/cli/tests/test_extra_yaml.py @@ -1,4 +1,7 @@ +import pytest + from cumulusci.cli.extra_yaml import resolve_extra_yaml +from cumulusci.core.exceptions import CumulusCIUsageError def test_resolve_extra_yaml__none_when_no_input(monkeypatch): @@ -14,3 +17,73 @@ def test_resolve_extra_yaml__single_file(tmp_path, monkeypatch): assert result is not None assert "from file" in result assert result.startswith("tasks:") + + +def test_resolve_extra_yaml__missing_file_raises(tmp_path, monkeypatch): + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + with pytest.raises(CumulusCIUsageError, match="not found"): + resolve_extra_yaml((str(tmp_path / "does_not_exist.yml"),)) + + +def test_resolve_extra_yaml__multiple_files_deep_merged(tmp_path, monkeypatch): + """Multiple files are deep-merged; last file wins on scalar conflicts.""" + import yaml + + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + a = tmp_path / "a.yml" + a.write_text("tasks:\n foo:\n description: from A\n group: alpha\n") + b = tmp_path / "b.yml" + b.write_text("tasks:\n foo:\n description: from B\n") + result = resolve_extra_yaml((str(a), str(b))) + assert result is not None + parsed = yaml.safe_load(result) + # Later file's scalar wins. + assert parsed["tasks"]["foo"]["description"] == "from B" + # Sibling keys from earlier file are preserved (deep merge). + assert parsed["tasks"]["foo"]["group"] == "alpha" + + +def test_resolve_extra_yaml__env_var_fallback(tmp_path, monkeypatch): + p = tmp_path / "env.yml" + p.write_text("project:\n name: env-loaded\n") + monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", str(p)) + result = resolve_extra_yaml(()) + assert result is not None + assert "env-loaded" in result + + +def test_resolve_extra_yaml__env_var_multiple_colon_separated(tmp_path, monkeypatch): + """Env var with multiple paths produces a deep-merged document.""" + import yaml + + a = tmp_path / "a.yml" + a.write_text("tasks:\n a:\n description: from A\n") + b = tmp_path / "b.yml" + b.write_text("tasks:\n b:\n description: from B\n") + monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", f"{a}:{b}") + result = resolve_extra_yaml(()) + assert result is not None + parsed = yaml.safe_load(result) + assert parsed["tasks"]["a"]["description"] == "from A" + assert parsed["tasks"]["b"]["description"] == "from B" + + +def test_resolve_extra_yaml__flag_overrides_env_var(tmp_path, monkeypatch): + flag_file = tmp_path / "flag.yml" + flag_file.write_text("tasks:\n from: flag\n") + env_file = tmp_path / "env.yml" + env_file.write_text("tasks:\n from: env\n") + monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", str(env_file)) + result = resolve_extra_yaml((str(flag_file),)) + assert result is not None + assert "from: flag" in result + assert "from: env" not in result + + +def test_resolve_extra_yaml__empty_env_var_segments_ignored(tmp_path, monkeypatch): + p = tmp_path / "x.yml" + p.write_text("project: {}\n") + monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", f"::{p}::") + result = resolve_extra_yaml(()) + assert result is not None + assert "project: {}" in result From 29c75aac8e61cea2aa88750f2868ac4fb43745b6 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:51:34 -0700 Subject: [PATCH 04/13] feat(cli): add CliRuntime.reload_project_config for post-init overrides Adds a method that rebuilds project_config with an additional_yaml kwarg and re-binds the keychain. Needed because CliRuntime is constructed in cci.main() before Click has parsed subcommand options, so flags like --extra-yaml cannot be applied at construction time. --- cumulusci/cli/runtime.py | 21 ++++++++++++++ cumulusci/cli/tests/test_runtime.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/cumulusci/cli/runtime.py b/cumulusci/cli/runtime.py index 9e596fa7da..3fbfbd4f71 100644 --- a/cumulusci/cli/runtime.py +++ b/cumulusci/cli/runtime.py @@ -3,6 +3,7 @@ import sys from logging import getLogger from subprocess import call +from typing import Optional import click import keyring @@ -26,6 +27,26 @@ def __init__(self, *args, **kwargs): except (KeychainKeyNotFound) as e: raise click.UsageError(f"Keychain Error: {str(e)}") + def reload_project_config(self, additional_yaml: Optional[str] = None) -> None: + """Rebuild project_config with an ``additional_yaml`` override. + + ``CliRuntime`` loads ``project_config`` at construction time (via + ``BaseCumulusCI.__init__``), but Click options are only known after + construction. This method rebuilds ``project_config`` with the given + ``additional_yaml`` string and re-binds the keychain if one is + attached. No-ops when ``additional_yaml`` is ``None``. + """ + if additional_yaml is None: + return + try: + self._load_project_config(additional_yaml=additional_yaml) + except ConfigError as e: + raise click.UsageError(f"Config Error: {str(e)}") + if self.keychain is not None: + self.keychain.project_config = self.project_config + if self.project_config is not None: + self.project_config.keychain = self.keychain + def get_keychain_class(self): default_keychain_class = ( self.project_config.cumulusci__keychain diff --git a/cumulusci/cli/tests/test_runtime.py b/cumulusci/cli/tests/test_runtime.py index e482b4c493..1038b4b257 100644 --- a/cumulusci/cli/tests/test_runtime.py +++ b/cumulusci/cli/tests/test_runtime.py @@ -107,6 +107,51 @@ def test_get_org_missing(self): with pytest.raises(click.UsageError): org_name, org_config_result = config.get_org("test", fail_if_missing=True) + def test_reload_project_config__applies_additional_yaml(self): + config = CliRuntime() + assert config.project_config.config_additional_yaml == {} + + config.reload_project_config( + additional_yaml=( + "tasks:\n" + " injected:\n" + " description: via reload\n" + " class_path: cumulusci.tasks.util.Sleep\n" + ) + ) + + assert config.project_config.config_additional_yaml != {} + assert config.project_config.tasks["injected"]["description"] == "via reload" + + def test_reload_project_config__rebinds_keychain(self): + config = CliRuntime() + old_keychain = config.keychain + assert old_keychain is not None + old_project_config = config.project_config + + config.reload_project_config( + additional_yaml="project:\n custom:\n reloaded: true\n" + ) + + assert config.keychain is old_keychain + assert config.project_config is not old_project_config + assert config.keychain.project_config is config.project_config + assert config.project_config.keychain is config.keychain + + def test_reload_project_config__noop_when_none(self): + config = CliRuntime() + before = config.project_config + config.reload_project_config(additional_yaml=None) + assert config.project_config is before + + def test_reload_project_config__wraps_config_error(self): + config = CliRuntime() + + with mock.patch.object( + config, "_load_project_config", side_effect=ConfigError("boom") + ), pytest.raises(click.UsageError, match="Config Error: boom"): + config.reload_project_config(additional_yaml="project: {}\n") + def test_check_org_expired(self): config = CliRuntime() config.keychain = mock.Mock() From f5a89df36753c0e09598a2367122bacf0143a0b7 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:53:24 -0700 Subject: [PATCH 05/13] feat(cli): add --extra-yaml to cci flow run and cci flow info Click option accepts multiple paths and honors CUMULUSCI_EXTRA_YAML as a fallback. The resolved YAML string is passed to CliRuntime.reload_project_config before flow resolution. --- cumulusci/cli/flow.py | 31 +++++++++++- cumulusci/cli/tests/test_flow.py | 87 +++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/cumulusci/cli/flow.py b/cumulusci/cli/flow.py index 96bd8db9cf..cb8be300e1 100644 --- a/cumulusci/cli/flow.py +++ b/cumulusci/cli/flow.py @@ -5,6 +5,7 @@ import click +from cumulusci.cli.extra_yaml import resolve_extra_yaml from cumulusci.core.exceptions import FlowNotFoundError from cumulusci.core.utils import format_duration from cumulusci.utils import document_flow, flow_ref_title_and_intro @@ -106,8 +107,21 @@ def flow_list(runtime, plain, print_json): @flow.command(name="info", help="Displays information for a flow") @click.argument("flow_name") +@click.option( + "--extra-yaml", + "extra_yaml", + multiple=True, + type=click.Path(), + help=( + "Path to an additional YAML file to merge into the project config " + "for this command only. Can be specified multiple times; later files " + "override earlier ones. Also honors CUMULUSCI_EXTRA_YAML env var " + "(colon-separated paths) as a fallback." + ), +) @pass_runtime(require_keychain=True) -def flow_info(runtime, flow_name): +def flow_info(runtime, flow_name, extra_yaml): + runtime.reload_project_config(additional_yaml=resolve_extra_yaml(extra_yaml)) try: coordinator = runtime.get_flow(flow_name) output = coordinator.get_summary(verbose=True) @@ -141,8 +155,21 @@ def flow_info(runtime, flow_name): is_flag=True, help="Disables all prompts. Set for non-interactive mode use such as calling from scripts or CI systems", ) +@click.option( + "--extra-yaml", + "extra_yaml", + multiple=True, + type=click.Path(), + help=( + "Path to an additional YAML file to merge into the project config " + "for this command only. Can be specified multiple times; later files " + "override earlier ones. Also honors CUMULUSCI_EXTRA_YAML env var " + "(colon-separated paths) as a fallback." + ), +) @pass_runtime(require_keychain=True) -def flow_run(runtime, flow_name, org, delete_org, debug, o, no_prompt): +def flow_run(runtime, flow_name, org, delete_org, debug, o, no_prompt, extra_yaml): + runtime.reload_project_config(additional_yaml=resolve_extra_yaml(extra_yaml)) # Get necessary configs org, org_config = runtime.get_org(org) diff --git a/cumulusci/cli/tests/test_flow.py b/cumulusci/cli/tests/test_flow.py index 7b43b4448f..e0a71c95c1 100644 --- a/cumulusci/cli/tests/test_flow.py +++ b/cumulusci/cli/tests/test_flow.py @@ -64,7 +64,7 @@ def test_flow_info(echo): load_keychain=False, ) - run_click_command(flow.flow_info, runtime=runtime, flow_name="test") + run_click_command(flow.flow_info, runtime=runtime, flow_name="test", extra_yaml=()) echo.assert_called_with( "\nFlow Steps\n1) task: test_task [from current folder]\n options:\n option_name: option_value" @@ -75,7 +75,36 @@ def test_flow_info__not_found(): runtime = mock.Mock() runtime.get_flow.side_effect = FlowNotFoundError with pytest.raises(click.UsageError): - run_click_command(flow.flow_info, runtime=runtime, flow_name="test") + run_click_command( + flow.flow_info, runtime=runtime, flow_name="test", extra_yaml=() + ) + + +def test_flow_info__extra_yaml_applied(tmp_path): + extra = tmp_path / "extra.yml" + extra.write_text( + "flows:\n" + " injected_flow:\n" + " description: injected flow\n" + " steps:\n" + " 1:\n" + " task: util_sleep\n" + ) + runtime = mock.Mock() + runtime.get_flow.return_value.get_summary.return_value = "summary text" + + run_click_command( + flow.flow_info, + runtime=runtime, + flow_name="injected_flow", + extra_yaml=(str(extra),), + ) + + runtime.reload_project_config.assert_called_once() + assert ( + "injected flow" + in runtime.reload_project_config.call_args.kwargs["additional_yaml"] + ) @mock.patch("cumulusci.cli.flow.group_items") @@ -157,6 +186,7 @@ def test_flow_run(): debug=False, o=[("test_task__color", "blue")], no_prompt=True, + extra_yaml=(), ) runtime.get_flow.assert_called_once_with( @@ -165,6 +195,55 @@ def test_flow_run(): org_config.delete_org.assert_called_once() +def test_flow_run__extra_yaml_applied(tmp_path): + extra = tmp_path / "extra.yml" + extra.write_text( + "tasks:\n" + " injected_task:\n" + " description: injected via --extra-yaml\n" + " class_path: cumulusci.tasks.util.Sleep\n" + ) + runtime = mock.Mock() + runtime.get_org.return_value = ("dev", mock.Mock(scratch=False)) + runtime.get_flow.return_value.run.return_value = None + + run_click_command( + flow.flow_run, + runtime=runtime, + flow_name="test_flow", + org="dev", + delete_org=False, + debug=False, + o=(), + no_prompt=True, + extra_yaml=(str(extra),), + ) + + runtime.reload_project_config.assert_called_once() + call_kwargs = runtime.reload_project_config.call_args.kwargs + assert "injected via --extra-yaml" in call_kwargs["additional_yaml"] + + +def test_flow_run__no_extra_yaml_does_not_reload(): + runtime = mock.Mock() + runtime.get_org.return_value = ("dev", mock.Mock(scratch=False)) + runtime.get_flow.return_value.run.return_value = None + + run_click_command( + flow.flow_run, + runtime=runtime, + flow_name="test_flow", + org="dev", + delete_org=False, + debug=False, + o=(), + no_prompt=True, + extra_yaml=(), + ) + + runtime.reload_project_config.assert_called_once_with(additional_yaml=None) + + def test_flow_run__delete_org_when_error_occurs_in_flow(): org_config = mock.Mock(scratch=True, config={}) runtime = CliRuntime( @@ -194,6 +273,7 @@ def test_flow_run__delete_org_when_error_occurs_in_flow(): debug=False, o=[("test_task__color", "blue")], no_prompt=True, + extra_yaml=(), ) runtime.get_flow.assert_called_once_with( @@ -217,6 +297,7 @@ def test_flow_run__option_error(): debug=False, o=[("test_task", "blue")], no_prompt=True, + extra_yaml=(), ) @@ -235,6 +316,7 @@ def test_flow_run__delete_non_scratch(): debug=False, o=None, no_prompt=True, + extra_yaml=(), ) @@ -267,6 +349,7 @@ def test_flow_run__org_delete_error(echo): "debug": False, "no_prompt": True, "o": (("test_task__color", "blue"),), + "extra_yaml": (), } run_click_command(flow.flow_run, **kwargs) From 220b2308a6a840928fb95212c3f7495140b385ea Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:54:10 -0700 Subject: [PATCH 06/13] feat(cli): add --extra-yaml to cci task info --- cumulusci/cli/task.py | 16 +++++++++++++++- cumulusci/cli/tests/test_task.py | 27 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cumulusci/cli/task.py b/cumulusci/cli/task.py index cfbe749b91..badb2deb6b 100644 --- a/cumulusci/cli/task.py +++ b/cumulusci/cli/task.py @@ -5,6 +5,7 @@ from rich.console import Console from rst2ansi import rst2ansi +from cumulusci.cli.extra_yaml import resolve_extra_yaml from cumulusci.core.config import TaskConfig from cumulusci.core.exceptions import CumulusCIUsageError from cumulusci.utils import doc_task @@ -95,8 +96,21 @@ def task_doc(runtime, project=False, write=False): @task.command(name="info", help="Displays information for a task") @click.argument("task_name") +@click.option( + "--extra-yaml", + "extra_yaml", + multiple=True, + type=click.Path(), + help=( + "Path to an additional YAML file to merge into the project config " + "for this command only. Can be specified multiple times; later files " + "override earlier ones. Also honors CUMULUSCI_EXTRA_YAML env var " + "(colon-separated paths) as a fallback." + ), +) @pass_runtime(require_project=False, require_keychain=True) -def task_info(runtime, task_name): +def task_info(runtime, task_name, extra_yaml): + runtime.reload_project_config(additional_yaml=resolve_extra_yaml(extra_yaml)) task_config = ( runtime.project_config.get_task(task_name) if runtime.project_config is not None diff --git a/cumulusci/cli/tests/test_task.py b/cumulusci/cli/tests/test_task.py index 398fb1b8b1..ef77453043 100644 --- a/cumulusci/cli/tests/test_task.py +++ b/cumulusci/cli/tests/test_task.py @@ -252,11 +252,36 @@ def test_task_doc_project_write(doc_task, echo, Path): def test_task_info(doc_task, rst2ansi): runtime = Mock() runtime.project_config.tasks__test = {"options": {}} - run_click_command(task.task_info, runtime=runtime, task_name="test") + run_click_command(task.task_info, runtime=runtime, task_name="test", extra_yaml=()) doc_task.assert_called_once() rst2ansi.assert_called_once() +@patch("cumulusci.cli.task.rst2ansi") +@patch("cumulusci.cli.task.doc_task") +def test_task_info__extra_yaml_applied(doc_task, rst2ansi, tmp_path): + extra = tmp_path / "extra.yml" + extra.write_text( + "tasks:\n" + " injected_task:\n" + " description: injected\n" + " class_path: cumulusci.tasks.util.Sleep\n" + ) + runtime = Mock() + runtime.project_config.tasks__injected_task = {"options": {}} + + run_click_command( + task.task_info, + runtime=runtime, + task_name="injected_task", + extra_yaml=(str(extra),), + ) + + runtime.reload_project_config.assert_called_once() + call_kwargs = runtime.reload_project_config.call_args.kwargs + assert "injected" in call_kwargs["additional_yaml"] + + class SetTrace(Exception): pass From d2b243644d46bff8edbf39bc26393795d5d30e39 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 17:57:42 -0700 Subject: [PATCH 07/13] 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. --- cumulusci/cli/task.py | 49 +++++++++++++++++++++++ cumulusci/cli/tests/test_task.py | 69 +++++++++++++++++++++++++++++++- cumulusci/core/tasks.py | 2 +- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/cumulusci/cli/task.py b/cumulusci/cli/task.py index badb2deb6b..f2f7f14f67 100644 --- a/cumulusci/cli/task.py +++ b/cumulusci/cli/task.py @@ -121,6 +121,29 @@ def task_info(runtime, task_name, extra_yaml): click.echo(rst2ansi(doc)) +def _peek_extra_yaml(args): + """Extract --extra-yaml values from args before Click parses them. + + Matches both ``--extra-yaml PATH`` and ``--extra-yaml=PATH`` forms. + Returns a tuple suitable for resolve_extra_yaml(). + """ + paths = [] + i = 0 + while i < len(args): + arg = args[i] + if arg == "--extra-yaml": + if i + 1 >= len(args): + raise CumulusCIUsageError("--extra-yaml requires a path argument") + paths.append(args[i + 1]) + i += 2 + elif arg.startswith("--extra-yaml="): + paths.append(arg.split("=", 1)[1]) + i += 1 + else: + i += 1 + return tuple(paths) + + class RunTaskCommand(click.MultiCommand): # options that are not task specific global_options = { @@ -140,6 +163,17 @@ class RunTaskCommand(click.MultiCommand): "help": "Drops into the Python debugger at task completion.", "is_flag": True, }, + "extra-yaml": { + "help": ( + "Path to an additional YAML file to merge into the project " + "config for this command only. Can be specified multiple times; " + "later files override earlier ones. Also honors " + "CUMULUSCI_EXTRA_YAML env var (colon-separated paths) as a " + "fallback." + ), + "is_flag": False, + "multiple": True, + }, } def list_commands(self, ctx): @@ -147,6 +181,20 @@ def list_commands(self, ctx): tasks = runtime.get_available_tasks() return sorted([t["name"] for t in tasks]) + def resolve_command(self, ctx, args): + # Peek at --extra-yaml / CUMULUSCI_EXTRA_YAML before Click resolves + # the task. Click's MultiCommand protocol calls resolve_command -> + # get_command; by the time get_command runs, ctx.args is empty. We + # read from the raw `args` list here so --extra-yaml values are + # available before get_task() runs. + runtime = ctx.obj + if runtime is not None and runtime.project_config is not None: + extra_yaml_paths = _peek_extra_yaml(args) + runtime.reload_project_config( + additional_yaml=resolve_extra_yaml(extra_yaml_paths) + ) + return super().resolve_command(ctx, args) + def get_command(self, ctx, task_name): runtime = ctx.obj if runtime.project_config is None: @@ -285,6 +333,7 @@ def _get_default_command_options(self, is_salesforce_task): click.Option( param_decls=(f"--{opt_name}",), is_flag=config["is_flag"], + multiple=config.get("multiple", False), help=config["help"], ) ) diff --git a/cumulusci/cli/tests/test_task.py b/cumulusci/cli/tests/test_task.py index ef77453043..cd8726c955 100644 --- a/cumulusci/cli/tests/test_task.py +++ b/cumulusci/cli/tests/test_task.py @@ -49,6 +49,58 @@ def test_task_run(runtime): DummyTask._run_task.assert_called_once() +def test_task_run__extra_yaml_applied(runtime, tmp_path): + """--extra-yaml resolves via resolve_command before get_task runs.""" + extra = tmp_path / "extra.yml" + extra.write_text( + "tasks:\n dummy-task:\n description: Overridden from --extra-yaml\n" + ) + + DummyTask._run_task = Mock() + runtime.reload_project_config = Mock() + multi_cmd = task.RunTaskCommand() + with click.Context(multi_cmd, obj=runtime) as ctx: + multi_cmd.resolve_command(ctx, ["dummy-task", "--extra-yaml", str(extra)]) + + runtime.reload_project_config.assert_called_once() + call_kwargs = runtime.reload_project_config.call_args.kwargs + assert "Overridden from --extra-yaml" in call_kwargs["additional_yaml"] + + +def test_task_run__extra_yaml_equals_syntax(runtime, tmp_path): + """--extra-yaml= form is also accepted.""" + extra = tmp_path / "extra.yml" + extra.write_text("tasks:\n dummy-task:\n description: eq-syntax\n") + + DummyTask._run_task = Mock() + runtime.reload_project_config = Mock() + multi_cmd = task.RunTaskCommand() + with click.Context(multi_cmd, obj=runtime) as ctx: + multi_cmd.resolve_command(ctx, ["dummy-task", f"--extra-yaml={extra}"]) + + runtime.reload_project_config.assert_called_once() + assert ( + "eq-syntax" in runtime.reload_project_config.call_args.kwargs["additional_yaml"] + ) + + +def test_task_run__no_extra_yaml_still_calls_reload_with_none(runtime): + DummyTask._run_task = Mock() + runtime.reload_project_config = Mock() + multi_cmd = task.RunTaskCommand() + with click.Context(multi_cmd, obj=runtime) as ctx: + multi_cmd.resolve_command(ctx, ["dummy-task"]) + + runtime.reload_project_config.assert_called_once_with(additional_yaml=None) + + +def test_task_run__extra_yaml_missing_path_raises(runtime): + multi_cmd = task.RunTaskCommand() + with click.Context(multi_cmd, obj=runtime) as ctx: + with pytest.raises(CumulusCIUsageError, match="requires a path"): + multi_cmd.resolve_command(ctx, ["dummy-task", "--extra-yaml"]) + + def test_task_run__no_project(runtime): runtime.project_config = None runtime.project_config_error = Exception("Broken") @@ -110,6 +162,18 @@ def test_task_run__list_commands(runtime): assert commands == ["dummy-derived-task", "dummy-task"] +def test_task_run__list_commands_with_extra_yaml_in_args(runtime, tmp_path): + """--help paths reach list_commands/format_help directly, not through + resolve_command. --extra-yaml in the arg vector must not break them.""" + extra = tmp_path / "extra.yml" + extra.write_text("tasks:\n dummy-task:\n description: from extra\n") + multi_cmd = task.RunTaskCommand() + with click.Context(multi_cmd, obj=runtime) as ctx: + ctx.args = ["--extra-yaml", str(extra)] + commands = multi_cmd.list_commands(ctx) + assert "dummy-task" in commands + + def test_format_help(runtime): runtime.universal_config = Mock() multi_cmd = task.RunTaskCommand() @@ -126,11 +190,12 @@ def test_format_help(runtime): def test_get_default_command_options(): opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=False) - assert len(opts) == 4 + assert len(opts) == 5 opts = task.RunTaskCommand()._get_default_command_options(is_salesforce_task=True) - assert len(opts) == 5 + assert len(opts) == 6 assert any([o.name == "org" for o in opts]) + assert any([o.name == "extra_yaml" for o in opts]) def test_collect_task_options(): diff --git a/cumulusci/core/tasks.py b/cumulusci/core/tasks.py index 7c8cffc2fb..575a6a21fc 100644 --- a/cumulusci/core/tasks.py +++ b/cumulusci/core/tasks.py @@ -158,7 +158,7 @@ def process_options(option): if self.Options: try: - specials = ["debug_before", "debug_after", "no_prompt"] + specials = ["debug_before", "debug_after", "no_prompt", "extra_yaml"] options_without_specials = { opt: val for opt, val in self.options.items() if opt not in specials } From 4ed8bc22aee02a48d9938fd1667c6419324158e9 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 18:11:02 -0700 Subject: [PATCH 08/13] test(cli): end-to-end --extra-yaml merge behavior Exercises the full path from resolve_extra_yaml through CliRuntime.reload_project_config into BaseProjectConfig.get_task: - Option override preserves sibling keys (deep merge) - Multi-file last-wins ordering - New task definition via --extra-yaml is resolvable - class_path override imports the replacement class (documents the trust posture rather than preventing it) --- cumulusci/cli/tests/test_extra_yaml.py | 99 ++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/cumulusci/cli/tests/test_extra_yaml.py b/cumulusci/cli/tests/test_extra_yaml.py index 1c42275cbd..ad3d892d1b 100644 --- a/cumulusci/cli/tests/test_extra_yaml.py +++ b/cumulusci/cli/tests/test_extra_yaml.py @@ -1,6 +1,10 @@ +import textwrap +from contextlib import contextmanager + import pytest from cumulusci.cli.extra_yaml import resolve_extra_yaml +from cumulusci.cli.runtime import CliRuntime from cumulusci.core.exceptions import CumulusCIUsageError @@ -87,3 +91,98 @@ def test_resolve_extra_yaml__empty_env_var_segments_ignored(tmp_path, monkeypatc result = resolve_extra_yaml(()) assert result is not None assert "project: {}" in result + + +@contextmanager +def _minimal_project(tmp_path, monkeypatch): + """Create a minimal cci project dir and chdir into it.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".git").mkdir() + (tmp_path / "cumulusci.yml").write_text( + textwrap.dedent( + """\ + minimum_cumulusci_version: '3.0.0' + project: + name: extra_yaml_test + package: + name: ExtraYamlTest + api_version: '58.0' + git: + default_branch: main + tasks: + existing_task: + description: From cumulusci.yml + class_path: cumulusci.tasks.util.Sleep + options: + seconds: 1 + """ + ) + ) + yield tmp_path + + +def test_extra_yaml__overrides_existing_task_option(tmp_path, monkeypatch): + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + with _minimal_project(tmp_path, monkeypatch): + extra = tmp_path / "extra.yml" + extra.write_text("tasks:\n existing_task:\n options:\n seconds: 99\n") + runtime = CliRuntime(load_keychain=False) + runtime.reload_project_config(additional_yaml=resolve_extra_yaml((str(extra),))) + assert runtime.project_config is not None + task_cfg = runtime.project_config.get_task("existing_task") + assert task_cfg.options["seconds"] == 99 + # Description from cumulusci.yml still present (deep merge). + assert task_cfg.description == "From cumulusci.yml" + + +def test_extra_yaml__multi_file_last_wins(tmp_path, monkeypatch): + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + with _minimal_project(tmp_path, monkeypatch): + a = tmp_path / "a.yml" + a.write_text("tasks:\n existing_task:\n options:\n seconds: 10\n") + b = tmp_path / "b.yml" + b.write_text("tasks:\n existing_task:\n options:\n seconds: 20\n") + runtime = CliRuntime(load_keychain=False) + runtime.reload_project_config( + additional_yaml=resolve_extra_yaml((str(a), str(b))) + ) + assert runtime.project_config is not None + task_cfg = runtime.project_config.get_task("existing_task") + assert task_cfg.options["seconds"] == 20 + + +def test_extra_yaml__defines_new_task(tmp_path, monkeypatch): + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + with _minimal_project(tmp_path, monkeypatch): + extra = tmp_path / "extra.yml" + extra.write_text( + "tasks:\n" + " brand_new_task:\n" + " description: defined in extra\n" + " class_path: cumulusci.tasks.util.Sleep\n" + " options:\n" + " seconds: 0\n" + ) + runtime = CliRuntime(load_keychain=False) + runtime.reload_project_config(additional_yaml=resolve_extra_yaml((str(extra),))) + assert runtime.project_config is not None + task_cfg = runtime.project_config.get_task("brand_new_task") + assert task_cfg.description == "defined in extra" + + +def test_extra_yaml__class_path_override_imports_new_class(tmp_path, monkeypatch): + """Extra YAML can swap class_path to any importable class. + + Demonstrates (rather than prevents) the documented trust posture. + """ + monkeypatch.delenv("CUMULUSCI_EXTRA_YAML", raising=False) + with _minimal_project(tmp_path, monkeypatch): + extra = tmp_path / "extra.yml" + extra.write_text( + "tasks:\n existing_task:\n class_path: cumulusci.tasks.util.LogLine\n" + ) + runtime = CliRuntime(load_keychain=False) + runtime.reload_project_config(additional_yaml=resolve_extra_yaml((str(extra),))) + assert runtime.project_config is not None + task_cfg = runtime.project_config.get_task("existing_task") + assert task_cfg.class_path == "cumulusci.tasks.util.LogLine" From 7d0c39e5e3f91a9c2dac0854daa7dfc053e40825 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 18:12:19 -0700 Subject: [PATCH 09/13] test(cli): -o overrides --extra-yaml for the same task option --- cumulusci/cli/tests/test_task.py | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cumulusci/cli/tests/test_task.py b/cumulusci/cli/tests/test_task.py index cd8726c955..498495d1b0 100644 --- a/cumulusci/cli/tests/test_task.py +++ b/cumulusci/cli/tests/test_task.py @@ -101,6 +101,48 @@ def test_task_run__extra_yaml_missing_path_raises(runtime): multi_cmd.resolve_command(ctx, ["dummy-task", "--extra-yaml"]) +def test_task_run__dash_o_overrides_extra_yaml(runtime): + """``-o taskname__option value`` wins over ``--extra-yaml``. + + Extra YAML merges into the project config at load time; ``-o`` options + are applied to ``task_config.config["options"]`` at invocation time + (see ``task.py`` -- after ``reload_project_config``). The final task + receives the ``-o`` value. + + ``reload_project_config`` is mocked because the fixture builds a fake + project in-memory; the relevant assertion is that ``-o`` wins over any + option already present in ``task_config.config['options']`` regardless + of whether that value came from ``cumulusci.yml`` or from extra YAML. + """ + # Pretend the extra YAML already set tasks.dummy-task.options.color + # (the runtime fixture lets us just pre-populate the config). + runtime.project_config.config["tasks"]["dummy-task"]["options"] = { + "color": "red-from-extra-yaml", + } + runtime.reload_project_config = Mock() + + captured = {} + + def _capture_options(self): + captured["color"] = self.options["color"] + + DummyTask._run_task = _capture_options + multi_cmd = task.RunTaskCommand() + with click.Context(multi_cmd, obj=runtime) as ctx: + cmd = multi_cmd.get_command(ctx, "dummy-task") + cmd.callback( + runtime, + "dummy-task", + o=(("color", "blue"),), + no_prompt=False, + debug=False, + debug_before=False, + debug_after=False, + ) + + assert captured["color"] == "blue" + + def test_task_run__no_project(runtime): runtime.project_config = None runtime.project_config_error = Exception("Broken") From 616614217766c2be2d6d003d6971321ba7473b79 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 18:12:44 -0700 Subject: [PATCH 10/13] docs(cli): document --extra-yaml flag and CUMULUSCI_EXTRA_YAML --- docs/cli.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/cli.md b/docs/cli.md index 383928ab20..4e5bb350f6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -480,6 +480,35 @@ All CumulusCI commands can be passed the `--debug` flag, so that: To exit a debugging session, type the command `quit` or `exit`. ``` +### The `--extra-yaml` Flag + +`cci flow run`, `cci flow info`, `cci task run`, and `cci task info` +accept a `--extra-yaml PATH` option that merges an additional YAML file +into the project config for that single command. The flag can be +repeated; later files override earlier ones via deep merge. + +```console +$ cci task run my_task --extra-yaml migrations/v2.yml +$ cci flow run dev_org --extra-yaml base.yml --extra-yaml override.yml +``` + +The `CUMULUSCI_EXTRA_YAML` environment variable (colon-separated paths) +supplies a default when the flag is absent. When both are set, the flag +wins; they are not merged. + +Extra YAML is merged using the same deep-merge semantics as the project +`cumulusci.yml`. Mappings and scalars are overridden; lists are +concatenated, not replaced. See +[Configuration Scopes](config.md#configuration-scopes) for details. +Per-option overrides via `-o taskname__option value` still win over +extra YAML. + +```{warning} +Extra YAML can redefine any `class_path` entry and therefore trigger +arbitrary Python imports when the task runs. Only load files you trust. +A stderr warning is printed each time the flag is used. +``` + ### Log Files CumulusCI creates a log file every time a cci command runs. There are From 2823e9da14c4b50f81cdc87ab324cd6c521827b0 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 18:13:03 -0700 Subject: [PATCH 11/13] docs(config): document --extra-yaml as per-invocation scope --- docs/config.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index fc9ebd6921..4479db53c0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -86,7 +86,9 @@ Options Require at least X percent code coverage across the org following the test run. Default: 90 ``` + (add-a-custom-task)= + ### Add a Custom Task To define a new task for your project, add the task name under the @@ -648,9 +650,13 @@ You can configure files at these scope levels: _Project_, _Local Project_ and _Global_. Configurations have an order of override precedence (from highest to lowest): -1. Project -2. Local Project -3. Global +1. Per-invocation (`--extra-yaml`) +2. Project +3. Local Project +4. Global + +Per-option overrides passed via `-o taskname__option value` on the +command line take precedence over all of the above. One override only cascades over another when two configurations set a value for the same element on a task or flow. @@ -668,6 +674,16 @@ default value for `opt2`, this new default `opt2` value takes precedence over the default `opt2` value specified in your global `cumulusci.yml` file. +### Per-Invocation (`--extra-yaml`) + +The `--extra-yaml PATH` flag on `cci flow run`, `cci flow info`, +`cci task run`, and `cci task info` merges an additional YAML file +into the project config for a single command invocation. Multiple +files are deep-merged in order (later files win); the +`CUMULUSCI_EXTRA_YAML` environment variable (colon-separated paths) +is honored as a fallback. See [the CLI reference](cli.md#the-extra-yaml-flag) +for usage and security implications. + ### Project Configurations **macOS/Linux:** `.../path/to/project/cumulusci.yml` From 6f892cc589f8453d097375841c686e20d89ecde9 Mon Sep 17 00:00:00 2001 From: James Estevez Date: Wed, 22 Apr 2026 18:13:23 -0700 Subject: [PATCH 12/13] docs: add CUMULUSCI_EXTRA_YAML env var entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intentionally skipping docs/history.md — release notes there are auto-generated from PR titles between latest-start/latest-stop markers. --- docs/env-var-reference.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/env-var-reference.md b/docs/env-var-reference.md index 5f17768c57..90e0c3c85f 100644 --- a/docs/env-var-reference.md +++ b/docs/env-var-reference.md @@ -16,6 +16,14 @@ information from `HEROKU_TEST_RUN_BRANCH` and If present, will instruct CumulusCI to not refresh OAuth tokens for orgs. +## `CUMULUSCI_EXTRA_YAML` + +Colon-separated list of paths to additional YAML files to merge into +the project config for each `cci task run`, `cci task info`, +`cci flow run`, and `cci flow info` invocation. The `--extra-yaml` +flag on those commands takes precedence when both are set. See +[the CLI reference](cli.md#the-extra-yaml-flag) for merge semantics. + ## `CUMULUSCI_KEY` An alphanumeric string used to encrypt org credentials at rest when an @@ -27,6 +35,7 @@ Used for specifying a GitHub Repository for CumulusCI to use when running in a CI environment. (cumulusci-system-certs)= + ## `CUMULUSCI_SYSTEM_CERTS` If set to `True`, CumulusCI will configure the Python `requests` library @@ -44,6 +53,7 @@ Contents of a JSON Web Token (JWT) used to [authenticate a GitHub app](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/##authenticating-as-a-github-app). (github-token)= + ## `GITHUB_TOKEN` A GitHub [personal access From 62e27c468fb3eb5bc578eca9f858391bf05b6e2d Mon Sep 17 00:00:00 2001 From: James Estevez Date: Fri, 24 Apr 2026 15:28:06 -0700 Subject: [PATCH 13/13] fix: parse CUMULUSCI_EXTRA_YAML as comma-separated, use pathlib Splitting the env var on ":" broke on Windows where drive letters contain colons (e.g. C:\tmp\a.yml). Switch to comma, the convention used everywhere else in CCI for list-valued strings, via process_list_arg. Replace os.path.isfile/open with pathlib.Path. Update all CLI help strings, docstring, and docs. --- cumulusci/cli/extra_yaml.py | 14 ++++++++------ cumulusci/cli/flow.py | 10 +++++----- cumulusci/cli/task.py | 4 ++-- cumulusci/cli/tests/test_extra_yaml.py | 6 +++--- docs/cli.md | 5 +++-- docs/env-var-reference.md | 6 ++++-- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/cumulusci/cli/extra_yaml.py b/cumulusci/cli/extra_yaml.py index 926a35eff3..68f9aa0052 100644 --- a/cumulusci/cli/extra_yaml.py +++ b/cumulusci/cli/extra_yaml.py @@ -4,14 +4,16 @@ kwarg, which already merges into the project config via the existing YAML merge stack. """ + import os +from pathlib import Path from typing import Optional, Tuple import click import yaml from cumulusci.core.exceptions import CumulusCIUsageError -from cumulusci.core.utils import dictmerge +from cumulusci.core.utils import dictmerge, process_list_arg ENV_VAR = "CUMULUSCI_EXTRA_YAML" @@ -22,7 +24,7 @@ def resolve_extra_yaml(paths: Tuple[str, ...]) -> Optional[str]: Args: paths: Tuple of paths from Click's ``multiple=True`` option. Empty means the flag was not supplied; fall back to - ``CUMULUSCI_EXTRA_YAML`` (colon-separated paths). + ``CUMULUSCI_EXTRA_YAML`` (comma-separated paths). Returns: A single YAML document representing the deep-merge of all input files @@ -37,7 +39,7 @@ def resolve_extra_yaml(paths: Tuple[str, ...]) -> Optional[str]: if not effective_paths: env_value = os.environ.get(ENV_VAR) if env_value: - effective_paths = tuple(p for p in env_value.split(":") if p) + effective_paths = tuple(p for p in (process_list_arg(env_value) or []) if p) if not effective_paths: return None @@ -51,11 +53,11 @@ def resolve_extra_yaml(paths: Tuple[str, ...]) -> Optional[str]: merged: dict = {} for path in effective_paths: - if not os.path.isfile(path): + file_path = Path(path) + if not file_path.is_file(): raise CumulusCIUsageError(f"--extra-yaml file not found: {path}") try: - with open(path, "r", encoding="utf-8") as f: - raw = f.read() + raw = file_path.read_text(encoding="utf-8") except OSError as e: raise CumulusCIUsageError(f"--extra-yaml could not read {path}: {e}") try: diff --git a/cumulusci/cli/flow.py b/cumulusci/cli/flow.py index cb8be300e1..bf7195405b 100644 --- a/cumulusci/cli/flow.py +++ b/cumulusci/cli/flow.py @@ -45,9 +45,9 @@ def flow_doc(runtime, project=False): flows_by_group = group_items(flows) flow_groups = sorted( flows_by_group.keys(), - key=lambda group: flow_info_groups.index(group) - if group in flow_info_groups - else 100, + key=lambda group: ( + flow_info_groups.index(group) if group in flow_info_groups else 100 + ), ) for group in flow_groups: @@ -116,7 +116,7 @@ def flow_list(runtime, plain, print_json): "Path to an additional YAML file to merge into the project config " "for this command only. Can be specified multiple times; later files " "override earlier ones. Also honors CUMULUSCI_EXTRA_YAML env var " - "(colon-separated paths) as a fallback." + "(comma-separated paths) as a fallback." ), ) @pass_runtime(require_keychain=True) @@ -164,7 +164,7 @@ def flow_info(runtime, flow_name, extra_yaml): "Path to an additional YAML file to merge into the project config " "for this command only. Can be specified multiple times; later files " "override earlier ones. Also honors CUMULUSCI_EXTRA_YAML env var " - "(colon-separated paths) as a fallback." + "(comma-separated paths) as a fallback." ), ) @pass_runtime(require_keychain=True) diff --git a/cumulusci/cli/task.py b/cumulusci/cli/task.py index f2f7f14f67..429c92c3ef 100644 --- a/cumulusci/cli/task.py +++ b/cumulusci/cli/task.py @@ -105,7 +105,7 @@ def task_doc(runtime, project=False, write=False): "Path to an additional YAML file to merge into the project config " "for this command only. Can be specified multiple times; later files " "override earlier ones. Also honors CUMULUSCI_EXTRA_YAML env var " - "(colon-separated paths) as a fallback." + "(comma-separated paths) as a fallback." ), ) @pass_runtime(require_project=False, require_keychain=True) @@ -168,7 +168,7 @@ class RunTaskCommand(click.MultiCommand): "Path to an additional YAML file to merge into the project " "config for this command only. Can be specified multiple times; " "later files override earlier ones. Also honors " - "CUMULUSCI_EXTRA_YAML env var (colon-separated paths) as a " + "CUMULUSCI_EXTRA_YAML env var (comma-separated paths) as a " "fallback." ), "is_flag": False, diff --git a/cumulusci/cli/tests/test_extra_yaml.py b/cumulusci/cli/tests/test_extra_yaml.py index ad3d892d1b..7ab0305ec9 100644 --- a/cumulusci/cli/tests/test_extra_yaml.py +++ b/cumulusci/cli/tests/test_extra_yaml.py @@ -56,7 +56,7 @@ def test_resolve_extra_yaml__env_var_fallback(tmp_path, monkeypatch): assert "env-loaded" in result -def test_resolve_extra_yaml__env_var_multiple_colon_separated(tmp_path, monkeypatch): +def test_resolve_extra_yaml__env_var_multiple_comma_separated(tmp_path, monkeypatch): """Env var with multiple paths produces a deep-merged document.""" import yaml @@ -64,7 +64,7 @@ def test_resolve_extra_yaml__env_var_multiple_colon_separated(tmp_path, monkeypa a.write_text("tasks:\n a:\n description: from A\n") b = tmp_path / "b.yml" b.write_text("tasks:\n b:\n description: from B\n") - monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", f"{a}:{b}") + monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", f"{a},{b}") result = resolve_extra_yaml(()) assert result is not None parsed = yaml.safe_load(result) @@ -87,7 +87,7 @@ def test_resolve_extra_yaml__flag_overrides_env_var(tmp_path, monkeypatch): def test_resolve_extra_yaml__empty_env_var_segments_ignored(tmp_path, monkeypatch): p = tmp_path / "x.yml" p.write_text("project: {}\n") - monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", f"::{p}::") + monkeypatch.setenv("CUMULUSCI_EXTRA_YAML", f",,{p},,") result = resolve_extra_yaml(()) assert result is not None assert "project: {}" in result diff --git a/docs/cli.md b/docs/cli.md index 4e5bb350f6..c7b4d74d51 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -492,9 +492,10 @@ $ cci task run my_task --extra-yaml migrations/v2.yml $ cci flow run dev_org --extra-yaml base.yml --extra-yaml override.yml ``` -The `CUMULUSCI_EXTRA_YAML` environment variable (colon-separated paths) +The `CUMULUSCI_EXTRA_YAML` environment variable (comma-separated paths) supplies a default when the flag is absent. When both are set, the flag -wins; they are not merged. +wins; they are not merged. Paths containing commas cannot be expressed +in the env var; pass them via the repeatable `--extra-yaml` flag instead. Extra YAML is merged using the same deep-merge semantics as the project `cumulusci.yml`. Mappings and scalars are overridden; lists are diff --git a/docs/env-var-reference.md b/docs/env-var-reference.md index 90e0c3c85f..1ba4082eb1 100644 --- a/docs/env-var-reference.md +++ b/docs/env-var-reference.md @@ -18,10 +18,12 @@ orgs. ## `CUMULUSCI_EXTRA_YAML` -Colon-separated list of paths to additional YAML files to merge into +Comma-separated list of paths to additional YAML files to merge into the project config for each `cci task run`, `cci task info`, `cci flow run`, and `cci flow info` invocation. The `--extra-yaml` -flag on those commands takes precedence when both are set. See +flag on those commands takes precedence when both are set. Paths +containing commas cannot be expressed here; use the repeatable +`--extra-yaml` flag instead. See [the CLI reference](cli.md#the-extra-yaml-flag) for merge semantics. ## `CUMULUSCI_KEY`