From 94d54ea887a0d80de357ec190d5e74f9d163d356 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 29 Jan 2026 11:16:47 +0100 Subject: [PATCH 1/3] Auto-cleanup single-image containers after run. Previously, containers created via docker_image or docker_file_path were not automatically removed after running, unlike docker-compose containers which had cleanup logic. This adds --rm flag to docker run command so containers are automatically removed when they exit. --- runo | 4 ++-- tests/unit/test_runo.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/runo b/runo index 07c37e7..82978fa 100755 --- a/runo +++ b/runo @@ -29,7 +29,7 @@ from typing import ( Union, ) -__version__ = "25.10.13-99069d04" +__version__ = "26.01.29-d1b27d75" # runo uses .toml format of config files, but parsers for this format # might be not available in old python versions (only in 3.11 tomllib @@ -324,7 +324,7 @@ class Image: def run(self, docker_run_options: List[str], command_to_run: str): full_docker_command = ( - ["docker", "run", "-e", f"RUNO_CONTAINER_NAME={self._cfg['name']}"] + ["docker", "run", "--rm", "-e", f"RUNO_CONTAINER_NAME={self._cfg['name']}"] + docker_run_options + [self._img_tag] + [command_to_run] diff --git a/tests/unit/test_runo.py b/tests/unit/test_runo.py index 17e1ab2..0564beb 100644 --- a/tests/unit/test_runo.py +++ b/tests/unit/test_runo.py @@ -1036,7 +1036,7 @@ def expected_calls(self, command: dict, run_options: List[str], env_specific_dat ) run_command = list_to_str( - ["docker", "run", "-e", "RUNO_CONTAINER_NAME=test_docker_file"] + ["docker", "run", "--rm", "-e", "RUNO_CONTAINER_NAME=test_docker_file"] + _expected_docker_run_options(docker_run_options_str) + [helpers["expected_tag"]] + [self._generate_command_to_run(command, run_options)] @@ -1132,7 +1132,7 @@ def expected_calls(self, command: dict, run_options: List[str], env_specific_dat container_config = env_specific_data["config_overrides"]["docker_containers"][0] run_command = list_to_str( - ["docker", "run", "-e", "RUNO_CONTAINER_NAME=test_image_from_repo"] + ["docker", "run", "--rm", "-e", "RUNO_CONTAINER_NAME=test_image_from_repo"] + _expected_docker_run_options(docker_run_options_str) + [container_config["docker_image"]] + [self._generate_command_to_run(command, run_options)] @@ -1307,6 +1307,7 @@ def _expected_call(self, image_name: str, container_name: str): [ "docker", "run", + "--rm", "-e", f"RUNO_CONTAINER_NAME={container_name}", "--user", From d09ef48581f745d4fbf3fdc1abd9a0635931ae96 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 29 Jan 2026 11:39:43 +0100 Subject: [PATCH 2/3] Replace --containers with -c to list available containers. Simplify user experience - it is much easier to type '-c' than '--containers' to list available containers. The -c/--container option now works without a value to show the container list. --- README.md | 2 +- docs/CONFIG.md | 3 ++- runo | 17 ++++++----------- tests/unit/test_runo.py | 42 +++++++++++++++-------------------------- 4 files changed, 24 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 575620f..7bf76c3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ But what if you want to perform build/tests/... for different platforms? Not a problem, just ask `runo` to show all available containers and run command in any of them as easy as that: ``` -> ./runo --containers +> ./runo -c Following containers are available: * Debian * Centos diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2e73fed..dc6f3f8 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -120,7 +120,8 @@ Please note that these command are executed on host machine (not inside containe Name of the container (defined in the same config), which should be used by the command by default. It can be overwritten via CLI at moment of run -with help of `-c`/`--container` option. +with help of `-c`/`--container` option. You can also use `-c` without a value +to list all available containers configured in the config file. #### `docker_run_options` (`string`) diff --git a/runo b/runo index 82978fa..99adf7e 100755 --- a/runo +++ b/runo @@ -29,7 +29,7 @@ from typing import ( Union, ) -__version__ = "26.01.29-d1b27d75" +__version__ = "26.01.29-27d75720" # runo uses .toml format of config files, but parsers for this format # might be not available in old python versions (only in 3.11 tomllib @@ -573,9 +573,7 @@ def _get_container_config(target_container_name: str, cfg: dict) -> dict: output.append(f" - {e}") else: output = [f"Container '{target_container_name}' is not found in the config."] - output.append( - "Please use '--containers' option to list all containers, present in the config" - ) + output.append("Please use '-c' without value to list all containers") _logger.error(output) sys.exit(os.EX_CONFIG) @@ -956,8 +954,10 @@ def _parse_arguments() -> argparse.Namespace: parser.add_argument( "-c", "--container", + nargs="?", action="append", - help='force command to be run in specific container(s). Use "*" to run in all containers', + help="force command to be run in specific container(s). " + 'Use "*" to run in all containers. Use without value to list available containers', ) parser.add_argument( "-d", @@ -969,11 +969,6 @@ def _parse_arguments() -> argparse.Namespace: "--config", help="path to the actual config file", ) - parser.add_argument( - "--containers", - action="store_true", - help="show all containers, present in the config file", - ) parser.add_argument( "--init", action="store_true", @@ -1013,7 +1008,7 @@ def main(): _init_config_and_exit(config_path) cfg: dict = _read_config(config_path) - if args.containers: + if args.container is not None and None in args.container: _show_configured_containers_and_exit(cfg) if not args.command: diff --git a/tests/unit/test_runo.py b/tests/unit/test_runo.py index 0564beb..08cafab 100644 --- a/tests/unit/test_runo.py +++ b/tests/unit/test_runo.py @@ -46,31 +46,24 @@ def test_help_message(self, capfd, monkeypatch, help_flag): std_out, std_err = capfd.readouterr() assert std_err == "" expected_strings = [ - "usage: runo [-c CONTAINER] [-d] [--config CONFIG] [--containers] [--init] [-h]", - " [-v]", - " ...", - "", + "usage: runo [-c [CONTAINER]] [-d] [--config CONFIG] [--init] [-h] [-v]", "positional arguments:", - " command exact command to be executed (might be supplemented", - " with options). You could try `./runo.py` to get list", - " of available commands.", - "", + " command", + "exact command to be executed", "option", # can be "optional arguments:" in old versions and "options:" on new # can be "CONTAINER, --container CONTAINER" in old versions, but: # "-c, --container CONTAINER" on new, starting from Python 3.13 - ", --container CONTAINER", - " force command to be run in specific container(s). Use", - ' "*" to run in all containers', + ", --container", + "force command to be run in specific container(s)", + '"*" to run in all containers', + "list available containers", " -d, --debug verbose output", " --config CONFIG path to the actual config file", " --init create and initialize config file", - " --containers show all containers, present in the config file", " -h, --help", " -v, --version show actual version of runo", - "", ] - assert len(std_out.split("\n")) == len(expected_strings) for expected_string in expected_strings: assert expected_string in std_out @@ -117,8 +110,9 @@ def test_version(self, capfd, monkeypatch, version_flag): ), ], ) - def test_containers(self, capfd, monkeypatch, config_content, expected_output): - monkeypatch.setattr(sys, "argv", ["runo", "--containers"]) + @pytest.mark.parametrize("container_flag", ["-c", "--container"]) + def test_containers(self, capfd, monkeypatch, config_content, expected_output, container_flag): + monkeypatch.setattr(sys, "argv", ["runo", container_flag]) config_path = pathlib.Path(os.getcwd()) / "runo.py.toml" with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( @@ -192,14 +186,8 @@ def test_unexpected_option(self, capfd, monkeypatch): std_out, std_err = capfd.readouterr() assert std_out == "" - assert ( - std_err - == """usage: runo [-c CONTAINER] [-d] [--config CONFIG] [--containers] [--init] [-h] - [-v] - ... -runo: error: unrecognized arguments: --wrong-option -""" - ) + assert "usage: runo [-c [CONTAINER]] [-d] [--config CONFIG] [--init] [-h] [-v]" in std_err + assert "runo: error: unrecognized arguments: --wrong-option" in std_err class TestInit: @@ -685,7 +673,7 @@ def test_nok_containers( expected_std_out, expected_std_err, ): - monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "--containers"]) + monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "-c"]) with pytest.raises(SystemExit, match=f"^{expected_rc}$"), _config_file( toml.dumps(config_content), config_path @@ -1458,7 +1446,7 @@ def test_no_any_containers_available(self, config_path, monkeypatch, capfd): assert err == "\n".join( [ "Container 'no_such_container' is not found in the config.", - "Please use '--containers' option to list all containers, present in the config\n", + "Please use '-c' without value to list all containers\n", ] ) @@ -1491,7 +1479,7 @@ def test_container_configuration_is_wrong(self, config_path, monkeypatch, capfd) [ "Container 'bad_container' is invalid:", " - docker_containers.0.docker_image: ['should be of type str, got int']", - "Please use '--containers' option to list all containers, present in the config\n", + "Please use '-c' without value to list all containers\n", ] ) From 4910444ba49731fc997893c569b1df0f4e1837a2 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 29 Jan 2026 11:45:14 +0100 Subject: [PATCH 3/3] AI-review. --- README.md | 4 +- docs/CONFIG.md | 8 +-- docs/QUICK_START.md | 6 +- runo | 125 ++++++++++++++++++++++++++-------------- tests/unit/test_runo.py | 119 ++++++++++++++++++++++++++++---------- 5 files changed, 178 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 7bf76c3..6331a7f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ You immediately see **WHAT** can be done in this repo and **HOW** it can be done Now, running build is as simple as: ``` > ./runo build -Buld is running +Build is running done ``` @@ -55,7 +55,7 @@ Following containers are available: * RockyLinux > ./runo -c Debian build -Buld for Debian is running +Build for Debian is running done ``` diff --git a/docs/CONFIG.md b/docs/CONFIG.md index dc6f3f8..d4072fd 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1,6 +1,6 @@ # `runo` Config File Reference -## Table of content +## Table of contents - [Introduction](#introduction) - [Commands](#commands) - - [Mandatory fields](#mandatory-fields) @@ -32,7 +32,7 @@ Therefore, `runo` config contains 2 lists of objects: ## Commands -Configuration of every command consists of several simple fields. +Configuration of every command consists of several simple fields. First, few examples, and then we will discuss details. - Simplest case: command config uses only mandatory fields: ```toml @@ -46,7 +46,7 @@ execute = "python3 -m tests.e2e.run" [[commands]] name = "test" description = "runs unit tests" -before = ["echo This is just an exampe", "echo You should configure your tests here"] +before = ["echo This is just an example", "echo You should configure your tests here"] execute = "pytest" after =["echo done > /dev/null"] examples = ["tests --cov -vv", "tests --last-failed"] @@ -114,7 +114,7 @@ the main part. Example: Similar to `before`, but executed after the main part. Example: - `["rm -f /tmp/unneeded_files*"]` -Please note that these command are executed on host machine (not inside container). +Please note that these commands are executed on host machine (not inside container). #### `docker_container` (`string`) diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 69298bd..72d4298 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -16,7 +16,7 @@ on any development machine by default nowadays: Go to root of your repository and do one of two things:
-Just copy file directly to you repo (and make it executable) +Just copy file directly to your repo (and make it executable) ``` wget https://raw.githubusercontent.com/frwl404/runo/refs/heads/master/runo &&\ @@ -63,9 +63,9 @@ Following commands are available: * pre-commit - quick checks/fixes of code formatting (ruff/mypy) ['./runo pre-commit'] * update-deps - updates dependencies, used in project, to the latest versions ['./runo update-deps'] ``` -At this moment most part of them are just a mocks, which prints something to console, +At this moment most of them are just mocks, which print something to console, because at this moment `./runo` doesn't know what `test`/`build`/`...` means for your project. -Only `./runo shell` do exactly what you expect from it (because it is quite universal thing, which means the same in any repo/project). +Only `./runo shell` does exactly what you expect from it (because it is quite universal thing, which means the same in any repo/project). ## 3. Adopt generated config for your needs. diff --git a/runo b/runo index 99adf7e..e96700e 100755 --- a/runo +++ b/runo @@ -12,6 +12,7 @@ Official repo: https://github.com/frwl404/runo """ import argparse +import json import os import os.path import subprocess @@ -29,7 +30,7 @@ from typing import ( Union, ) -__version__ = "26.01.29-27d75720" +__version__ = "26.01.29-716a0e17" # runo uses .toml format of config files, but parsers for this format # might be not available in old python versions (only in 3.11 tomllib @@ -69,10 +70,10 @@ DEFAULT_CFG_PATH = f"{TOOL_RELATIVE_PATH}.toml" INITIAL_CONFIG_CONTENT = """ # This is auto-generated file, which contains recommended set of commands and examples. -# To make it working for you project, please update configuration. -# For real-word examples, please check https://github.com/frwl404/runo +# To make it working for your project, please update configuration. +# For real-world examples, please check https://github.com/frwl404/runo # You can find all details, needed for updating it to your needs in: -# https://github.com/frwl404/runo/blob/main/docs/CONFIG +# https://github.com/frwl404/runo/blob/main/docs/CONFIG.md ####################################################### # Examples of commands @@ -82,17 +83,17 @@ name = "test" description = "runs unit tests" # OPTIONALLY, you can specify actions, which you want to perform before the main command. # For python projects, you may want to activate venv here. -before = ["echo This is just an exampe", "echo You should configure your tests here"] +before = ["echo This is just an example", "echo You should configure your tests here"] # Actual command to run (can be single command, or script). # You can pass additional options to this executable via console (see 'examples' section) execute = "echo ALL TESTS PASSED" -# OPTIONALLY, you can specify actions, which you want to perfrom after the main command +# OPTIONALLY, you can specify actions, which you want to perform after the main command # to do cleanup -after =["echo done > /dev/null"] +after = ["echo done > /dev/null"] # OPTIONALLY, you can specify examples of command usage. # if missing, ./runo will auto generate single example. examples = ["tests --cov -vv", "tests --last-failed"] -## OPTIONALLY you can specify container, in which command should be executed by defaut. +## OPTIONALLY you can specify container, in which command should be executed by default. ## Container must be defined in the same file. ## It can be overwritten, or set from CLI as well. # docker_container = "alpine" @@ -103,7 +104,7 @@ examples = ["tests --cov -vv", "tests --last-failed"] [[commands]] name = "build" description = "builds the project" -execute = "echo Buld is running" +execute = "echo Build is running" after = ["echo done"] #docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" @@ -119,7 +120,7 @@ docker_run_options = "-it -v .:/app -w /app" name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)" execute = "echo Ruff is formatting the code" -after = ["echo Formating completed"] +after = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" #docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" @@ -196,16 +197,38 @@ class Logger: self._print_message(message, file=sys.stderr) +# Global logger instance, initialized by main() before any usage. +# Intentionally not initialized at module level to ensure fresh state +# (e.g., debug flag) for each main() call. All code paths that use _logger +# are only reachable after main() has initialized it. _logger: Logger def _subprocess_run(cmd: str, **kwargs) -> subprocess.CompletedProcess: + """ + Execute a shell command with full shell expansion support. + + SECURITY NOTE (INTENTIONAL DESIGN): + This function deliberately uses shell=True to enable shell features that are + essential for runo's use cases: + + 1. Environment variable substitution: Commands like "-u $(id -u):$(id -g)" need + shell expansion to resolve user/group IDs dynamically. + + 2. Shell operators: Users may need pipes, redirects, and command chaining in + their 'execute', 'before', and 'after' configuration directives. + + 3. Glob expansion: File patterns in commands should expand naturally. + + The security model assumes that: + - The runo.toml configuration file is trusted (maintained by repository owners). + - CLI arguments passed to commands are under the user's control. + - runo is run by the same user who controls the configuration. + + This is consistent with tools like Make, npm scripts, and shell aliases, which + also execute user-defined commands with full shell capabilities. + """ _logger.debug(f"running: {cmd}") - # We use 'shell=True', because want to support configurations, - # where client will want to provide environment variable to some arguments. - # For example, in case if we will have the following docker run options: - # "-u $(id -u):$(id -g)", we want substitution of env variables to happen - # automatically. return subprocess.run(cmd, shell=True, **kwargs) @@ -272,22 +295,30 @@ def drop_interactive_if_not_tty(docker_run_options: List[str]) -> List[str]: if sys.stdin.isatty(): return docker_run_options - for f in ("--interactive", "-i"): - if f in docker_run_options: - _str = " ".join(docker_run_options) - _logger.debug(f"the input device is not TTY, dropping '{f}' from '{_str}'") - docker_run_options.remove(f) + result: List[str] = [] + original_str = " ".join(docker_run_options) - for idx, value in enumerate(docker_run_options): + for value in docker_run_options: + if value in ("--interactive", "-i"): + _logger.debug(f"the input device is not TTY, dropping '{value}' from '{original_str}'") + continue + + # Handle combined short flags like "-it" -> "-t" if value.startswith("-") and not value.startswith("--") and "i" in value: + new_value = value.replace("i", "") _logger.debug(f"the input device is not TTY, dropping 'i' from '{value}'") - docker_run_options[idx] = value.replace("i", "") + # Only add if there's something left after removing 'i' (e.g., "-it" -> "-t") + if new_value != "-": + result.append(new_value) + continue - return docker_run_options + result.append(value) + + return result class Image: - def __init__(self, container_cfg: dict): + def __init__(self, container_cfg: Dict[str, Any]): self._cfg = container_cfg self._img_tag: str = "" @@ -332,13 +363,20 @@ class Image: return _subprocess_run(" ".join(full_docker_command)) def cleanup(self): + """ + Clean up resources after command execution. + + For standalone images, this is a no-op since `docker run --rm` already + removes the container automatically. This method exists to satisfy the + docker backend interface shared with Composition, which requires cleanup. + """ pass class Composition: compose_base = "docker compose" - def __init__(self, container_cfg: dict): + def __init__(self, container_cfg: Dict[str, Any]): self._cfg = container_cfg self._compose_file: str = "" self._generated_options: List[str] = [] @@ -375,17 +413,19 @@ class Composition: # Clean up after ourselves. It should be completely silent, # this is the reason why we redirect stdout and stderr to /dev/null _subprocess_run( - f"{self.compose_base} down --remove-orphans", + f"{self.compose_base} --file {self._compose_file} down --remove-orphans", stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) _subprocess_run( f"{self.compose_base} --file {self._compose_file} rm -fsv", stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) class Container: - def __init__(self, container_cfg: dict): + def __init__(self, container_cfg: Dict[str, Any]): self._docker_backend: Union[Composition, Image] = ( Composition(container_cfg) if "docker_compose_service" in container_cfg @@ -413,7 +453,7 @@ def _string_as_list(shell_cmd: Optional[str]) -> List[str]: return shell_cmd.split() -def _determine_config_path(path_from_user: Optional[str], for_write: bool = False): +def _determine_config_path(path_from_user: Optional[str], for_write: bool = False) -> Path: if path_from_user: if for_write: # When user asks to write config (--init) under some non-default path, @@ -454,8 +494,6 @@ class TomlParser: Hope this parser will not get into the game too often. """ - import json - def load(self, file): result: dict = {} @@ -507,7 +545,7 @@ class TomlParser: # after last element of the list, but this is invalid json. # Since this parser is very-very simple, we just will try # to drop that comma. - value = self.json.loads(value.replace(",]", "]")) + value = json.loads(value.replace(",]", "]")) if not current_section_name: # This is for root-level settings (not sections) @@ -520,10 +558,10 @@ class TomlParser: return result -def _init_config_and_exit(config_path: Path): +def _init_config_and_exit(config_path: Path) -> None: if os.path.isfile(config_path): _logger.error( - f"file '{config_path}' already exist.\n" + f"file '{config_path}' already exists.\n" "Please review that file. If it is needed, you can either:\n" "- keep it on the same place, and generate new config " "under different path/name (use '--config')\n" @@ -580,7 +618,7 @@ def _get_container_config(target_container_name: str, cfg: dict) -> dict: return valid_containers[0] -def _generate_bin_sh_cmd(commands: List[str]): +def _generate_bin_sh_cmd(commands: List[str]) -> str: return f"/bin/sh -c '{' && '.join(commands)}'" @@ -665,7 +703,7 @@ def _run_command( ] + [f" - {name} has returned {rc}" for name, rc in nok_containers.items()] ) - return -1 + return os.EX_SOFTWARE ################################################################################## @@ -769,7 +807,7 @@ class Validator: return {} -def _validate_name(name: str): +def _validate_name(name: str) -> Optional[str]: _name = name _name = _name.replace("-", "") _name = _name.replace("_", "") @@ -836,7 +874,6 @@ _container_validator = Validator( "docker_file_path", "docker_image", "docker_compose_file_path", - "docker_compose_options", }, ) @@ -897,7 +934,7 @@ def _examples_representation(cmd: dict) -> str: return " " + str([f"{TOOL_RELATIVE_PATH} {example}" for example in examples]) -def _show_main_menu_and_exit(cfg: dict): +def _show_main_menu_and_exit(cfg: dict) -> None: validated, errors = _validate_commands(cfg.get("commands", [])) commands = [ @@ -909,7 +946,7 @@ def _show_main_menu_and_exit(cfg: dict): _logger.info(["Following commands are available:"] + commands) else: _logger.info( - ["Config file is present, but there are no any valid commands configured there"] + ["Config file is present, but there aren't any valid commands configured there"] ) if errors: @@ -930,7 +967,7 @@ def _show_configured_containers_and_exit(cfg: dict): if containers: _logger.info(["Following containers are available:"] + containers) else: - _logger.info(["No any valid container configuration found"]) + _logger.info(["No valid container configuration found"]) if errors: _logger.error( @@ -943,7 +980,7 @@ def _show_configured_containers_and_exit(cfg: dict): sys.exit(os.EX_OK) -def _show_version_and_exit(): +def _show_version_and_exit() -> None: _logger.info(f"runo version: {__version__}") sys.exit(os.EX_OK) @@ -990,7 +1027,7 @@ def _parse_arguments() -> argparse.Namespace: return parser.parse_args() -def main(): +def main() -> None: global _logger _logger = Logger() @@ -1020,8 +1057,8 @@ def main(): rc = _run_command(cmd_name, cmd_options, cfg, containers_requested_via_cli=args.container) except Exception as ex: - _logger.error(f"error happen: {ex}") - if args.debug: + _logger.error(f"error happened: {ex}") + if "args" in dir() and args.debug: raise ex rc = os.EX_SOFTWARE diff --git a/tests/unit/test_runo.py b/tests/unit/test_runo.py index 08cafab..ee45498 100644 --- a/tests/unit/test_runo.py +++ b/tests/unit/test_runo.py @@ -83,11 +83,11 @@ def test_version(self, capfd, monkeypatch, version_flag): [ ( {}, - "No any valid container configuration found\n", + "No valid container configuration found\n", ), ( {"containers": []}, - "No any valid container configuration found\n", + "No valid container configuration found\n", ), ( { @@ -197,10 +197,10 @@ class TestInit: INIT_CONTENT = """ # This is auto-generated file, which contains recommended set of commands and examples. -# To make it working for you project, please update configuration. -# For real-word examples, please check https://github.com/frwl404/runo +# To make it working for your project, please update configuration. +# For real-world examples, please check https://github.com/frwl404/runo # You can find all details, needed for updating it to your needs in: -# https://github.com/frwl404/runo/blob/main/docs/CONFIG +# https://github.com/frwl404/runo/blob/main/docs/CONFIG.md ####################################################### # Examples of commands @@ -210,17 +210,17 @@ class TestInit: description = "runs unit tests" # OPTIONALLY, you can specify actions, which you want to perform before the main command. # For python projects, you may want to activate venv here. -before = ["echo This is just an exampe", "echo You should configure your tests here"] +before = ["echo This is just an example", "echo You should configure your tests here"] # Actual command to run (can be single command, or script). # You can pass additional options to this executable via console (see 'examples' section) execute = "echo ALL TESTS PASSED" -# OPTIONALLY, you can specify actions, which you want to perfrom after the main command +# OPTIONALLY, you can specify actions, which you want to perform after the main command # to do cleanup -after =["echo done > /dev/null"] +after = ["echo done > /dev/null"] # OPTIONALLY, you can specify examples of command usage. # if missing, ./runo will auto generate single example. examples = ["tests --cov -vv", "tests --last-failed"] -## OPTIONALLY you can specify container, in which command should be executed by defaut. +## OPTIONALLY you can specify container, in which command should be executed by default. ## Container must be defined in the same file. ## It can be overwritten, or set from CLI as well. # docker_container = "alpine" @@ -231,7 +231,7 @@ class TestInit: [[commands]] name = "build" description = "builds the project" -execute = "echo Buld is running" +execute = "echo Build is running" after = ["echo done"] #docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" @@ -247,7 +247,7 @@ class TestInit: name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)" execute = "echo Ruff is formatting the code" -after = ["echo Formating completed"] +after = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" #docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" @@ -330,7 +330,7 @@ def test_already_exist(self, capfd, monkeypatch): std_out, std_err = capfd.readouterr() assert ( std_err - == """file 'runo.py.toml' already exist. + == """file 'runo.py.toml' already exists. Please review that file. If it is needed, you can either: - keep it on the same place, and generate new config under different path/name (use '--config') - move it to other place and try to call '--init' again @@ -436,13 +436,13 @@ class TestConfigCommandsFormat: {}, os.EX_OK, "", - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( {"commands": []}, os.EX_OK, "", - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( {"commands": {}}, @@ -450,7 +450,7 @@ class TestConfigCommandsFormat: """errors detected in configured commands: - commands should be represented by list, got dict """, - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( {"commands": "hello"}, @@ -458,7 +458,7 @@ class TestConfigCommandsFormat: """errors detected in configured commands: - commands should be represented by list, got str """, - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( {"commands": ["hello"]}, @@ -466,7 +466,7 @@ class TestConfigCommandsFormat: """errors detected in configured commands: - commands.0.*: ["must be represented by 'dict', got 'str'"] """, - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( {"commands": [{}]}, @@ -476,7 +476,7 @@ class TestConfigCommandsFormat: - commands.0.execute: ['mandatory field missing'] - commands.0.name: ['mandatory field missing'] """, - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( {"commands": [{"name": 13, "wrong_field": "something"}]}, @@ -487,7 +487,7 @@ class TestConfigCommandsFormat: - commands.0.name: ['should be of type str, got int'] - commands.0.wrong_field: ['unsupported field'] """, - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ( # Test 'name' field format { @@ -525,7 +525,7 @@ class TestConfigCommandsFormat: - commands.3.name: ["should consist only of letters, \ digits, '-', or '_', got '?not_allowed_also'"] """, - "Config file is present, but there are no any valid commands configured there\n", + "Config file is present, but there aren't any valid commands configured there\n", ), ], ) @@ -559,7 +559,7 @@ class TestConfigContainersFormat: ( {"docker_containers": 3}, os.EX_CONFIG, - "No any valid container configuration found\n", + "No valid container configuration found\n", """errors detected in configured containers: - docker_containers should be represented by list, got int """, @@ -567,7 +567,7 @@ class TestConfigContainersFormat: ( {"docker_containers": {}}, os.EX_CONFIG, - "No any valid container configuration found\n", + "No valid container configuration found\n", """errors detected in configured containers: - docker_containers should be represented by list, got dict """, @@ -575,7 +575,7 @@ class TestConfigContainersFormat: ( {"docker_containers": ["should be dict"]}, os.EX_CONFIG, - "No any valid container configuration found\n", + "No valid container configuration found\n", """errors detected in configured containers: - docker_containers.0.*: ["must be represented by 'dict', got 'str'"] """, @@ -607,10 +607,10 @@ class TestConfigContainersFormat: ] }, os.EX_CONFIG, - "No any valid container configuration found\n", + "No valid container configuration found\n", """errors detected in configured containers: - docker_containers.0.*: ["one of the following fields must be present: \ -['docker_compose_file_path', 'docker_compose_options', 'docker_file_path', 'docker_image']"] +['docker_compose_file_path', 'docker_file_path', 'docker_image']"] - docker_containers.0.name: ['mandatory field missing'] - docker_containers.1.docker_build_options: ['should be of type str, got int'] - docker_containers.1.docker_file_path: ['should be of type str, got bool'] @@ -638,8 +638,10 @@ class TestConfigContainersFormat: ] }, os.EX_CONFIG, - "No any valid container configuration found\n", + "No valid container configuration found\n", """errors detected in configured containers: + - docker_containers.0.*: ["one of the following fields must be present: \ +['docker_compose_file_path', 'docker_file_path', 'docker_image']"] - docker_containers.0.docker_compose_service: ["requires following fields to be present \ as well, but they are not found: {'docker_compose_file_path'}"] - docker_containers.0.name: ['mandatory field missing'] @@ -655,7 +657,7 @@ class TestConfigContainersFormat: ] }, os.EX_CONFIG, - "No any valid container configuration found\n", + "No valid container configuration found\n", """errors detected in configured containers: - docker_containers.0.docker_compose_file_path: ["requires following fields to be present \ as well, but they are not found: {'docker_compose_service'}"] @@ -1230,14 +1232,23 @@ def expected_calls(self, command: dict, run_options: List[str], env_specific_dat clean_up_commands = [ ( - list_to_str(["docker", "compose", "down", "--remove-orphans"]), - {"stdout": subprocess.DEVNULL}, + list_to_str( + [ + "docker", + "compose", + "--file", + expected_docker_compose_file, + "down", + "--remove-orphans", + ] + ), + {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}, ), ( list_to_str( ["docker", "compose", "--file", expected_docker_compose_file, "rm", "-fsv"] ), - {"stdout": subprocess.DEVNULL}, + {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}, ), ] @@ -1503,7 +1514,7 @@ def test_part_of_containers_fail( for c in self.config_content["docker_containers"] ] - with pytest.raises(SystemExit, match="^-1$"), _config_file( + with pytest.raises(SystemExit, match=f"^{os.EX_SOFTWARE}$"), _config_file( toml.dumps(self.config_content), config_path ): main() @@ -1747,3 +1758,49 @@ def test_user_already_set( assert all( [should_not_be_in_final_options not in str(_call) for _call in patched_run.mock_calls] ) + + +class TestDebugExceptionHandling: + """ + Test that exceptions are re-raised when debug mode is enabled, + allowing developers to see the full traceback. + """ + + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo test", + } + ] + } + + @patch("runo.subprocess.run") + def test_exception_suppressed_without_debug(self, patched_run, capfd, monkeypatch, config_path): + """Without -d flag, exceptions are caught and logged, exits with EX_SOFTWARE.""" + patched_run.side_effect = RuntimeError("simulated error") + monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=f"^{os.EX_SOFTWARE}$"), _config_file( + toml.dumps(self.config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert "error happened: simulated error" in std_err + + @patch("runo.subprocess.run") + def test_exception_reraised_with_debug(self, patched_run, capfd, monkeypatch, config_path): + """With -d flag, exceptions are re-raised to show full traceback.""" + patched_run.side_effect = RuntimeError("simulated error for debug") + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(RuntimeError, match="simulated error for debug"), _config_file( + toml.dumps(self.config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + # Error is still logged before re-raising + assert "error happened: simulated error for debug" in std_err