Skip to content

Commit d0e8242

Browse files
committed
3. add morebuiltins.funcs.LogHelper to quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
1 parent 5fce62c commit d0e8242

File tree

5 files changed

+235
-20
lines changed

5 files changed

+235
-20
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
1. add `force_signum` to `morebuiltins.shared_memory.PLock`, to specify the signal number to force kill the process if it exists.
55
2. add `is_free` to `morebuiltins.shared_memory.PLock`, to check if the lock is free.
66
3. add `kill_with_name` to `morebuiltins.shared_memory.PLock`, to kill the process with the given name and signal number.
7+
3. add `morebuiltins.funcs.LogHelper` to quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
78

89
### 1.3.1 (2025-06-19)
910
1. add `check_recursion` to `morebuiltins.funcs`, to check if a function is recursive.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ print(morebuiltins.__file__)
173173

174174
3.14 `check_recursion` - Check if a function is recursive by inspecting its AST.
175175

176+
3.15 `LogHelper` - Quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
177+
176178

177179
## 4. morebuiltins.ipc
178180

doc.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,6 +1900,34 @@ Demo::
19001900
---
19011901

19021902

1903+
1904+
3.15 `LogHelper` - Quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
1905+
1906+
1907+
```python
1908+
1909+
The default handler is a StreamHandler to sys.stderr.
1910+
The default file handler is a SizedTimedRotatingFileHandler, which can rotate logs by both time and size.
1911+
1912+
Examples::
1913+
1914+
import logging
1915+
from morebuiltins.log import LogHelper
1916+
1917+
LogHelper.shorten_level()
1918+
logger = LogHelper.bind_handler(name="mylogger", filename=sys.stdout, maxBytes=100 * 1024**2, backupCount=7)
1919+
# use logging.getLogger to get the same logger instance
1920+
logger2 = logging.getLogger("mylogger")
1921+
assert logger is logger2
1922+
logger.info("This is an info message")
1923+
logger.fatal("This is a critical message")
1924+
1925+
```
1926+
1927+
1928+
---
1929+
1930+
19031931
## 4. morebuiltins.ipc
19041932

19051933

morebuiltins/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
__version__ = "1.3.2"
2+
3+
# this __all__ is used for documentation generation, not for imports
24
__all__ = [
35
"morebuiltins.utils",
46
"morebuiltins.date",

morebuiltins/funcs.py

Lines changed: 202 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from contextvars import copy_context
1515
from functools import partial, wraps
1616
from gzip import GzipFile
17+
from io import TextIOBase
1718
from itertools import chain
1819
from logging.handlers import QueueHandler, QueueListener, TimedRotatingFileHandler
1920
from multiprocessing import Queue as ProcessQueue
@@ -29,6 +30,7 @@
2930
Optional,
3031
OrderedDict,
3132
Set,
33+
TextIO,
3234
Tuple,
3335
Union,
3436
)
@@ -49,6 +51,7 @@
4951
"get_function",
5052
"to_thread",
5153
"check_recursion",
54+
"LogHelper",
5255
]
5356

5457

@@ -743,6 +746,168 @@ def __del__(self):
743746
self.do_compress()
744747

745748

