diff --git a/README.md b/README.md index 575620f..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 ``` @@ -48,14 +48,14 @@ 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 * 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 2e73fed..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,13 +114,14 @@ 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`) 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/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 07c37e7..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__ = "25.10.13-99069d04" +__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 = "" @@ -324,7 +355,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] @@ -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" @@ -573,16 +611,14 @@ 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) 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)}'" @@ -667,7 +703,7 @@ def _run_command( ] + [f" - {name} has returned {rc}" for name, rc in nok_containers.items()] ) - return -1 + return os.EX_SOFTWARE ################################################################################## @@ -771,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("_", "") @@ -838,7 +874,6 @@ _container_validator = Validator( "docker_file_path", "docker_image", "docker_compose_file_path", - "docker_compose_options", }, ) @@ -899,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 = [ @@ -911,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: @@ -932,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( @@ -945,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) @@ -956,8 +991,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 +1006,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", @@ -995,7 +1027,7 @@ def _parse_arguments() -> argparse.Namespace: return parser.parse_args() -def main(): +def main() -> None: global _logger _logger = Logger() @@ -1013,7 +1045,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: @@ -1025,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 17e1ab2..ee45498 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 @@ -90,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", ), ( { @@ -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: @@ -209,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 @@ -222,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" @@ -243,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" @@ -259,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" @@ -342,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 @@ -448,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": {}}, @@ -462,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"}, @@ -470,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"]}, @@ -478,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": [{}]}, @@ -488,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"}]}, @@ -499,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 { @@ -537,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", ), ], ) @@ -571,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 """, @@ -579,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 """, @@ -587,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'"] """, @@ -619,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'] @@ -650,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'] @@ -667,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'}"] @@ -685,7 +675,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 @@ -1036,7 +1026,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 +1122,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)] @@ -1242,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}, ), ] @@ -1307,6 +1306,7 @@ def _expected_call(self, image_name: str, container_name: str): [ "docker", "run", + "--rm", "-e", f"RUNO_CONTAINER_NAME={container_name}", "--user", @@ -1457,7 +1457,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", ] ) @@ -1490,7 +1490,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", ] ) @@ -1514,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() @@ -1758,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