Skip to content

Commit 76215ea

Browse files
committed
Add task log capture and export (closes #169)
1 parent 0a02ef7 commit 76215ea

File tree

12 files changed

+587
-52
lines changed

12 files changed

+587
-52
lines changed

docs/source/_static/md/commands/build-options.md

Lines changed: 39 additions & 30 deletions
Large diffs are not rendered by default.
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
| Command | Description |
2-
| ----------------------------------------- | ------------------------------------------------------------- |
3-
| [`build`](../../../commands/build.md) | Collect tasks, execute them and report the results. |
4-
| [`clean`](../../../commands/clean.md) | Clean the provided paths by removing files unknown to pytask. |
5-
| [`collect`](../../../commands/collect.md) | Collect tasks and report information about them. |
6-
| [`dag`](../../../commands/dag.md) | Create a visualization of the directed acyclic graph. |
7-
| [`markers`](../../../commands/markers.md) | Show all registered markers. |
8-
| [`profile`](../../../commands/profile.md) | Show information about resource consumption. |
1+
| Command | Description |
2+
| ----------------------- | ------------------------------------------------------------- |
3+
| [`build`](build.md) | Collect tasks, execute them and report the results. |
4+
| [`clean`](clean.md) | Clean the provided paths by removing files unknown to pytask. |
5+
| [`collect`](collect.md) | Collect tasks and report information about them. |
6+
| [`dag`](dag.md) | Create a visualization of the directed acyclic graph. |
7+
| [`markers`](markers.md) | Show all registered markers. |
8+
| [`profile`](profile.md) | Show information about resource consumption. |

docs/source/tutorials/capturing_output.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ default.
1010
If the task fails, the output is shown along with the traceback to help you track down
1111
the error.
1212

13-
## Default stdout/stderr/stdin capturing behavior
13+
## Default stdout/stderr/logging/stdin capturing behavior
1414

1515
Any output sent to `stdout` and `stderr` is captured during task execution. pytask
1616
displays it only if the task fails in addition to the traceback.
1717

18+
Log records emitted with Python's `logging` module are also captured during task
19+
execution and shown in their own report section for failing tasks.
20+
1821
In addition, `stdin` is set to a "null" object which will fail on attempts to read from
1922
it because it is rarely desired to wait for interactive input when running automated
2023
tasks.
@@ -46,6 +49,15 @@ $ pytask --capture=tee-sys # combines 'sys' and '-s', capturing sys.stdout/std
4649
# and passing it along to the actual sys.stdout/stderr
4750
```
4851

52+
## Controlling captured log output
53+
54+
Use `--show-capture=log` to only display captured log records for failing tasks or
55+
`--show-capture=all` to display logs together with captured `stdout` and `stderr`.
56+
57+
You can also export task logs to a file with `--log-file` and customize the formatting
58+
with `--log-format`, `--log-date-format`, `--log-file-format`, and
59+
`--log-file-date-format`.
60+
4961
## Using print statements for debugging
5062

5163
One primary benefit of the default capturing of stdout/stderr output is that you can use

scripts/demo_logging_capture.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Run a small demo project for log capture and export.
2+
3+
Usage
4+
-----
5+
uv run python scripts/demo_logging_capture.py
6+
7+
The script creates a temporary demo project, runs a few `pytask` commands against it,
8+
prints their output, and leaves the files on disk for inspection.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import subprocess
15+
import sys
16+
import tempfile
17+
import textwrap
18+
from pathlib import Path
19+
20+
21+
def main() -> None:
22+
repo_root = Path(__file__).resolve().parents[1]
23+
demo_root = Path(tempfile.mkdtemp(prefix="pytask-logging-demo-"))
24+
_write_line(f"Demo project: {demo_root}")
25+
_write_line()
26+
27+
_write_demo_project(demo_root)
28+
29+
_run_demo_case(
30+
repo_root=repo_root,
31+
title="Show only captured logs for the failing task",
32+
args=[
33+
str(demo_root),
34+
"--log-level=INFO",
35+
"--log-date-format=%H:%M:%S",
36+
"--log-format=%(asctime)s %(levelname)-8s %(name)s:%(message)s",
37+
"--show-capture=log",
38+
],
39+
)
40+
41+
_run_demo_case(
42+
repo_root=repo_root,
43+
title="Show logs, stdout, and stderr, and export logs to a file",
44+
args=[
45+
str(demo_root),
46+
"--force",
47+
"--log-level=INFO",
48+
"--log-date-format=%H:%M:%S",
49+
"--show-capture=all",
50+
"--log-file=build.log",
51+
"--log-format=%(asctime)s %(levelname)-8s %(name)s:%(message)s",
52+
],
53+
)
54+
55+
log_file = demo_root.joinpath("build.log")
56+
if log_file.exists():
57+
_write_line("=" * 80)
58+
_write_line(f"Exported log file: {log_file}")
59+
_write_line("=" * 80)
60+
_write(log_file.read_text())
61+
_write_line()
62+
63+
_write_line("=" * 80)
64+
_write_line("You can rerun the demo project manually with commands like:")
65+
_write_line(f" cd {repo_root}")
66+
_write_line(
67+
" uv run python -m pytask "
68+
f"{demo_root} --log-level=INFO --show-capture=all --log-file=build.log"
69+
)
70+
_write_line("=" * 80)
71+
72+
73+
def _write_demo_project(demo_root: Path) -> None:
74+
source = """
75+
from __future__ import annotations
76+
77+
import logging
78+
import sys
79+
from pathlib import Path
80+
81+
logger = logging.getLogger("demo")
82+
83+
84+
def task_prepare_report(produces=Path("report.txt")):
85+
logger.info("preparing report.txt")
86+
produces.write_text("report created\\n")
87+
88+
89+
def task_publish_report(
90+
depends_on=Path("report.txt"), produces=Path("published.txt")
91+
):
92+
logger.warning("publishing report is about to fail")
93+
print("stdout from task_publish_report")
94+
sys.stderr.write("stderr from task_publish_report\\n")
95+
raise RuntimeError("simulated publish failure")
96+
"""
97+
demo_root.joinpath("task_logging_demo.py").write_text(textwrap.dedent(source))
98+
99+
100+
def _run_demo_case(*, repo_root: Path, title: str, args: list[str]) -> None:
101+
command = [sys.executable, "-m", "pytask", *args]
102+
rendered_command = " ".join(command)
103+
104+
_write_line("=" * 80)
105+
_write_line(title)
106+
_write_line(rendered_command)
107+
_write_line("=" * 80)
108+
109+
result = subprocess.run(
110+
command,
111+
cwd=repo_root,
112+
check=False,
113+
text=True,
114+
capture_output=True,
115+
env=os.environ | {"PYTHONIOENCODING": "utf-8"},
116+
)
117+
118+
if result.stdout:
119+
_write(result.stdout)
120+
if not result.stdout.endswith("\n"):
121+
_write_line()
122+
if result.stderr:
123+
_write_line("-" * 80)
124+
_write_line("stderr")
125+
_write_line("-" * 80)
126+
_write(result.stderr)
127+
if not result.stderr.endswith("\n"):
128+
_write_line()
129+
130+
_write_line("-" * 80)
131+
_write_line(f"exit code: {result.returncode}")
132+
_write_line()
133+
134+
135+
def _write(text: str) -> None:
136+
sys.stdout.write(text)
137+
138+
139+
def _write_line(text: str = "") -> None:
140+
sys.stdout.write(f"{text}\n")
141+
142+
143+
if __name__ == "__main__":
144+
main()

src/_pytask/build.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,16 @@ def build( # noqa: PLR0913
8585
paths: Path | Iterable[Path] = (),
8686
pdb: bool = False,
8787
pdb_cls: str = "",
88+
log_date_format: str = "%H:%M:%S",
89+
log_file: Path | str | None = None,
90+
log_file_date_format: str | None = None,
91+
log_file_format: str | None = None,
92+
log_file_level: int | str | None = None,
93+
log_file_mode: Literal["w", "a"] = "w",
94+
log_format: str = "%(levelname)-8s %(name)s:%(filename)s:%(lineno)d %(message)s",
95+
log_level: int | str | None = None,
8896
s: bool = False,
89-
show_capture: Literal["no", "stdout", "stderr", "all"]
97+
show_capture: Literal["no", "stdout", "stderr", "log", "all"]
9098
| ShowCapture = ShowCapture.ALL,
9199
show_errors_immediately: bool = False,
92100
show_locals: bool = False,
@@ -148,9 +156,26 @@ def build( # noqa: PLR0913
148156
pdb_cls : str, default=""
149157
Start a custom debugger on errors. For example:
150158
``--pdbcls=IPython.terminal.debugger:TerminalPdb``
159+
log_date_format : str, default="%H:%M:%S"
160+
The date format used for captured logs.
161+
log_file : Path | str | None, default=None
162+
A path to a file where logs from executed tasks should be written.
163+
log_file_date_format : str | None, default=None
164+
The date format used for exported logs. Falls back to ``log_date_format``.
165+
log_file_format : str | None, default=None
166+
The format used for exported logs. Falls back to ``log_format``.
167+
log_file_level : int | str | None, default=None
168+
The level of messages written to the log file. Falls back to ``log_level``.
169+
log_file_mode : Literal["w", "a"], default="w"
170+
The file mode used for the exported log file.
171+
log_format : str, default="%(levelname)-8s %(name)s:%(filename)s:%(lineno)d "
172+
"%(message)s"
173+
The format used for captured logs.
174+
log_level : int | str | None, default=None
175+
The level of messages to capture. If not set, the logger configuration is used.
151176
s : bool, default=False
152177
Shortcut for ``capture="no"``.
153-
show_capture : Literal["no", "stdout", "stderr", "all"] | ShowCapture
178+
show_capture : Literal["no", "stdout", "stderr", "log", "all"] | ShowCapture
154179
Choose which captured output should be shown for failed tasks.
155180
show_errors_immediately : bool, default=False
156181
Show errors with tracebacks as soon as the task fails.
@@ -202,6 +227,14 @@ def build( # noqa: PLR0913
202227
"paths": paths,
203228
"pdb": pdb,
204229
"pdb_cls": pdb_cls,
230+
"log_date_format": log_date_format,
231+
"log_file": log_file,
232+
"log_file_date_format": log_file_date_format,
233+
"log_file_format": log_file_format,
234+
"log_file_level": log_file_level,
235+
"log_file_mode": log_file_mode,
236+
"log_format": log_format,
237+
"log_level": log_level,
205238
"s": s,
206239
"show_capture": show_capture,
207240
"show_errors_immediately": show_errors_immediately,

src/_pytask/capture_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class ShowCapture(enum.Enum):
99
NO = "no"
1010
STDOUT = "stdout"
1111
STDERR = "stderr"
12+
LOG = "log"
1213
ALL = "all"
1314

1415

0 commit comments

Comments
 (0)