diff --git a/src/scriv/config.py b/src/scriv/config.py index e388d8d..1d1e63c 100644 --- a/src/scriv/config.py +++ b/src/scriv/config.py @@ -13,6 +13,7 @@ from .exceptions import ScrivException from .literals import find_literal from .optional import tomllib +from .shell import run_shell_command logger = logging.getLogger(__name__) @@ -363,6 +364,7 @@ def resolve_value(self, value: str) -> str: Prefixes: "file:" read the content from a file. "literal:" read a literal string from a file. + "command:" read the output of a shell command. """ value = value.replace("${config:format}", self._options.format) @@ -392,6 +394,14 @@ def resolve_value(self, value: str) -> str: + f"{value!r}" ) value = found + elif value.startswith("command:"): + cmd = value.partition(":")[2].strip() + ok, out = run_shell_command(cmd) + if not ok: + raise ScrivException(f"Command {cmd!r} failed:\n{out}") + if out.count("\n") == 1: + out = out.rstrip("\n") + value = out return value def read_file_value(self, file_name: str) -> str: diff --git a/src/scriv/shell.py b/src/scriv/shell.py index e2cdc2c..e496dd0 100644 --- a/src/scriv/shell.py +++ b/src/scriv/shell.py @@ -46,3 +46,19 @@ def run_simple_command(cmd: Union[str, List[str]]) -> str: if not ok: return "" return out.strip() + + +def run_shell_command(cmd: str) -> CmdResult: + logger.debug(f"Running shell command {cmd!r}") + proc = subprocess.run( + cmd, + shell=True, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + output = proc.stdout.decode("utf-8") + logger.debug( + f"Command exited with {proc.returncode} status. Output: {output!r}" + ) + return proc.returncode == 0, output diff --git a/tests/faker.py b/tests/faker.py index b0657a1..2c630b4 100644 --- a/tests/faker.py +++ b/tests/faker.py @@ -11,7 +11,7 @@ class FakeRunCommand: """ - A fake implementation of run_command. + A fake implementation of run_command and similar functions. Add handlers for commands with `add_handler`. """ @@ -22,9 +22,9 @@ def __init__(self, mocker): self.mocker = mocker self.patch_module("scriv.shell") - def patch_module(self, mod_name: str) -> None: + def patch_module(self, mod_name: str, func_name: str = "run_command") -> None: """Replace ``run_command`` in `mod_name` with our fake.""" - self.mocker.patch(f"{mod_name}.run_command", self) + self.mocker.patch(f"{mod_name}.{func_name}", self) def add_handler(self, argv0: str, handler: CmdHandler) -> None: """ diff --git a/tests/test_config.py b/tests/test_config.py index 5fb1fd6..e559e7c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -344,3 +344,31 @@ def test_no_toml_installed_no_settings(self, temp_dir): with without_module(scriv.config, "tomllib"): config = Config.read() assert config.categories[0] == "Removed" + + +def test_command_running(fake_run_command): + # Any setting can be the output of a command. + fake_run_command.patch_module("scriv.config", "run_shell_command") + fake_run_command.add_handler( + "showtext", lambda argv: (True, " ".join(argv[1:])) + ) + text = Config(output_file="command: showtext Xyzzy 2 3").output_file + assert text == "Xyzzy 2 3" + + +def test_real_command_running(): + text = Config(output_file="command: echo Xyzzy 2 3").output_file + assert text == "Xyzzy 2 3" + + +@pytest.mark.parametrize( + "bad_cmd, msg_rx", + [ + ("xyzzyplugh", "Couldn't read 'output_file' setting: Command 'xyzzyplugh' failed:"), + ("'hello!2><", re.escape('Couldn\'t read \'output_file\' setting: Command "\'hello!2><" failed:')), + ] +) +def test_bad_command(fake_run_command, bad_cmd, msg_rx): + # Any setting can be the output of a command. + with pytest.raises(ScrivException, match=msg_rx): + Config(output_file=f"command: {bad_cmd}").output_file