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.
Files changed (21) hide show
  1. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/PKG-INFO +1 -1
  2. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/events.py +23 -5
  3. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/helper.py +7 -0
  4. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/logger.py +7 -3
  5. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/PKG-INFO +1 -1
  6. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/pyproject.toml +1 -1
  7. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_event.py +40 -0
  8. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_logger.py +49 -0
  9. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/LICENSE +0 -0
  10. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/README.md +0 -0
  11. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/__init__.py +0 -0
  12. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/context.py +0 -0
  13. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/output.py +0 -0
  14. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog/typing.py +0 -0
  15. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/SOURCES.txt +0 -0
  16. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/dependency_links.txt +0 -0
  17. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/requires.txt +0 -0
  18. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/fluentlog.egg-info/top_level.txt +0 -0
  19. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/setup.cfg +0 -0
  20. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_context.py +0 -0
  21. {fluentlog-0.1.0 → fluentlog-0.1.0.dev0}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluentlog
3
- Version: 0.1.0
3
+ Version: 0.1.0.dev0
4
4
  Summary: Opinionated structured logging library for Python with a fluent interface
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.13
@@ -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
- self._fields: log_fields = {"level": str(level), **parent_fields}
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
- self._fields[name] = value
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 or {}
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 or []
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
- self._fields[name] = value
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":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluentlog
3
- Version: 0.1.0
3
+ Version: 0.1.0.dev0
4
4
  Summary: Opinionated structured logging library for Python with a fluent interface
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.13
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluentlog"
3
- version = "0.1.0"
3
+ version = "0.1.0.dev"
4
4
  description = "Opinionated structured logging library for Python with a fluent interface"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -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