logctx 0.0.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.
logctx/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """logctx package
2
+
3
+ This package provides a convenient way to manage logging contexts in Python.
4
+
5
+ It allows you to manage key-value pairs for log-contexts which can be automatically
6
+ added to log messages within their respective context.
7
+ """
8
+
9
+ __author__ = "Alexander Schulte"
10
+ __maintainer__ = "Alexander Schulte"
11
+
12
+ __version__ = "0.0.0"
13
+
14
+ from logctx import decorators
15
+ from logctx._core import (
16
+ ContextInjectingLoggingFilter,
17
+ LogContext,
18
+ clear,
19
+ get_current,
20
+ new_context,
21
+ update,
22
+ )
23
+
24
+ __all__ = [
25
+ "ContextInjectingLoggingFilter",
26
+ "LogContext",
27
+ "clear",
28
+ "get_current",
29
+ "new_context",
30
+ "update",
31
+ "decorators",
32
+ ]
logctx/_core.py ADDED
@@ -0,0 +1,165 @@
1
+ import contextvars
2
+ import dataclasses
3
+ import logging
4
+ from contextlib import contextmanager
5
+ from typing import Any, Generator, Mapping, Optional
6
+
7
+ __all__: list[str] = [
8
+ "LogContext",
9
+ "get_current",
10
+ "new_context",
11
+ "update",
12
+ "clear",
13
+ "ContextInjectingLoggingFilter",
14
+ ]
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class LogContext:
19
+ """Dataclass holding information about one specific log context.
20
+
21
+ This class is used to store key-value pairs that are relevant for the
22
+ current logging context. It is designed to be immutable to prevent
23
+ accidental mutations by users.
24
+
25
+ If you want to update the context, use `logctx.update()` or `logctx.new_context()`.
26
+
27
+ Attributes:
28
+ data (Mapping[str, Any]): A mapping of key-value pairs representing
29
+ the context data.
30
+ """
31
+
32
+ data: Mapping[str, Any] = dataclasses.field(default_factory=dict)
33
+
34
+ def with_values(self, **kwargs) -> "LogContext":
35
+ """Create a new context with additional key-value pairs.
36
+
37
+ This method returns a new instance of LogContext with the current
38
+ context data merged with the provided key-value pairs. Duplicate keys
39
+ will be overwritten by the new values.
40
+
41
+ Caution:
42
+ This method does not affect the current active context, meaning that the
43
+ resulting context will not be included in any log messages.
44
+
45
+ Args:
46
+ **kwargs: Key-value pairs to be added to the new context.
47
+ Returns:
48
+ LogContext: A new instance of LogContext with the merged data.
49
+ """
50
+
51
+ return LogContext({**self.data, **kwargs})
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Convert the context to a dictionary."""
55
+
56
+ return dict(self.data)
57
+
58
+
59
+ _mdc_context: contextvars.ContextVar[LogContext] = contextvars.ContextVar("_mdc_context")
60
+
61
+
62
+ # TODO: return None or raise on no context
63
+ def get_current() -> LogContext:
64
+ """Retrieve current context.
65
+
66
+ This function retrieves the current logging context from the context
67
+ variable. If no context is found, it returns an empty LogContext
68
+ instance.
69
+
70
+ Returns:
71
+ LogContext: The current logging context.
72
+ """
73
+ try:
74
+ return _mdc_context.get()
75
+ except LookupError:
76
+ return LogContext()
77
+
78
+
79
+ @contextmanager
80
+ def new_context(**kwargs) -> Generator[LogContext, None, None]:
81
+ """Create a new context with the provided key-value pairs.
82
+
83
+ The new context inherits all key-value pairs from the current context and
84
+ adds the provided pairs. Duplicate keys will be overwritten by the new values.
85
+
86
+ Args:
87
+ **kwargs: Key-value pairs to be included in the new context.
88
+
89
+ Yields:
90
+ LogContext: The new logging context.
91
+ """
92
+
93
+ current_log_ctx: LogContext = get_current()
94
+ new_log_ctx = current_log_ctx.with_values(**kwargs)
95
+ token = _mdc_context.set(new_log_ctx)
96
+ try:
97
+ yield new_log_ctx
98
+ finally:
99
+ _mdc_context.reset(token)
100
+
101
+
102
+ def update(**kwargs) -> LogContext:
103
+ """Append key-value pairs to the current context.
104
+
105
+ Duplicate keys will be overwritten by the new values.
106
+
107
+ Will not affect log calls in current context made before the update.
108
+
109
+ Args:
110
+ **kwargs: Key-value pairs to be added to the current context.
111
+
112
+ Returns:
113
+ LogContext: The updated logging context with the appended key-value
114
+ pairs.
115
+ """
116
+ current_log_ctx = get_current()
117
+ updated_log_ctx = current_log_ctx.with_values(**kwargs)
118
+ _mdc_context.set(updated_log_ctx)
119
+
120
+ return updated_log_ctx
121
+
122
+
123
+ def clear() -> None:
124
+ """Clear the current context.
125
+
126
+ Only affects current context. After leaving current context, the context
127
+ will be reset to its previous state.
128
+
129
+ Example:
130
+ ```python
131
+ with logctx.new_context(a=1, b=2):
132
+ with logctx.new_context(c=3):
133
+ # Context is now: {'a': 1, 'b': 2, 'c': 3}
134
+ logctx.clear()
135
+ # Context is now: {}
136
+ # Context is now: {'a': 1, 'b': 2}
137
+ ```
138
+ """
139
+ _mdc_context.set(LogContext())
140
+
141
+
142
+ class ContextInjectingLoggingFilter(logging.Filter):
143
+ """Logging filter that injects the current context into log records.
144
+
145
+ Attributes:
146
+ name (str): The name of the filter. This is used to identify the
147
+ filter in the logging system.
148
+
149
+ output_field (str): The name of the field in the log record where the
150
+ context data will be injected. If not provided, the context data
151
+ will be injected into the log record as root level attributes.
152
+ """
153
+
154
+ def __init__(self, name: str = "", output_field: Optional[str] = None) -> None:
155
+ super().__init__(name=name)
156
+ self._output_field: Optional[str] = output_field
157
+
158
+ def filter(self, record: logging.LogRecord) -> bool:
159
+ context: LogContext = get_current()
160
+ if self._output_field is not None:
161
+ setattr(record, self._output_field, context.to_dict())
162
+ else:
163
+ for k, v in context.to_dict().items():
164
+ setattr(record, k, v)
165
+ return True
logctx/decorators.py ADDED
@@ -0,0 +1,187 @@
1
+ """Decorators for function based context injection.
2
+
3
+ This module provides decorators for injecting static context or function arguments
4
+ into functions and methods.
5
+
6
+ The decorators automatically create a new context around the
7
+ decorated function, including all provided keyword arguments into the new context.
8
+ """
9
+
10
+ import inspect
11
+ from collections.abc import Callable, Generator
12
+ from functools import wraps
13
+ from typing import AsyncGenerator, Awaitable, Optional, TypeVar
14
+
15
+ from typing_extensions import ParamSpec
16
+
17
+ from logctx import _core
18
+
19
+ _P = ParamSpec("_P")
20
+ _R = TypeVar("_R")
21
+
22
+
23
+ def _sync_wrapper(func: Callable[_P, _R], **static_context) -> Callable[_P, _R]:
24
+ """Context wrapper for synchronous functions."""
25
+
26
+ @wraps(func)
27
+ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
28
+ with _core.new_context(**static_context):
29
+ return func(*args, **kwargs)
30
+
31
+ return wrapper
32
+
33
+
34
+ def _async_wrapper(
35
+ func: Callable[_P, Awaitable[_R]], **static_context
36
+ ) -> Callable[_P, Awaitable[_R]]:
37
+ """Context wrapper for async functions."""
38
+
39
+ @wraps(func)
40
+ async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
41
+ with _core.new_context(**static_context):
42
+ return await func(*args, **kwargs)
43
+
44
+ return wrapper
45
+
46
+
47
+ _GenYield = TypeVar("_GenYield")
48
+ _GenSend = TypeVar("_GenSend")
49
+ _GenReturn = TypeVar("_GenReturn")
50
+
51
+
52
+ def _sync_generator_wrapper(
53
+ func: Callable[_P, Generator[_GenYield, _GenSend, _GenReturn]], **static_context
54
+ ) -> Callable[_P, Generator[_GenYield, _GenSend, _GenReturn]]:
55
+ """Context wrapper for synchronous generators."""
56
+
57
+ @wraps(func)
58
+ def wrapper(
59
+ *args: _P.args, **kwargs: _P.kwargs
60
+ ) -> Generator[_GenYield, _GenSend, _GenReturn]:
61
+ gen = func(*args, **kwargs)
62
+ with _core.new_context(**static_context):
63
+ first_element: _GenYield = next(gen)
64
+
65
+ to_send: _GenSend = yield first_element
66
+
67
+ while True:
68
+ try:
69
+ with _core.new_context(**static_context):
70
+ to_yield: _GenYield = gen.send(to_send)
71
+
72
+ except StopIteration as e:
73
+ # since PEP 479 generators should gracefully return a value without
74
+ # raising StopIteration.
75
+ return e.value
76
+ else:
77
+ to_send = yield to_yield
78
+
79
+ return wrapper
80
+
81
+
82
+ def _async_generator_wrapper(
83
+ func: Callable[_P, AsyncGenerator[_GenYield, _GenSend]], **static_context
84
+ ):
85
+ @wraps(func)
86
+ async def wrapper(
87
+ *args: _P.args, **kwargs: _P.kwargs
88
+ ) -> AsyncGenerator[_GenYield, _GenSend]:
89
+ gen = func(*args, **kwargs)
90
+
91
+ with _core.new_context(**static_context):
92
+ first_element: _GenYield = await gen.__anext__()
93
+
94
+ to_send: _GenSend = yield first_element
95
+
96
+ while True:
97
+ try:
98
+ with _core.new_context(**static_context):
99
+ to_yield: _GenYield = await gen.asend(to_send)
100
+ except StopAsyncIteration:
101
+ return
102
+ else:
103
+ to_send = yield to_yield
104
+
105
+ return wrapper
106
+
107
+
108
+ def inject_context(**static_context):
109
+ """Decorator injecting static context into a function.
110
+
111
+ This decorator will automatically create a new context around the decorated function
112
+ including all provided keyword arguments into the new context.
113
+
114
+ Args:
115
+ **static_context: Keyword arguments representing the static context
116
+ to be injected.
117
+ """
118
+
119
+ def _decorator(func):
120
+ if inspect.isgeneratorfunction(func):
121
+ return _sync_generator_wrapper(func, **static_context)
122
+
123
+ elif inspect.isasyncgenfunction(func):
124
+ return _async_generator_wrapper(func, **static_context)
125
+
126
+ elif inspect.iscoroutinefunction(func):
127
+ return _async_wrapper(func, **static_context)
128
+
129
+ elif inspect.isfunction(func):
130
+ return _sync_wrapper(func, **static_context)
131
+
132
+ else:
133
+ raise TypeError(
134
+ f"Unsupported function type: {type(func)}. "
135
+ "Function must be a coroutine, generator, or regular function."
136
+ )
137
+
138
+ return _decorator
139
+
140
+
141
+ def log_arguments(args: Optional[list[str]] = None, **kwargs):
142
+ """Decorator for auto-injecting function arguments into log context.
143
+
144
+ This decorator will automatically create a new context around the decorated function
145
+ including specified function arguments into the new context.
146
+
147
+ Args:
148
+ args (list[str], optional): A list of function argument names to log.
149
+ Each argument will be injected into the context with its normal key.
150
+ **kwargs: A mapping of function argument names to context keys.
151
+
152
+ Raises:
153
+ ValueError: If any specified argument is not found in the function's signature.
154
+ """
155
+ args = args or []
156
+
157
+ def decorator(func: Callable[_P, _R]) -> Callable[_P, _R]:
158
+ func_params = inspect.signature(func).parameters
159
+ for arg in args:
160
+ if arg not in func_params:
161
+ raise ValueError(
162
+ f"Argument '{arg}' not found in the function's signature."
163
+ )
164
+ for arg in kwargs:
165
+ if arg not in func_params:
166
+ raise ValueError(
167
+ f"Argument '{arg}' not found in the function's signature."
168
+ )
169
+
170
+ @wraps(func)
171
+ def wrapper(*func_args: _P.args, **func_kwargs: _P.kwargs) -> _R:
172
+ signature = inspect.signature(func)
173
+ bound = signature.bind(*func_args, **func_kwargs)
174
+ bound.apply_defaults()
175
+
176
+ context_data = {}
177
+ for arg in args:
178
+ context_data[arg] = bound.arguments[arg]
179
+ for arg, dest in kwargs.items():
180
+ context_data[dest] = bound.arguments[arg]
181
+
182
+ with _core.new_context(**context_data):
183
+ return func(*func_args, **func_kwargs)
184
+
185
+ return wrapper
186
+
187
+ return decorator
logctx/py.typed ADDED
File without changes
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: logctx
3
+ Version: 0.0.0
4
+ Summary: Management and injection of contextual variables into log messages.
5
+ Author: Alexander Schulte
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/aschulte201/logctx
8
+ Project-URL: Source, https://github.com/aschulte201/logctx
9
+ Project-URL: Documentation, https://github.com/aschulte201/logctx/blob/README.md
10
+ Project-URL: Changelog, https://github.com/aschulte201/logctx/blob/CHANGELOG.md
11
+ Keywords: logging,context,log,logger,logctx,log-context
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Natural Language :: English
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: typing-extensions>=4.12
28
+ Dynamic: license-file
29
+
30
+ [![CICD](https://github.com/aschulte201/logctx/actions/workflows/cicd.yml/badge.svg?branch=main)](https://github.com/aschulte201/logctx/actions/workflows/cicd.yml)
31
+
32
+ ## Enabling
33
+
34
+ The core module provides a `logging.Filter` subclass designed to inject the current active context into any log messages.
35
+
36
+ Below is a demo usage on how to enable context injection:
37
+
38
+ ```python
39
+ import logging
40
+ import logctx
41
+
42
+ root_logger = logging.getLogger()
43
+ console_handler = logging.StreamHandler()
44
+
45
+ formatter = jsonlogger.JsonFormatter("%(logctx)s")
46
+ context_filter = ContextInjectingLoggingFilter(output_field="logctx")
47
+
48
+ console_handler.setFormatter(formatter)
49
+ console_handler.addFilter(context_filter)
50
+
51
+ root_logger.addHandler(console_handler)
52
+ logger.setLevel(logging.DEBUG)
53
+ ```
54
+
55
+ # Generators
56
+ * During execution
57
+ * Between yields
58
+
59
+ ## Log Arguments
60
+ * Raises during initializtaion
61
+ * Value Error
62
+ * Able to rename args
63
+ * Unable to extract from kwargs
64
+ * Unable to work on generators
65
+ * Unable to work on async functions
66
+
67
+ # update
68
+ * can change root context
@@ -0,0 +1,9 @@
1
+ logctx/__init__.py,sha256=ykMLP_N00UDG1NTo3efsNuK8b3Z9U7yiYB58jSH1iWQ,655
2
+ logctx/_core.py,sha256=rUvs_6Kxq-IrWMeVxIav4sJlNAZC0CG5W4e3thl8XoM,5042
3
+ logctx/decorators.py,sha256=MWvIbQPLn1gtBJw8l9EFK72kQR2Gsuq5AuazD4WdgiE,5942
4
+ logctx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ logctx-0.0.0.dist-info/licenses/LICENSE,sha256=IkyD1DVxC2xxRy7n0jM5t1WUT-zply0h_Uv34hJPbEs,1082
6
+ logctx-0.0.0.dist-info/METADATA,sha256=3UiNUrPiRCfaOBMb2dbuyfGPlRyKSqII_37hYsYa22Q,2289
7
+ logctx-0.0.0.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
8
+ logctx-0.0.0.dist-info/top_level.txt,sha256=9vWyBBL40SUsnQbX02ztlfBFgwg5Dv_1dndj-p9CY7w,7
9
+ logctx-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.4.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Alexander Schulte.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ logctx