Skip to content

Commit f4f5cf2

Browse files
committed
Too large commit: Add actions plugin and lots of rework to solve some logging config weirdness it revealed
1 parent 56adb9b commit f4f5cf2

12 files changed

Lines changed: 397 additions & 203 deletions

File tree

annotated_logger/__init__.py

Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from makefun import wraps
2525

26+
from annotated_logger.filter import AnnotatedFilter
2627
from annotated_logger.plugins import BasePlugin
2728

2829
if TYPE_CHECKING: # pragma: no cover
@@ -98,16 +99,12 @@ class AnnotatedClass(Protocol[C_co]):
9899
"handlers": {
99100
"annotated_handler": {
100101
"class": "logging.StreamHandler",
101-
"filters": ["annotated_filter"],
102102
"formatter": "annotated_formatter",
103103
},
104104
},
105105
"formatters": {
106106
"annotated_formatter": {
107107
"class": "pythonjsonlogger.jsonlogger.JsonFormatter", # pragma: no mutate
108-
# Note that this format string uses `time` and `level` which are
109-
# set by the renamer plugin. Because the handler is using the
110-
# annotated_filter the plugings will be run and the fields will be renamed
111108
"format": "{created} {levelname} {name} {message}", # pragma: no mutate
112109
"style": "{",
113110
},
@@ -167,61 +164,6 @@ def __next__(self) -> T:
167164
return value
168165

169166

170-
class AnnotatedFilter(logging.Filter):
171-
"""Filter class that stores the annotations and plugins."""
172-
173-
def __init__(
174-
self,
175-
annotations: Annotations,
176-
runtime_annotations: Annotations,
177-
class_annotations: Annotations,
178-
plugins: list[BasePlugin],
179-
) -> None:
180-
"""Store the annotations, attributes and plugins."""
181-
self.annotations = annotations
182-
self.class_annotations = class_annotations
183-
self.runtime_annotations = runtime_annotations or {}
184-
self.plugins = plugins
185-
186-
# This allows plugins to determine what fields were added by the user
187-
# vs the ones native to the log record
188-
# TODO(crimsonknave): Make a test for this # noqa: TD003, FIX002
189-
self.base_attributes = logging.makeLogRecord({}).__dict__ # pragma: no mutate
190-
191-
def _all_annotations(self, record: logging.LogRecord) -> Annotations:
192-
annotations = {}
193-
# Using copy might be better, but, we don't want to add
194-
# the runtime annotations to the stored annotations
195-
annotations.update(self.class_annotations)
196-
annotations.update(self.annotations)
197-
for key, function in self.runtime_annotations.items():
198-
annotations[key] = function(record)
199-
annotations["annotated"] = True
200-
return annotations
201-
202-
def filter(self, record: logging.LogRecord) -> bool:
203-
"""Add the annotations to the record and allow plugins to filter the record.
204-
205-
The `filter` method is called on each plugin in the order they are listed.
206-
The plugin is then able to maniuplate the record object before the next plugin
207-
sees it. Returning False from the filter method will stop the evaluation and
208-
the log record won't be emitted.
209-
"""
210-
record.__dict__.update(self._all_annotations(record))
211-
for plugin in self.plugins:
212-
try:
213-
result = plugin.filter(record)
214-
except Exception: # noqa: BLE001
215-
failed_plugins = record.__dict__.get("failed_plugins", [])
216-
failed_plugins.append(str(plugin.__class__))
217-
record.__dict__["failed_plugins"] = failed_plugins
218-
result = True
219-
220-
if not result:
221-
return False
222-
return True
223-
224-
225167
class AnnotatedAdapter(logging.LoggerAdapter): # pyright: ignore[reportMissingTypeArgument]
226168
"""Adapter that provides extra methods."""
227169

annotated_logger/filter.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any
5+
6+
import annotated_logger
7+
8+
Annotations = dict[str, Any]
9+
10+
11+
class AnnotatedFilter(logging.Filter):
12+
"""Filter class that stores the annotations and plugins."""
13+
14+
def __init__(
15+
self,
16+
annotations: Annotations | None = None,
17+
runtime_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.runtime_annotations = runtime_annotations or {}
25+
self.plugins = plugins or [annotated_logger.BasePlugin()]
26+
27+
# This allows plugins to determine what fields were added by the user
28+
# vs the ones native to the log record
29+
# TODO(crimsonknave): Make a test for this # noqa: TD003, FIX002
30+
self.base_attributes = logging.makeLogRecord({}).__dict__ # pragma: no mutate
31+
32+
def _all_annotations(self, record: logging.LogRecord) -> Annotations:
33+
annotations = {}
34+
# Using copy might be better, but, we don't want to add
35+
# the runtime annotations to the stored annotations
36+
annotations.update(self.class_annotations)
37+
annotations.update(self.annotations)
38+
for key, function in self.runtime_annotations.items():
39+
annotations[key] = function(record)
40+
annotations["annotated"] = True
41+
return annotations
42+
43+
def filter(self, record: logging.LogRecord) -> bool:
44+
"""Add the annotations to the record and allow plugins to filter the record.
45+
46+
The `filter` method is called on each plugin in the order they are listed.
47+
The plugin is then able to maniuplate the record object before the next plugin
48+
sees it. Returning False from the filter method will stop the evaluation and
49+
the log record won't be emitted.
50+
"""
51+
record.__dict__.update(self._all_annotations(record))
52+
for plugin in self.plugins:
53+
try:
54+
result = plugin.filter(record)
55+
except Exception: # noqa: BLE001
56+
failed_plugins = record.__dict__.get("failed_plugins", [])
57+
failed_plugins.append(str(plugin.__class__))
58+
record.__dict__["failed_plugins"] = failed_plugins
59+
result = True
60+
61+
if not result:
62+
return False
63+
return True

annotated_logger/plugins.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import logging
45
from typing import TYPE_CHECKING
56

67
from requests.exceptions import HTTPError
78

8-
if TYPE_CHECKING: # pragma: no cover
9-
import logging
9+
from annotated_logger.filter import AnnotatedFilter
1010

11+
if TYPE_CHECKING: # pragma: no cover
1112
from annotated_logger import AnnotatedAdapter
1213

1314

@@ -122,3 +123,68 @@ def delete_keys_nested(
122123

123124
record.__dict__ = delete_keys_nested(record.__dict__, self.keys_to_remove)
124125
return True
126+
127+
128+
class GitHubActionsPlugin(BasePlugin):
129+
"""Plugin that will format log messages for actions annotations."""
130+
131+
def __init__(self, annotation_level: int) -> None:
132+
"""Save the annotation level."""
133+
self.annotation_level = annotation_level
134+
self.base_attributes = logging.makeLogRecord({}).__dict__ # pragma: no mutate
135+
self.attributes_to_exclude = {"annotated"}
136+
137+
def filter(self, record: logging.LogRecord) -> bool:
138+
"""Set the actions command to be an annotation if desired."""
139+
if record.levelno < self.annotation_level:
140+
return False
141+
142+
added_attributes = {
143+
k: v
144+
for k, v in record.__dict__.items()
145+
if k not in self.base_attributes and k not in self.attributes_to_exclude
146+
}
147+
record.__dict__["added_attributes"] = added_attributes
148+
name = record.levelname.lower()
149+
if name == "info": # pragma: no cover
150+
name = "notice"
151+
record.__dict__["github_annotation"] = f"{name}::"
152+
153+
return True
154+
155+
def logging_config(self) -> dict[str, dict[str, object]]:
156+
"""Generate the default logging config for the plugin."""
157+
return {
158+
"handlers": {
159+
"actions_handler": {
160+
"class": "logging.StreamHandler",
161+
"filters": ["actions_filter"],
162+
"formatter": "actions_formatter",
163+
},
164+
},
165+
"filters": {
166+
"actions_filter": {
167+
"()": AnnotatedFilter,
168+
"plugins": [
169+
BasePlugin(),
170+
self,
171+
],
172+
},
173+
},
174+
"formatters": {
175+
"actions_formatter": {
176+
"format": "{github_annotation} {message} - {added_attributes}",
177+
"style": "{",
178+
},
179+
},
180+
"loggers": {
181+
"annotated_logger.actions": {
182+
"level": "DEBUG",
183+
"handlers": [
184+
# This is from the default logging config
185+
"annotated_handler",
186+
"actions_handler",
187+
],
188+
},
189+
},
190+
}

example/actions.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
from copy import deepcopy
3+
4+
from annotated_logger import (
5+
DEFAULT_LOGGING_CONFIG,
6+
AnnotatedAdapter,
7+
AnnotatedLogger,
8+
)
9+
from annotated_logger.plugins import GitHubActionsPlugin
10+
11+
actions_plugin = GitHubActionsPlugin(annotation_level=logging.INFO)
12+
13+
LOGGING = deepcopy(DEFAULT_LOGGING_CONFIG)
14+
15+
# The GitHubActionsPlugin provides a `logging_config` method that returns some
16+
# defaults that will annotate at the info (notice) and above.
17+
# Making a copy of the default logging config and updating with this
18+
# lets us keep the standard logger and also annotate in actions.
19+
# But, we need to do it bit by bit so we are updating the loggers and so on
20+
# instead of replacing the loggers.
21+
LOGGING["loggers"].update(actions_plugin.logging_config()["loggers"])
22+
LOGGING["filters"].update(actions_plugin.logging_config()["filters"])
23+
LOGGING["handlers"].update(actions_plugin.logging_config()["handlers"])
24+
LOGGING["formatters"].update(actions_plugin.logging_config()["formatters"])
25+
26+
annotated_logger = AnnotatedLogger(
27+
plugins=[
28+
actions_plugin,
29+
],
30+
name="annotated_logger.actions",
31+
config=LOGGING,
32+
)
33+
34+
annotate_logs = annotated_logger.annotate_logs
35+
36+
37+
class ActionsExample:
38+
"""Example application that is designed to run in actions."""
39+
40+
@annotate_logs(_typing_requested=True)
41+
def first_step(self, annotated_logger: AnnotatedAdapter) -> None:
42+
"""First step of your action."""
43+
annotated_logger.info("Step 1 running!")

example/logging_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@
5757
},
5858
"logging_config.logger_handler": {
5959
"class": "logging.StreamHandler",
60+
# Note that this handler is specifically attached to
61+
# `annotated_logger.logging_config.logger` which we intend to use only as a
62+
# logger not generated by a decorator. If you add filters to a handler that
63+
# will be invoked when logging from a logger generated by the decorator
64+
# AKA, logging via the `annotated_logger` parameter, these filters will
65+
# be applied *after* the filters that are dynamically generated/updated
66+
# and so can override any annotations that share a name.
67+
# In this case, if a function annotated `custom_runtime` or one of the
68+
# other fields set in the filters above, that value would be overridden
69+
# by the value in the filter set in the config.
6070
"filters": [
6171
"logging_config.logger_filter",
6272
"logging_config.logger_filter_parens",

pyproject.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ dependencies = [
6161
"pyright",
6262
"pytest",
6363
"pytest-cov",
64-
"pytest-forked",
6564
"pytest-freezer",
6665
"pytest-github-actions-annotate-failures",
6766
"pytest-mock",
67+
"pytest-randomly",
6868
"requests-mock",
6969
"ruff",
7070
"typing_extensions",
@@ -92,11 +92,9 @@ reportMissingTypeArgument = true
9292

9393
[tool.pytest.ini_options]
9494
# -p no:annotated_logger disables the plugin so that we can request it conftest.py
95-
# get coverage correctly, users will not have to do this.
95+
# to get coverage correctly, users will not have to do this.
9696
# See https://github.com/pytest-dev/pytest/issues/935
97-
# --forked will run each test in it's own process, which prevents the logging configs
98-
# from overriding eachother and clobbering the handlers and the like
99-
addopts = "--cov=annotated_logger --cov=example --cov-report html --cov-report term -p no:annotated_logger --forked"
97+
addopts = "--cov=annotated_logger --cov=example --cov-report html --cov-report term -p no:annotated_logger"
10098
filterwarnings = [
10199
"error",
102100
]

requirements/requirements-dev.txt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
# - pyright
88
# - pytest
99
# - pytest-cov
10-
# - pytest-forked
1110
# - pytest-freezer
1211
# - pytest-github-actions-annotate-failures
1312
# - pytest-mock
13+
# - pytest-randomly
1414
# - requests-mock
1515
# - ruff
1616
# - typing_extensions
@@ -70,8 +70,6 @@ pony==0.7.17
7070
# via mutmut
7171
pre-commit==3.7.1
7272
# via hatch.envs.dev
73-
py==1.11.0
74-
# via pytest-forked
7573
pychoir==0.0.27
7674
# via hatch.envs.dev
7775
pyright==1.1.376
@@ -80,20 +78,20 @@ pytest==8.2.2
8078
# via
8179
# hatch.envs.dev
8280
# pytest-cov
83-
# pytest-forked
8481
# pytest-freezer
8582
# pytest-github-actions-annotate-failures
8683
# pytest-mock
84+
# pytest-randomly
8785
pytest-cov==5.0.0
8886
# via hatch.envs.dev
89-
pytest-forked==1.6.0
90-
# via hatch.envs.dev
9187
pytest-freezer==0.4.8
9288
# via hatch.envs.dev
9389
pytest-github-actions-annotate-failures==0.2.0
9490
# via hatch.envs.dev
9591
pytest-mock==3.14.0
9692
# via hatch.envs.dev
93+
pytest-randomly==3.15.0
94+
# via hatch.envs.dev
9795
python-dateutil==2.9.0.post0
9896
# via freezegun
9997
python-json-logger==2.0.7

0 commit comments

Comments
 (0)