structlog-config 0.7.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.
@@ -1,5 +1,5 @@
1
1
  from contextlib import _GeneratorContextManager
2
- from typing import Generator, Protocol
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 is_production, is_pytest, is_staging
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
  )
@@ -62,12 +63,16 @@ def log_processors_for_mode(json_logger: bool) -> list[structlog.types.Processor
62
63
  structlog.processors.JSONRenderer(serializer=orjson_dumps_sorted),
63
64
  ]
64
65
 
66
+ # Passing None skips the ConsoleRenderer default, so use the explicit dev default.
67
+ exception_formatter = structlog.dev.default_exception_formatter
68
+
69
+ if packages.beautiful_traceback:
70
+ exception_formatter = beautiful_traceback_exception_formatter
71
+
65
72
  return [
66
73
  structlog.dev.ConsoleRenderer(
67
74
  colors=not NO_COLOR,
68
- exception_formatter=beautiful_traceback_exception_formatter
69
- if packages.beautiful_traceback
70
- else structlog.dev.default_exception_formatter,
75
+ exception_formatter=exception_formatter,
71
76
  )
72
77
  ]
73
78
 
@@ -142,6 +147,10 @@ class LoggerWithContext(FilteringBoundLogger, Protocol):
142
147
  "clear thread-local context"
143
148
  ...
144
149
 
150
+ def trace(self, *args, **kwargs) -> None: # noqa: F811
151
+ "trace level logging"
152
+ ...
153
+
145
154
 
146
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
147
156
  def add_simple_context_aliases(log) -> LoggerWithContext:
@@ -153,7 +162,10 @@ def add_simple_context_aliases(log) -> LoggerWithContext:
153
162
 
154
163
 
155
164
  def configure_logger(
156
- *, logger_factory=None, json_logger: bool | None = None
165
+ *,
166
+ json_logger: bool = False,
167
+ logger_factory=None,
168
+ install_exception_hook: bool = False,
157
169
  ) -> LoggerWithContext:
158
170
  """
159
171
  Create a struct logger with some special additions:
@@ -166,9 +178,10 @@ def configure_logger(
166
178
  >>> log.clear()
167
179
 
168
180
  Args:
181
+ json_logger: Flag to use JSON logging. Defaults to False.
169
182
  logger_factory: Optional logger factory to override the default
170
- json_logger: Optional flag to use JSON logging. If None, defaults to
171
- production or staging environment sourced from PYTHON_ENV.
183
+ install_exception_hook: Optional flag to install a global exception hook
184
+ that logs uncaught exceptions using structlog. Defaults to False.
172
185
  """
173
186
  setup_trace()
174
187
 
@@ -176,8 +189,10 @@ def configure_logger(
176
189
  # This is important for tests where configure_logger might be called multiple times
177
190
  structlog.reset_defaults()
178
191
 
179
- if json_logger is None:
180
- json_logger = is_production() or is_staging()
192
+ if install_exception_hook:
193
+ from .hook import install_exception_hook as _install_hook
194
+
195
+ _install_hook(json_logger)
181
196
 
182
197
  redirect_stdlib_loggers(json_logger)
183
198
  redirect_showwarnings()
@@ -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
@@ -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):
@@ -77,6 +77,7 @@ def beautiful_traceback_exception_formatter(sio: TextIO, exc_info: ExcInfo) -> N
77
77
  from beautiful_traceback.formatting import exc_to_traceback_str
78
78
 
79
79
  _, exc_value, traceback = exc_info
80
+ assert traceback is not None
80
81
  # TODO support local_stack_only env var support
81
82
  formatted_exception = exc_to_traceback_str(exc_value, traceback, color=not NO_COLOR)
82
83
  sio.write("\n" + formatted_exception)
