From fca2fc626b446a28d102fba9e3d00fa386e51863 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 3 Feb 2026 16:46:06 +0100 Subject: [PATCH 1/2] Split 'after' into 'after'+'cleanup' Currently 'after' always executes on host machine, not in container, while in many cases it might be usefull to execute it inside container. For example in rust to measure test coverage, we should perform 2 steps: 1) cargo llvm-cov --workspace --tests --features full --color never 2) cargo llvm-cov report --html Even if at step 1 some tests fails, we want to execute command 2, to get coverage report even if tests are failed. But exit code should be non-0, based on main command #1. Now best what we can do is to use just "execute": cargo llvm-cov --workspace --tests --features full --color never && cargo llvm-cov report --html This way we will achive main requirement (to get negative exit code if tests will fail), but it will not generate coverage report in case of tests failure. This change would break the logic for existing setups, but furtunately we don't have many at the moment. --- docs/CONFIG.md | 22 +++-- runo | 27 +++--- runo.toml | 2 +- tests/e2e/runo.toml | 30 ++++++- tests/unit/test_runo.py | 188 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 240 insertions(+), 29 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d4072fd..93ffd1d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -10,6 +10,7 @@ - - [Optional fields](#optional-fields) - - - [before](#before-liststring) - - - [after](#after-liststring) +- - - [cleanup](#cleanup-liststring) - - - [docker_container](#docker_container-string) - - - [docker_run_options](#docker_run_options-string) - [Containers](#containers) @@ -48,7 +49,8 @@ name = "test" description = "runs unit tests" before = ["echo This is just an example", "echo You should configure your tests here"] execute = "pytest" -after =["echo done > /dev/null"] +after = ["echo tests completed"] +cleanup = ["rm -f .coverage"] examples = ["tests --cov -vv", "tests --last-failed"] docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" @@ -105,16 +107,26 @@ then resulting command, which will be executed by `runo` will be: `pytest --cov Before executing the main part, you may want to perform some setup (activate `venv` in case of Python project). In this section you can specify list of commands/scripts, which should be executed before -the main part. Example: +the main part. These commands are executed inside the container +(or natively if no container is used). Example: - `["source /tmp/.venv/bin/activate", "echo Starting tests"]` #### `after` (`list[string]`) -Similar to `before`, but executed after the main part. Example: -- `["rm -f /tmp/unneeded_files*"]` +Similar to `before`, but executed after the main part. These commands +are executed inside the container (or natively if no container is used), +which makes them useful for post-execution actions that need access +to the container environment. Example: +- `["echo tests completed", "echo generating report"]` + -Please note that these commands are executed on host machine (not inside container). +#### `cleanup` (`list[string]`) + +Cleanup commands to run on the host machine after the container exits +(or after native execution completes). Useful for removing temporary files +or performing other host-side cleanup. Example: +- `["rm -f /tmp/unneeded_files*"]` #### `docker_container` (`string`) diff --git a/runo b/runo index e96700e..815c049 100755 --- a/runo +++ b/runo @@ -30,7 +30,7 @@ from typing import ( Union, ) -__version__ = "26.01.29-716a0e17" +__version__ = "26.02.03-9a15aeec" # 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 @@ -83,13 +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. +# These commands are executed inside the container (or natively if no container is used). 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 perform after the main command -# to do cleanup -after = ["echo done > /dev/null"] +# OPTIONALLY, you can specify actions to perform after the main command, but still inside +# the container. Useful for generating coverage reports or other post-test actions. +after = ["echo generating coverage report"] +# OPTIONALLY, you can specify cleanup actions to perform on the host machine after +# the container exits. Useful for removing temporary files. +cleanup = ["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"] @@ -105,7 +109,7 @@ examples = ["tests --cov -vv", "tests --last-failed"] name = "build" description = "builds the project" execute = "echo Build is running" -after = ["echo done"] +cleanup = ["echo done"] #docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" @@ -120,7 +124,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 Formatting completed"] +cleanup = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" #docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" @@ -627,7 +631,9 @@ def _generate_command_to_run(command_cfg: dict, command_options: List[str]) -> s execute = " ".join([command_cfg["execute"]] + command_options) - return _generate_bin_sh_cmd(before + [execute]) + after = command_cfg.get("after", []) + + return _generate_bin_sh_cmd(before + [execute] + after) def _containers_to_use( @@ -656,9 +662,9 @@ def _containers_to_use( def _generate_cleanup_cmd(command_cfg: dict) -> Optional[str]: - after = command_cfg.get("after") - if after: - return _generate_bin_sh_cmd(after) + cleanup = command_cfg.get("cleanup") + if cleanup: + return _generate_bin_sh_cmd(cleanup) return None @@ -823,6 +829,7 @@ _command_validator = Validator( "before": dict(value_type=list, required=False), "execute": dict(value_type=str, required=True), "after": dict(value_type=list, required=False), + "cleanup": dict(value_type=list, required=False), "examples": dict(value_type=list, required=False), # Docker section "docker_container": dict( diff --git a/runo.toml b/runo.toml index 48d288c..b819d3b 100644 --- a/runo.toml +++ b/runo.toml @@ -10,7 +10,7 @@ before = ["source /tmp/runo_venv/.venv/bin/activate", "ln -s runo runo.py"] execute = "pytest" # clean all files, generated by test (__pycache__/.coverage), # except of htmlcov and pytest_cache, which are useful for us -after = ["rm -f runo.py", "rm -rf .coverage", "rm -rf tests/unit/__pycache__", "rm -rf __pycache__"] +cleanup = ["rm -f runo.py", "rm -rf .coverage", "rm -rf tests/unit/__pycache__", "rm -rf __pycache__"] examples = ["test --cov -vv", "test --last-failed"] docker_container = "python39" docker_run_options = "-it -v .:/app -w /app" diff --git a/tests/e2e/runo.toml b/tests/e2e/runo.toml index c5b86e1..110ee16 100644 --- a/tests/e2e/runo.toml +++ b/tests/e2e/runo.toml @@ -15,8 +15,23 @@ name = "example_native_multi" description = "example of command, executing 2 linux commands natively on your OS" before = ["echo BE", "echo FOR"] execute = "echo PASSED" -after = ["echo AFTER"] -# e2e_tests = ["example_native_multi|BE\nFOR\nPASSED\nAFTER\n|0"] +after = ["echo AFTER_IN_CMD"] +cleanup = ["echo CLEANUP"] +# e2e_tests = ["example_native_multi|BE\nFOR\nPASSED\nAFTER_IN_CMD\nCLEANUP\n|0"] + +[[commands]] +name = "example_native_after_only" +description = "example of command with only 'after' (runs in same shell as execute)" +execute = "echo MAIN" +after = ["echo POST_EXECUTE"] +# e2e_tests = ["example_native_after_only|MAIN\nPOST_EXECUTE\n|0"] + +[[commands]] +name = "example_native_cleanup_only" +description = "example of command with only 'cleanup' (runs on host after main command)" +execute = "echo MAIN" +cleanup = ["echo HOST_CLEANUP"] +# e2e_tests = ["example_native_cleanup_only|MAIN\nHOST_CLEANUP\n|0"] [[commands]] name = "example_native_script" @@ -43,6 +58,17 @@ before = ["echo Your OS:"] execute = "grep 'PRETTY_NAME' /etc/os-release" # e2e_tests = ["example_docker_file_multi|Your OS:\r\nPRETTY_NAME=\"Alpine Linux v3.20\"\r\n|0"] +[[commands]] +name = "example_docker_file_after" +description = "example of 'after' running inside docker container (after execute). Shows RUNO_CONTAINER_NAME is set in container but not on host." +docker_container = "container_defined_by_docker_file" +docker_run_options = "-it -v .:/app -w /app --user 1000:1000" +before = ["echo CONTAINER_NAME_IN_BEFORE: $RUNO_CONTAINER_NAME"] +execute = "echo CONTAINER_NAME_IN_EXECUTE: $RUNO_CONTAINER_NAME" +after = ["echo CONTAINER_NAME_IN_AFTER: $RUNO_CONTAINER_NAME"] +cleanup = ["echo CLEANUP_RUNO_CONTAINER_NAME_IS_EMPTY: ${RUNO_CONTAINER_NAME:-true}"] +# e2e_tests = ["example_docker_file_after|CONTAINER_NAME_IN_BEFORE: container_defined_by_docker_file\r\nCONTAINER_NAME_IN_EXECUTE: container_defined_by_docker_file\r\nCONTAINER_NAME_IN_AFTER: container_defined_by_docker_file\r\nCLEANUP_RUNO_CONTAINER_NAME_IS_EMPTY: true\n|0"] + [[commands]] name = "example_docker_file_script" description = "example of command, executing script in docker container, defined with docker file" diff --git a/tests/unit/test_runo.py b/tests/unit/test_runo.py index ee45498..2f5073c 100644 --- a/tests/unit/test_runo.py +++ b/tests/unit/test_runo.py @@ -210,13 +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. +# These commands are executed inside the container (or natively if no container is used). 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 perform after the main command -# to do cleanup -after = ["echo done > /dev/null"] +# OPTIONALLY, you can specify actions to perform after the main command, but still inside +# the container. Useful for generating coverage reports or other post-test actions. +after = ["echo generating coverage report"] +# OPTIONALLY, you can specify cleanup actions to perform on the host machine after +# the container exits. Useful for removing temporary files. +cleanup = ["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"] @@ -232,7 +236,7 @@ class TestInit: name = "build" description = "builds the project" execute = "echo Build is running" -after = ["echo done"] +cleanup = ["echo done"] #docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" @@ -247,7 +251,7 @@ class TestInit: name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)" execute = "echo Ruff is formatting the code" -after = ["echo Formatting completed"] +cleanup = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" #docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" @@ -714,11 +718,12 @@ class BaseCommandsTest: COMMON_COMMANDS_TEMPLATES = [ # Simple command {"execute": "echo PASSED"}, - # before/after + # before/after (in-container) and cleanup (on-host) { "before": ["echo BEFORE", "echo TEST"], "execute": "echo PASSED", - "after": ["echo done"], + "after": ["echo AFTER_IN_CONTAINER"], + "cleanup": ["echo done"], }, ] @@ -748,13 +753,15 @@ def _generate_command_to_run(command_cfg: dict, command_options: List[str]) -> s execute = " ".join([command_cfg["execute"]] + command_options) - return f"/bin/sh -c '{' && '.join(before + [execute])}'" + after = command_cfg.get("after", []) + + return f"/bin/sh -c '{' && '.join(before + [execute] + after)}'" @staticmethod def _generate_configured_cleanup(command_cfg: dict): - after = command_cfg.get("after") - if after: - return f"/bin/sh -c '{' && '.join(after)}'" + cleanup = command_cfg.get("cleanup") + if cleanup: + return f"/bin/sh -c '{' && '.join(cleanup)}'" return "" @staticmethod @@ -1804,3 +1811,162 @@ def test_exception_reraised_with_debug(self, patched_run, capfd, monkeypatch, co std_out, std_err = capfd.readouterr() # Error is still logged before re-raising assert "error happened: simulated error for debug" in std_err + + +class TestAfterAndCleanupBehavior: + """ + Test that 'after' commands run inside the container (part of the main command) + and 'cleanup' commands run on the host (separate subprocess call). + """ + + @patch("runo.subprocess.run") + def test_after_runs_inside_container(self, patched_run, capfd, monkeypatch, config_path): + """ + 'after' commands should be part of the /bin/sh -c command that runs in container, + executed after the main 'execute' command. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "before": ["echo BEFORE"], + "execute": "echo EXECUTE", + "after": ["echo AFTER"], + "docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + # The 'after' command should be part of the single /bin/sh -c command + expected_cmd = "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER'" + assert expected_cmd in std_out + + @patch("runo.subprocess.run") + def test_cleanup_runs_on_host(self, patched_run, capfd, monkeypatch, config_path): + """ + 'cleanup' commands should run as a separate subprocess call on the host, + after the container command completes. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo EXECUTE", + "cleanup": ["echo CLEANUP"], + "docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + # The cleanup command should be a separate call, not part of the container command + assert "/bin/sh -c 'echo CLEANUP'" in std_out + # And it should NOT be part of the main docker command + assert "echo EXECUTE && echo CLEANUP" not in std_out + + @patch("runo.subprocess.run") + def test_after_and_cleanup_together(self, patched_run, capfd, monkeypatch, config_path): + """ + Test that both 'after' (in-container) and 'cleanup' (on-host) work together correctly. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "before": ["echo BEFORE"], + "execute": "echo EXECUTE", + "after": ["echo AFTER_IN_CONTAINER"], + "cleanup": ["echo CLEANUP_ON_HOST"], + "docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + # 'after' should be in the container command + expected_container_cmd = ( + "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER_IN_CONTAINER'" + ) + assert expected_container_cmd in std_out + # 'cleanup' should be a separate host command + assert "/bin/sh -c 'echo CLEANUP_ON_HOST'" in std_out + + @patch("runo.subprocess.run") + def test_after_runs_natively_without_container( + self, patched_run, capfd, monkeypatch, config_path + ): + """ + When no container is used, 'after' should still be part of the main command. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "before": ["echo BEFORE"], + "execute": "echo EXECUTE", + "after": ["echo AFTER"], + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + expected_cmd = "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER'" + assert expected_cmd in std_out From a877576d5699b909b16bffc814556813dad32d53 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 3 Feb 2026 17:20:40 +0100 Subject: [PATCH 2/2] Rename 'docker_container' to 'default_docker_container' Current name is short, but might be a bit misleading and makes user think that certain command can be executed in this particular container only. In the future we plan to add field with explicit list of containers supported per command, but for now lets just make name more clear. --- docs/CONFIG.md | 6 +- runo | 55 +++++++--- runo.toml | 12 +-- tests/e2e/runo.toml | 21 ++-- tests/unit/test_runo.py | 216 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 265 insertions(+), 45 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 93ffd1d..6e0035b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -11,7 +11,7 @@ - - - [before](#before-liststring) - - - [after](#after-liststring) - - - [cleanup](#cleanup-liststring) -- - - [docker_container](#docker_container-string) +- - - [default_docker_container](#default_docker_container-string) - - - [docker_run_options](#docker_run_options-string) - [Containers](#containers) - - [Containers, based on images from repo](#containers-based-on-images-from-repo), @@ -52,7 +52,7 @@ execute = "pytest" after = ["echo tests completed"] cleanup = ["rm -f .coverage"] examples = ["tests --cov -vv", "tests --last-failed"] -docker_container = "alpine" +default_docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" ``` @@ -128,7 +128,7 @@ Cleanup commands to run on the host machine after the container exits or performing other host-side cleanup. Example: - `["rm -f /tmp/unneeded_files*"]` -#### `docker_container` (`string`) +#### `default_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 diff --git a/runo b/runo index 815c049..cb6bd9e 100755 --- a/runo +++ b/runo @@ -30,7 +30,7 @@ from typing import ( Union, ) -__version__ = "26.02.03-9a15aeec" +__version__ = "26.02.03-1ec55b48" # 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 @@ -97,10 +97,10 @@ cleanup = ["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 default. +## OPTIONALLY you can specify default container, in which command should be executed. ## Container must be defined in the same file. -## It can be overwritten, or set from CLI as well. -# docker_container = "alpine" +## It can be overwritten, or set from CLI as well with '-c'/'--container' option. +# default_docker_container = "alpine" ## OPTIONALLY, you can specify 'docker run' options, which should be used at command execution. ## See official docker documentation: https://docs.docker.com/reference/cli/docker/container/run/ # docker_run_options = "-it -v .:/app -w /app" @@ -110,14 +110,14 @@ name = "build" description = "builds the project" execute = "echo Build is running" cleanup = ["echo done"] -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "shell" description = "debug container by running shell in interactive mode (keep container running)" execute = "/bin/sh" -docker_container = "alpine" +default_docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" [[commands]] @@ -126,7 +126,7 @@ description = "quick checks/fixes of code formatting (ruff/mypy)" execute = "echo Ruff is formatting the code" cleanup = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" [[commands]] @@ -134,7 +134,7 @@ name = "update-deps" description = "updates dependencies, used in project, to the latest versions" execute = "echo Often it is pain, but if you will script it and put here, it will be super easy" #execute = "scripts/update-requirements.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" ####################################################### @@ -636,6 +636,24 @@ def _generate_command_to_run(command_cfg: dict, command_options: List[str]) -> s return _generate_bin_sh_cmd(before + [execute] + after) +def _get_default_container_from_config(command_cfg: dict) -> Optional[str]: + """ + Get the default container name from command config. + Supports both 'default_docker_container' (preferred) and 'docker_container' (deprecated). + If 'docker_container' is used, logs a deprecation warning. + """ + if "default_docker_container" in command_cfg: + return command_cfg["default_docker_container"] + if "docker_container" in command_cfg: + _logger.error( + "DEPRECATION WARNING: 'docker_container' section in config is deprecated " + "and will be removed after 2027-01-01. " + "Please rename it to 'default_docker_container'." + ) + return command_cfg["docker_container"] + return None + + def _containers_to_use( cfg: dict, command_cfg: dict, containers_requested_via_cli: Optional[List[str]] ) -> list: @@ -649,9 +667,8 @@ def _containers_to_use( This function returns resulting list of containers, which should be used to handle the given command run. """ - containers_from_command_config = ( - [command_cfg["docker_container"]] if "docker_container" in command_cfg else [] - ) + default_container = _get_default_container_from_config(command_cfg) + containers_from_command_config = [default_container] if default_container else [] containers_to_use = containers_requested_via_cli or containers_from_command_config if len(containers_to_use) == 1 and containers_to_use[0] == "*": @@ -797,6 +814,12 @@ class Validator: if present_conflicting: field_errors.append(f"conflicting fields found: {present_conflicting}") + requires_one_of = self._schema[field].get("requires_one_of_fields", set()) + if requires_one_of and not requires_one_of.intersection(set(data.keys())): + field_errors.append( + f"requires one of the following fields to be present: {sorted(requires_one_of)}" + ) + if field_errors: errors[field].extend(field_errors) @@ -832,14 +855,22 @@ _command_validator = Validator( "cleanup": dict(value_type=list, required=False), "examples": dict(value_type=list, required=False), # Docker section + # DEPRECATED: 'docker_container' will be removed on 2027-01-01. + # Use 'default_docker_container' instead. "docker_container": dict( value_type=str, required=False, + excludes_fields={"default_docker_container"}, + ), + "default_docker_container": dict( + value_type=str, + required=False, + excludes_fields={"docker_container"}, ), "docker_run_options": dict( value_type=str, required=False, - requires_fields={"docker_container"}, + requires_one_of_fields={"docker_container", "default_docker_container"}, ), } ) diff --git a/runo.toml b/runo.toml index b819d3b..3ba687f 100644 --- a/runo.toml +++ b/runo.toml @@ -12,28 +12,28 @@ execute = "pytest" # except of htmlcov and pytest_cache, which are useful for us cleanup = ["rm -f runo.py", "rm -rf .coverage", "rm -rf tests/unit/__pycache__", "rm -rf __pycache__"] examples = ["test --cov -vv", "test --last-failed"] -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "shell" description = "debug container by running shell in interactive mode (keep container running)" execute = "/bin/sh" -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)." execute = "scripts/pre_commit.sh" -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[commands]] name = "update-deps" description = "updates dependencies, used in project, to the latest versions" execute = "scripts/update_deps.sh" -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[commands]] @@ -42,7 +42,7 @@ description = "runs 'mypy' static type checker" before = ["source /tmp/runo_venv/.venv/bin/activate"] execute = "mypy" examples = ["mypy runo tests"] -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[commands]] @@ -51,7 +51,7 @@ description = "runs 'ruff' (checker/formatter)" before = ["source /tmp/runo_venv/.venv/bin/activate"] execute = "ruff" examples = ["ruff check runo tests", "ruff format runo tests --check"] -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[docker_containers]] diff --git a/tests/e2e/runo.toml b/tests/e2e/runo.toml index 110ee16..6f70c69 100644 --- a/tests/e2e/runo.toml +++ b/tests/e2e/runo.toml @@ -44,7 +44,7 @@ execute = "dev/say.sh" [[commands]] name = "example_docker_file_single" description = "example of command, executing 1 linux command in docker container, defined with docker file" -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" execute = "grep 'PRETTY_NAME' /etc/os-release" # e2e_tests = ["example_docker_file_single|PRETTY_NAME=\"Alpine Linux v3.20\"\r\n|0"] @@ -52,7 +52,7 @@ execute = "grep 'PRETTY_NAME' /etc/os-release" [[commands]] name = "example_docker_file_multi" description = "example of command, executing 2 linux commands in docker container, defined with docker file" -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" before = ["echo Your OS:"] execute = "grep 'PRETTY_NAME' /etc/os-release" @@ -61,7 +61,7 @@ execute = "grep 'PRETTY_NAME' /etc/os-release" [[commands]] name = "example_docker_file_after" description = "example of 'after' running inside docker container (after execute). Shows RUNO_CONTAINER_NAME is set in container but not on host." -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" before = ["echo CONTAINER_NAME_IN_BEFORE: $RUNO_CONTAINER_NAME"] execute = "echo CONTAINER_NAME_IN_EXECUTE: $RUNO_CONTAINER_NAME" @@ -72,17 +72,26 @@ cleanup = ["echo CLEANUP_RUNO_CONTAINER_NAME_IS_EMPTY: ${RUNO_CONTAINER_NAME:-tr [[commands]] name = "example_docker_file_script" description = "example of command, executing script in docker container, defined with docker file" -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" examples = ["example_docker_file_script say_hello"] before = ["echo Your OS:", "grep 'PRETTY_NAME' /etc/os-release"] execute = "dev/say.sh" # e2e_tests = ["example_docker_file_script hello_from_docker|Your OS:\r\nPRETTY_NAME=\"Alpine Linux v3.20\"\r\nyou asked to print 'hello_from_docker'\r\n|0"] +# Test backward compatibility with deprecated 'docker_container' field +[[commands]] +name = "example_docker_file_deprecated" +description = "example using deprecated docker_container field (backward compatibility test)" +docker_container = "container_defined_by_docker_file" +docker_run_options = "-it -v .:/app -w /app --user 1000:1000" +execute = "grep 'PRETTY_NAME' /etc/os-release" +# e2e_tests = ["example_docker_file_deprecated|PRETTY_NAME=\"Alpine Linux v3.20\"\r\n|0"] + [[commands]] name = "example_docker_compose_simple" description = "example of command, exicuting simple command from docker compose service" -docker_container = "container_defined_by_docker_compose" +default_docker_container = "container_defined_by_docker_compose" docker_run_options = "--quiet --build -i" execute = "echo PASSED" # e2e_tests = ["example_docker_compose_simple|PASSED\n|0"] @@ -90,7 +99,7 @@ execute = "echo PASSED" [[commands]] name = "example_docker_compose_test_client" description = "example of command, which depends on docker compose service" -docker_container = "container_defined_by_docker_compose" +default_docker_container = "container_defined_by_docker_compose" docker_run_options = "--quiet --build -i" execute = "./dev/client.py" # e2e_tests = ["example_docker_compose_test_client|Server is alive\n|0"] diff --git a/tests/unit/test_runo.py b/tests/unit/test_runo.py index 2f5073c..c54a26c 100644 --- a/tests/unit/test_runo.py +++ b/tests/unit/test_runo.py @@ -224,10 +224,10 @@ class TestInit: # 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 default. +## OPTIONALLY you can specify default container, in which command should be executed. ## Container must be defined in the same file. -## It can be overwritten, or set from CLI as well. -# docker_container = "alpine" +## It can be overwritten, or set from CLI as well with '-c'/'--container' option. +# default_docker_container = "alpine" ## OPTIONALLY, you can specify 'docker run' options, which should be used at command execution. ## See official docker documentation: https://docs.docker.com/reference/cli/docker/container/run/ # docker_run_options = "-it -v .:/app -w /app" @@ -237,14 +237,14 @@ class TestInit: description = "builds the project" execute = "echo Build is running" cleanup = ["echo done"] -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "shell" description = "debug container by running shell in interactive mode (keep container running)" execute = "/bin/sh" -docker_container = "alpine" +default_docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" [[commands]] @@ -253,7 +253,7 @@ class TestInit: execute = "echo Ruff is formatting the code" cleanup = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" [[commands]] @@ -261,7 +261,7 @@ class TestInit: description = "updates dependencies, used in project, to the latest versions" execute = "echo Often it is pain, but if you will script it and put here, it will be super easy" #execute = "scripts/update-requirements.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" ####################################################### @@ -937,7 +937,7 @@ class TestLocallyBuiltContainerCommands(BaseCommandsTest): BASE_COMMAND_CFG: dict = { "name": "test_cmd", "description": "-", - "docker_container": "test_docker_file", + "default_docker_container": "test_docker_file", } @pytest.fixture( @@ -1062,7 +1062,7 @@ def test_build_failed( { "name": "test_cmd", "description": "-", - "docker_container": "test_docker_file", + "default_docker_container": "test_docker_file", "execute": "echo OK", } ], @@ -1090,7 +1090,7 @@ class TestContainerFromImageCommands(BaseCommandsTest): BASE_COMMAND_CFG: dict = { "name": "test_cmd", "description": "-", - "docker_container": "test_image_from_repo", + "default_docker_container": "test_image_from_repo", } @pytest.fixture( @@ -1142,7 +1142,7 @@ class TestDockerComposeServiceCommands(BaseCommandsTest): BASE_COMMAND_CFG: dict = { "name": "test_cmd", "description": "-", - "docker_container": "test_docker_compose", + "default_docker_container": "test_docker_compose", } @pytest.fixture( @@ -1279,7 +1279,7 @@ class TestContainersSelection: "name": "command_with_default_container", "description": "-", "execute": "echo OK", - "docker_container": "container1", + "default_docker_container": "container1", }, ], "docker_containers": [ @@ -1449,7 +1449,7 @@ def test_no_any_containers_available(self, config_path, monkeypatch, capfd): "name": "test_cmd", "description": "-", "execute": "echo OK", - "docker_container": "no_such_container", + "default_docker_container": "no_such_container", }, ], } @@ -1475,7 +1475,7 @@ def test_container_configuration_is_wrong(self, config_path, monkeypatch, capfd) "name": "test_cmd", "description": "-", "execute": "echo OK", - "docker_container": "bad_container", + "default_docker_container": "bad_container", }, ], "docker_containers": [ @@ -1573,7 +1573,7 @@ def generate_config_content(containers_to_test: dict, docker_run_options: str): "name": "test_cmd", "description": "-", "execute": "echo OK", - "docker_container": containers_to_test["name"], + "default_docker_container": containers_to_test["name"], "docker_run_options": docker_run_options, }, ], @@ -1834,7 +1834,7 @@ def test_after_runs_inside_container(self, patched_run, capfd, monkeypatch, conf "before": ["echo BEFORE"], "execute": "echo EXECUTE", "after": ["echo AFTER"], - "docker_container": "test_container", + "default_docker_container": "test_container", "docker_run_options": "-v .:/app", } ], @@ -1872,7 +1872,7 @@ def test_cleanup_runs_on_host(self, patched_run, capfd, monkeypatch, config_path "description": "test command", "execute": "echo EXECUTE", "cleanup": ["echo CLEANUP"], - "docker_container": "test_container", + "default_docker_container": "test_container", "docker_run_options": "-v .:/app", } ], @@ -1912,7 +1912,7 @@ def test_after_and_cleanup_together(self, patched_run, capfd, monkeypatch, confi "execute": "echo EXECUTE", "after": ["echo AFTER_IN_CONTAINER"], "cleanup": ["echo CLEANUP_ON_HOST"], - "docker_container": "test_container", + "default_docker_container": "test_container", "docker_run_options": "-v .:/app", } ], @@ -1970,3 +1970,183 @@ def test_after_runs_natively_without_container( assert std_err == "" expected_cmd = "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER'" assert expected_cmd in std_out + + +class TestDefaultDockerContainer: + """ + Test the 'default_docker_container' field which replaces the deprecated 'docker_container'. + """ + + @patch("runo.subprocess.run") + def test_default_docker_container_works(self, patched_run, capfd, monkeypatch, config_path): + """ + 'default_docker_container' should work the same as 'docker_container'. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + # No deprecation warning should be shown + assert "DEPRECATION WARNING" not in std_err + # Container should be used + assert "docker run" in std_out + assert "alpine:latest" in std_out + + @patch("runo.subprocess.run") + def test_deprecated_docker_container_shows_warning( + self, patched_run, capfd, monkeypatch, config_path + ): + """ + 'docker_container' should still work but show a deprecation warning. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + # Deprecation warning should be shown with exact text + expected_warning = ( + "DEPRECATION WARNING: 'docker_container' section in config is deprecated " + "and will be removed after 2027-01-01. " + "Please rename it to 'default_docker_container'.\n" + ) + assert std_err == expected_warning + # Container should still be used + assert "docker run" in std_out + assert "alpine:latest" in std_out + + def test_both_fields_conflict(self, capfd, monkeypatch, config_path): + """ + Using both 'docker_container' and 'default_docker_container' should fail validation. + """ + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "docker_container": "container1", + "default_docker_container": "container2", + } + ], + "docker_containers": [ + {"name": "container1", "docker_image": "alpine:latest"}, + {"name": "container2", "docker_image": "alpine:latest"}, + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit) as exc_info, _config_file( + toml.dumps(config_content), config_path + ): + main() + + assert exc_info.value.code == os.EX_CONFIG + std_out, std_err = capfd.readouterr() + assert "conflicting fields found" in std_err + + @patch("runo.subprocess.run") + def test_docker_run_options_works_with_default_docker_container( + self, patched_run, capfd, monkeypatch, config_path + ): + """ + 'docker_run_options' should work correctly with 'default_docker_container'. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app -w /app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + assert "-v .:/app" in std_out + assert "-w /app" in std_out + + def test_docker_run_options_requires_container_field(self, capfd, monkeypatch, config_path): + """ + 'docker_run_options' should fail if neither 'docker_container' nor + 'default_docker_container' is present. + """ + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "docker_run_options": "-v .:/app", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit) as exc_info, _config_file( + toml.dumps(config_content), config_path + ): + main() + + assert exc_info.value.code == os.EX_CONFIG + std_out, std_err = capfd.readouterr() + assert "requires one of the following fields" in std_err