|
14 | 14 | from contextvars import copy_context |
15 | 15 | from functools import partial, wraps |
16 | 16 | from gzip import GzipFile |
| 17 | +from io import TextIOBase |
17 | 18 | from itertools import chain |
18 | 19 | from logging.handlers import QueueHandler, QueueListener, TimedRotatingFileHandler |
19 | 20 | from multiprocessing import Queue as ProcessQueue |
|
29 | 30 | Optional, |
30 | 31 | OrderedDict, |
31 | 32 | Set, |
| 33 | + TextIO, |
32 | 34 | Tuple, |
33 | 35 | Union, |
34 | 36 | ) |
|
49 | 51 | "get_function", |
50 | 52 | "to_thread", |
51 | 53 | "check_recursion", |
| 54 | + "LogHelper", |
52 | 55 | ] |
53 | 56 |
|
54 | 57 |
|
@@ -743,6 +746,168 @@ def __del__(self): |
743 | 746 | self.do_compress() |
744 | 747 |
|
745 | 748 |
|
| 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 | + |
746 | 911 | def get_type_default(tp, default=None): |
747 | 912 | """Get the default value for a type. {int: 0, float: 0.0, bytes: b"", str: "", list: [], tuple: (), set: set(), dict: {}}""" |
748 | 913 | return { |
@@ -1039,7 +1204,7 @@ def rotate(self, new_length): |
1039 | 1204 | self.reopen_file() |
1040 | 1205 | if not self.compress: |
1041 | 1206 | self.clean_backups(count=None) |
1042 | | - else: |
| 1207 | + elif self.file: |
1043 | 1208 | self.file.seek(0) |
1044 | 1209 | self.file.truncate() |
1045 | 1210 |
|
@@ -1510,32 +1675,49 @@ def test_AsyncQueueListener(): |
1510 | 1675 | async def _test(): |
1511 | 1676 | from io import StringIO |
1512 | 1677 |
|
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 | + |
1531 | 1701 |
|
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 |
1533 | 1714 |
|
1534 | 1715 |
|
1535 | 1716 | def test_utils(): |
1536 | 1717 | test_bg_task() |
1537 | 1718 | test_named_lock() |
1538 | 1719 | test_AsyncQueueListener() |
| 1720 | + test_LogHelper() |
1539 | 1721 |
|
1540 | 1722 |
|
1541 | 1723 | def test(): |
|
0 commit comments