@@ -180,7 +181,7 @@ def add_fastapi_context(
180
181
 
181
182
  https://github.com/tomwojcik/starlette-context/blob/master/example/setup_logging.py
182
183
  """
183
- from starlette_context import context
184
+ from starlette_context import context # type: ignore
184
185
 
185
186
  if context.exists():
186
187
  event_dict.update(context.data)
@@ -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
@@ -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
- Pytest plugin for capturing and displaying logs only on test failures.
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
- This plugin integrates with structlog-config's file logging to capture logs per-test
5
- and display them only when tests fail, keeping output clean for passing tests.
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
- 1. Install the plugin (automatically registered via entry point):
9
- pip install structlog-config[fastapi]
10
-
11
- 2. Enable in pytest.ini or pyproject.toml:
12
- [tool.pytest.ini_options]
13
- addopts = ["--capture-logs-on-fail"]
14
-
15
- Or enable for a single test run:
16
- pytest --capture-logs-on-fail
17
-
18
- 3. Optional: Persist all logs to a directory:
19
- pytest --capture-logs-dir=/path/to/logs
20
-
21
- How it works:
22
- - Sets PYTHON_LOG_PATH to a unique temp file for each test
23
- - Logs are written to /tmp/<project-name>-pytest-logs-*/test_name.log
24
- - On test failure, prints captured logs to stdout
25
- - Cleans up temp files after each test (unless --capture-logs-dir is set)
26
- - Automatically disabled if PYTHON_LOG_PATH is already set
27
-
28
- Example output on failure:
29
- --- Captured logs for failed test: tests/test_foo.py::test_bar ---
30
- 2025-10-31 23:30:00 [info] Starting test
31
- 2025-10-31 23:30:01 [error] Something went wrong
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 re
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
- logger = logging.getLogger(__name__)
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
- def sanitize_filename(name: str) -> str:
51
- """Replace non-filename-safe characters with underscores.
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
- Returns:
57
- A filesystem-safe filename string.
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 pytest_addoption(parser: pytest.Parser) -> None:
63
- """Register the --capture-logs-on-fail command line option.
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
- Args:
66
- parser: The pytest parser to add options to.
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
- parser.addoption(
69
- "--capture-logs-on-fail",
70
- action="store_true",
71
- default=False,
72
- help="Capture logs to a temp file and dump them to stdout on test failure.",
73
- )
74
- parser.addoption(
75
- "--capture-logs-dir",
76
- action="store",
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
- help="Directory to persist all test logs (disables automatic cleanup).",
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) -> None:
84
- """Configure the plugin at pytest startup.
282
+ def pytest_configure(config: pytest.Config):
283
+ """Configure the plugin."""
284
+ output_dir_str = config.option.structlog_output
85
285
 
86
- Stores configuration state on the config object for use by fixtures and hooks.
286
+ if not output_dir_str:
287
+ config.stash[CAPTURE_KEY] = {"enabled": False}
288
+ return
87
289
 
88
- Args:
89
- config: The pytest config object.
90
- """
91
- logs_dir = config.getoption("--capture-logs-dir")
92
- enabled = config.getoption("--capture-logs-on-fail") or logs_dir is not None
93
-
94
- plugin_config = {
95
- "enabled": enabled,
96
- "logs_dir": logs_dir,
97
- "project_name": os.path.basename(str(config.rootdir)),
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
- Args:
107
- session: The pytest session object.
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
- @pytest.hookimpl(trylast=True)
128
- def pytest_sessionfinish(session: pytest.Session) -> None:
129
- """Clean up session-level temp directory unless --capture-logs-dir was used.
314
+ 1. Single test - add to function signature:
315
+ def test_foo(file_descriptor_output_capture):
316
+ ...
130
317
 
131
- Args:
132
- session: The pytest session object.
133
- """
134
- config = session.config
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
- This fixture runs automatically for every test when --capture-logs-on-fail is enabled.
152
- It sets PYTHON_LOG_PATH to redirect logs to a unique temp file, then cleans up after.
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
- Yields:
158
- Control back to the test, then handles cleanup after test completion.
159
- """
160
- config = request.config
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 "PYTHON_LOG_PATH" in os.environ:
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
- tmpdir = plugin_config.get(SESSION_TMPDIR_KEY)
175
- if not tmpdir:
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 = sanitize_filename(request.node.nodeid)
181
- log_file = tmpdir / f"{test_name}.log"
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
- logger.info(f"Logs for test '{request.node.nodeid}' will be stored at: {log_file}")
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
- yield
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
- setattr(request.node, "_pytest_log_file", str(log_file))
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
- def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> None:
199
- """Hook called after each test phase to create test reports.
373
+ if output.stderr:
374
+ (test_dir / "stderr.txt").write_text(output.stderr)
200
375
 
201
- On test failure, reads and prints the captured log file to stdout.
202
- Handles failures in setup, call, and teardown phases.
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
- log_file = getattr(item, "_pytest_log_file", None)
216
- if log_file and os.path.exists(log_file):
217
- with open(log_file, "r") as f:
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._log_levels import NAME_TO_LEVEL
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
- # patch structlog maps to include the additional level
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
- logging.TRACE = TRACE_LOG_LEVEL
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
- logging.Logger.trace = partialmethod(logging.Logger.log, logging.TRACE)
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.trace = partial(logging.log, logging.TRACE)
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,13 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: structlog-config
3
- Version: 0.7.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
7
7
  Author-email: Michael Bianco <mike@mikebian.co>
8
8
  Requires-Dist: orjson>=3.10.15
9
9
  Requires-Dist: python-decouple-typed>=3.11.0
10
- Requires-Dist: python-ipware>=3.0.0
10
+ Requires-Dist: fastapi-ipware>=0.1.1
11
11
  Requires-Dist: structlog>=25.2.0
12
12
  Requires-Dist: fastapi-ipware>=0.1.0 ; extra == 'fastapi'
13
13
  Requires-Python: >=3.11
@@ -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 for Production
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. Note that `PYTHON_LOG_PATH` is ignored with JSON logging (stdout only).
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`. This is useful for extremely detailed debugging scenarios.
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 Logs on Failure
192
+ ## Pytest Plugin: Capture Output on Failure
197
193
 
198
- A pytest plugin that captures logs per-test and displays them only when tests fail. This keeps your test output clean while ensuring you have all the debugging information you need when something goes wrong.
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
- - Only shows logs for failing tests (keeps output clean)
203
- - Captures logs from all test phases (setup, call, teardown)
204
- - Unique log file per test
205
- - Optional persistent log storage for debugging
206
- - Automatically handles `PYTHON_LOG_PATH` environment variable
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 `--capture-logs-on-fail` flag:
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 --capture-logs-on-fail
209
+ pytest --structlog-output=./test-output -s
214
210
  ```
215
211
 
216
- Or enable it permanently in `pytest.ini` or `pyproject.toml`:
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
- ```toml
219
- [tool.pytest.ini_options]
220
- addopts = ["--capture-logs-on-fail"]
216
+ ```bash
217
+ pytest --structlog-output=./test-output -s -p no:logging
221
218
  ```
222
219
 
223
- ### Persist Logs to Directory
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
- To keep all test logs for later inspection (useful for CI/CD debugging):
222
+ ### Output Structure
226
223
 
227
- ```bash
228
- pytest --capture-logs-dir=./test-logs
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
- This creates a log file for each test and disables automatic cleanup.
256
+ Alternatively, you can use `@pytest.mark.usefixtures("file_descriptor_output_capture")`
232
257
 
233
- ### How It Works
234
258
 
235
- 1. Sets `PYTHON_LOG_PATH` environment variable to a unique temp file for each test
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
- ### Example Output
261
+ Add to `conftest.py`:
241
262
 
242
- When a test fails, you'll see:
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
- --- Captured logs for failed test (call): tests/test_user.py::test_user_login ---
248
- 2025-11-01 18:30:00 [info] User login started user_id=123
249
- 2025-11-01 18:30:01 [error] Database connection failed timeout=5.0
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
- For passing tests, no log output is shown, keeping your test output clean and focused.
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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.13
2
+ Generator: uv 0.9.28
3
3
  Root-Is-Purelib: true
4
- Tag: py3-none-any
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [pytest11]
2
+ structlog_config = structlog_config.pytest_plugin
3
+
@@ -1,15 +0,0 @@
1
- structlog_config/__init__.py,sha256=wTIMChYHwuzzgqoahCyF6YkJxjIYb-Gpyx8LkPkUpGk,6798
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=KJ3PlkrFva1RKz5SmFmEwVCqJuHxq3Qsqhd9ezB983I,6715
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.7.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
14
- structlog_config-0.7.0.dist-info/METADATA,sha256=yjnflk2btwm0xBy-0xRjhe8lwXPmsdToMx2Kn2doylU,10994
15
- structlog_config-0.7.0.dist-info/RECORD,,