Coverage for moptipy / utils / sys_info.py: 79%

194 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-14 07:09 +0000

1"""A tool for writing a section with system information into log files.""" 

2import contextlib 

3import importlib.metadata as ilm 

4import os 

5import platform 

6import re 

7import socket 

8import sys 

9from contextlib import suppress 

10from datetime import UTC, datetime 

11from typing import Final, Iterable 

12 

13import psutil # type: ignore 

14from pycommons.io.csv import CSV_SEPARATOR, SCOPE_SEPARATOR 

15from pycommons.io.path import Path 

16from pycommons.processes.caller import is_build 

17 

18import moptipy.version as ver 

19from moptipy.api import logging 

20from moptipy.utils.logger import ( 

21 KEY_VALUE_SEPARATOR, 

22 InMemoryLogger, 

23 KeyValueLogSection, 

24 Logger, 

25) 

26 

27 

28def __cpu_affinity(proc: psutil.Process | None = None) -> str | None: 

29 """ 

30 Get the CPU affinity. 

31 

32 :param proc: the process handle 

33 :return: the CPU affinity string. 

34 """ 

35 if proc is None: 

36 proc = psutil.Process() 

37 if proc is None: 

38 return None 

39 cpua = proc.cpu_affinity() 

40 if cpua is not None: 

41 cpua = CSV_SEPARATOR.join(map(str, cpua)) 

42 if len(cpua) > 0: 

43 return cpua 

44 return None 

45 

46 

47#: the dependencies 

48__DEPENDENCIES: set[str] | None = { 

49 "cmaes", "contourpy", "cycler", "fonttools", "intel-cmplr-lib-rt", 

50 "joblib", "kiwisolver", "llvmlite", "matplotlib", "moptipy", "numba", 

51 "numpy", "packaging", "pillow", "psutil", "pycommons", "pyparsing", 

52 "python-dateutil", "scipy", "setuptools", "six", "threadpoolctl"} 

53 

54 

55def add_dependency(dependency: str, 

56 ignore_if_make_build: bool = False) -> None: 

57 """ 

58 Add a dependency so that its version can be stored in log files. 

59 

60 Warning: You must add all dependencies *before* the first log file is 

61 written. As soon as the :func:`log_sys_info` is invoked for the first 

62 time, adding new dependencies will cause an error. And writing a log 

63 file via the :mod:`~moptipy.api.experiment` API or to a file specified in 

64 the :mod:`~moptipy.api.execution` API will invoke this function. 

65 

66 :param dependency: the basic name of the library, exactly as you would 

67 `import` it in a Python module. For example, to include the version of 

68 `numpy` in the log files, you would do `add_dependency("numpy")` (of 

69 course, the version of `numpy` is already automatically included 

70 anyway). 

71 :param ignore_if_make_build: should this dependency be ignored if this 

72 method is invoked during a `make` build? This makes sense if the 

73 dependency itself is a package which is uninstalled and then 

74 re-installed during a `make` build process. In such a situation, the 

75 dependency version may be unavailable and cause an exception. 

76 :raises TypeError: if `dependency` is not a string 

77 :raises ValueError: if `dependency` is an invalid string or the log 

78 information has already been accessed before and modifying it now is 

79 not permissible. 

80 """ 

81 if (str.__len__(dependency) <= 0) or (dependency != str.strip(dependency))\ 

82 or (" " in dependency): 

83 raise ValueError(f"Invalid dependency string {dependency!r}.") 

84 if __DEPENDENCIES is None: 

85 raise ValueError( 

86 f"Too late. Cannot add dependency {dependency!r} anymore.") 

87 if ignore_if_make_build and is_build(): 

88 return 

89 __DEPENDENCIES.add(dependency) 

90 

91 

92# noinspection PyBroadException 

93def __make_sys_info() -> str: 

94 """ 

95 Build the system info string. 

96 

97 This method is only used once and then deleted. 

98 

99 :returns: the system info string. 

100 """ 

101 global __DEPENDENCIES # noqa: PLW0603 # pylint: disable=W0603 

102 if __DEPENDENCIES is None: 

