From 951c0f8a107446232d9b40d458110250598d14b8 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Mon, 9 Oct 2023 14:41:15 -0400 Subject: [PATCH] feat: run commands to get settings --- .../20231009_153026_nedbat_command_config.rst | 6 +++ docs/configuration.rst | 14 +++++++ src/scriv/config.py | 10 +++++ src/scriv/shell.py | 19 +++++++++ tests/test_config.py | 40 +++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 changelog.d/20231009_153026_nedbat_command_config.rst diff --git a/changelog.d/20231009_153026_nedbat_command_config.rst b/changelog.d/20231009_153026_nedbat_command_config.rst new file mode 100644 index 0000000..154a0d2 --- /dev/null +++ b/changelog.d/20231009_153026_nedbat_command_config.rst @@ -0,0 +1,6 @@ +Added +..... + +- Settings can now be prefixed with ``command:`` to execute the rest of the + setting as a shell command. The output of the command will be used as the + value of the setting. diff --git a/docs/configuration.rst b/docs/configuration.rst index 89ede3b..3fcc8dc 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -56,8 +56,13 @@ Settings use the usual syntax, but with some extra features: - A prefix of ``literal:`` reads a literal data from a source file. +- A prefix of ``command:`` runs the command and uses the output as the setting. + - Value substitutions can make a setting depend on another setting. +These are each explained below: + + File Prefix ----------- @@ -159,6 +164,15 @@ When using a Cabal file, the version of the package can be accessed using:: [scriv] version = literal: my-package.cabal: version +Commands +-------- + +A ``command:`` prefix indicates that the setting is a shell command to run. +The output will be used as the setting:: + + [scriv] + version = command: my_version_tool --next + Value Substitution ------------------ 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