structlog-config 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structlog_config/__init__.py +19 -8
- structlog_config/environments.py +1 -24
- structlog_config/fastapi_access_logger.py +1 -1
- structlog_config/formatters.py +4 -4
- structlog_config/hook.py +20 -0
- structlog_config/packages.py +7 -7
- structlog_config/pytest_plugin.py +349 -167
- structlog_config/stdlib_logging.py +2 -1
- structlog_config/trace.py +39 -12
- {structlog_config-0.8.0.dist-info → structlog_config-0.9.0.dist-info}/METADATA +82 -47
- structlog_config-0.9.0.dist-info/RECORD +17 -0
- {structlog_config-0.8.0.dist-info → structlog_config-0.9.0.dist-info}/WHEEL +2 -2
- structlog_config-0.9.0.dist-info/entry_points.txt +3 -0
- structlog_config-0.8.0.dist-info/RECORD +0 -15
structlog_config/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from contextlib import _GeneratorContextManager
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Protocol
|
|
3
3
|
|
|
4
4
|
import orjson
|
|
5
5
|
import structlog
|
|
@@ -20,11 +20,12 @@ from structlog_config.formatters import (
|
|
|
20
20
|
|
|
21
21
|
from . import (
|
|
22
22
|
packages,
|
|
23
|
-
trace, # noqa: F401
|
|
23
|
+
trace, # noqa: F401 (import has side effects for trace level setup)
|
|
24
24
|
)
|
|
25
25
|
from .constants import NO_COLOR, package_logger
|
|
26
|
-
from .environments import
|
|
26
|
+
from .environments import is_pytest
|
|
27
27
|
from .levels import get_environment_log_level_as_string
|
|
28
|
+
from .hook import install_exception_hook
|
|
28
29
|
from .stdlib_logging import (
|
|
29
30
|
redirect_stdlib_loggers,
|
|
30
31
|
)
|
|
@@ -146,6 +147,10 @@ class LoggerWithContext(FilteringBoundLogger, Protocol):
|
|
|
146
147
|
"clear thread-local context"
|
|
147
148
|
...
|
|
148
149
|
|
|
150
|
+
def trace(self, *args, **kwargs) -> None: # noqa: F811
|
|
151
|
+
"trace level logging"
|
|
152
|
+
...
|
|
153
|
+
|
|
149
154
|
|
|
150
155
|
# TODO this may be a bad idea, but I really don't like how the `bound` stuff looks and how to access it, way too ugly
|
|
151
156
|
def add_simple_context_aliases(log) -> LoggerWithContext:
|
|
@@ -157,7 +162,10 @@ def add_simple_context_aliases(log) -> LoggerWithContext:
|
|
|
157
162
|
|
|
158
163
|
|
|
159
164
|
def configure_logger(
|
|
160
|
-
*,
|
|
165
|
+
*,
|
|
166
|
+
json_logger: bool = False,
|
|
167
|
+
logger_factory=None,
|
|
168
|
+
install_exception_hook: bool = False,
|
|
161
169
|
) -> LoggerWithContext:
|
|
162
170
|
"""
|
|
163
171
|
Create a struct logger with some special additions:
|
|
@@ -170,9 +178,10 @@ def configure_logger(
|
|
|
170
178
|
>>> log.clear()
|
|
171
179
|
|
|
172
180
|
Args:
|
|
181
|
+
json_logger: Flag to use JSON logging. Defaults to False.
|
|
173
182
|
logger_factory: Optional logger factory to override the default
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
install_exception_hook: Optional flag to install a global exception hook
|
|
184
|
+
that logs uncaught exceptions using structlog. Defaults to False.
|
|
176
185
|
"""
|
|
177
186
|
setup_trace()
|
|
178
187
|
|
|
@@ -180,8 +189,10 @@ def configure_logger(
|
|
|
180
189
|
# This is important for tests where configure_logger might be called multiple times
|
|
181
190
|
structlog.reset_defaults()
|
|
182
191
|
|
|
183
|
-
if
|
|
184
|
-
|
|
192
|
+
if install_exception_hook:
|
|
193
|
+
from .hook import install_exception_hook as _install_hook
|
|
194
|
+
|
|
195
|
+
_install_hook(json_logger)
|
|
185
196
|
|
|
186
197
|
redirect_stdlib_loggers(json_logger)
|
|
187
198
|
redirect_showwarnings()
|
structlog_config/environments.py
CHANGED
|
@@ -1,31 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
|
-
import typing as t
|
|
3
|
-
|
|
4
|
-
from decouple import config
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def python_environment() -> str:
|
|
8
|
-
return t.cast(str, config("PYTHON_ENV", default="development", cast=str)).lower()
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def is_testing():
|
|
12
|
-
return python_environment() == "test"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def is_production():
|
|
16
|
-
return python_environment() == "production"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def is_staging():
|
|
20
|
-
return python_environment() == "staging"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def is_development():
|
|
24
|
-
return python_environment() == "development"
|
|
25
2
|
|
|
26
3
|
|
|
27
4
|
def is_pytest():
|
|
28
5
|
"""
|
|
29
6
|
PYTEST_CURRENT_TEST is set by pytest to indicate the current test being run
|
|
30
7
|
"""
|
|
31
|
-
return "PYTEST_CURRENT_TEST" in os.environ
|
|
8
|
+
return "PYTEST_CURRENT_TEST" in os.environ
|
|
@@ -61,7 +61,7 @@ def client_ip_from_request(request: Request | WebSocket) -> str | None:
|
|
|
61
61
|
Uses fastapi-ipware library to properly extract client IP from various proxy headers.
|
|
62
62
|
Fallback to direct client connection if no proxy headers found.
|
|
63
63
|
"""
|
|
64
|
-
ip, trusted_route = ipware.get_client_ip_from_request(request)
|
|
64
|
+
ip, trusted_route = ipware.get_client_ip_from_request(request) # type: ignore
|
|
65
65
|
if ip:
|
|
66
66
|
log.debug(
|
|
67
67
|
"extracted client IP from headers", ip=ip, trusted_route=trusted_route
|
structlog_config/formatters.py
CHANGED
|
@@ -21,9 +21,9 @@ def simplify_activemodel_objects(
|
|
|
21
21
|
What's tricky about this method, and other structlog processors, is they are run *after* a response
|
|
22
22
|
is returned to the user. So, they don't error out in tests and it doesn't impact users. They do show up in Sentry.
|
|
23
23
|
"""
|
|
24
|
-
from activemodel import BaseModel
|
|
25
|
-
from sqlalchemy.orm.base import object_state
|
|
26
|
-
from typeid import TypeID
|
|
24
|
+
from activemodel import BaseModel # type: ignore
|
|
25
|
+
from sqlalchemy.orm.base import object_state # type: ignore
|
|
26
|
+
from typeid import TypeID # type: ignore
|
|
27
27
|
|
|
28
28
|
for key, value in list(event_dict.items()):
|
|
29
29
|
if isinstance(value, BaseModel):
|
|
@@ -181,7 +181,7 @@ def add_fastapi_context(
|
|
|
181
181
|
|
|
182
182
|
https://github.com/tomwojcik/starlette-context/blob/master/example/setup_logging.py
|
|
183
183
|
"""
|
|
184
|
-
from starlette_context import context
|
|
184
|
+
from starlette_context import context # type: ignore
|
|
185
185
|
|
|
186
186
|
if context.exists():
|
|
187
187
|
event_dict.update(context.data)
|
structlog_config/hook.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def install_exception_hook(json_logger: bool = False):
|
|
7
|
+
def structlog_excepthook(exc_type, exc_value, exc_traceback):
|
|
8
|
+
if issubclass(exc_type, KeyboardInterrupt):
|
|
9
|
+
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
|
10
|
+
return
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger()
|
|
13
|
+
|
|
14
|
+
# We rely on structlog's configuration (configured in __init__.py)
|
|
15
|
+
# to handle the exception formatting based on whether it's JSON or Console mode.
|
|
16
|
+
logger.exception(
|
|
17
|
+
"uncaught_exception", exc_info=(exc_type, exc_value, exc_traceback)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
sys.excepthook = structlog_excepthook
|
structlog_config/packages.py
CHANGED
|
@@ -3,36 +3,36 @@ Determine if certain packages are installed to conditionally enable processors
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
try:
|
|
6
|
-
import orjson
|
|
6
|
+
import orjson # type: ignore
|
|
7
7
|
except ImportError:
|
|
8
8
|
orjson = None
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
|
-
import sqlalchemy
|
|
11
|
+
import sqlalchemy # type: ignore
|
|
12
12
|
except ImportError:
|
|
13
13
|
sqlalchemy = None
|
|
14
14
|
|
|
15
15
|
try:
|
|
16
|
-
import activemodel
|
|
16
|
+
import activemodel # type: ignore
|
|
17
17
|
except ImportError:
|
|
18
18
|
activemodel = None
|
|
19
19
|
|
|
20
20
|
try:
|
|
21
|
-
import typeid
|
|
21
|
+
import typeid # type: ignore
|
|
22
22
|
except ImportError:
|
|
23
23
|
typeid = None
|
|
24
24
|
|
|
25
25
|
try:
|
|
26
|
-
import beautiful_traceback
|
|
26
|
+
import beautiful_traceback # type: ignore
|
|
27
27
|
except ImportError:
|
|
28
28
|
beautiful_traceback = None
|
|
29
29
|
|
|
30
30
|
try:
|
|
31
|
-
import starlette_context
|
|
31
|
+
import starlette_context # type: ignore
|
|
32
32
|
except ImportError:
|
|
33
33
|
starlette_context = None
|
|
34
34
|
|
|
35
35
|
try:
|
|
36
|
-
import whenever
|
|
36
|
+
import whenever # type: ignore
|
|
37
37
|
except ImportError:
|
|
38
38
|
whenever = None
|
|
@@ -1,222 +1,404 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
1
|
+
"""Pytest plugin for capturing test output to files on failure.
|
|
2
|
+
|
|
3
|
+
This plugin captures stdout, stderr, and exceptions from failing tests and writes
|
|
4
|
+
them to organized output directories. It supports both simple capture (via sys.stdout/stderr
|
|
5
|
+
replacement) and fd-level capture (for subprocess output).
|
|
6
|
+
|
|
7
|
+
Relationship to pytest's built-in capture:
|
|
8
|
+
- pytest has built-in output capture that shows output only for failing tests
|
|
9
|
+
- This plugin REPLACES pytest's capture (we require -s to disable it)
|
|
10
|
+
- Instead of showing output inline, we write it to organized files
|
|
11
|
+
- Useful for CI/CD where you need persistent files to inspect later
|
|
12
|
+
|
|
13
|
+
Capture modes:
|
|
14
|
+
1. SimpleCapture (default): Like pytest's capture, replaces sys.stdout/stderr
|
|
15
|
+
- Captures: print(), logging, most Python output
|
|
16
|
+
- Misses: subprocess output, direct fd writes
|
|
3
17
|
|
|
4
|
-
|
|
5
|
-
|
|
18
|
+
2. FdCapture (opt-in): OS-level file descriptor redirection
|
|
19
|
+
- Captures: everything SimpleCapture does PLUS subprocess output
|
|
20
|
+
- Activate via _fd_capture fixture in conftest.py
|
|
6
21
|
|
|
7
22
|
Usage:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
pytest --structlog-output=./test-output -s
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--structlog-output=DIR Enable output capture and write to DIR
|
|
27
|
+
|
|
28
|
+
Requirements:
|
|
29
|
+
- Must use -s (--capture=no) flag to disable pytest's built-in capture
|
|
30
|
+
|
|
31
|
+
Output Structure:
|
|
32
|
+
DIR/
|
|
33
|
+
test_module__test_name/
|
|
34
|
+
stdout.txt # stdout from test
|
|
35
|
+
stderr.txt # stderr from test
|
|
36
|
+
exception.txt # exception traceback
|
|
37
|
+
|
|
38
|
+
Enabling fd-level capture (optional):
|
|
39
|
+
To capture subprocess output, you have three options:
|
|
40
|
+
|
|
41
|
+
1. Single test - add to function signature:
|
|
42
|
+
def test_foo(file_descriptor_output_capture):
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
2. Single test - use marker decorator:
|
|
46
|
+
@pytest.mark.usefixtures("file_descriptor_output_capture")
|
|
47
|
+
def test_foo():
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
3. All tests in directory - add to conftest.py:
|
|
51
|
+
import pytest
|
|
52
|
+
pytestmark = pytest.mark.usefixtures("file_descriptor_output_capture")
|
|
32
53
|
"""
|
|
33
54
|
|
|
34
|
-
import logging
|
|
35
55
|
import os
|
|
36
|
-
import
|
|
37
|
-
import shutil
|
|
56
|
+
import sys
|
|
38
57
|
import tempfile
|
|
58
|
+
from dataclasses import dataclass
|
|
39
59
|
from pathlib import Path
|
|
40
|
-
from typing import Generator
|
|
41
60
|
|
|
42
61
|
import pytest
|
|
62
|
+
import structlog
|
|
63
|
+
|
|
64
|
+
logger = structlog.get_logger(logger_name=__name__)
|
|
43
65
|
|
|
44
|
-
|
|
66
|
+
CAPTURE_KEY = pytest.StashKey[dict]()
|
|
45
67
|
|
|
46
|
-
PLUGIN_KEY = pytest.StashKey[dict]()
|
|
47
|
-
SESSION_TMPDIR_KEY = "session_tmpdir"
|
|
48
68
|
|
|
69
|
+
@dataclass
|
|
70
|
+
class CapturedOutput:
|
|
71
|
+
"""Container for captured output from a test phase."""
|
|
49
72
|
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
stdout: str
|
|
74
|
+
stderr: str
|
|
75
|
+
exception: str | None = None
|
|
52
76
|
|
|
53
|
-
Args:
|
|
54
|
-
name: The filename to sanitize (typically a pytest nodeid).
|
|
55
77
|
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
class SimpleCapture:
|
|
79
|
+
"""Captures via sys.stdout/sys.stderr replacement. No subprocess support.
|
|
80
|
+
|
|
81
|
+
This works similarly to pytest's built-in capture (which we disable with -s).
|
|
82
|
+
It replaces sys.stdout and sys.stderr with StringIO objects, capturing any
|
|
83
|
+
Python code that writes to these streams (print(), logging, etc.).
|
|
84
|
+
|
|
85
|
+
Limitations:
|
|
86
|
+
- Does NOT capture subprocess output (subprocesses inherit file descriptors,
|
|
87
|
+
not Python sys.stdout/stderr objects)
|
|
88
|
+
- Does NOT capture direct file descriptor writes (os.write(1, ...))
|
|
89
|
+
- Only captures output from the current Python process
|
|
90
|
+
|
|
91
|
+
This is the default capture mode and works for most tests. Use FdCapture
|
|
92
|
+
(via the _fd_capture fixture) if you need to capture subprocess output.
|
|
58
93
|
"""
|
|
59
|
-
return re.sub(r"[^A-Za-z0-9_.-]", "_", name)
|
|
60
94
|
|
|
95
|
+
def __init__(self):
|
|
96
|
+
self._stdout_capture = None
|
|
97
|
+
self._stderr_capture = None
|
|
98
|
+
self._orig_stdout = None
|
|
99
|
+
self._orig_stderr = None
|
|
100
|
+
|
|
101
|
+
def start(self):
|
|
102
|
+
"""Start capturing stdout and stderr."""
|
|
103
|
+
import io
|
|
104
|
+
import logging
|
|
105
|
+
|
|
106
|
+
self._orig_stdout = sys.stdout
|
|
107
|
+
self._orig_stderr = sys.stderr
|
|
108
|
+
self._stdout_capture = io.StringIO()
|
|
109
|
+
self._stderr_capture = io.StringIO()
|
|
110
|
+
sys.stdout = self._stdout_capture
|
|
111
|
+
sys.stderr = self._stderr_capture
|
|
112
|
+
|
|
113
|
+
# Update any existing logging handlers that point to the old stdout/stderr
|
|
114
|
+
# This ensures stdlib loggers created before capture started will output
|
|
115
|
+
# to our StringIO objects instead of the original streams
|
|
116
|
+
for handler in logging.root.handlers[:]:
|
|
117
|
+
if isinstance(handler, logging.StreamHandler):
|
|
118
|
+
if handler.stream == self._orig_stdout:
|
|
119
|
+
handler.setStream(self._stdout_capture) # type: ignore[arg-type]
|
|
120
|
+
elif handler.stream == self._orig_stderr:
|
|
121
|
+
handler.setStream(self._stderr_capture) # type: ignore[arg-type]
|
|
122
|
+
|
|
123
|
+
def stop(self) -> CapturedOutput:
|
|
124
|
+
"""Stop capturing and return captured output."""
|
|
125
|
+
import logging
|
|
126
|
+
|
|
127
|
+
# Restore logging handlers to original streams
|
|
128
|
+
for handler in logging.root.handlers[:]:
|
|
129
|
+
if isinstance(handler, logging.StreamHandler):
|
|
130
|
+
if handler.stream == self._stdout_capture:
|
|
131
|
+
handler.setStream(self._orig_stdout) # type: ignore[arg-type]
|
|
132
|
+
elif handler.stream == self._stderr_capture:
|
|
133
|
+
handler.setStream(self._orig_stderr) # type: ignore[arg-type]
|
|
134
|
+
|
|
135
|
+
sys.stdout = self._orig_stdout
|
|
136
|
+
sys.stderr = self._orig_stderr
|
|
137
|
+
|
|
138
|
+
stdout = self._stdout_capture.getvalue() if self._stdout_capture else ""
|
|
139
|
+
stderr = self._stderr_capture.getvalue() if self._stderr_capture else ""
|
|
140
|
+
|
|
141
|
+
return CapturedOutput(stdout=stdout, stderr=stderr)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FdCapture:
|
|
145
|
+
"""Captures at file descriptor level. Supports subprocess output.
|
|
146
|
+
|
|
147
|
+
This provides deeper capture than SimpleCapture by redirecting at the OS level.
|
|
148
|
+
Instead of just replacing Python's sys.stdout/stderr objects, it redirects
|
|
149
|
+
the actual file descriptors (1=stdout, 2=stderr) that the OS uses.
|
|
150
|
+
|
|
151
|
+
How it works:
|
|
152
|
+
1. Backup original file descriptors using os.dup(1) and os.dup(2)
|
|
153
|
+
2. Create temporary files to receive the output
|
|
154
|
+
3. Use os.dup2() to redirect fd 1 and 2 to point to the temp files
|
|
155
|
+
4. Reopen sys.stdout/stderr to match the new file descriptors
|
|
156
|
+
5. All writes to fd 1/2 now go to temp files (including from subprocesses!)
|
|
157
|
+
6. On stop(), restore original fds, read temp file contents, cleanup
|
|
158
|
+
|
|
159
|
+
What this captures:
|
|
160
|
+
- Everything SimpleCapture captures (print(), logging, etc.)
|
|
161
|
+
- Subprocess output (when subprocess inherits stdout/stderr)
|
|
162
|
+
- Direct file descriptor writes (os.write(1, ...))
|
|
163
|
+
- C extension output that writes directly to fds
|
|
164
|
+
|
|
165
|
+
Comparison to pytest's built-in capture:
|
|
166
|
+
- pytest's -s flag disables pytest's capture (which is SimpleCapture-like)
|
|
167
|
+
- This plugin works INSTEAD of pytest's capture, writing to files rather
|
|
168
|
+
than showing output inline in test results
|
|
169
|
+
- FdCapture is more comprehensive than pytest's default capture
|
|
170
|
+
|
|
171
|
+
Note: Opt-in only via _fd_capture fixture due to added complexity.
|
|
172
|
+
"""
|
|
61
173
|
|
|
62
|
-
def
|
|
63
|
-
|
|
174
|
+
def __init__(self):
|
|
175
|
+
self._stdout_fd: int | None = None
|
|
176
|
+
self._stderr_fd: int | None = None
|
|
177
|
+
self._stdout_file: tempfile._TemporaryFileWrapper | None = None
|
|
178
|
+
self._stderr_file: tempfile._TemporaryFileWrapper | None = None
|
|
179
|
+
self._orig_stdout_fd: int | None = None
|
|
180
|
+
self._orig_stderr_fd: int | None = None
|
|
181
|
+
self._orig_stdout = None
|
|
182
|
+
self._orig_stderr = None
|
|
183
|
+
|
|
184
|
+
def start(self):
|
|
185
|
+
"""Start capturing stdout and stderr at the file descriptor level."""
|
|
186
|
+
sys.stdout.flush()
|
|
187
|
+
sys.stderr.flush()
|
|
188
|
+
|
|
189
|
+
self._orig_stdout = sys.stdout
|
|
190
|
+
self._orig_stderr = sys.stderr
|
|
191
|
+
self._orig_stdout_fd = os.dup(1)
|
|
192
|
+
self._orig_stderr_fd = os.dup(2)
|
|
193
|
+
|
|
194
|
+
self._stdout_file = tempfile.NamedTemporaryFile(
|
|
195
|
+
mode="w+b", delete=False
|
|
196
|
+
)
|
|
197
|
+
self._stderr_file = tempfile.NamedTemporaryFile(
|
|
198
|
+
mode="w+b", delete=False
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
os.dup2(self._stdout_file.fileno(), 1)
|
|
202
|
+
os.dup2(self._stderr_file.fileno(), 2)
|
|
203
|
+
|
|
204
|
+
sys.stdout = open(1, "w", encoding="utf-8", errors="replace", closefd=False)
|
|
205
|
+
sys.stderr = open(2, "w", encoding="utf-8", errors="replace", closefd=False)
|
|
206
|
+
|
|
207
|
+
def stop(self) -> CapturedOutput:
|
|
208
|
+
"""Stop capturing and return captured output."""
|
|
209
|
+
try:
|
|
210
|
+
sys.stdout.flush()
|
|
211
|
+
sys.stderr.flush()
|
|
212
|
+
os.fsync(1)
|
|
213
|
+
os.fsync(2)
|
|
214
|
+
|
|
215
|
+
assert self._orig_stdout_fd is not None
|
|
216
|
+
assert self._orig_stderr_fd is not None
|
|
217
|
+
assert self._stdout_file is not None
|
|
218
|
+
assert self._stderr_file is not None
|
|
219
|
+
|
|
220
|
+
os.dup2(self._orig_stdout_fd, 1)
|
|
221
|
+
os.dup2(self._orig_stderr_fd, 2)
|
|
222
|
+
os.close(self._orig_stdout_fd)
|
|
223
|
+
os.close(self._orig_stderr_fd)
|
|
224
|
+
|
|
225
|
+
sys.stdout = self._orig_stdout
|
|
226
|
+
sys.stderr = self._orig_stderr
|
|
64
227
|
|
|
65
|
-
|
|
66
|
-
|
|
228
|
+
self._stdout_file.flush()
|
|
229
|
+
self._stderr_file.flush()
|
|
230
|
+
self._stdout_file.seek(0)
|
|
231
|
+
self._stderr_file.seek(0)
|
|
232
|
+
stdout = self._stdout_file.read().decode("utf-8", errors="replace")
|
|
233
|
+
stderr = self._stderr_file.read().decode("utf-8", errors="replace")
|
|
234
|
+
|
|
235
|
+
self._stdout_file.close()
|
|
236
|
+
self._stderr_file.close()
|
|
237
|
+
|
|
238
|
+
os.unlink(self._stdout_file.name)
|
|
239
|
+
os.unlink(self._stderr_file.name)
|
|
240
|
+
|
|
241
|
+
return CapturedOutput(stdout=stdout, stderr=stderr)
|
|
242
|
+
except Exception:
|
|
243
|
+
sys.stdout = self._orig_stdout
|
|
244
|
+
sys.stderr = self._orig_stderr
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _validate_pytest_config(config: pytest.Config) -> bool:
|
|
249
|
+
"""Check that -s is enabled. Log error if not.
|
|
250
|
+
|
|
251
|
+
Note: We also recommend using -p no:logging to disable pytest's logging plugin,
|
|
252
|
+
but we can't reliably detect if it's enabled. The pluginmanager.has_plugin()
|
|
253
|
+
check doesn't work consistently across pytest versions. The plugin will work
|
|
254
|
+
regardless, but having both logging captures enabled may cause confusion.
|
|
67
255
|
"""
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
256
|
+
capture_mode = config.option.capture
|
|
257
|
+
|
|
258
|
+
if capture_mode != "no":
|
|
259
|
+
logger.error(
|
|
260
|
+
"structlog output capture requires -s flag to disable pytest's built-in capture",
|
|
261
|
+
pytest_capture_mode=capture_mode,
|
|
262
|
+
required_flag="-s or --capture=no",
|
|
263
|
+
)
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def pytest_addoption(parser: pytest.Parser):
|
|
270
|
+
"""Add command line options for output capture."""
|
|
271
|
+
group = parser.getgroup("Structlog Capture")
|
|
272
|
+
group.addoption(
|
|
273
|
+
"--structlog-output",
|
|
274
|
+
type=str,
|
|
77
275
|
default=None,
|
|
78
|
-
|
|
276
|
+
metavar="DIR",
|
|
277
|
+
help="Enable output capture on test failure and write to DIR",
|
|
79
278
|
)
|
|
80
279
|
|
|
81
280
|
|
|
82
281
|
@pytest.hookimpl(tryfirst=True)
|
|
83
|
-
def pytest_configure(config: pytest.Config)
|
|
84
|
-
"""Configure the plugin
|
|
282
|
+
def pytest_configure(config: pytest.Config):
|
|
283
|
+
"""Configure the plugin."""
|
|
284
|
+
output_dir_str = config.option.structlog_output
|
|
85
285
|
|
|
86
|
-
|
|
286
|
+
if not output_dir_str:
|
|
287
|
+
config.stash[CAPTURE_KEY] = {"enabled": False}
|
|
288
|
+
return
|
|
87
289
|
|
|
88
|
-
|
|
89
|
-
config
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
"
|
|
290
|
+
if not _validate_pytest_config(config):
|
|
291
|
+
config.stash[CAPTURE_KEY] = {"enabled": False}
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
output_dir = Path(output_dir_str)
|
|
295
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
|
|
297
|
+
config.stash[CAPTURE_KEY] = {
|
|
298
|
+
"enabled": True,
|
|
299
|
+
"output_dir": str(output_dir),
|
|
98
300
|
}
|
|
99
|
-
config.stash[PLUGIN_KEY] = plugin_config
|
|
100
301
|
|
|
302
|
+
logger.info(
|
|
303
|
+
"structlog output capture enabled",
|
|
304
|
+
output_directory=str(output_dir),
|
|
305
|
+
)
|
|
101
306
|
|
|
102
|
-
@pytest.hookimpl(tryfirst=True)
|
|
103
|
-
def pytest_sessionstart(session: pytest.Session) -> None:
|
|
104
|
-
"""Create a session-level temp directory for log files.
|
|
105
307
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"""
|
|
109
|
-
config = session.config
|
|
110
|
-
plugin_config = config.stash.get(PLUGIN_KEY, {})
|
|
111
|
-
|
|
112
|
-
if not plugin_config.get("enabled"):
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
logs_dir = plugin_config.get("logs_dir")
|
|
116
|
-
if logs_dir:
|
|
117
|
-
tmpdir = Path(logs_dir)
|
|
118
|
-
tmpdir.mkdir(parents=True, exist_ok=True)
|
|
119
|
-
else:
|
|
120
|
-
project_name = plugin_config.get("project_name", "pytest")
|
|
121
|
-
tmpdir = Path(tempfile.mkdtemp(prefix=f"{project_name}-pytest-logs-"))
|
|
122
|
-
|
|
123
|
-
plugin_config[SESSION_TMPDIR_KEY] = tmpdir
|
|
124
|
-
config.stash[PLUGIN_KEY] = plugin_config
|
|
308
|
+
@pytest.fixture
|
|
309
|
+
def file_descriptor_output_capture(request):
|
|
310
|
+
"""Activates fd-level capture for a test.
|
|
125
311
|
|
|
312
|
+
This fixture can be used in three ways:
|
|
126
313
|
|
|
127
|
-
|
|
128
|
-
def
|
|
129
|
-
|
|
314
|
+
1. Single test - add to function signature:
|
|
315
|
+
def test_foo(file_descriptor_output_capture):
|
|
316
|
+
...
|
|
130
317
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
plugin_config = config.stash.get(PLUGIN_KEY, {})
|
|
136
|
-
|
|
137
|
-
if not plugin_config.get("enabled"):
|
|
138
|
-
return
|
|
139
|
-
|
|
140
|
-
logs_dir = plugin_config.get("logs_dir")
|
|
141
|
-
tmpdir = plugin_config.get(SESSION_TMPDIR_KEY)
|
|
142
|
-
|
|
143
|
-
if tmpdir and not logs_dir:
|
|
144
|
-
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
318
|
+
2. Single test - use marker decorator:
|
|
319
|
+
@pytest.mark.usefixtures("file_descriptor_output_capture")
|
|
320
|
+
def test_foo():
|
|
321
|
+
...
|
|
145
322
|
|
|
323
|
+
3. All tests in directory - add to conftest.py:
|
|
324
|
+
pytestmark = pytest.mark.usefixtures("file_descriptor_output_capture")
|
|
325
|
+
"""
|
|
326
|
+
request.node._fd_capture_active = True
|
|
327
|
+
capture = FdCapture()
|
|
328
|
+
capture.start()
|
|
329
|
+
yield
|
|
330
|
+
output = capture.stop()
|
|
331
|
+
request.node._fd_captured_output = output
|
|
146
332
|
|
|
147
|
-
@pytest.fixture(autouse=True)
|
|
148
|
-
def capture_logs_on_fail(request: pytest.FixtureRequest) -> Generator[None, None, None]:
|
|
149
|
-
"""Set up per-test log capture to a temporary file.
|
|
150
333
|
|
|
151
|
-
|
|
152
|
-
|
|
334
|
+
def _is_fd_capture_active(item: pytest.Item) -> bool:
|
|
335
|
+
"""Check if the fd-level capture fixture is active for this test."""
|
|
336
|
+
return getattr(item, "_fd_capture_active", False)
|
|
153
337
|
|
|
154
|
-
Args:
|
|
155
|
-
request: The pytest request fixture providing test context.
|
|
156
338
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
""
|
|
160
|
-
|
|
161
|
-
plugin_config = config.stash.get(PLUGIN_KEY, {})
|
|
162
|
-
|
|
163
|
-
if not plugin_config.get("enabled"):
|
|
164
|
-
yield
|
|
339
|
+
def _write_output_files(item: pytest.Item):
|
|
340
|
+
"""Write captured output to files on failure."""
|
|
341
|
+
config = item.config.stash.get(CAPTURE_KEY, {"enabled": False})
|
|
342
|
+
if not config["enabled"]:
|
|
165
343
|
return
|
|
166
344
|
|
|
167
|
-
if "
|
|
168
|
-
logger.warning(
|
|
169
|
-
"PYTHON_LOG_PATH is already set; pytest-capture-logs-on-fail plugin is disabled for this test."
|
|
170
|
-
)
|
|
171
|
-
yield
|
|
345
|
+
if not hasattr(item, "_excinfo"):
|
|
172
346
|
return
|
|
173
347
|
|
|
174
|
-
|
|
175
|
-
if not
|
|
176
|
-
logger.warning("Session temp directory not initialized")
|
|
177
|
-
yield
|
|
348
|
+
output_dir_value = config["output_dir"]
|
|
349
|
+
if not isinstance(output_dir_value, (str, Path)):
|
|
178
350
|
return
|
|
351
|
+
output_dir = Path(output_dir_value)
|
|
179
352
|
|
|
180
|
-
test_name =
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
original_log_path = os.environ.get("PYTHON_LOG_PATH")
|
|
184
|
-
os.environ["PYTHON_LOG_PATH"] = str(log_file)
|
|
353
|
+
test_name = item.nodeid.replace("::", "__").replace("/", "_")
|
|
354
|
+
test_dir = output_dir / test_name
|
|
355
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
185
356
|
|
|
186
|
-
|
|
357
|
+
if hasattr(item, "_full_captured_output"):
|
|
358
|
+
output = item._full_captured_output # type: ignore[attr-defined]
|
|
359
|
+
elif _is_fd_capture_active(item) and hasattr(item, "_fd_captured_output"):
|
|
360
|
+
output = item._fd_captured_output # type: ignore[attr-defined]
|
|
361
|
+
else:
|
|
362
|
+
output = CapturedOutput(stdout="", stderr="")
|
|
187
363
|
|
|
188
|
-
|
|
364
|
+
exception_parts = []
|
|
365
|
+
for _when, excinfo in item._excinfo: # type: ignore[attr-defined]
|
|
366
|
+
exception_parts.append(str(excinfo.getrepr(style="long")))
|
|
189
367
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if original_log_path is not None:
|
|
193
|
-
os.environ["PYTHON_LOG_PATH"] = original_log_path
|
|
194
|
-
else:
|
|
195
|
-
del os.environ["PYTHON_LOG_PATH"]
|
|
368
|
+
output.exception = "\n\n".join(exception_parts) if exception_parts else None
|
|
196
369
|
|
|
370
|
+
if output.stdout:
|
|
371
|
+
(test_dir / "stdout.txt").write_text(output.stdout)
|
|
197
372
|
|
|
198
|
-
|
|
199
|
-
|
|
373
|
+
if output.stderr:
|
|
374
|
+
(test_dir / "stderr.txt").write_text(output.stderr)
|
|
200
375
|
|
|
201
|
-
|
|
202
|
-
|
|
376
|
+
if output.exception:
|
|
377
|
+
(test_dir / "exception.txt").write_text(output.exception)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@pytest.hookimpl(wrapper=True, tryfirst=True)
|
|
381
|
+
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None): # noqa: ARG001
|
|
382
|
+
"""Capture output for entire test lifecycle including makereport phases."""
|
|
383
|
+
config = item.config.stash.get(CAPTURE_KEY, {"enabled": False})
|
|
384
|
+
|
|
385
|
+
if not config["enabled"] or _is_fd_capture_active(item):
|
|
386
|
+
return (yield)
|
|
387
|
+
|
|
388
|
+
capture = SimpleCapture()
|
|
389
|
+
capture.start()
|
|
390
|
+
try:
|
|
391
|
+
result = yield
|
|
392
|
+
return result
|
|
393
|
+
finally:
|
|
394
|
+
output = capture.stop()
|
|
395
|
+
item._full_captured_output = output # type: ignore[attr-defined]
|
|
396
|
+
_write_output_files(item)
|
|
203
397
|
|
|
204
|
-
Args:
|
|
205
|
-
item: The test item being reported on.
|
|
206
|
-
call: The call object containing execution info and any exception.
|
|
207
|
-
"""
|
|
208
|
-
config = item.config
|
|
209
|
-
plugin_config = config.stash.get(PLUGIN_KEY, {})
|
|
210
|
-
|
|
211
|
-
if not plugin_config.get("enabled"):
|
|
212
|
-
return
|
|
213
398
|
|
|
399
|
+
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo):
|
|
400
|
+
"""Track exception info for failed tests."""
|
|
214
401
|
if call.excinfo is not None:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
logs = f.read()
|
|
219
|
-
|
|
220
|
-
if logs.strip():
|
|
221
|
-
phase = call.when
|
|
222
|
-
print(f"\n--- Captured logs for failed test ({phase}): {item.nodeid} ---\n{logs}\n")
|
|
402
|
+
if not hasattr(item, "_excinfo"):
|
|
403
|
+
item._excinfo = [] # type: ignore[attr-defined]
|
|
404
|
+
item._excinfo.append((call.when, call.excinfo)) # type: ignore[attr-defined]
|
|
@@ -5,6 +5,7 @@ Redirect all stdlib loggers to use the structlog configuration.
|
|
|
5
5
|
import logging
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
import structlog
|
|
10
11
|
from decouple import config
|
|
@@ -101,7 +102,7 @@ def redirect_stdlib_loggers(json_logger: bool):
|
|
|
101
102
|
|
|
102
103
|
# TODO there is a JSON-like format that can be used to configure loggers instead :/
|
|
103
104
|
# we should probably transition to using that format instead of this customized mapping
|
|
104
|
-
std_logging_configuration = {
|
|
105
|
+
std_logging_configuration: dict[str, dict[str, Any]] = {
|
|
105
106
|
"httpx": {
|
|
106
107
|
"levels": {
|
|
107
108
|
"INFO": "WARNING",
|
structlog_config/trace.py
CHANGED
|
@@ -12,7 +12,8 @@ import logging
|
|
|
12
12
|
import typing
|
|
13
13
|
from functools import partial, partialmethod
|
|
14
14
|
|
|
15
|
-
from structlog
|
|
15
|
+
from structlog import _output
|
|
16
|
+
from structlog._log_levels import LEVEL_TO_NAME, NAME_TO_LEVEL
|
|
16
17
|
from structlog._native import LEVEL_TO_FILTERING_LOGGER, _make_filtering_bound_logger
|
|
17
18
|
|
|
18
19
|
from structlog_config.constants import TRACE_LOG_LEVEL
|
|
@@ -28,11 +29,6 @@ class Logger(logging.Logger):
|
|
|
28
29
|
) -> None: ... # pragma: nocover
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
# def trace(self, message: str, *args: typing.Any, **kwargs: typing.Any) -> None:
|
|
32
|
-
# if self.isEnabledFor(TRACE_LOG_LEVEL):
|
|
33
|
-
# self._log(TRACE_LOG_LEVEL, message, args, **kwargs)
|
|
34
|
-
|
|
35
|
-
|
|
36
32
|
def setup_trace() -> None:
|
|
37
33
|
"""Setup TRACE logging level. Safe to call multiple times."""
|
|
38
34
|
global _setup_called
|
|
@@ -41,24 +37,55 @@ def setup_trace() -> None:
|
|
|
41
37
|
return
|
|
42
38
|
|
|
43
39
|
# TODO consider adding warning to check the state of the underlying patched code
|
|
44
|
-
|
|
40
|
+
|
|
41
|
+
# patch structlog maps to include the additional level, there are three separate places that need to be patched
|
|
45
42
|
NAME_TO_LEVEL["trace"] = TRACE_LOG_LEVEL
|
|
43
|
+
LEVEL_TO_NAME[TRACE_LOG_LEVEL] = "trace"
|
|
46
44
|
LEVEL_TO_FILTERING_LOGGER[TRACE_LOG_LEVEL] = _make_filtering_bound_logger(
|
|
47
45
|
TRACE_LOG_LEVEL
|
|
48
46
|
)
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
# Check if TRACE attribute already exists in logging module
|
|
49
|
+
if not hasattr(logging, "TRACE"):
|
|
50
|
+
setattr(logging, "TRACE", TRACE_LOG_LEVEL)
|
|
51
|
+
|
|
51
52
|
logging.addLevelName(TRACE_LOG_LEVEL, "TRACE")
|
|
52
53
|
|
|
54
|
+
# patches are guarded with hasattr since the user could have patched this on their own
|
|
55
|
+
|
|
53
56
|
if hasattr(logging.Logger, "trace"):
|
|
54
57
|
logging.warning("Logger.trace method already exists, not overriding it")
|
|
55
58
|
else:
|
|
56
|
-
|
|
59
|
+
setattr(
|
|
60
|
+
logging.Logger,
|
|
61
|
+
"trace",
|
|
62
|
+
partialmethod(logging.Logger.log, TRACE_LOG_LEVEL),
|
|
63
|
+
)
|
|
57
64
|
|
|
58
|
-
# Check if trace function already exists in logging module
|
|
59
65
|
if hasattr(logging, "trace"):
|
|
60
|
-
logging.warning("logging.trace function already exists, overriding it")
|
|
66
|
+
logging.warning("logging.trace function already exists, not overriding it")
|
|
61
67
|
else:
|
|
62
|
-
logging
|
|
68
|
+
setattr(logging, "trace", partial(logging.log, TRACE_LOG_LEVEL))
|
|
69
|
+
|
|
70
|
+
_patch_structlog_output_loggers()
|
|
63
71
|
|
|
64
72
|
_setup_called = True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _patch_structlog_output_loggers() -> None:
|
|
76
|
+
"""
|
|
77
|
+
Each individual logger backend needs to be patched.
|
|
78
|
+
|
|
79
|
+
There's no structlog API to get a list of all available loggers.
|
|
80
|
+
"""
|
|
81
|
+
logger_classes = (
|
|
82
|
+
_output.PrintLogger,
|
|
83
|
+
_output.WriteLogger,
|
|
84
|
+
_output.BytesLogger,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for logger_class in logger_classes:
|
|
88
|
+
if hasattr(logger_class, "trace"):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
setattr(logger_class, "trace", logger_class.msg)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: structlog-config
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A comprehensive structlog configuration with sensible defaults for development and production environments, featuring context management, exception formatting, and path prettification.
|
|
5
5
|
Keywords: logging,structlog,json-logging,structured-logging
|
|
6
6
|
Author: Michael Bianco
|
|
@@ -26,19 +26,16 @@ Here are the main goals:
|
|
|
26
26
|
* High performance JSON logging in production
|
|
27
27
|
* All loggers, even plugin or system loggers, should route through the same formatter
|
|
28
28
|
* Structured logging everywhere
|
|
29
|
+
* Pytest plugin to easily capture logs and dump to a directory on failure. This is really important for LLMs so they can
|
|
30
|
+
easily consume logs and context for each test and handle them sequentially.
|
|
29
31
|
* Ability to easily set thread-local log context
|
|
30
32
|
* Nice log formatters for stack traces, ORM ([ActiveModel/SQLModel](https://github.com/iloveitaly/activemodel)), etc
|
|
31
33
|
* Ability to log level and output (i.e. file path) *by logger* for easy development debugging
|
|
32
34
|
* If you are using fastapi, structured logging for access logs
|
|
35
|
+
* [Improved exception logging with beautiful-traceback](https://github.com/iloveitaly/beautiful-traceback)
|
|
33
36
|
|
|
34
37
|
## Installation
|
|
35
38
|
|
|
36
|
-
```bash
|
|
37
|
-
pip install structlog-config
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Or with [uv](https://docs.astral.sh/uv/):
|
|
41
|
-
|
|
42
39
|
```bash
|
|
43
40
|
uv add structlog-config
|
|
44
41
|
```
|
|
@@ -51,9 +48,12 @@ from structlog_config import configure_logger
|
|
|
51
48
|
log = configure_logger()
|
|
52
49
|
|
|
53
50
|
log.info("the log", key="value")
|
|
51
|
+
|
|
52
|
+
# named logger just like stdlib, but with a different syntax
|
|
53
|
+
custom_named_logger = structlog.get_logger(logger_name="test")
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
## JSON Logging
|
|
56
|
+
## JSON Logging in Production
|
|
57
57
|
|
|
58
58
|
JSON logging is automatically enabled in production and staging environments (`PYTHON_ENV=production` or `PYTHON_ENV=staging`):
|
|
59
59
|
|
|
@@ -72,11 +72,13 @@ log = configure_logger(json_logger=True)
|
|
|
72
72
|
log = configure_logger(json_logger=False)
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
JSON logs use [orjson](https://github.com/ijl/orjson) for performance, include sorted keys and ISO timestamps, and serialize exceptions cleanly.
|
|
75
|
+
JSON logs use [orjson](https://github.com/ijl/orjson) for performance, include sorted keys and ISO timestamps, and serialize exceptions cleanly.
|
|
76
|
+
|
|
77
|
+
Note that `PYTHON_LOG_PATH` is ignored with JSON logging (stdout only).
|
|
76
78
|
|
|
77
79
|
## TRACE Logging Level
|
|
78
80
|
|
|
79
|
-
This package adds support for a custom `TRACE` logging level (level 5) that's even more verbose than `DEBUG`.
|
|
81
|
+
This package adds support for a custom `TRACE` logging level (level 5) that's even more verbose than `DEBUG`.
|
|
80
82
|
|
|
81
83
|
The `TRACE` level is automatically set up when you call `configure_logger()`. You can use it like any other logging level:
|
|
82
84
|
|
|
@@ -88,7 +90,7 @@ log = configure_logger()
|
|
|
88
90
|
|
|
89
91
|
# Using structlog
|
|
90
92
|
log.info("This is info")
|
|
91
|
-
log.debug("This is debug")
|
|
93
|
+
log.debug("This is debug")
|
|
92
94
|
log.trace("This is trace") # Most verbose
|
|
93
95
|
|
|
94
96
|
# Using stdlib logging
|
|
@@ -136,8 +138,6 @@ log.info("Processing file", file_path=Path.cwd() / "data" / "users.csv")
|
|
|
136
138
|
|
|
137
139
|
### Whenever Datetime Formatter
|
|
138
140
|
|
|
139
|
-
**Note:** Requires `pip install whenever` to be installed.
|
|
140
|
-
|
|
141
141
|
Formats [whenever](https://github.com/ariebovenberg/whenever) datetime objects without their class wrappers for cleaner output:
|
|
142
142
|
|
|
143
143
|
```python
|
|
@@ -152,8 +152,6 @@ Supports all whenever datetime types: `ZonedDateTime`, `Instant`, `LocalDateTime
|
|
|
152
152
|
|
|
153
153
|
### ActiveModel Object Formatter
|
|
154
154
|
|
|
155
|
-
**Note:** Requires `pip install activemodel` and `pip install typeid-python` to be installed.
|
|
156
|
-
|
|
157
155
|
Automatically converts [ActiveModel](https://github.com/iloveitaly/activemodel) BaseModel instances to their ID representation and TypeID objects to strings:
|
|
158
156
|
|
|
159
157
|
```python
|
|
@@ -166,8 +164,6 @@ log.info("User action", user=user)
|
|
|
166
164
|
|
|
167
165
|
### FastAPI Context
|
|
168
166
|
|
|
169
|
-
**Note:** Requires `pip install starlette-context` to be installed.
|
|
170
|
-
|
|
171
167
|
Automatically includes all context data from [starlette-context](https://github.com/tomwojcik/starlette-context) in your logs, useful for request tracing:
|
|
172
168
|
|
|
173
169
|
```python
|
|
@@ -193,63 +189,102 @@ Here's how to use it:
|
|
|
193
189
|
1. [Disable fastapi's default logging.](https://github.com/iloveitaly/python-starter-template/blob/f54cb47d8d104987f2e4a668f9045a62e0d6818a/main.py#L55-L56)
|
|
194
190
|
2. [Add the middleware to your FastAPI app.](https://github.com/iloveitaly/python-starter-template/blob/f54cb47d8d104987f2e4a668f9045a62e0d6818a/app/routes/middleware/__init__.py#L63-L65)
|
|
195
191
|
|
|
196
|
-
## Pytest Plugin: Capture
|
|
192
|
+
## Pytest Plugin: Capture Output on Failure
|
|
197
193
|
|
|
198
|
-
A pytest plugin that captures
|
|
194
|
+
A pytest plugin that captures stdout, stderr, and exceptions from failing tests and writes them to organized output files. This is useful for debugging test failures, especially in CI/CD environments where you need to inspect output after the fact.
|
|
199
195
|
|
|
200
196
|
### Features
|
|
201
197
|
|
|
202
|
-
-
|
|
203
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
198
|
+
- Captures stdout, stderr, and exception tracebacks for failing tests
|
|
199
|
+
- Only creates output for failing tests (keeps directories clean)
|
|
200
|
+
- Separate files for each output type (stdout.txt, stderr.txt, exception.txt)
|
|
201
|
+
- Captures all test phases (setup, call, teardown)
|
|
202
|
+
- Optional fd-level capture for subprocess output
|
|
207
203
|
|
|
208
204
|
### Usage
|
|
209
205
|
|
|
210
|
-
Enable the plugin with the `--
|
|
206
|
+
Enable the plugin with the `--structlog-output` flag and `-s` (to disable pytest's built-in capture):
|
|
211
207
|
|
|
212
208
|
```bash
|
|
213
|
-
pytest --
|
|
209
|
+
pytest --structlog-output=./test-output -s
|
|
214
210
|
```
|
|
215
211
|
|
|
216
|
-
|
|
212
|
+
The `--structlog-output` flag both enables the plugin and specifies where output files should be written.
|
|
213
|
+
|
|
214
|
+
**Recommended:** Also disable pytest's logging plugin with `-p no:logging` to avoid duplicate/interfering capture:
|
|
217
215
|
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
addopts = ["--capture-logs-on-fail"]
|
|
216
|
+
```bash
|
|
217
|
+
pytest --structlog-output=./test-output -s -p no:logging
|
|
221
218
|
```
|
|
222
219
|
|
|
223
|
-
|
|
220
|
+
While the plugin works without this flag, disabling pytest's logging capture ensures cleaner output and avoids any potential conflicts between the two capture mechanisms.
|
|
224
221
|
|
|
225
|
-
|
|
222
|
+
### Output Structure
|
|
226
223
|
|
|
227
|
-
|
|
228
|
-
|
|
224
|
+
Each failing test gets its own directory with separate files:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
test-output/
|
|
228
|
+
test_module__test_name/
|
|
229
|
+
stdout.txt # stdout from test (includes setup, call, and teardown phases)
|
|
230
|
+
stderr.txt # stderr from test (includes setup, call, and teardown phases)
|
|
231
|
+
exception.txt # exception traceback
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Advanced: fd-level Capture
|
|
235
|
+
|
|
236
|
+
For tests that spawn subprocesses or write directly to file descriptors, you can enable fd-level capture. This is useful for integration tests that run external processes (such a server which replicates a production environment).
|
|
237
|
+
|
|
238
|
+
#### Add fixture to function signature
|
|
239
|
+
|
|
240
|
+
Great for a single single test:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
def test_with_subprocess(file_descriptor_output_capture):
|
|
244
|
+
# subprocess.run() output will be captured
|
|
245
|
+
subprocess.run(["echo", "hello from subprocess"])
|
|
246
|
+
|
|
247
|
+
# multiprocessing.Process output will be captured
|
|
248
|
+
from multiprocessing import Process
|
|
249
|
+
proc = Process(target=lambda: print("hello from process"))
|
|
250
|
+
proc.start()
|
|
251
|
+
proc.join()
|
|
252
|
+
|
|
253
|
+
assert False # Trigger failure to write output files
|
|
229
254
|
```
|
|
230
255
|
|
|
231
|
-
|
|
256
|
+
Alternatively, you can use `@pytest.mark.usefixtures("file_descriptor_output_capture")`
|
|
232
257
|
|
|
233
|
-
### How It Works
|
|
234
258
|
|
|
235
|
-
|
|
236
|
-
2. Your application logs (via `configure_logger()`) write to this file
|
|
237
|
-
3. On test failure, the plugin prints the captured logs to stdout
|
|
238
|
-
4. Log files are cleaned up after the test session (unless `--capture-logs-dir` is used)
|
|
259
|
+
#### All tests in directory
|
|
239
260
|
|
|
240
|
-
|
|
261
|
+
Add to `conftest.py`:
|
|
241
262
|
|
|
242
|
-
|
|
263
|
+
```python
|
|
264
|
+
import pytest
|
|
243
265
|
|
|
266
|
+
pytestmark = pytest.mark.usefixtures("file_descriptor_output_capture")
|
|
244
267
|
```
|
|
245
|
-
FAILED tests/test_user.py::test_user_login
|
|
246
268
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
269
|
+
### Example
|
|
270
|
+
|
|
271
|
+
When a test fails:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
def test_user_login():
|
|
275
|
+
print("Starting login process")
|
|
276
|
+
print("ERROR: Connection failed", file=sys.stderr)
|
|
277
|
+
assert False, "Login failed"
|
|
250
278
|
```
|
|
251
279
|
|
|
252
|
-
|
|
280
|
+
You'll get:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
test-output/test_user__test_user_login/
|
|
284
|
+
stdout.txt: "Starting login process"
|
|
285
|
+
stderr.txt: "ERROR: Connection failed"
|
|
286
|
+
exception.txt: Full traceback with "AssertionError: Login failed"
|
|
287
|
+
```
|
|
253
288
|
|
|
254
289
|
## Beautiful Traceback Support
|
|
255
290
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
structlog_config/__init__.py,sha256=02rdVud040i_yENnPhjAFrQvAcqePffueHwBmqXWid0,7266
|
|
2
|
+
structlog_config/constants.py,sha256=O1nPnB29yZdqqaI7aeTUrimA3LOtA5WpP6BGPLWJvj8,510
|
|
3
|
+
structlog_config/env_config.py,sha256=_EJO0rgAKndRPSh4wuBaH3bui9F3nIpn8FaEkjAjZso,1737
|
|
4
|
+
structlog_config/environments.py,sha256=9rojSoyjS5ZOglB7X86gZIzhIsavQ03mJ1ICVk1IuLk,171
|
|
5
|
+
structlog_config/fastapi_access_logger.py,sha256=NAnYJZ3uHpdrJ8Dd48I0DV6JGQtfVDT5QAYGYmHuOQI,4379
|
|
6
|
+
structlog_config/formatters.py,sha256=u1u88lz-h6-NMZzpEkbvKeXq1AzbplxuyE_yIMjPOps,6812
|
|
7
|
+
structlog_config/hook.py,sha256=97C9TB88Gpit8s7beTh1n3OAtt8YkAgS-Zr2Crvp9HA,656
|
|
8
|
+
structlog_config/levels.py,sha256=LqXG4l01mIpxS2qI7PF_Vp9K7IrO0D5qU_x-Uo3LNuM,2372
|
|
9
|
+
structlog_config/packages.py,sha256=D27IijZy2igLRPe8aNoSMM_gKL0EGyH_sCl6OTXF3ac,703
|
|
10
|
+
structlog_config/pytest_plugin.py,sha256=kKhDm_Y4wOtcsAoYJ2OplnBdHaNIfF5z2XNzinvEvb0,14259
|
|
11
|
+
structlog_config/stdlib_logging.py,sha256=amLVZUw46mIenTDp3LVW1sYXdHAMLOecJHkQcIuZ--c,7431
|
|
12
|
+
structlog_config/trace.py,sha256=fh6gYGUrWBjk6BzZbyeLN1uo3AGGRs7rysfKC0v5wYE,2808
|
|
13
|
+
structlog_config/warnings.py,sha256=gKEcuHWqH0BaKitJtQkv-uJ0Z3uCH5nn6k8qpqjR-RM,998
|
|
14
|
+
structlog_config-0.9.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
|
|
15
|
+
structlog_config-0.9.0.dist-info/entry_points.txt,sha256=ZSQscydGXSpu4dzPkbUCQuRA8FjNQPwHXyZut8VzkDw,62
|
|
16
|
+
structlog_config-0.9.0.dist-info/METADATA,sha256=6RGsl6gC7GLnAypkpi-19EC439n2AzlSll74geqa5OA,12356
|
|
17
|
+
structlog_config-0.9.0.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
structlog_config/__init__.py,sha256=jTw47yiht0XmINIDUMicHZQYF_757ALXopxgOkAEhaQ,6939
|
|
2
|
-
structlog_config/constants.py,sha256=O1nPnB29yZdqqaI7aeTUrimA3LOtA5WpP6BGPLWJvj8,510
|
|
3
|
-
structlog_config/env_config.py,sha256=_EJO0rgAKndRPSh4wuBaH3bui9F3nIpn8FaEkjAjZso,1737
|
|
4
|
-
structlog_config/environments.py,sha256=JpZYVVDGxEf1EaKdPdn6Jo-4wJK6SqF0ueFl7e2TBvI,612
|
|
5
|
-
structlog_config/fastapi_access_logger.py,sha256=CYZsww0AIcdfrU5Wgr6POwdJxJ5vMB96ttsYqoE30BU,4363
|
|
6
|
-
structlog_config/formatters.py,sha256=4B485qMTn9y5LqV7uWPJbgJrdIW5IiyRS3A0PEjcrS4,6748
|
|
7
|
-
structlog_config/levels.py,sha256=LqXG4l01mIpxS2qI7PF_Vp9K7IrO0D5qU_x-Uo3LNuM,2372
|
|
8
|
-
structlog_config/packages.py,sha256=xO4wHPIhAwGG6jv0kHdCr9NHpoIFx4VUeRzmztXp2is,591
|
|
9
|
-
structlog_config/pytest_plugin.py,sha256=XBtef1KpuGV_RmXoWf13b0EUy69iAKMAk4-e5ZZnAuM,6819
|
|
10
|
-
structlog_config/stdlib_logging.py,sha256=iHApYdsAs_ZC7Y8NpbKs23AHAJB1qLUCZPvFd-Nf_4I,7381
|
|
11
|
-
structlog_config/trace.py,sha256=esSzTulaVr63H3iOYpHlU-NXPSwpEGS7GEYbv6Iq-3A,2118
|
|
12
|
-
structlog_config/warnings.py,sha256=gKEcuHWqH0BaKitJtQkv-uJ0Z3uCH5nn6k8qpqjR-RM,998
|
|
13
|
-
structlog_config-0.8.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
14
|
-
structlog_config-0.8.0.dist-info/METADATA,sha256=SmpaIUr3-ELE2u_dOAvrVBJkj6IX33rVe-lZgz9PiCU,10995
|
|
15
|
-
structlog_config-0.8.0.dist-info/RECORD,,
|