Coverage for functions \ flipdare \ app_log.py: 100%
0 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
13# pragma: no cover
14from __future__ import annotations
15import logging
16from typing import Self, cast, Any
18from flipdare.generated.shared.backend.system_log_type import SystemLogType
19from flipdare.util.app_log_formatter import PERFORMANCE_LEVEL, TRACE_LEVEL
21__all__ = ["SystemLogType", "LOG", "APP_LOG_NAME"]
23_NOISY_LOGGERS: list[str] = [
24 "faker",
25 "requests",
26 "urllib3",
27 "matplotlib",
28 "httpcore",
29 "httpx",
30 "asyncio",
31]
33APP_LOG_NAME = "flipdare"
36# RELEASE: temporarily set to permanent debug while finishing.
37_DEFAULT_PROD_LEVEL = 5 # <------ THIS NEEDS TO BE CHANGED TO 20 WHEN RELEASING
38_DEFAULT_TEST_LEVEL = 10
41def LOG() -> AppLog: # pragma: no cover
42 return AppLog.instance()
45# NOTE: we avoid singleton pattern here to prevent circular imports
46class AppLog: # pragma: no cover
47 _instance: AppLog | None = None
49 __slots__ = [
50 "_initialized",
51 "_is_prod",
52 "_is_trace",
53 "_logger",
54 "_stacklevel",
55 ]
57 _initialized: bool
58 _stacklevel: int
59 _logger: logging.Logger | None
60 _is_prod: bool
61 _is_trace: bool
63 def __new__(cls, stacklevel: int = 2, is_trace: bool = False) -> Self:
64 if cls._instance is not None:
65 return cast("Self", cls._instance)
67 instance = super().__new__(cls)
68 # set immediately to prevent possible recursion issues.
69 cls._instance = instance
71 instance._initialized = True
72 instance._stacklevel = stacklevel
73 instance._logger = None
74 instance._is_prod = True
75 instance._is_trace = is_trace
77 instance._setup_logging()
78 return cls._instance
80 @classmethod
81 def instance(cls) -> AppLog:
82 # Now this just calls the constructor, which handles the logic
83 return cls()
85 @property
86 def is_trace(self) -> bool:
87 return self.log.isEnabledFor(TRACE_LEVEL)
89 @property
90 def is_debug(self) -> bool:
91 return self.log.isEnabledFor(10) # DEBUG level
93 @property
94 def is_info(self) -> bool:
95 return self.log.isEnabledFor(20) # INFO level
97 @property
98 def stacklevel(self) -> int:
99 return self._stacklevel
101 @property
102 def log(self) -> logging.Logger:
103 if self._logger is None:
104 self._setup_logging()
106 return self._logger # type: ignore
108 def system(self, log_type: SystemLogType, msg: str, include_stack: bool = False) -> None:
109 # not used in testing, therefore no stacklevel is required..
110 stack_level = self.stacklevel
112 if log_type == SystemLogType.FATAL:
113 self.log.critical(msg, stacklevel=stack_level, stack_info=include_stack)
114 elif log_type == SystemLogType.ERROR:
115 self.error(msg, include_stack=include_stack)
116 elif log_type == SystemLogType.WARNING:
117 self.warning(msg, include_stack=include_stack)
118 else:
119 self.info(msg, include_stack=include_stack)
121 def trace(self, msg: str, include_stack: bool = False) -> None:
122 # not used in testing, therefore no stacklevel is required..
123 if not self.is_trace:
124 return
126 self.log._log(
127 TRACE_LEVEL,
128 msg,
129 args=(),
130 stack_info=include_stack,
131 stacklevel=self.stacklevel,
132 )
133 # self.log.debug("TRACE: %s", msg, stacklevel=self.stacklevel, stack_info=include_stack)
135 def debug(self, msg: str, include_stack: bool = False, stacklevel: int | None = None) -> None:
136 stack_level = stacklevel if stacklevel is not None else self.stacklevel
137 self.log.debug(msg, stacklevel=stack_level, stack_info=include_stack)
139 def info(self, msg: str, include_stack: bool = False, stacklevel: int | None = None) -> None:
140 stack_level = stacklevel if stacklevel is not None else self.stacklevel
141 self.log.info(msg, stacklevel=stack_level, stack_info=include_stack)
143 def warning(
144 self, msg: str, include_stack: bool = False, stacklevel: int | None = None
145 ) -> None:
146 stack_level = stacklevel if stacklevel is not None else self.stacklevel
147 self.log.warning(msg, stacklevel=stack_level, stack_info=include_stack)
149 def error(self, msg: str, include_stack: bool = False, stacklevel: int | None = None) -> None:
150 stack_level = stacklevel if stacklevel is not None else self.stacklevel
151 self.log.error(msg, stacklevel=stack_level, stack_info=include_stack)
153 def performance(self, msg: str, include_stack: bool = False) -> None:
154 # not used in testing, therefore no stacklevel is required..
155 self.log._log(
156 PERFORMANCE_LEVEL,
157 msg,
158 args=(),
159 stacklevel=self.stacklevel,
160 stack_info=include_stack,
161 )
163 def _setup_logging(self) -> None: # pragma: no cover
164 if self._logger is not None:
165 return
167 # setup the default stack printer..
168 import stackprinter
170 stackprinter.set_excepthook(
171 reverse=True, # Most recent call at the bottom (matches modern IDEs)
172 source_lines=3, # 3 lines provides context without bloat
173 show_vals="line", # Keeps variable values on the same line as code
174 truncate_vals=100, # Prevents giant strings/dicts from pushing code off-screen
175 line_wrap=120, # Standard width for most modern terminal windows
176 suppressed_paths=[r"lib/python.*/site-packages"], # Hides library internals
177 style="color", # Use 'color' or 'monochrome' depending on your terminal
178 )
180 from flipdare.app_env import get_env_type
181 from flipdare.util.app_log_formatter import AppLogFormatter
183 # the env may not be initialized at this point,
184 # so we get it directly from os.environ ..
185 env_type = get_env_type()
186 self._is_prod = env_type.is_prod
188 import logging as loaded_logging
190 lvl = _DEFAULT_PROD_LEVEL if self._is_prod else _DEFAULT_TEST_LEVEL
192 # rather than clearing root logger just updated levels
193 for logger_name in _NOISY_LOGGERS:
194 loaded_logging.getLogger(logger_name).setLevel(loaded_logging.WARNING)
196 app_logger = loaded_logging.getLogger(APP_LOG_NAME)
197 app_logger.setLevel(lvl)
198 msg = f"AppLogger initialized with level {lvl} and is_prod={self._is_prod}"
199 print(msg) # noqa: T201
201 loaded_logging.addLevelName(TRACE_LEVEL, "TRACE")
202 loaded_logging.addLevelName(PERFORMANCE_LEVEL, "PERFORMANCE")
203 loaded_logging.Logger.trace = self._trace_wrapper # type: ignore
204 loaded_logging.Logger.performance = self._performance_wrapper # type: ignore
206 # Add our custom handler to root logger
207 handler = loaded_logging.StreamHandler()
208 handler.setLevel(lvl)
209 handler.setFormatter(AppLogFormatter())
210 app_logger.addHandler(handler)
212 # CRITICAL: Prevent duplicate logging to root logger
213 app_logger.propagate = False
214 self._logger = app_logger
216 def _trace_wrapper(self, message: str, *args: Any, **kws: Any) -> None:
217 if self.log.isEnabledFor(TRACE_LEVEL):
218 # Yes, Logger._log takes its '*args' as 'args'
219 self.trace(message, *args, **kws)
221 def _performance_wrapper(self, message: str, *args: Any, **kws: Any) -> None:
222 if self.log.isEnabledFor(PERFORMANCE_LEVEL):
223 # Yes, Logger._log takes its '*args' as 'args'
224 self.performance(message, *args, **kws)