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

1"""

2 Configure local logging for the CGSE.

3

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

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

6

7 Environment 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

27 import logging

28 import os

29 import textwrap

30 from pathlib import Path

31

32 import 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

37 LOG_FORMAT_STYLE = "{"

38 LOG_FORMAT_FULL = (

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

40 "message}"

41)

42 LOG_FORMAT_CLEAN = (

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

44)

45

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

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

48

49

50 class 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

77 class EGSEFilter(logging.Filter):

78 def filter(self, record):

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

80

81

82 class NonEGSEFilter(logging.Filter):

83 def filter(self, record):

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

85

86

87 def 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

112 egse_logger = logging.getLogger("egse")

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

114

115 root_logger = logging.getLogger()

116 root_logger.level = get_log_level_from_env()

117

118 egse_handler = logging.StreamHandler()

119 if 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)

121 else:

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

123

124 egse_handler.setFormatter(egse_formatter)

125 egse_handler.addFilter(EGSEFilter())

126 egse_handler.addFilter(PackageFilter())

127

128 root_logger.addHandler(egse_handler)

129

130 for 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

135 logger = egse_logger

136

137 if __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.")