749+
class LogHelper:
750+
"""Quickly bind a logging handler to a logger, with a StreamHandler or SizedTimedRotatingFileHandler.
751+
752+
The default handler is a StreamHandler to sys.stderr.
753+
The default file handler is a SizedTimedRotatingFileHandler, which can rotate logs by both time and size.
754+
755+
Examples::
756+
757+
import logging
758+
from morebuiltins.log import LogHelper
759+
760+
LogHelper.shorten_level()
761+
logger = LogHelper.bind_handler(name="mylogger", filename=sys.stdout, maxBytes=100 * 1024**2, backupCount=7)
762+
# use logging.getLogger to get the same logger instance
763+
logger2 = logging.getLogger("mylogger")
764+
assert logger is logger2
765+
logger.info("This is an info message")
766+
logger.fatal("This is a critical message")
767+
"""
768+
769+
DEFAULT_FORMAT = (
770+
"%(asctime)s %(levelname)-5s %(funcName)s:%(filename)s:%(lineno)s | %(message)s"
771+
)
772+
DEFAULT_FORMATTER = logging.Formatter(DEFAULT_FORMAT)
773+
FILENAME_HANDLER_MAP: Dict[str, logging.Handler] = {}
774+
775+
@classmethod
776+
def close_all_handlers(cls):
777+
"""Close all handlers in the FILENAME_HANDLER_MAP."""
778+
result: List[Tuple[str, bool, Optional[Exception]]] = []
779+
for key, handler in list(cls.FILENAME_HANDLER_MAP.items()):
780+
try:
781+
handler.close()
782+
cls.FILENAME_HANDLER_MAP.pop(key, None)
783+
result.append((key, True, None))
784+
except Exception as e:
785+
result.append((key, False, e))
786+
return result
787+
788+
@classmethod
789+
def bind_handler(
790+
cls,
791+
name="main",
792+
filename: Union[
793+
TextIO, TextIOBase, None, logging.Handler, str, Path
794+
] = sys.stderr,
795+
when="h",
796+
interval=1,
797+
backupCount=0,
798+
maxBytes=0,
799+
encoding=None,
800+
delay=False,
801+
utc=False,
802+
compress=False,
803+
formatter: Union[str, logging.Formatter, None] = DEFAULT_FORMAT,
804+
handler_level: Union[None, str, int] = "INFO",
805+
logger_level: Union[None, str, int] = "INFO",
806+
):
807+
"""Bind a logging handler to the specified logger name, with support for file, stream, or custom handler.
808+
This sets up the logger with the desired handler, formatter, and log levels.
809+
810+
Args:
811+
name (str, optional): The logger name. Defaults to "main".
812+
filename (Union[TextIO, TextIOWrapper, None, logging.Handler, str, Path], optional):
813+
The log destination. Can be a file path, stream, handler, or None to clear handlers. Defaults to sys.stderr.
814+
when (str, optional): Time interval for log rotation (if file handler). Defaults to "h".
815+
interval (int, optional): Rotation interval. Defaults to 1.
816+
backupCount (int, optional): Number of backup files to keep. Defaults to 0.
817+
maxBytes (int, optional): Maximum file size before rotation. Defaults to 0 (no size-based rotation).
818+
encoding (str, optional): File encoding. Defaults to None.
819+
delay (bool, optional): Delay file opening until first write. Defaults to False.
820+
utc (bool, optional): Use UTC time for file rotation. Defaults to False.
821+
compress (bool, optional): Compress rotated log files. Defaults to False.
822+
formatter (Union[str, logging.Formatter, None], optional): Formatter or format string. Defaults to DEFAULT_FORMAT.
823+
handler_level (Union[None, str, int], optional): Log level for the handler. Defaults to "INFO".
824+
logger_level (Union[None, str, int], optional): Log level for the logger. Defaults to "INFO".
825+
826+
Raises:
827+
TypeError: If filename is not a supported type.
828+
829+
Returns:
830+
logging.Logger: The configured logger instance.
831+
832+
Demo::
833+
>>> logger = LogHelper.bind_handler(name="mylogger", filename=sys.stdout)
834+
>>> len(logger.handlers)
835+
1
836+
>>> logger = LogHelper.bind_handler(name="mylogger", filename=sys.stderr)
837+
>>> logger2 = logging.getLogger("mylogger")
838+
>>> assert logger is logger2
839+
>>> len(logger.handlers)
840+
2
841+
>>> bool(LogHelper.close_all_handlers() or True)
842+
True
843+
"""
844+
logger = logging.getLogger(name)
845+
if filename is None:
846+
logger.handlers.clear()
847+
return logger
848+
# Check if handler already exists for the given filename
849+
elif isinstance(filename, TextIOBase):
850+
key = str(id(filename))
851+
if key in cls.FILENAME_HANDLER_MAP:
852+
handler: logging.Handler = cls.FILENAME_HANDLER_MAP[key]
853+
else:
854+
handler = logging.StreamHandler(filename)
855+
cls.FILENAME_HANDLER_MAP[key] = handler
856+
elif isinstance(filename, logging.Handler):
857+
key = str(id(filename))
858+
if key in cls.FILENAME_HANDLER_MAP:
859+
handler = cls.FILENAME_HANDLER_MAP[key]
860+
else:
861+
handler = filename
862+
cls.FILENAME_HANDLER_MAP[key] = handler
863+
elif isinstance(filename, str) or isinstance(filename, Path):
864+
key = Path(filename).resolve().as_posix()
865+
if key in cls.FILENAME_HANDLER_MAP:
866+
handler = cls.FILENAME_HANDLER_MAP[key]
867+
else:
868+
handler = SizedTimedRotatingFileHandler(
869+
key,
870+
when=when,
871+
interval=interval,
872+
backupCount=backupCount,
873+
maxBytes=maxBytes,
874+
encoding=encoding,
875+
delay=delay,
876+
utc=utc,
877+
compress=compress,
878+
)
879+
cls.FILENAME_HANDLER_MAP[key] = handler
880+
else:
881+
raise TypeError(
882+
f"filename must be str, Path, TextIO, or logging.Handler, not {type(filename)}"
883+
)
884+
# Update the levels of the handler and logger
885+
if handler_level is not None:
886+
handler.setLevel(handler_level)
887+
if logger_level is not None:
888+
logger.setLevel(logger_level)
889+
# Set the formatter for the handler
890+
if isinstance(formatter, str):
891+
formatter = logging.Formatter(formatter)
892+
elif isinstance(formatter, logging.Formatter):
893+
formatter = formatter
894+
else:
895+
formatter = logging.Formatter(cls.DEFAULT_FORMAT)
896+
handler.setFormatter(formatter)
897+
# Add the handler to the logger
898+
logger.addHandler(handler)
899+
return logger
900+
901+
@classmethod
902+
def shorten_level(
903+
cls,
904+
mapping: Dict[int, str] = {logging.WARNING: "WARN", logging.CRITICAL: "FATAL"},
905+
):
906+
"""Shorten the level names less than 5 chars: WARNING to WARN, CRITICAL to FATAL."""
907+
for level, name in mapping.items():
908+
logging.addLevelName(level, name)
909+
910+
746911
def get_type_default(tp, default=None):
747912
"""Get the default value for a type. {int: 0, float: 0.0, bytes: b"", str: "", list: [], tuple: (), set: set(), dict: {}}"""
748913
return {
@@ -1039,7 +1204,7 @@ def rotate(self, new_length):
10391204
self.reopen_file()
10401205
if not self.compress:
10411206
self.clean_backups(count=None)
1042-
else:
1207+
elif self.file:
10431208
self.file.seek(0)
10441209
self.file.truncate()
10451210

@@ -1510,32 +1675,49 @@ def test_AsyncQueueListener():
15101675
async def _test():
15111676
from io import StringIO
15121677

1513-
mock_stdout = StringIO()
1514-
# Create logger with a blocking handler
1515-
logger = logging.getLogger("example")
1516-
logger.setLevel(logging.INFO)
1517-
stream_handler = logging.StreamHandler(stream=mock_stdout)
1518-
stream_handler.setFormatter(
1519-
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
1520-
)
1521-
logger.addHandler(stream_handler)
1522-
# Use async queue listener
1523-
async with AsyncQueueListener(logger):
1524-
# Log won't block the event loop
1525-
for i in range(5):
1526-
logger.info("log info")
1527-
logger.debug("log debug")
1528-
text = mock_stdout.getvalue()
1529-
assert text.count("log info") == 5
1530-
assert text.count("log debug") == 0
1678+
with StringIO() as mock_stdout:
1679+
# Create logger with a blocking handler
1680+
logger = logging.getLogger("example")
1681+
logger.handlers.clear()
1682+
logger.setLevel(logging.INFO)
1683+
stream_handler = logging.StreamHandler(stream=mock_stdout)
1684+
stream_handler.setFormatter(
1685+
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
1686+
)
1687+
logger.addHandler(stream_handler)
1688+
# Use async queue listener
1689+
async with AsyncQueueListener(logger):
1690+
# Log won't block the event loop
1691+
for _ in range(5):
1692+
logger.info("log info")
1693+
logger.debug("log debug")
1694+
text = mock_stdout.getvalue()
1695+
assert text.count("log info") == 5, text
1696+
assert text.count("log debug") == 0, text
1697+
return True
1698+
1699+
assert asyncio.run(_test()) is True
1700+
15311701

1532-
asyncio.run(_test()) is True
1702+
def test_LogHelper():
1703+
logger = LogHelper.bind_handler(
1704+
"app_test", filename="app_test.log", maxBytes=1, backupCount=2
1705+
)
1706+
for i in range(3):
1707+
logger.info(str(i))
1708+
LogHelper.close_all_handlers()
1709+
count = 0
1710+
for path in Path(".").glob("app_test.log*"):
1711+
path.unlink(missing_ok=True)
1712+
count += 1
1713+
assert count == 2, count
15331714

15341715

15351716
def test_utils():
15361717
test_bg_task()
15371718
test_named_lock()
15381719
test_AsyncQueueListener()
1720+
test_LogHelper()
15391721

15401722

15411723
def test():

0 commit comments

Comments
 (0)