103 raise ValueError("Cannot re-create log info.") 

104 dep: Final[set[str]] = __DEPENDENCIES 

105 __DEPENDENCIES = None # noqa: PLW0603 # pylint: disable=W0603 

106 

107 def __v(sec: KeyValueLogSection, key: str, value) -> None: 

108 """ 

109 Create a key-value pair if value is not empty. 

110 

111 :param sec: the section to write to. 

112 :param key: the key 

113 :param value: an arbitrary value, maybe consisting of multiple lines. 

114 """ 

115 if value is None: 

116 return 

117 value = str.strip(" ".join([str.strip(ts) for ts in 

118 str.strip(str(value)).split("\n")])) 

119 if str.__len__(value) <= 0: 

120 return 

121 sec.key_value(key, value) 

122 

123 def __get_processor_name() -> str | None: 

124 """ 

125 Get the processor name. 

126 

127 :returns: a string if there is any processor information 

128 """ 

129 with contextlib.suppress(Exception): 

130 if platform.system() == "Windows": 

131 return platform.processor() 

132 if platform.system() == "Linux": 

133 with Path("/proc/cpuinfo").open_for_read() as rd: 

134 for line in rd: 

135 if "model name" in line: 

136 return re.sub( 

137 pattern=".*model name.*:", 

138 repl="", string=line, count=1).strip() 

139 return None 

140 

141 def __get_mem_size_sysconf() -> int | None: 

142 """ 

143 Get the memory size information from sysconf. 

144 

145 :returns: an integer with the memory size if available 

146 """ 

147 with contextlib.suppress(Exception): 

148 k1 = "SC_PAGESIZE" 

149 if k1 not in os.sysconf_names: 

150 k1 = "SC_PAGE_SIZE" 

151 if k1 not in os.sysconf_names: 

152 return None 

153 v1 = os.sysconf(k1) 

154 if not isinstance(v1, int): 

155 return None 

156 k2 = "SC_PHYS_PAGES" 

157 if k2 not in os.sysconf_names: 

158 k2 = "_SC_PHYS_PAGES" 

159 if k2 not in os.sysconf_names: 

160 return None 

161 v2 = os.sysconf(k2) 

162 if not isinstance(v1, int): 

163 return None 

164 

165 if (v1 > 0) and (v2 > 0): 

166 return v1 * v2 

167 return None 

168 

169 def __get_mem_size_meminfo() -> int | None: 

170 """ 

171 Get the memory size information from meminfo. 

172 

173 :returns: an integer with the memory size if available 

174 """ 

175 with contextlib.suppress(Exception): 

176 with Path("/proc/meminfo").open_for_read() as rd: 

177 meminfo = {i.split()[0].rstrip(":"): int(i.split()[1]) 

178 for i in rd} 

179 mem_kib = meminfo["MemTotal"] # e.g. 3921852 

180 mem_kib = int(mem_kib) 

181 if mem_kib > 0: 

182 return 1024 * mem_kib 

183 return None 

184 

185 def __get_mem_size() -> int | None: 

186 """ 

187 Get the memory size information from any available source. 

188 

189 :returns: an integer with the memory size if available 

190 """ 

191 vs = __get_mem_size_sysconf() 

192 if vs is None: 

193 vs = __get_mem_size_meminfo() 

194 if vs is None: 

195 return psutil.virtual_memory().total 

196 return vs 

197 

198 # log all information in memory to convert it to one constant string. 

199 with InMemoryLogger() as imr: 

200 with imr.key_values(logging.SECTION_SYS_INFO) as kv: 

201 with kv.scope(logging.SCOPE_SESSION) as k: 

202 __v(k, logging.KEY_SESSION_START, 

203 datetime.now(tz=UTC)) 

204 __v(k, logging.KEY_NODE_NAME, platform.node()) 

205 proc = psutil.Process() 

206 __v(k, logging.KEY_PROCESS_ID, hex(proc.pid)) 

207 cpua = __cpu_affinity(proc) 

208 if cpua is not None: 

209 __v(k, logging.KEY_CPU_AFFINITY, cpua) 

