Coverage for moptipy / api / improvement_logger.py: 85%

61 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-29 10:36 +0000

1"""A base class for logging improvements.""" 

2from os import remove 

3from typing import Callable, Final 

4 

5from pycommons.io.path import Path 

6from pycommons.types import check_int_range 

7 

8from moptipy.api.logging import FILE_SUFFIX 

9from moptipy.utils.logger import FileLogger, Logger 

10 

11#: the default base directory for logging 

12_DEFAULT_DIR_NAME: Final[str] = "improvements" 

13#: the default base directory for logging 

14_DEFAULT_BASE_DIR: Final[str] = f"./{_DEFAULT_DIR_NAME}" 

15#: the default base name for log files 

16_DEFAULT_BASE_NAME: Final[str] = "improvement" 

17 

18 

19class ImprovementLogger: 

20 """A improvement logger.""" 

21 

22 def log_improvement(self, call: Callable[[Logger], None]) -> None: 

23 """ 

24 Provide a logger to the callable to log an improvement. 

25 

26 This method is invoked whenever an improvement was discovered during 

27 the optimization process. The method needs to create and provide a 

28 :class:`~moptipy.utils.logger.Logger` to the callable it receives as 

29 input. 

30 

31 :param call: A :class:`Callable` to invoke with a logger to write the 

32 improvement information to. 

33 """ 

34 raise NotImplementedError 

35 

36 

37class ImprovementLoggerFactory: 

38 """A factory for file improvement loggers.""" 

39 

40 def create(self, reference_path: str | None, 

41 reference_name: str | None) -> ImprovementLogger: 

42 """ 

43 Create a new file improvement logger. 

44 

45 :param reference_path: a path to a file used as reference for creating 

46 the logging base directory and file, or `None` if none is 

47 available 

48 :param reference_name: a name that can be used as reference for file 

49 names, or `None` if none is available 

50 :returns: the improvement logger 

51 """ 

52 raise NotImplementedError 

53 

54 

55class FileImprovementLogger(ImprovementLogger): 

56 """ 

57 A file-based improvement logger. 

58 

59 This logger creates a new text file for each improvement. 

60 The improved solution is stored in the text file. 

61 The logger allows you to limit the number of retained files. 

62 If more improvements than the provided limit are created, then it will 

63 delete the oldest log file. 

64 """ 

65 

66 def __init__(self, log_dir: str | None = None, 

67 log_base_name: str | None = None, 

68 max_files: int | None = None) -> None: 

69 """ 

70 Create the file improvement logger. 

71 

72 :param log_dir: the directory to store the improvement logs in 

73 :param log_base_name: the base name of the improvement logs 

74 :param max_files: a limit for the number of log files to be retained, 

75 or `None` for unlimited 

76 """ 

77 #: the logging directory 

78 self.__log_dir: Final[Path] = Path( 

79 _DEFAULT_BASE_DIR if log_dir is None else log_dir) 

80 self.__log_dir.ensure_dir_exists() 

81 

82 log_base_name = _DEFAULT_BASE_NAME if log_base_name is None else ( 

83 str.removesuffix(str.strip(log_base_name), "_")) 

84 if str.__len__(log_base_name) <= 0: 

85 raise ValueError("Log base name cannot be empty or just " 

86 "consist of whitespace.") 

87 #: the log file base name 

88 self.__log_base_name: Final[str] = log_base_name 

89 #: the maximum number of files to keep alive 

90 self.__max_files: Final[int | None] = None if max_files is None \ 

91 else check_int_range(max_files, "max_files", 1, 1_000_000_000_000) 

92 #: the file history 

93 self.__file_history: Final[list[Path] | None] = \ 

94 None if max_files is None else [] 

95 

96 #: the file index 

97 self.__index: int = 0 

98 

99 def log_improvement(self, call: Callable[[Logger], None]) -> None: 

100 """ 

101 Log an improvement. 

102 

103 :param call: the callable to do the logging 

104 """ 

105 file: Path | None = None 

106 while True: 

107 self.__index += 1 

108 file = self.__log_dir.resolve_inside( 

109 f"{self.__log_base_name}_{self.__index}{FILE_SUFFIX}") 

110 if not file.ensure_file_exists(): 

111 break 

112 

113 call(FileLogger(file)) 

114 if (self.__file_history is not None) and ( 

115 self.__max_files is not None): 

116 self.__file_history.append(file) 

117 if list.__len__(self.__file_history) > self.__max_files: 

118 remove(self.__file_history.pop(0)) 

119 

120 

121class FileImprovementLoggerFactory(ImprovementLoggerFactory): 

122 """A file-based improvement logger factory.""" 

123 

124 def __init__(self, base_dir: str | None = None, 

125 log_base_name: str | None = None, 

126 max_files: int | None = None) -> None: 

127 """ 

128 Create the file improvement logger factory. 

129 

130 :param base_dir: the base directory to store the improvement 

131 logs in, `None` for default 

132 :param log_base_name: the default base name of the improvement logs, 

133 `None` for default 

134 :param max_files: a limit for the number of log files to be retained, 

135 or `None` for unlimited 

136 """ 

137 #: the logging directory 

138 self.__base_dir: Final[Path] = Path( 

139 _DEFAULT_BASE_DIR if base_dir is None else base_dir) 

140 

141 log_base_name = _DEFAULT_BASE_NAME if log_base_name is None \ 

142 else str.strip(log_base_name) 

143 if str.__len__(log_base_name) <= 0: 

144 raise ValueError("Log base name cannot be empty or just " 

145 "consist of whitespace.") 

146 #: the log file base name 

147 self.__log_base_name: Final[str] = log_base_name 

148 #: the maximum number of files to keep alive 

149 self.__max_files: Final[int | None] = None if max_files is None \ 

150 else check_int_range(max_files, "max_files", 1, 1_000_000_000_000) 

151 

152 def create(self, reference_path: str | None, 

153 reference_name: str | None) -> ImprovementLogger: 

154 """ 

155 Create a new file improvement logger. 

156 

157 :param reference_path: a path to a file used as reference for creating 

158 the logging base directory and file, or `None` if none is 

159 available 

160 :param reference_name: a name that can be used as reference for file 

161 names, or `None` if none is available 

162 :returns: the improvement logger 

163 """ 

164 log_dir: Path | None = None 

165 log_name: str | None = reference_name 

166 

167 if reference_path is not None: 

168 pt: Path = Path(reference_path) 

169 log_name = (pt.basename().removesuffix(FILE_SUFFIX) 

170 .removesuffix("_") + f"_{_DEFAULT_BASE_NAME}") 

171 log_dir = Path(str.removesuffix( 

172 pt, FILE_SUFFIX).removesuffix("_") + f"_{_DEFAULT_DIR_NAME}") 

173 

174 if log_dir is None: 

175 log_dir = self.__base_dir 

176 log_dir.ensure_dir_exists() 

177 if log_name is not None: 

178 log_dir = log_dir.resolve_inside(str.removesuffix( 

179 log_name, "_")) 

180 if log_name is None: 

181 log_name = self.__log_base_name 

182 

183 return FileImprovementLogger(log_dir, log_name, self.__max_files)