annotated-logger 1.2.1__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.
- annotated_logger/__init__.py +966 -0
- annotated_logger/filter.py +58 -0
- annotated_logger/mocks.py +253 -0
- annotated_logger/plugins.py +208 -0
- annotated_logger/py.typed +0 -0
- annotated_logger-1.2.1.dist-info/METADATA +65 -0
- annotated_logger-1.2.1.dist-info/RECORD +10 -0
- annotated_logger-1.2.1.dist-info/WHEEL +4 -0
- annotated_logger-1.2.1.dist-info/entry_points.txt +2 -0
- annotated_logger-1.2.1.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from copy import copy
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import annotated_logger
|
|
8
|
+
|
|
9
|
+
Annotations = dict[str, Any]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnnotatedFilter(logging.Filter):
|
|
13
|
+
"""Filter class that stores the annotations and plugins."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
annotations: Annotations | None = None,
|
|
18
|
+
class_annotations: Annotations | None = None,
|
|
19
|
+
plugins: list[annotated_logger.BasePlugin] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Store the annotations, attributes and plugins."""
|
|
22
|
+
self.annotations = annotations or {}
|
|
23
|
+
self.class_annotations = class_annotations or {}
|
|
24
|
+
self.plugins = plugins or [annotated_logger.BasePlugin()]
|
|
25
|
+
|
|
26
|
+
# This allows plugins to determine what fields were added by the user
|
|
27
|
+
# vs the ones native to the log record
|
|
28
|
+
# TODO(crimsonknave): Make a test for this # noqa: TD003, FIX002
|
|
29
|
+
self.base_attributes = logging.makeLogRecord({}).__dict__ # pragma: no mutate
|
|
30
|
+
|
|
31
|
+
def _all_annotations(self) -> Annotations:
|
|
32
|
+
annotations = {}
|
|
33
|
+
annotations.update(copy(self.class_annotations))
|
|
34
|
+
annotations.update(copy(self.annotations))
|
|
35
|
+
annotations["annotated"] = True
|
|
36
|
+
return annotations
|
|
37
|
+
|
|
38
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
39
|
+
"""Add the annotations to the record and allow plugins to filter the record.
|
|
40
|
+
|
|
41
|
+
The `filter` method is called on each plugin in the order they are listed.
|
|
42
|
+
The plugin is then able to maniuplate the record object before the next plugin
|
|
43
|
+
sees it. Returning False from the filter method will stop the evaluation and
|
|
44
|
+
the log record won't be emitted.
|
|
45
|
+
"""
|
|
46
|
+
record.__dict__.update(self._all_annotations())
|
|
47
|
+
for plugin in self.plugins:
|
|
48
|
+
try:
|
|
49
|
+
result = plugin.filter(record)
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
failed_plugins = record.__dict__.get("failed_plugins", [])
|
|
52
|
+
failed_plugins.append(str(plugin.__class__))
|
|
53
|
+
record.__dict__["failed_plugins"] = failed_plugins
|
|
54
|
+
result = True
|
|
55
|
+
|
|
56
|
+
if not result:
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
import pychoir
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AssertLogged:
|
|
11
|
+
"""Stores the data from a call to `assert_logged` and checks if there is a match."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
level: str | pychoir.core.Matcher,
|
|
16
|
+
message: str | pychoir.core.Matcher,
|
|
17
|
+
present: dict[str, str],
|
|
18
|
+
absent: set[str] | Literal["ALL"],
|
|
19
|
+
*,
|
|
20
|
+
count: int | pychoir.core.Matcher,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Store the arguments that were passed to `assert_logged` and set defaults."""
|
|
23
|
+
self.level = level
|
|
24
|
+
self.message = message
|
|
25
|
+
self.present = present
|
|
26
|
+
self.absent = absent
|
|
27
|
+
self.count = count
|
|
28
|
+
self.found = 0
|
|
29
|
+
self.failed_matches: dict[str, int] = {}
|
|
30
|
+
|
|
31
|
+
def check(self, mock: AnnotatedLogMock) -> None:
|
|
32
|
+
"""Loop through calls in passed mock and check for matches."""
|
|
33
|
+
for record in mock.records:
|
|
34
|
+
differences = self._check_record_matches(record)
|
|
35
|
+
if len(differences) == 0:
|
|
36
|
+
self.found = self.found + 1
|
|
37
|
+
diff_str = str(differences)
|
|
38
|
+
if diff_str in self.failed_matches:
|
|
39
|
+
self.failed_matches[diff_str] += 1
|
|
40
|
+
else:
|
|
41
|
+
self.failed_matches[diff_str] = 1
|
|
42
|
+
|
|
43
|
+
fail_message = self.build_message()
|
|
44
|
+
if len(fail_message) > 0:
|
|
45
|
+
pytest.fail("\n".join(fail_message))
|
|
46
|
+
|
|
47
|
+
def _failed_sort_key(self, failed_tuple: tuple[str, int]) -> str:
|
|
48
|
+
failed, count = failed_tuple
|
|
49
|
+
message_match = failed.count("Desired message")
|
|
50
|
+
count_diff = 0 # pragma: no mutate
|
|
51
|
+
if isinstance(self.count, int):
|
|
52
|
+
count_diff = abs(count - self.count)
|
|
53
|
+
number = (
|
|
54
|
+
failed.count("Desired")
|
|
55
|
+
+ failed.count("Missing key")
|
|
56
|
+
+ failed.count("Unwanted key")
|
|
57
|
+
)
|
|
58
|
+
length = len(failed)
|
|
59
|
+
# This will order by if the message matched then how the count differs
|
|
60
|
+
# then number of incorrect bits and finally the length
|
|
61
|
+
return f"{message_match}-{count_diff:04d}-{number:04d}-{length:04d}" # pragma: no mutate # noqa: E501
|
|
62
|
+
|
|
63
|
+
def build_message(self) -> list[str]:
|
|
64
|
+
"""Create failure message."""
|
|
65
|
+
if self.count == 0 and self.found == 0:
|
|
66
|
+
return []
|
|
67
|
+
if self.found == 0:
|
|
68
|
+
fail_message = [
|
|
69
|
+
f"No matching log record found. There were {sum(self.failed_matches.values())} log messages.", # noqa: E501
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
fail_message.append("Desired:")
|
|
73
|
+
if isinstance(self.count, int):
|
|
74
|
+
fail_message.append(f"Count: {self.count}")
|
|
75
|
+
fail_message.append(f"Message: '{self.message}'")
|
|
76
|
+
fail_message.append(f"Level: '{self.level}'")
|
|
77
|
+
# only put in these if they were specified
|
|
78
|
+
fail_message.append(f"Present: '{self.present}'")
|
|
79
|
+
fail_message.append(f"Absent: '{self.absent}'")
|
|
80
|
+
fail_message.append("")
|
|
81
|
+
|
|
82
|
+
if len(self.failed_matches) == 0:
|
|
83
|
+
return fail_message
|
|
84
|
+
fail_message.append(
|
|
85
|
+
"Below is a list of the values for the selected extras for those failed matches.", # noqa: E501
|
|
86
|
+
)
|
|
87
|
+
for match, count in sorted(
|
|
88
|
+
self.failed_matches.items(), key=self._failed_sort_key
|
|
89
|
+
):
|
|
90
|
+
msg = match
|
|
91
|
+
if self.count and self.count != count:
|
|
92
|
+
msg = (
|
|
93
|
+
match[:-1]
|
|
94
|
+
+ f', "Desired {self.count} call{"" if self.count == 1 else "s"}, actual {count} call{"" if count == 1 else "s"}"' # noqa: E501
|
|
95
|
+
+ match[-1:]
|
|
96
|
+
)
|
|
97
|
+
fail_message.append(msg)
|
|
98
|
+
return fail_message
|
|
99
|
+
|
|
100
|
+
if self.count != self.found:
|
|
101
|
+
return [f"Found {self.found} matching messages, {self.count} were desired"]
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
def _check_record_matches(
|
|
105
|
+
self,
|
|
106
|
+
record: logging.LogRecord,
|
|
107
|
+
) -> list[str]:
|
|
108
|
+
differences = []
|
|
109
|
+
# `levelname` is often renamed. But, `levelno` shouldn't be touched as often
|
|
110
|
+
# So, don't try to guess what the level name is, just use the levelno.
|
|
111
|
+
level = {
|
|
112
|
+
logging.DEBUG: "DEBUG",
|
|
113
|
+
logging.INFO: "INFO",
|
|
114
|
+
logging.WARNING: "WARNING",
|
|
115
|
+
logging.ERROR: "ERROR",
|
|
116
|
+
}[record.levelno]
|
|
117
|
+
actual = {
|
|
118
|
+
"level": level,
|
|
119
|
+
"msg": record.msg,
|
|
120
|
+
# The extras are already added as attributes, so this is the easiest way
|
|
121
|
+
# to get them. There are more things in here, but that should be fine
|
|
122
|
+
"extra": record.__dict__,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if self.level != actual["level"]:
|
|
126
|
+
differences.append(
|
|
127
|
+
f"Desired level: {self.level}, actual level: {actual['level']}",
|
|
128
|
+
)
|
|
129
|
+
# TODO @<crimsonknave>: Do a better string diff here # noqa: FIX002, TD003
|
|
130
|
+
if self.message != actual["msg"]:
|
|
131
|
+
differences.append(
|
|
132
|
+
f"Desired message: '{self.message}', actual message: '{actual['msg']}'",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
actual_keys = set(actual["extra"].keys())
|
|
136
|
+
desired_keys = set(self.present.keys())
|
|
137
|
+
|
|
138
|
+
missing = desired_keys - actual_keys
|
|
139
|
+
unwanted = set()
|
|
140
|
+
if self.absent == AnnotatedLogMock.ALL:
|
|
141
|
+
unwanted = actual_keys - AnnotatedLogMock.DEFAULT_LOG_KEYS
|
|
142
|
+
elif isinstance(self.absent, set):
|
|
143
|
+
unwanted = actual_keys & self.absent
|
|
144
|
+
shared = desired_keys & actual_keys
|
|
145
|
+
differences.extend([f"Missing key: `{key}`" for key in sorted(missing)])
|
|
146
|
+
|
|
147
|
+
differences.extend([f"Unwanted key: `{key}`" for key in sorted(unwanted)])
|
|
148
|
+
|
|
149
|
+
differences.extend(
|
|
150
|
+
[
|
|
151
|
+
f"Extra `{key}` value is incorrect. Desired `{self.present[key]}` ({self.present[key].__class__}) , actual `{actual['extra'][key]}` ({actual['extra'][key].__class__})" # noqa: E501
|
|
152
|
+
for key in sorted(shared)
|
|
153
|
+
if self.present[key] != actual["extra"][key]
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
return differences
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AnnotatedLogMock(logging.Handler):
|
|
160
|
+
"""Mock that captures logs and provides extra assertion logic."""
|
|
161
|
+
|
|
162
|
+
ALL = "ALL"
|
|
163
|
+
DEFAULT_LOG_KEYS = frozenset(
|
|
164
|
+
[
|
|
165
|
+
"action",
|
|
166
|
+
"annotated",
|
|
167
|
+
"args",
|
|
168
|
+
"created",
|
|
169
|
+
"exc_info",
|
|
170
|
+
"exc_text",
|
|
171
|
+
"filename",
|
|
172
|
+
"funcName",
|
|
173
|
+
"levelname",
|
|
174
|
+
"levelno",
|
|
175
|
+
"lineno",
|
|
176
|
+
"message",
|
|
177
|
+
"module",
|
|
178
|
+
"msecs",
|
|
179
|
+
"msg",
|
|
180
|
+
"name",
|
|
181
|
+
"pathname",
|
|
182
|
+
"process",
|
|
183
|
+
"processName",
|
|
184
|
+
"relativeCreated",
|
|
185
|
+
"stack_info",
|
|
186
|
+
"thread",
|
|
187
|
+
"threadName",
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def __init__(self, handler: logging.Handler) -> None:
|
|
192
|
+
"""Store the handler and initialize the messages and records lists."""
|
|
193
|
+
self.messages = []
|
|
194
|
+
self.records = []
|
|
195
|
+
self.handler = handler
|
|
196
|
+
|
|
197
|
+
def __getattr__(self, name: str) -> Any: # noqa: ANN401
|
|
198
|
+
"""Fall back to the real handler object."""
|
|
199
|
+
return getattr(self.handler, name)
|
|
200
|
+
|
|
201
|
+
def handle(self, record: logging.LogRecord) -> bool:
|
|
202
|
+
"""Wrap the real handle method, store the formatted message and log record."""
|
|
203
|
+
self.messages.append(self.handler.format(record))
|
|
204
|
+
self.records.append(record)
|
|
205
|
+
return self.handler.handle(record)
|
|
206
|
+
|
|
207
|
+
def assert_logged(
|
|
208
|
+
self,
|
|
209
|
+
level: str | pychoir.core.Matcher | None = None,
|
|
210
|
+
message: str | pychoir.core.Matcher | None = None,
|
|
211
|
+
present: dict[str, Any] | None = None,
|
|
212
|
+
absent: str | set[str] | list[str] | None = None,
|
|
213
|
+
count: int | pychoir.core.Matcher | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Check if the mock received a log call that matches the arguments."""
|
|
216
|
+
if level is None:
|
|
217
|
+
level = pychoir.existential.Anything()
|
|
218
|
+
elif isinstance(level, str):
|
|
219
|
+
level = level.upper()
|
|
220
|
+
if message is None:
|
|
221
|
+
message = pychoir.existential.Anything()
|
|
222
|
+
if present is None:
|
|
223
|
+
present = {}
|
|
224
|
+
if absent is None:
|
|
225
|
+
absent = []
|
|
226
|
+
if isinstance(absent, list):
|
|
227
|
+
absent = set(absent)
|
|
228
|
+
if isinstance(absent, str) and absent != "ALL":
|
|
229
|
+
absent = {absent}
|
|
230
|
+
if count is None:
|
|
231
|
+
count = pychoir.numeric.IsPositive()
|
|
232
|
+
__tracebackhide__ = True # pragma: no mutate
|
|
233
|
+
assert_logged = AssertLogged(level, message, present, absent, count=count)
|
|
234
|
+
assert_logged.check(self)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.fixture
|
|
238
|
+
def annotated_logger_object() -> logging.Logger:
|
|
239
|
+
"""Logger to wrap with the `annotated_logger_mock` fixture."""
|
|
240
|
+
return logging.getLogger("annotated_logger")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.fixture
|
|
244
|
+
def annotated_logger_mock(annotated_logger_object: logging.Logger) -> AnnotatedLogMock:
|
|
245
|
+
"""Fixture for a mock of the annotated logger."""
|
|
246
|
+
handler = annotated_logger_object.handlers[0]
|
|
247
|
+
annotated_logger_object.removeHandler(handler)
|
|
248
|
+
mock_handler = AnnotatedLogMock(
|
|
249
|
+
handler=handler,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
annotated_logger_object.addHandler(mock_handler)
|
|
253
|
+
return mock_handler
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
6
|
+
|
|
7
|
+
from requests.exceptions import HTTPError
|
|
8
|
+
|
|
9
|
+
from annotated_logger.filter import AnnotatedFilter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
12
|
+
from annotated_logger import AnnotatedAdapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BasePlugin:
|
|
16
|
+
"""Base class for plugins."""
|
|
17
|
+
|
|
18
|
+
def filter(self, _record: logging.LogRecord) -> bool:
|
|
19
|
+
"""Determine if the record should be sent."""
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
def uncaught_exception(
|
|
23
|
+
self, exception: Exception, logger: AnnotatedAdapter
|
|
24
|
+
) -> AnnotatedAdapter:
|
|
25
|
+
"""Handle an uncaught excaption."""
|
|
26
|
+
if "success" not in logger.filter.annotations:
|
|
27
|
+
logger.annotate(success=False)
|
|
28
|
+
if "exception_title" not in logger.filter.annotations:
|
|
29
|
+
logger.annotate(exception_title=str(exception))
|
|
30
|
+
return logger
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuntimeAnnotationsPlugin(BasePlugin):
|
|
34
|
+
"""Plugin that sets annotations dynamically."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self, runtime_annotations: dict[str, Callable[[logging.LogRecord], Any]]
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Store the runtime annotations."""
|
|
40
|
+
self.runtime_annotations = runtime_annotations
|
|
41
|
+
|
|
42
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
43
|
+
"""Add any configured runtime annotations."""
|
|
44
|
+
for key, function in self.runtime_annotations.items():
|
|
45
|
+
record.__dict__[key] = function(record)
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RequestsPlugin(BasePlugin):
|
|
50
|
+
"""Plugin for the requests library."""
|
|
51
|
+
|
|
52
|
+
def uncaught_exception(
|
|
53
|
+
self, exception: Exception, logger: AnnotatedAdapter
|
|
54
|
+
) -> AnnotatedAdapter:
|
|
55
|
+
"""Add the status code if possible."""
|
|
56
|
+
if isinstance(exception, HTTPError) and exception.response is not None:
|
|
57
|
+
logger.annotate(status_code=exception.response.status_code)
|
|
58
|
+
logger.annotate(exception_title=exception.response.reason)
|
|
59
|
+
return logger
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RenamerPlugin(BasePlugin):
|
|
63
|
+
"""Plugin that prevents name collisions."""
|
|
64
|
+
|
|
65
|
+
class FieldNotPresentError(Exception):
|
|
66
|
+
"""Exception for a field that is supposed to be renamed, but is not present."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *, strict: bool = False, **kwargs: str) -> None:
|
|
69
|
+
"""Store the list of names to rename and pre/post fixs."""
|
|
70
|
+
self.targets = kwargs
|
|
71
|
+
self.strict = strict
|
|
72
|
+
|
|
73
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
74
|
+
"""Adjust the name of any fields that match a provided list if they exist."""
|
|
75
|
+
for new, old in self.targets.items():
|
|
76
|
+
if old in record.__dict__:
|
|
77
|
+
record.__dict__[new] = record.__dict__[old]
|
|
78
|
+
del record.__dict__[old]
|
|
79
|
+
elif self.strict:
|
|
80
|
+
raise RenamerPlugin.FieldNotPresentError(old)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RemoverPlugin(BasePlugin):
|
|
85
|
+
"""Plugin that removed fields."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, targets: list[str] | str) -> None:
|
|
88
|
+
"""Store the list of names to remove."""
|
|
89
|
+
if isinstance(targets, str):
|
|
90
|
+
targets = [targets]
|
|
91
|
+
self.targets = targets
|
|
92
|
+
|
|
93
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
94
|
+
"""Remove the specified fields."""
|
|
95
|
+
for target in self.targets:
|
|
96
|
+
with contextlib.suppress(KeyError):
|
|
97
|
+
del record.__dict__[target]
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NameAdjusterPlugin(BasePlugin):
|
|
102
|
+
"""Plugin that prevents name collisions with splunk field names."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, names: list[str], prefix: str = "", postfix: str = "") -> None:
|
|
105
|
+
"""Store the list of names to rename and pre/post fixs."""
|
|
106
|
+
self.names = names
|
|
107
|
+
self.prefix = prefix
|
|
108
|
+
self.postfix = postfix
|
|
109
|
+
|
|
110
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
111
|
+
"""Adjust the name of any fields that match a provided list."""
|
|
112
|
+
for name in self.names:
|
|
113
|
+
if name in record.__dict__:
|
|
114
|
+
value = record.__dict__[name]
|
|
115
|
+
del record.__dict__[name]
|
|
116
|
+
record.__dict__[f"{self.prefix}{name}{self.postfix}"] = value
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class NestedRemoverPlugin(BasePlugin):
|
|
121
|
+
"""Plugin that removes nested fields."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, keys_to_remove: list[str]) -> None:
|
|
124
|
+
"""Store the list of keys to remove."""
|
|
125
|
+
self.keys_to_remove = keys_to_remove
|
|
126
|
+
|
|
127
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
128
|
+
"""Remove the specified fields."""
|
|
129
|
+
|
|
130
|
+
def delete_keys_nested(
|
|
131
|
+
target: dict, # pyright: ignore[reportMissingTypeArgument]
|
|
132
|
+
keys_to_remove: list, # pyright: ignore[reportMissingTypeArgument]
|
|
133
|
+
) -> dict: # pyright: ignore[reportMissingTypeArgument]
|
|
134
|
+
for key in keys_to_remove:
|
|
135
|
+
with contextlib.suppress(KeyError):
|
|
136
|
+
del target[key]
|
|
137
|
+
for value in target.values():
|
|
138
|
+
if isinstance(value, dict):
|
|
139
|
+
delete_keys_nested(value, keys_to_remove)
|
|
140
|
+
return target
|
|
141
|
+
|
|
142
|
+
record.__dict__ = delete_keys_nested(record.__dict__, self.keys_to_remove)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class GitHubActionsPlugin(BasePlugin):
|
|
147
|
+
"""Plugin that will format log messages for actions annotations."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, annotation_level: int) -> None:
|
|
150
|
+
"""Save the annotation level."""
|
|
151
|
+
self.annotation_level = annotation_level
|
|
152
|
+
self.base_attributes = logging.makeLogRecord({}).__dict__ # pragma: no mutate
|
|
153
|
+
self.attributes_to_exclude = {"annotated"}
|
|
154
|
+
|
|
155
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
156
|
+
"""Set the actions command to be an annotation if desired."""
|
|
157
|
+
if record.levelno < self.annotation_level:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
added_attributes = {
|
|
161
|
+
k: v
|
|
162
|
+
for k, v in record.__dict__.items()
|
|
163
|
+
if k not in self.base_attributes and k not in self.attributes_to_exclude
|
|
164
|
+
}
|
|
165
|
+
record.added_attributes = added_attributes
|
|
166
|
+
name = record.levelname.lower()
|
|
167
|
+
if name == "info": # pragma: no cover
|
|
168
|
+
name = "notice"
|
|
169
|
+
record.github_annotation = f"{name}::"
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
def logging_config(self) -> dict[str, dict[str, object]]:
|
|
174
|
+
"""Generate the default logging config for the plugin."""
|
|
175
|
+
return {
|
|
176
|
+
"handlers": {
|
|
177
|
+
"actions_handler": {
|
|
178
|
+
"class": "logging.StreamHandler",
|
|
179
|
+
"filters": ["actions_filter"],
|
|
180
|
+
"formatter": "actions_formatter",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
"filters": {
|
|
184
|
+
"actions_filter": {
|
|
185
|
+
"()": AnnotatedFilter,
|
|
186
|
+
"plugins": [
|
|
187
|
+
BasePlugin(),
|
|
188
|
+
self,
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
"formatters": {
|
|
193
|
+
"actions_formatter": {
|
|
194
|
+
"format": "{github_annotation} {message} - {added_attributes}",
|
|
195
|
+
"style": "{",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
"loggers": {
|
|
199
|
+
"annotated_logger.actions": {
|
|
200
|
+
"level": "DEBUG",
|
|
201
|
+
"handlers": [
|
|
202
|
+
# This is from the default logging config
|
|
203
|
+
# "annotated_handler",
|
|
204
|
+
"actions_handler",
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: annotated-logger
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: A decorator that logs the start and end of a function as well as providing an optional logger with annotations
|
|
5
|
+
Project-URL: Homepage, https://github.com/github/annotated_logger
|
|
6
|
+
Author-email: Vuln Mgmt Eng <security+vulnmgmteng@github.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: annotation,decorator,logging
|
|
9
|
+
Requires-Python: >=3.6
|
|
10
|
+
Requires-Dist: makefun
|
|
11
|
+
Requires-Dist: pychoir
|
|
12
|
+
Requires-Dist: python-json-logger
|
|
13
|
+
Requires-Dist: requests
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Annotated Logger
|
|
17
|
+
|
|
18
|
+
[contribution]: https://github.com/github/annotated-logger/blob/main/CONTRIBUTING.md
|
|
19
|
+
|
|
20
|
+
[](https://github.com/github/annotated-logger/tree/python-coverage-comment-action-data) [](https://github.com/astral-sh/ruff) [](https://microsoft.github.io/pyright/)
|
|
21
|
+
|
|
22
|
+
The `annotated-logger` package provides a decorator that can inject a annotatable logger object into a method or class. This logger object is a drop in replacement for `logging.logger` with additional functionality.
|
|
23
|
+
|
|
24
|
+
## Background
|
|
25
|
+
|
|
26
|
+
Annotated Logger is actively used by GitHub's Vulnerability Management team to help to easily add context to our logs in splunk. It is more or less feature complete for our current use cases, but we will add additional features/fixes as we discover a need for them. But, we'd love feature requests, bug report and or PRs for either (see our [contribution guidelines][contribution] for more information if you wish to contribute).
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
Annotated Logger is a Python package. It should work on any version of Python 3, but it is currently tested on 3.9 and higher.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
The `annotated-logger` package allows you to decorate a function so that the start and end of that function is logged as well as allowing that function to request an `annotated_logger` object which can be used as if it was a standard python `logger`. Additionally, the `annotated_logger` object will have added annotations based on the method it requested from, any other annotations that were configured ahead of time and any annotations that were added prior to a log being made. Finally, any uncaught exceptions in a decorated method will be logged and re-raised, which allows you to when and how a method ended regardless of if it was successful or not.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from annotated_logger import AnnotatedLogger
|
|
37
|
+
|
|
38
|
+
annotated_logger = AnnotatedLogger(
|
|
39
|
+
annotations={"this": "will show up in every log"},
|
|
40
|
+
)
|
|
41
|
+
annotate_logs = annotated_logger.annotate_logs
|
|
42
|
+
|
|
43
|
+
@annotate_logs()
|
|
44
|
+
def foo(annotated_logger, bar):
|
|
45
|
+
annotated_logger.annotate(bar=bar)
|
|
46
|
+
annotated_logger.info("Hi there!", extra={"mood": "happy"})
|
|
47
|
+
|
|
48
|
+
foo("this is the bar parameter")
|
|
49
|
+
|
|
50
|
+
{"created": 1708476277.102495, "levelname": "INFO", "name": "annotated_logger.fe18537a-d293-45d7-83c9-51dab3a4c436", "message": "Hi there!", "mood": "happy", "action": "__main__:foo", "this": "will show up in every log", "bar": "this is the bar parameter", "annotated": true}
|
|
51
|
+
{"created": 1708476277.1026022, "levelname": "INFO", "name": "annotated_logger.fe18537a-d293-45d7-83c9-51dab3a4c436", "message": "success", "action": "__main__:foo", "this": "will show up in every log", "bar": "this is the bar parameter", "run_time": "0.0", "success": true, "annotated": true}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The example directory has a few files that exercise all of the features of the annotated-logger package. The `Calculator` class is the most fully featured example (but not a fully featured calculator :wink:). The `logging_config` example shows how to configure a logger via a dictConfig, like django uses. It also shows some of the interactions that can exist between a `logging` logger and an `annotated_logger` if `logging` is configured to use the annotated logger filter.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
This project is licensed under the terms of the MIT open source license. Please refer to MIT for the full terms.
|
|
59
|
+
|
|
60
|
+
## Maintainers
|
|
61
|
+
This project is primarily maintained by `crimsonknave` on behalf of GitHub's Vulnerability Management team as it was initially developed for our internal use.
|
|
62
|
+
|
|
63
|
+
## Support
|
|
64
|
+
|
|
65
|
+
Reported bugs will be addressed, pull requests are welcome, but there is limited bandwidth for work on new features.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
annotated_logger/__init__.py,sha256=CNxhBSz5FC1GgfpTn1_PtDpI-PppaE2hx5LINS2D8wo,33616
|
|
2
|
+
annotated_logger/filter.py,sha256=FuE8XNsE6NqCeg5C0_O23BsTMCtauJIv2zdrLnsaqQc,2199
|
|
3
|
+
annotated_logger/mocks.py,sha256=ko_6jACn_mX7J7gEJKVAjQAfsyhMW-RVr5AivyzDwSY,9252
|
|
4
|
+
annotated_logger/plugins.py,sha256=AqwchYVQ5GOCblZQozE6LLQabWGoJxHBmSsL9IKG-ok,7339
|
|
5
|
+
annotated_logger/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
annotated_logger-1.2.1.dist-info/METADATA,sha256=hdBG3WF7pyRazHaqGVRorkwoFGvDk3H50gmA4_eoLLU,4456
|
|
7
|
+
annotated_logger-1.2.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
8
|
+
annotated_logger-1.2.1.dist-info/entry_points.txt,sha256=qEfPZaQTtLU05XwiqGoKdVrqTnfJyBWzUXeHjwGv3I0,53
|
|
9
|
+
annotated_logger-1.2.1.dist-info/licenses/LICENSE.txt,sha256=JRC0RrwfDPlwJFMHXSDNiGMeIOVkJljttzJdnB61NPc,1060
|
|
10
|
+
annotated_logger-1.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright GitHub, Inc.
|
|
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.
|