fluentlog 0.1.0__tar.gz → 0.1.0.dev0__tar.gz
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.
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/PKG-INFO +1 -1
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/events.py +23 -5
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/helper.py +7 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/logger.py +7 -3
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/PKG-INFO +1 -1
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/pyproject.toml +1 -1
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_event.py +40 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_logger.py +49 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/LICENSE +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/README.md +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/__init__.py +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/context.py +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/output.py +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/typing.py +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/SOURCES.txt +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/dependency_links.txt +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/requires.txt +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/top_level.txt +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/setup.cfg +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_context.py +0 -0
- {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_output.py +0 -0
|
@@ -13,6 +13,17 @@ from .typing import (
|
|
|
13
13
|
ts_function,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
+
# Cache level strings to avoid converting them on every event
|
|
17
|
+
# in benchmarks this improved the event rate by about 10k events per second
|
|
18
|
+
_LEVEL_STRINGS: dict[Level, str] = {
|
|
19
|
+
Level.TRACE: "TRACE",
|
|
20
|
+
Level.DEBUG: "DEBUG",
|
|
21
|
+
Level.INFO: "INFO",
|
|
22
|
+
Level.WARNING: "WARNING",
|
|
23
|
+
Level.ERROR: "ERROR",
|
|
24
|
+
Level.FATAL: "FATAL",
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
|
|
17
28
|
class _DummyEvent(Event): # pragma: no cover
|
|
18
29
|
def msg(self, message: str) -> None:
|
|
@@ -66,7 +77,6 @@ class _DummyEvent(Event): # pragma: no cover
|
|
|
66
77
|
|
|
67
78
|
_DUMMY_EVENT: Event = _DummyEvent()
|
|
68
79
|
|
|
69
|
-
|
|
70
80
|
class _ConcreteEvent(Event):
|
|
71
81
|
__slots__ = ("_fields", "_output_fn", "_time_fn", "_hooks")
|
|
72
82
|
|
|
@@ -77,12 +87,16 @@ class _ConcreteEvent(Event):
|
|
|
77
87
|
parent_fields: log_fields,
|
|
78
88
|
output_fn: output_function,
|
|
79
89
|
time_fn: ts_function = lambda: datetime.now(timezone.utc),
|
|
80
|
-
hooks: list[hook_function] =
|
|
90
|
+
hooks: list[hook_function] | None= None,
|
|
81
91
|
) -> None:
|
|
82
|
-
|
|
92
|
+
try:
|
|
93
|
+
level_str = _LEVEL_STRINGS[level]
|
|
94
|
+
except KeyError:
|
|
95
|
+
level_str = level.name # fallback to enum name if not in cache, should never happen
|
|
96
|
+
self._fields: log_fields = {"level": level_str, **parent_fields}
|
|
83
97
|
self._output_fn: output_function = output_fn
|
|
84
98
|
self._time_fn: ts_function = time_fn
|
|
85
|
-
self._hooks: list[hook_function] = hooks
|
|
99
|
+
self._hooks: list[hook_function] = [*hooks] if hooks is not None else []
|
|
86
100
|
|
|
87
101
|
def msg(self, message: str) -> None:
|
|
88
102
|
if message:
|
|
@@ -95,7 +109,11 @@ class _ConcreteEvent(Event):
|
|
|
95
109
|
self.msg("")
|
|
96
110
|
|
|
97
111
|
def any(self, name: str, value: typing.Any) -> typing.Self:
|
|
98
|
-
|
|
112
|
+
"""
|
|
113
|
+
Add a field with any value to the event.
|
|
114
|
+
The value is deep-copied to prevent mutations after the event is sent from affecting the logged data.
|
|
115
|
+
"""
|
|
116
|
+
self._fields[name] = copy.deepcopy(value)
|
|
99
117
|
return self
|
|
100
118
|
|
|
101
119
|
def bool(self, name: str, value: bool) -> typing.Self:
|
|
@@ -2,6 +2,7 @@ import inspect
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
5
|
+
_PACKAGE_DIR_STR = _PACKAGE_DIR.as_posix()
|
|
5
6
|
_DUMMY_TRACEBACK = inspect.Traceback(
|
|
6
7
|
filename="<unknown>",
|
|
7
8
|
lineno=0,
|
|
@@ -12,6 +13,12 @@ _DUMMY_TRACEBACK = inspect.Traceback(
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def _is_internal_frame(filename: str) -> bool:
|
|
16
|
+
# First check if the package directory string is a substring of the filename,
|
|
17
|
+
# which is a fast check for the common case.
|
|
18
|
+
if _PACKAGE_DIR_STR in filename:
|
|
19
|
+
return True
|
|
20
|
+
# Otherwise, resolve the path to deal with cases like symlinks, relative paths, etc.
|
|
21
|
+
# We'll always hit this branch at least once, since the caller frame is guaranteed to be outside the package
|
|
15
22
|
try:
|
|
16
23
|
file_path = Path(filename).resolve()
|
|
17
24
|
except (OSError, RuntimeError): # pragma: no cover
|
|
@@ -29,9 +29,9 @@ class Logger:
|
|
|
29
29
|
time_fn: ts_function | None = None,
|
|
30
30
|
) -> None:
|
|
31
31
|
self._level: Level = level
|
|
32
|
-
self._fields: log_fields = fields
|
|
32
|
+
self._fields: log_fields = {**fields} if fields else {}
|
|
33
33
|
self._output_fn: output_function = output_fn or json_output
|
|
34
|
-
self._hooks: list[hook_function] = hooks
|
|
34
|
+
self._hooks: list[hook_function] = [*hooks] if hooks else []
|
|
35
35
|
self._time_fn: ts_function = time_fn or (lambda: datetime.now(timezone.utc))
|
|
36
36
|
|
|
37
37
|
def set_level(self, level: Level) -> None:
|
|
@@ -102,7 +102,11 @@ class LoggerBuilder(LogFieldsBuilder):
|
|
|
102
102
|
self._hooks: list[hook_function] = [*parent_logger._hooks]
|
|
103
103
|
|
|
104
104
|
def any(self, name: str, value: typing.Any) -> "LoggerBuilder":
|
|
105
|
-
|
|
105
|
+
"""
|
|
106
|
+
Add a field with any value to the context.
|
|
107
|
+
The value is deep-copied to prevent mutations after the fact from affecting the log output.
|
|
108
|
+
"""
|
|
109
|
+
self._fields[name] = copy.deepcopy(value)
|
|
106
110
|
return self
|
|
107
111
|
|
|
108
112
|
def bool(self, name: str, value: bool) -> "LoggerBuilder":
|
|
@@ -52,3 +52,43 @@ def test_caller():
|
|
|
52
52
|
assert payload["code.file.path"].endswith("test_event.py") # this file
|
|
53
53
|
assert payload["code.function.name"] == "test_caller"
|
|
54
54
|
assert isinstance(payload["code.line.number"], int)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_event_any_deep_copies_dict():
|
|
58
|
+
"""Test that .any() deep copies dict values in events."""
|
|
59
|
+
output_fn = MagicMock()
|
|
60
|
+
config = MagicMock()
|
|
61
|
+
config.timeout = 30
|
|
62
|
+
config.retries = 3
|
|
63
|
+
ev = _ConcreteEvent(
|
|
64
|
+
level=Level.INFO, parent_fields={}, output_fn=output_fn
|
|
65
|
+
).any("config", config)
|
|
66
|
+
|
|
67
|
+
# Mutate the original dict after adding to event
|
|
68
|
+
config.timeout = 60
|
|
69
|
+
config.retries = 5
|
|
70
|
+
|
|
71
|
+
# Event's config should have original values
|
|
72
|
+
ev.msg("test")
|
|
73
|
+
payload = output_fn.call_args.args[0]
|
|
74
|
+
assert payload["config"].timeout == 30
|
|
75
|
+
assert payload["config"].retries == 3
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_event_hooks_not_shared_across_instances():
|
|
79
|
+
"""Test that hooks list is not shared between event instances (mutable default fix)."""
|
|
80
|
+
output_fn1 = MagicMock()
|
|
81
|
+
output_fn2 = MagicMock()
|
|
82
|
+
|
|
83
|
+
# Create first event without passing hooks
|
|
84
|
+
ev1 = _ConcreteEvent(level=Level.INFO, parent_fields={}, output_fn=output_fn1)
|
|
85
|
+
# Create second event without passing hooks
|
|
86
|
+
ev2 = _ConcreteEvent(level=Level.INFO, parent_fields={}, output_fn=output_fn2)
|
|
87
|
+
|
|
88
|
+
# Add hooks to first event
|
|
89
|
+
ev1.func(lambda e: e.int("hook_field", 42))
|
|
90
|
+
|
|
91
|
+
# Second event should not have the hook from first event
|
|
92
|
+
ev2.msg("test")
|
|
93
|
+
payload = output_fn2.call_args.args[0]
|
|
94
|
+
assert "hook_field" not in payload
|
|
@@ -70,3 +70,52 @@ def test_binded_caller(time_fn):
|
|
|
70
70
|
assert payload["code.file.path"].endswith("test_logger.py") # this file
|
|
71
71
|
assert payload["code.function.name"] == "test_binded_caller"
|
|
72
72
|
assert isinstance(payload["code.line.number"], int)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_logger_fields_not_mutated_by_external_dict():
|
|
76
|
+
output_function = MagicMock()
|
|
77
|
+
fields_dict = {"user_id": 123}
|
|
78
|
+
logger = Logger(fields=fields_dict).set_output(output_function)
|
|
79
|
+
|
|
80
|
+
# Mutate the original dict
|
|
81
|
+
fields_dict["user_id"] = 456
|
|
82
|
+
fields_dict["new_field"] = "should not appear"
|
|
83
|
+
|
|
84
|
+
# Logger should still have the original values
|
|
85
|
+
logger.info().msg("test")
|
|
86
|
+
output_function.assert_called_once_with(
|
|
87
|
+
{"level": "INFO", "user_id": 123, "message": "test"}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_logger_hooks_not_mutated_by_external_list():
|
|
92
|
+
output_function = MagicMock()
|
|
93
|
+
hooks_list = [lambda e: e.int("hook1", 1)]
|
|
94
|
+
logger = Logger(hooks=hooks_list).set_output(output_function)
|
|
95
|
+
|
|
96
|
+
# Mutate the original hooks list
|
|
97
|
+
hooks_list.append(lambda e: e.int("hook2", 2))
|
|
98
|
+
|
|
99
|
+
# Logger should not execute the added hook
|
|
100
|
+
logger.info().msg("test")
|
|
101
|
+
payload = output_function.call_args.args[0]
|
|
102
|
+
assert "hook2" not in payload
|
|
103
|
+
assert payload.get("hook1") == 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_logger_builder_any_deep_copies_dict():
|
|
107
|
+
output_function = MagicMock()
|
|
108
|
+
config = MagicMock()
|
|
109
|
+
config.timeout = 30
|
|
110
|
+
config.retries = 3
|
|
111
|
+
logger = Logger().set_output(output_function).bind().any("config", config).logger()
|
|
112
|
+
|
|
113
|
+
# Mutate the original dict
|
|
114
|
+
config.timeout = 60
|
|
115
|
+
config.retries = 5
|
|
116
|
+
|
|
117
|
+
# Logger's config should have original values
|
|
118
|
+
logger.info().msg("test")
|
|
119
|
+
payload = output_function.call_args.args[0]
|
|
120
|
+
assert payload["config"].timeout == 30
|
|
121
|
+
assert payload["config"].retries == 3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|