Coverage for /Users/rik/github/cgse/libs/cgse-common/src/egse/log.py: 67%

71 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-15 11:57 +0200

1""" 

2Configure local logging for the CGSE. 

3 

4I use 'local logging' here because the CGSE also has a Logger server which stores 

5all CGSE log messages in a rotated file. That server is part of the `cgse-core` package. 

6 

7Environment variables that affect logging: 

8 

9 - LOG_FORMAT: full | FULL 

10 - LOG_LEVEL: an integer [10, 50] or a level name DEBUG, INFO, WARNING, CRITICAL, ERROR 

11 

12""" 

13 

14__all__ = [ 

15 "LOG_FORMAT_FULL", 

16 "LOG_FORMAT_CLEAN", 

17 "LOG_FORMAT_STYLE", 

18 "LOG_DATE_FORMAT_FULL", 

19 "LOG_DATE_FORMAT_CLEAN", 

20 "logger", 

21 "root_logger", 

22 "egse_logger", 

23 "get_log_level_from_env", 

24 "PackageFilter", 

25] 

26 

27import logging 

28import os 

29import textwrap 

30from pathlib import Path 

31 

32import rich 

33 

34# The format for the log messages. 

35# The log record attributes are listed: https://docs.python.org/3.12/library/logging.html#logrecord-attributes 

36 

37LOG_FORMAT_STYLE = "{" 

38LOG_FORMAT_FULL = ( 

39 "{asctime:19s}.{msecs:03.0f} : {processName:20s} : {levelname:8s} : {name:^25s} : {lineno:6d} : {filename:20s} : {" 

40 "message}" 

41) 

42LOG_FORMAT_CLEAN = ( 

43 "{asctime} [{levelname:>8s}] {message} ({processName}[{process}]:{package_name}:{filename}:{lineno:d})" 

44) 

45 

46LOG_DATE_FORMAT_FULL = "%Y-%m-%d %H:%M:%S" 

47LOG_DATE_FORMAT_CLEAN = "%Y-%m-%d %H:%M:%S" 

48 

49 

50class PackageFilter(logging.Filter): 

51 """Adds 'package_name' to the log record. 

52 

53 When this filter is added to a handler of a logger, the formatter of that 

54 logger can use the 'package_name' attribute. 

55 

56 When the package name can not be determined, is will contain 'n/a'. 

57 

58 NOTE: this filer assumes the root package is 'egse'. 

59 """ 

60 

61 def filter(self, record): 

62 if hasattr(record, "pathname"): 62 ↛ 72line 62 didn't jump to line 72 because the condition on line 62 was always true

63 parts = Path(record.pathname).parent.parts 

64 try: 

65 egse_index = parts.index("egse") 

66 package_name = ".".join(parts[egse_index:]) 

67 except ValueError: 

68 package_name = "n/a" 

69 

70 record.package_name = package_name 

71 else: 

72 record.package_name = "n/a" 

73 

74 return True 

75 

76 

77class EGSEFilter(logging.Filter): 

78 def filter(self, record): 

79 return record.name.startswith("egse") 

80 

81 

82class NonEGSEFilter(logging.Filter): 

83 def filter(self, record): 

84 return not record.name.startswith("egse") 

85 

86 

87def get_log_level_from_env(env_var: str = "LOG_LEVEL", default: int = logging.INFO): 

88 """Read the log level from an environment variable.""" 

89 log_level_str = os.getenv(env_var, default) 

90 

91 # Try to convert to integer first (for numeric levels) 

92 try: 

93 log_level = int(log_level_str) 

94 

95 if 10 <= log_level <= 50: 95 ↛ 98line 95 didn't jump to line 98 because the condition on line 95 was always true

96 return log_level 

97 else: 

98 logging.warning( 

99 f"Log level {log_level} outside standard range (10-50). Using {logging.getLevelName(default)}." 

100 ) 

101 return default 

102 

103 except ValueError: 

104 log_level_str = log_level_str.upper() 

105 try: 

106 return getattr(logging, log_level_str) 

107 except AttributeError: 

108 logging.error(f"Invalid LOG_LEVEL '{log_level_str}'. Using {logging.getLevelName(default)}.") 

109 return default 

110 

111 

112egse_logger = logging.getLogger("egse") 

113egse_logger.level = get_log_level_from_env() # We might want to choose another env e.g. CGSE_LOG_LEVEL 

114 

115root_logger = logging.getLogger() 

116root_logger.level = get_log_level_from_env() 

117 

118egse_handler = logging.StreamHandler() 

119if os.getenv("LOG_FORMAT", "").lower() == "full": 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 egse_formatter = logging.Formatter(fmt=LOG_FORMAT_FULL, datefmt=LOG_DATE_FORMAT_FULL, style=LOG_FORMAT_STYLE) 

121else: 

122 egse_formatter = logging.Formatter(fmt=LOG_FORMAT_CLEAN, datefmt=LOG_DATE_FORMAT_CLEAN, style=LOG_FORMAT_STYLE) 

123 

124egse_handler.setFormatter(egse_formatter) 

125egse_handler.addFilter(EGSEFilter()) 

126egse_handler.addFilter(PackageFilter()) 

127 

128root_logger.addHandler(egse_handler) 

129 

130for handler in root_logger.handlers: 

131 if handler != egse_handler: # Don't filter our new handler 

132 handler.addFilter(NonEGSEFilter()) 

133 handler.addFilter(PackageFilter()) 

134 

135logger = egse_logger 

136 

137if __name__ == "__main__": 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 root_logger = logging.getLogger() 

139 

140 rich.print( 

141 textwrap.dedent( 

142 """ 

143 Example logging statements 

144 - logging level set to INFO 

145 - fields are separated by a colon ':' 

146 - fields: date & time: process name : level : logger name : lineno : filename : message 

147 """ 

148 ) 

149 ) 

150 

151 if os.getenv("LOG_FORMAT_FULL") == "true": 

152 rich.print( 

153 f"[b]{'Date & Time':^23s} : {'Process Name':20s} : {'Level':8s} : {'Logger Name':^25s} : {' Line '} : " 

154 f"{'Filename':20s} : {'Message'}[/]" 

155 ) 

156 else: 

157 rich.print(f"[b]{'Date & Time':^19s} [ Level ] Message (filename:lineno)[/]") 

158 

159 rich.print("-" * 150) 

160 for name, level in logging.getLevelNamesMapping().items(): 

161 logger.log(level, f"{name} logging message") 

162 

163 root_logger.info("This should come out of the root logger, not the egse logger.")