210 del proc, cpua 

211 

212 # get the command line and working directory of the process 

213 with contextlib.suppress(Exception): 

214 proc = psutil.Process(os.getpid()) 

215 cmd = proc.cmdline() 

216 if isinstance(cmd, Iterable): 

217 __v(k, logging.KEY_COMMAND_LINE, 

218 " ".join(map(repr, proc.cmdline()))) 

219 cwd = proc.cwd() 

220 if isinstance(cwd, str): 

221 __v(k, logging.KEY_WORKING_DIRECTORY, 

222 repr(proc.cwd())) 

223 

224 # see https://stackoverflow.com/questions/166506/. 

225 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 

226 try: 

227 # doesn't even have to be reachable 

228 s.connect(("10.255.255.255", 1)) 

229 ip = s.getsockname()[0] 

230 except Exception: # noqa 

231 ip = "127.0.0.1" 

232 finally: 

233 s.close() 

234 __v(k, logging.KEY_NODE_IP, ip) 

235 

236 with kv.scope(logging.SCOPE_VERSIONS) as k: 

237 for package in sorted(dep): 

238 if package == "moptipy": 

239 __v(k, "moptipy", ver.__version__) 

240 else: 

241 use_v: str | None = None 

242 with suppress(BaseException): 

243 use_v = str.strip(ilm.version(package)) 

244 if (use_v is None) or (str.__len__(use_v) <= 0): 

245 use_v = "notFound" 

246 __v(k, package.replace("-", ""), use_v) 

247 

248 with kv.scope(logging.SCOPE_HARDWARE) as k: 

249 __v(k, logging.KEY_HW_MACHINE, platform.machine()) 

250 __v(k, logging.KEY_HW_N_PHYSICAL_CPUS, 

251 psutil.cpu_count(logical=False)) 

252 __v(k, logging.KEY_HW_N_LOGICAL_CPUS, 

253 psutil.cpu_count(logical=True)) 

254 

255 # store the CPU speed information 

256 cpuf: dict[tuple[int, int], int] = {} 

257 total: int = 0 

258 for cf in psutil.cpu_freq(True): 

259 t = (int(cf.min), int(cf.max)) 

260 cpuf[t] = cpuf.get(t, 0) + 1 

261 total += 1 

262 memlst: list[tuple[int, ...]] 

263 if total > 1: 

264 memlst = [(key[0], key[1], value) for 

265 key, value in cpuf.items()] 

266 memlst.sort() 

267 else: 

268 memlst = list(cpuf) 

269 

270 def __make_mhz_str(tpl: tuple[int, ...]) -> str: 

271 """Convert a MHz tuple to a string.""" 

272 base: str = f"({tpl[0]}MHz..{tpl[1]}MHz)" \ 

273 if tpl[1] > tpl[0] else f"{tpl[0]}MHz" 

274 return base if (len(tpl) < 3) or (tpl[2] <= 1) \ 

275 else f"{base}*{tpl[2]}" 

276 __v(k, logging.KEY_HW_CPU_MHZ, 

277 "+".join([__make_mhz_str(t) for t in memlst])) 

278 

279 __v(k, logging.KEY_HW_BYTE_ORDER, sys.byteorder) 

280 __v(k, logging.KEY_HW_CPU_NAME, __get_processor_name()) 

281 __v(k, logging.KEY_HW_MEM_SIZE, __get_mem_size()) 

282 

283 with kv.scope(logging.SCOPE_PYTHON) as k: 

284 __v(k, logging.KEY_PYTHON_VERSION, sys.version) 

285 __v(k, logging.KEY_PYTHON_IMPLEMENTATION, 

286 platform.python_implementation()) 

287 

288 with kv.scope(logging.SCOPE_OS) as k: 

289 __v(k, logging.KEY_OS_NAME, platform.system()) 

290 __v(k, logging.KEY_OS_RELEASE, platform.release()) 

291 __v(k, logging.KEY_OS_VERSION, platform.version()) 

292 

293 lst = imr.get_log() 

294 

295 if len(lst) < 3: 

