From 72ec368aa249006b7c54e4c8a9ca500f8511cf23 Mon Sep 17 00:00:00 2001 From: Nat Noordanus Date: Sat, 18 Nov 2023 18:54:33 +0200 Subject: [PATCH] Improve, test, and document capture_stdout option (#188) - Add validations for capture_stdout option - Make capture_stdout resolve relative to project_root - Substitute environment variables in the capture_stdout option - Add two relevant test cases - Add documentation for the option --- docs/tasks/options.rst | 29 ++++++++++++++++++-- poethepoet/executor/base.py | 10 +++++-- poethepoet/task/base.py | 27 ++++++++++++------ poethepoet/task/switch.py | 1 - tests/fixtures/cmds_project/pyproject.toml | 4 +++ tests/fixtures/switch_project/pyproject.toml | 14 +++++++++- tests/test_cmd_tasks.py | 26 ++++++++++++++++++ tests/test_switch_task.py | 14 ++++++++++ 8 files changed, 110 insertions(+), 15 deletions(-) diff --git a/docs/tasks/options.rst b/docs/tasks/options.rst index 3af75b0c..a32897fa 100644 --- a/docs/tasks/options.rst +++ b/docs/tasks/options.rst @@ -29,6 +29,9 @@ The following options can be configured on your tasks and are not specific to an Allows this task to use the output of other tasks which are executed first. The value is a map where the values are invocations of the other tasks, and the keys are environment variables by which the results of those tasks will be accessible in this task. +**capture_stdout** : ``str`` :ref:`📖` + Causes the task output to be redirected to a file with the given path. + **use_exec** : ``bool`` :ref:`📖` Specify that this task should be executed in the same process, instead of as a subprocess. @@ -36,7 +39,6 @@ The following options can be configured on your tasks and are not specific to an This option is only applicable to **cmd**, **script**, and **expr** tasks, and it implies the task in question cannot be referenced by another task. - Setting task specific environment variables ------------------------------------------- @@ -76,7 +78,7 @@ It is also possible to reference existing environment variables when defining a .. _envfile_option: Loading environment variables from an env file ----------------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can also specify one or more env files (with bash-like syntax) to load per task like so: @@ -126,8 +128,28 @@ Poe provides its own :sh:`$POE_PWD` variable that is by default set to the direc cwd = "${POE_PWD}" +Redirect task output to a file +------------------------------ + +You can configure poe to redirect the standard output of a task to a file on disk by providing the ``capture_stdout`` option like so. + +.. code-block:: toml + + [tool.poe.tasks.serve] + cmd = "gunicorn ./my_app:run" + capture_stdout = "gunicorn_log.txt" + +If a relative path is provided, as in the example above, then it will be resolved relative to the project root directory. + +The ``capture_stdout`` option supports referencing environment variables. For example setting ``capture_stdout = "${POE_PWD}/output.txt"`` will cause the output file to be created within the current working directory of the parent process. + +.. warning:: + + The ``capture_stdout`` is incompatible with the ``use_exec`` option, and tasks that declare it cannot be referenced by another task via the ``uses`` option. + + Defining tasks that run via exec instead of a subprocess -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------------------------------------- Normally tasks are executed as subprocesses of the ``poe`` executable. This makes it possible for poe to run multiple tasks, for example within a sequence task or task graph. @@ -145,3 +167,4 @@ However in certain situations it can be desirable to define a task that is inste 1. a task configured in this way may not be referenced by another task 2. this does not work on windows becuase of `this issue `_. On windows a subprocess is always created. + diff --git a/poethepoet/executor/base.py b/poethepoet/executor/base.py index 2699a5cd..76736107 100644 --- a/poethepoet/executor/base.py +++ b/poethepoet/executor/base.py @@ -66,7 +66,13 @@ def __init__( self.working_dir = working_dir self.env = env self.dry = dry - self.capture_stdout = capture_stdout + self.capture_stdout = ( + Path(self.context.config.project_dir).joinpath( + self.env.fill_template(capture_stdout) + ) + if isinstance(capture_stdout, str) + else capture_stdout + ) self._is_windows = sys.platform == "win32" @classmethod @@ -227,7 +233,7 @@ def _exec_via_subproc( if input is not None: popen_kwargs["stdin"] = PIPE if self.capture_stdout: - if isinstance(self.capture_stdout, str): + if isinstance(self.capture_stdout, Path): # ruff: noqa: SIM115 popen_kwargs["stdout"] = open(self.capture_stdout, "wb") else: diff --git a/poethepoet/task/base.py b/poethepoet/task/base.py index 9f25374f..0e4cf378 100644 --- a/poethepoet/task/base.py +++ b/poethepoet/task/base.py @@ -449,6 +449,12 @@ def validate_def( "line breaks" ) + if task_def.get("use_exec") and task_def.get("capture_stdout"): + return ( + f"Invalid task: {task_name!r}, 'use_exec' and 'capture_stdout'" + " options cannot be both provided on the same task." + ) + all_task_names = set(config.tasks.keys()) if "deps" in task_def: @@ -488,14 +494,19 @@ def validate_def( ) referenced_task = config.tasks[dep_task_name] - if isinstance(referenced_task, dict) and referenced_task.get( - "use_exec" - ): - return ( - f"Invalid task: {task_name!r}. uses options contains " - "reference to task with use_exec set to true: " - f"{dep_task_name!r}" - ) + if isinstance(referenced_task, dict): + if referenced_task.get("use_exec"): + return ( + f"Invalid task: {task_name!r}, 'uses' option references" + " task with 'use_exec' set to true: " + f"{dep_task_name!r}" + ) + if referenced_task.get("capture_stdout"): + return ( + f"Invalid task: {task_name!r}, 'uses' option references" + " task with 'capture_stdout' option set: " + f"{dep_task_name!r}" + ) elif isinstance(task_def, list): task_type_key = config.default_array_task_type diff --git a/poethepoet/task/switch.py b/poethepoet/task/switch.py index 37ee0a31..719728c3 100644 --- a/poethepoet/task/switch.py +++ b/poethepoet/task/switch.py @@ -51,7 +51,6 @@ def __init__( capture_stdout: bool = False, inheritance: Optional[TaskInheritance] = None, ): - assert capture_stdout is False super().__init__( name, content, options, ui, config, invocation, False, inheritance ) diff --git a/tests/fixtures/cmds_project/pyproject.toml b/tests/fixtures/cmds_project/pyproject.toml index a36e010b..be064065 100644 --- a/tests/fixtures/cmds_project/pyproject.toml +++ b/tests/fixtures/cmds_project/pyproject.toml @@ -28,3 +28,7 @@ cmd = "poe_test_echo \"first: ${first} second: ${second}\"" positional = true multiple = true type = "integer" + +[tool.poe.tasks.meeseeks] +cmd = """poe_test_echo "I'm Mr. Meeseeks! Look at me!" """ +capture_stdout = "${POE_PWD}/message.txt" diff --git a/tests/fixtures/switch_project/pyproject.toml b/tests/fixtures/switch_project/pyproject.toml index d16d558c..3857a55a 100644 --- a/tests/fixtures/switch_project/pyproject.toml +++ b/tests/fixtures/switch_project/pyproject.toml @@ -81,8 +81,20 @@ control.expr = "42" cmd = "echo 'matched'" [[tool.poe.tasks.switcher.switch]] - cmd = "echo other" + cmd = "echo default" [tool.poe.tasks.switcher_user] uses = { switched = "switcher" } cmd = "echo switched=$switched" + + +[tool.poe.tasks.capture_out] +control.expr = "43" +capture_stdout = "./out.txt" + + [[tool.poe.tasks.capture_out.switch]] + case = "42" + cmd = "echo 'matched'" + + [[tool.poe.tasks.capture_out.switch]] + cmd = "echo default" diff --git a/tests/test_cmd_tasks.py b/tests/test_cmd_tasks.py index 8cdd7b12..1965dee2 100644 --- a/tests/test_cmd_tasks.py +++ b/tests/test_cmd_tasks.py @@ -1,3 +1,6 @@ +from pathlib import Path + + def test_call_echo_task(run_poe_subproc, projects, esc_prefix, is_windows): result = run_poe_subproc("echo", "foo", "!", project="cmds") if is_windows: @@ -166,3 +169,26 @@ def test_cmd_task_with_with_glob_arg_and_cwd( assert result.capture.endswith("foo\n") assert result.stdout == "bar.txt\n" assert result.stderr == "" + + +def test_cmd_with_capture_stdout(run_poe_subproc, projects, poe_project_path): + result = run_poe_subproc( + "--root", + str(projects["cmds"]), + "meeseeks", + project="cmds", + cwd=poe_project_path, + ) + assert ( + result.capture + == """Poe <= poe_test_echo 'I'"'"'m Mr. Meeseeks! Look at me!'\n""" + ) + assert result.stdout == "" + assert result.stderr == "" + + output_path = Path("message.txt") + try: + with output_path.open("r") as output_file: + assert output_file.read() == "I'm Mr. Meeseeks! Look at me!\n" + finally: + output_path.unlink() diff --git a/tests/test_switch_task.py b/tests/test_switch_task.py index 7874043d..6f0943fc 100644 --- a/tests/test_switch_task.py +++ b/tests/test_switch_task.py @@ -119,3 +119,17 @@ def test_switch_multivalue_case(run_poe_subproc): ) assert result.stdout == "It is not in 1-6\n" assert result.stderr == "" + + +def test_switch_capture_out(run_poe_subproc, projects): + result = run_poe_subproc("capture_out", project="switch") + assert result.capture == ("Poe <= 43\n" "Poe <= echo default\n") + assert result.stdout == "" + assert result.stderr == "" + + output_path = projects["switch"].joinpath("out.txt") + try: + with output_path.open("r") as output_file: + assert output_file.read() == "default\n" + finally: + output_path.unlink()