diff --git a/src/scriv/config.py b/src/scriv/config.py index e388d8d..0416c56 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("\r\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..7edd605 100644 --- a/src/scriv/shell.py +++ b/src/scriv/shell.py @@ -46,3 +46,22 @@ def run_simple_command(cmd: Union[str, List[str]]) -> str: if not ok: return "" return out.strip() + + +def run_shell_command(cmd: str) -> CmdResult: + """ + Run a command line with a shell. + """ + 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/test_config.py b/tests/test_config.py index 5fb1fd6..b80fbde 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -344,3 +344,43 @@ def test_no_toml_installed_no_settings(self, temp_dir): with without_module(scriv.config, "tomllib"): config = Config.read() assert config.categories[0] == "Removed" + + +@pytest.mark.parametrize( + "cmd_output, result", + [ + ("Xyzzy 2 3\n", "Xyzzy 2 3"), + ("Xyzzy 2 3\nAnother line\n", "Xyzzy 2 3\nAnother line\n"), + ], +) +def test_command_running(mocker, cmd_output, result): + # Any setting can be the output of a command. + mocker.patch( + "scriv.config.run_shell_command", lambda cmd: (True, cmd_output) + ) + text = Config(output_file="command: doesnt-matter").output_file + assert text == result + + +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:", + ), + ( + "'hi!2><", + "Couldn't read 'output_file' setting: Command \"'hi!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