296 raise ValueError("sys info turned out to be empty?") 

297 __DEPENDENCIES = None 

298 return "\n".join(lst[1:(len(lst) - 1)]) 

299 

300 

301def get_sys_info() -> str: 

302 r""" 

303 Get the system information as string. 

304 

305 :returns: the system information as string 

306 

307 >>> raw_infos = get_sys_info() 

308 >>> raw_infos is get_sys_info() # caching! 

309 True 

310 >>> for k in raw_infos.split("\n"): 

311 ... print(k[:k.find(": ")]) 

312 session.start 

313 session.node 

314 session.processId 

315 session.cpuAffinity 

316 session.commandLine 

317 session.workingDirectory 

318 session.ipAddress 

319 version.cmaes 

320 version.contourpy 

321 version.cycler 

322 version.fonttools 

323 version.intelcmplrlibrt 

324 version.joblib 

325 version.kiwisolver 

326 version.llvmlite 

327 version.matplotlib 

328 version.moptipy 

329 version.numba 

330 version.numpy 

331 version.packaging 

332 version.pillow 

333 version.psutil 

334 version.pycommons 

335 version.pyparsing 

336 version.pythondateutil 

337 version.scipy 

338 version.setuptools 

339 version.six 

340 version.threadpoolctl 

341 hardware.machine 

342 hardware.nPhysicalCpus 

343 hardware.nLogicalCpus 

344 hardware.cpuMhz 

345 hardware.byteOrder 

346 hardware.cpu 

347 hardware.memSize 

348 python.version 

349 python.implementation 

350 os.name 

351 os.release 

352 os.version 

353 """ 

354 the_object: Final[object] = get_sys_info 

355 the_attr: Final[str] = "__the_sysinfo" 

356 if hasattr(the_object, the_attr): 

357 return getattr(the_object, the_attr) 

358 sys_info: Final[str] = __make_sys_info() 

359 setattr(the_object, the_attr, sys_info) 

360 return sys_info 

361 

362 

363def update_sys_info_cpu_affinity() -> None: 

364 """Update the CPU affinity of the system information.""" 

365 sys_info_str = get_sys_info() 

366 start = (f"\n{logging.SCOPE_SESSION}{SCOPE_SEPARATOR}" 

367 f"{logging.KEY_CPU_AFFINITY}{KEY_VALUE_SEPARATOR}") 

368 start_i = sys_info_str.find(start) 

369 if start_i < 0: 

370 return # no affinity, don't need to update 

371 start_i += len(start) 

372 end_i = sys_info_str.find("\n", start_i) 

373 if end_i <= start_i: 

374 raise ValueError(f"Empty {logging.KEY_CPU_AFFINITY}?") 

375 affinity = __cpu_affinity() 

376 if affinity is None: 

377 raise ValueError( 

378 f"first affinity query is {sys_info_str[start_i:end_i]}," 

379 f" but second one is None?") 

380 sys_info_str = f"{sys_info_str[:start_i]}{affinity}{sys_info_str[end_i:]}" 

381 

382 the_object: Final[object] = get_sys_info 

383 the_attr: Final[str] = "__the_sysinfo" 

384 setattr(the_object, the_attr, sys_info_str) 

385 

386 

387def log_sys_info(logger: Logger) -> None: 

388 r""" 

389 Write the system information section to a logger. 

390 

391 The concept of this method is that we only construct the whole system 

392 configuration exactly once in memory and then always directly flush it 

393 as a string to the logger. This is much more efficient than querying it 

394 every single time. 

395 

396 :param logger: the logger 

397 

398 >>> from moptipy.utils.logger import InMemoryLogger 

399 >>> with InMemoryLogger() as l: 

400 ... log_sys_info(l) 

401 ... log = l.get_log() 

402 >>> print(log[0]) 

403 BEGIN_SYS_INFO 

404 >>> print(log[-1]) 

405 END_SYS_INFO 

406 >>> log[1:-1] == get_sys_info().split('\n') 

407 True 

408 """ 

409 with logger.text(logging.SECTION_SYS_INFO) as txt: 

410 txt.write(get_sys_info())