Skip to content

Commit e8c6c4e

Browse files
joamagclaude
andauthored
feat: add TRACE log level and patch_logging() (#84)
* feat: add TRACE log level and `patch_logging()` for fine-grained debugging Ports the TRACE log level support from netius (hivesolutions/netius#49) to the appier framework, enabling protocol-level verbose logging below DEBUG for fine-grained debugging of low-level operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use tempfile instead of /dev/null in rotating handler tests /dev/null does not exist on Windows, causing test failures on CI. Use tempfile.mkstemp() for cross-platform compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: small cosmetic fix --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5784214 commit e8c6c4e

5 files changed

Lines changed: 237 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
*
12+
* TRACE log level and `patch_logging()` for fine-grained protocol-level debugging
1313

1414
### Changed
1515

src/appier/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,16 @@
229229
from .graph import Graph
230230
from .http import file_g, get_f, get, post, put, delete, patch, basic_auth, HTTPResponse
231231
from .log import (
232+
SILENT,
233+
TRACE,
232234
MemoryHandler,
233235
BaseFormatter,
234236
ThreadFormatter,
235237
DummyLogger,
236238
reload_format,
237239
rotating_handler,
238240
smtp_handler,
241+
patch_logging,
239242
in_signature,
240243
)
241244
from .meta import Ordered, Indexed

src/appier/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3671,6 +3671,11 @@ def is_child(self):
36713671
def is_main(self):
36723672
return threading.current_thread().ident == self.tid
36733673

3674+
def is_trace(self):
3675+
if not self.level:
3676+
return False
3677+
return self.level <= log.TRACE
3678+
36743679
def is_devel(self):
36753680
if not self.level:
36763681
return False
@@ -4182,6 +4187,8 @@ def _level(cls, level):
41824187
return level
41834188
if level == "SILENT":
41844189
return log.SILENT
4190+
if level == "TRACE":
4191+
return log.TRACE
41854192
if hasattr(logging, "_checkLevel"):
41864193
return logging._checkLevel(level)
41874194
return logging.getLevelName(level)
@@ -4512,6 +4519,11 @@ def _load_config(self, apply=True):
45124519
def _load_logging(
45134520
self, level=None, set_default=True, format_base=None, format_tid=None
45144521
):
4522+
# patches the logging infra-structure so that the TRACE level
4523+
# is properly registered and available for usage, this call
4524+
# is idempotent and safe to be called multiple times
4525+
log.patch_logging()
4526+
45154527
format_base = format_base or log.LOGGING_FORMAT
45164528
format_tid = format_tid or log.LOGGING_FORMAT_TID
45174529

src/appier/log.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@
7272
or an handler, this is used as an utility for debugging
7373
purposes more that a real feature for production systems """
7474

75+
TRACE = logging.DEBUG - 5
76+
""" The trace level used for extremely detailed and verbose
77+
logging of protocol-level operations, this is meant to be
78+
used for fine-grained debugging of low-level operations
79+
like raw byte transfers and frame parsing """
80+
7581
LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
7682
""" The sequence of levels from the least sever to the
7783
most sever this sequence may be used to find all the
@@ -292,6 +298,9 @@ def set_tid(self, value, *args, **kwargs):
292298

293299

294300
class DummyLogger(object):
301+
def trace(self, object):
302+
pass
303+
295304
def debug(self, object):
296305
pass
297306

@@ -355,11 +364,28 @@ def smtp_handler(
355364
)
356365

357366

367+
def patch_logging():
368+
if hasattr(logging, "_appier_patched"):
369+
return
370+
371+
# patches the logging infra-structure adding the trace level
372+
# support and the corresponding trace method to the logger
373+
logging.addLevelName(TRACE, "TRACE")
374+
logging.Logger.trace = _trace
375+
376+
logging._appier_patched = True
377+
378+
358379
def in_signature(callable, name):
359380
has_full = hasattr(inspect, "getfullargspec")
360381
if has_full:
361382
spec = inspect.getfullargspec(callable)
362383
else:
363384
spec = inspect.getargspec(callable)
364385
args, _varargs, kwargs = spec[:3]
365-
return (args and name in args) or (kwargs and "secure" in kwargs)
386+
return bool((args and name in args) or (kwargs and "secure" in kwargs))
387+
388+
389+
def _trace(self, message, *args, **kwargs):
390+
if self.isEnabledFor(TRACE):
391+
self._log(TRACE, message, args, **kwargs)

src/appier/test/log.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,207 @@
2828
__license__ = "Apache License, Version 2.0"
2929
""" The license for the module """
3030

31+
import os
3132
import logging
33+
import tempfile
3234
import unittest
3335

36+
import logging.handlers
37+
3438
import appier
3539

40+
from appier import log
41+
3642

3743
class LogTest(unittest.TestCase):
44+
def test_silent_value(self):
45+
self.assertEqual(appier.SILENT, logging.CRITICAL + 1)
46+
self.assertEqual(type(appier.SILENT), int)
47+
48+
def test_silent_above_critical(self):
49+
self.assertTrue(appier.SILENT > logging.CRITICAL)
50+
51+
def test_trace_value(self):
52+
self.assertEqual(appier.TRACE, 5)
53+
self.assertEqual(appier.TRACE, logging.DEBUG - 5)
54+
self.assertEqual(type(appier.TRACE), int)
55+
56+
def test_trace_below_debug(self):
57+
self.assertTrue(appier.TRACE < logging.DEBUG)
58+
59+
def test_level_ordering(self):
60+
self.assertTrue(appier.TRACE < logging.DEBUG)
61+
self.assertTrue(logging.DEBUG < logging.INFO)
62+
self.assertTrue(logging.INFO < logging.WARNING)
63+
self.assertTrue(logging.WARNING < logging.ERROR)
64+
self.assertTrue(logging.ERROR < logging.CRITICAL)
65+
self.assertTrue(logging.CRITICAL < appier.SILENT)
66+
67+
def test_rotating_handler(self):
68+
fd, path = tempfile.mkstemp()
69+
os.close(fd)
70+
try:
71+
handler = appier.rotating_handler(path=path, max_bytes=1024, max_log=3)
72+
73+
self.assertEqual(type(handler), logging.handlers.RotatingFileHandler)
74+
self.assertEqual(handler.maxBytes, 1024)
75+
self.assertEqual(handler.backupCount, 3)
76+
77+
handler.close()
78+
finally:
79+
os.unlink(path)
80+
81+
def test_rotating_handler_defaults(self):
82+
fd, path = tempfile.mkstemp()
83+
os.close(fd)
84+
try:
85+
handler = appier.rotating_handler(path=path)
86+
87+
self.assertEqual(handler.maxBytes, 1048576)
88+
self.assertEqual(handler.backupCount, 5)
89+
90+
handler.close()
91+
finally:
92+
os.unlink(path)
93+
94+
def test_patch_logging(self):
95+
appier.patch_logging()
96+
97+
result = logging.getLevelName(appier.TRACE)
98+
99+
self.assertEqual(result, "TRACE")
100+
101+
def test_patch_logging_reverse(self):
102+
appier.patch_logging()
103+
104+
result = logging.getLevelName("TRACE")
105+
106+
self.assertEqual(result, appier.TRACE)
107+
108+
def test_patch_logging_idempotent(self):
109+
appier.patch_logging()
110+
appier.patch_logging()
111+
112+
result = logging.getLevelName(appier.TRACE)
113+
114+
self.assertEqual(result, "TRACE")
115+
116+
def test_patch_logging_logger_trace(self):
117+
appier.patch_logging()
118+
119+
logger = logging.getLogger("appier.test.trace")
120+
121+
self.assertTrue(hasattr(logger, "trace"))
122+
self.assertTrue(callable(logger.trace))
123+
124+
def test_patch_logging_logger_trace_call(self):
125+
appier.patch_logging()
126+
127+
logger = logging.getLogger("appier.test.trace.call")
128+
logger.setLevel(appier.TRACE)
129+
records = []
130+
handler = logging.Handler()
131+
handler.setLevel(appier.TRACE)
132+
handler.emit = lambda record: records.append(record)
133+
logger.addHandler(handler)
134+
135+
try:
136+
logger.trace("trace test message")
137+
138+
self.assertEqual(len(records), 1)
139+
self.assertEqual(records[0].getMessage(), "trace test message")
140+
self.assertEqual(records[0].levelno, appier.TRACE)
141+
self.assertEqual(records[0].levelname, "TRACE")
142+
finally:
143+
logger.removeHandler(handler)
144+
145+
def test_patch_logging_logger_trace_filtered(self):
146+
appier.patch_logging()
147+
148+
logger = logging.getLogger("appier.test.trace.filtered")
149+
logger.setLevel(logging.DEBUG)
150+
records = []
151+
handler = logging.Handler()
152+
handler.setLevel(appier.TRACE)
153+
handler.emit = lambda record: records.append(record)
154+
logger.addHandler(handler)
155+
156+
try:
157+
# the trace message should be filtered since the logger
158+
# level is set to DEBUG which is above TRACE
159+
logger.trace("this should be filtered")
160+
161+
self.assertEqual(len(records), 0)
162+
finally:
163+
logger.removeHandler(handler)
164+
165+
def test_level_trace_before_patch(self):
166+
# temporarily removes the patched state to simulate a
167+
# scenario where patch_logging() has not been called yet
168+
patched = getattr(logging, "_appier_patched", None)
169+
if patched:
170+
del logging._appier_patched
171+
trace_method = getattr(logging.Logger, "trace", None)
172+
if trace_method:
173+
del logging.Logger.trace
174+
try:
175+
result = appier.App._level("TRACE")
176+
177+
self.assertEqual(result, appier.TRACE)
178+
self.assertEqual(result, 5)
179+
finally:
180+
if patched:
181+
logging._appier_patched = patched
182+
if trace_method:
183+
logging.Logger.trace = trace_method
184+
185+
def test_level_trace_after_patch(self):
186+
appier.patch_logging()
187+
188+
result = appier.App._level("TRACE")
189+
190+
self.assertEqual(result, appier.TRACE)
191+
self.assertEqual(result, 5)
192+
193+
def test_level_silent(self):
194+
result = appier.App._level("SILENT")
195+
196+
self.assertEqual(result, appier.SILENT)
197+
198+
def test_level_integer(self):
199+
result = appier.App._level(logging.DEBUG)
200+
201+
self.assertEqual(result, logging.DEBUG)
202+
203+
def test_level_none(self):
204+
result = appier.App._level(None)
205+
206+
self.assertEqual(result, None)
207+
208+
def test_in_signature(self):
209+
def sample(a, b, secure=None):
210+
pass
211+
212+
result = log.in_signature(sample, "secure")
213+
214+
self.assertEqual(result, True)
215+
216+
def test_in_signature_missing(self):
217+
def sample(a, b):
218+
pass
219+
220+
result = log.in_signature(sample, "secure")
221+
222+
self.assertEqual(result, False)
223+
224+
def test_in_signature_args(self):
225+
def sample(a, b, secure):
226+
pass
227+
228+
result = log.in_signature(sample, "secure")
229+
230+
self.assertEqual(result, True)
231+
38232
def test_memory_handler(self):
39233
memory_handler = appier.MemoryHandler()
40234
formatter = logging.Formatter("%(message)s")

0 commit comments

Comments
 (0)