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

197 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-07 11:35 +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 try: 

40 cpua = proc.cpu_affinity() 

41 if cpua is not None: 

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

43 if len(cpua) > 0: 

44 return cpua 

45 except Exception: # noqa # nosec 

46 pass # noqa # nosec 

47 return None 

48 

49 

50#: the dependencies 

51__DEPENDENCIES: set[str] | None = { 

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

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

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

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

56 

57 

58def add_dependency(dependency: str, 

59 ignore_if_make_build: bool = False) -> None: 

60 """ 

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

62 

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

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

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

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

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

68 

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

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

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

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

73 anyway). 

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

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

76 dependency itself is a package which is uninstalled and then 

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

78 dependency version may be unavailable and cause an exception. 

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

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

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

82 not permissible. 

83 """ 

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

85 or (" " in dependency): 

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

87 if __DEPENDENCIES is None: 

88 raise ValueError( 

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

90 if ignore_if_make_build and is_build(): 

91 return 

92 __DEPENDENCIES.add(dependency) 

93 

94 

95# noinspection PyBroadException 

96def __make_sys_info() -> str: 

97 """ 

98 Build the system info string. 

99 

100 This method is only used once and then deleted. 

101 

102 :returns: the system info string. 

103 """ 

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

105 if __DEPENDENCIES is None: 

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

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

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

109 

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

111 """ 

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

113 

114 :param sec: the section to write to. 

115 :param key: the key 

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

117 """ 

118 if value is None: 

119 return 

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

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

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

123 return 

124 sec.key_value(key, value) 

125 

126 def __get_processor_name() -> str | None: 

127 """ 

128 Get the processor name. 

129 

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

131 """ 

132 with contextlib.suppress(Exception): 

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

134 return platform.processor() 

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

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

137 for line in rd: 

138 if "model name" in line: 

139 return re.sub( 

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

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

142 return None 

143 

144 def __get_mem_size_sysconf() -> int | None: 

145 """ 

146 Get the memory size information from sysconf. 

147 

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

149 """ 

150 with contextlib.suppress(Exception): 

151 k1 = "SC_PAGESIZE" 

152 if k1 not in os.sysconf_names: 

153 k1 = "SC_PAGE_SIZE" 

154 if k1 not in os.sysconf_names: 

155 return None 

156 v1 = os.sysconf(k1) 

157 if not isinstance(v1, int): 

158 return None 

159 k2 = "SC_PHYS_PAGES" 

160 if k2 not in os.sysconf_names: 

161 k2 = "_SC_PHYS_PAGES" 

162 if k2 not in os.sysconf_names: 

163 return None 

164 v2 = os.sysconf(k2) 

165 if not isinstance(v1, int): 

166 return None 

167 

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

169 return v1 * v2 

170 return None 

171 

172 def __get_mem_size_meminfo() -> int | None: 

173 """ 

174 Get the memory size information from meminfo. 

175 

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

177 """ 

178 with contextlib.suppress(Exception): 

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

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

181 for i in rd} 

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

183 mem_kib = int(mem_kib) 

184 if mem_kib > 0: 

185 return 1024 * mem_kib 

186 return None 

187 

188 def __get_mem_size() -> int | None: 

189 """ 

190 Get the memory size information from any available source. 

191 

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

193 """ 

194 vs = __get_mem_size_sysconf() 

195 if vs is None: 

196 vs = __get_mem_size_meminfo() 

197 if vs is None: 

198 return psutil.virtual_memory().total 

199 return vs 

200 

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

202 with InMemoryLogger() as imr: 

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

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

205 __v(k, logging.KEY_SESSION_START, 

206 datetime.now(tz=UTC)) 

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

208 proc = psutil.Process() 

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

210 cpua = __cpu_affinity(proc) 

211 if cpua is not None: 

212 __v(k, logging.KEY_CPU_AFFINITY, cpua) 

213 del proc, cpua 

214 

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

216 with contextlib.suppress(Exception): 

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

218 cmd = proc.cmdline() 

219 if isinstance(cmd, Iterable): 

220 __v(k, logging.KEY_COMMAND_LINE, 

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

222 cwd = proc.cwd() 

223 if isinstance(cwd, str): 

224 __v(k, logging.KEY_WORKING_DIRECTORY, 

225 repr(proc.cwd())) 

226 

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

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

229 try: 

230 # doesn't even have to be reachable 

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

232 ip = s.getsockname()[0] 

233 except Exception: # noqa 

234 ip = "127.0.0.1" 

235 finally: 

236 s.close() 

237 __v(k, logging.KEY_NODE_IP, ip) 

238 

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

240 for package in sorted(dep): 

241 if package == "moptipy": 

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

243 else: 

244 use_v: str | None = None 

245 with suppress(BaseException): 

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

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

248 use_v = "notFound" 

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

250 

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

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

253 __v(k, logging.KEY_HW_N_PHYSICAL_CPUS, 

254 psutil.cpu_count(logical=False)) 

255 __v(k, logging.KEY_HW_N_LOGICAL_CPUS, 

256 psutil.cpu_count(logical=True)) 

257 

258 # store the CPU speed information 

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

260 total: int = 0 

261 for cf in psutil.cpu_freq(True): 

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

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

264 total += 1 

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

266 if total > 1: 

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

268 key, value in cpuf.items()] 

269 memlst.sort() 

270 else: 

271 memlst = list(cpuf) 

272 

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

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

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

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

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

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

279 __v(k, logging.KEY_HW_CPU_MHZ, 

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

281 

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

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

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

285 

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

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

288 __v(k, logging.KEY_PYTHON_IMPLEMENTATION, 

289 platform.python_implementation()) 

290 

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

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

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

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

295 

296 lst = imr.get_log() 

297 

298 if len(lst) < 3: 

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

300 __DEPENDENCIES = None 

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

302 

303 

304def get_sys_info() -> str: 

305 r""" 

306 Get the system information as string. 

307 

308 :returns: the system information as string 

309 

310 >>> raw_infos = get_sys_info() 

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

312 True 

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

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

315 session.start 

316 session.node 

317 session.processId 

318 session.cpuAffinity 

319 session.commandLine 

320 session.workingDirectory 

321 session.ipAddress 

322 version.cmaes 

323 version.contourpy 

324 version.cycler 

325 version.fonttools 

326 version.intelcmplrlibrt 

327 version.joblib 

328 version.kiwisolver 

329 version.llvmlite 

330 version.matplotlib 

331 version.moptipy 

332 version.numba 

333 version.numpy 

334 version.packaging 

335 version.pillow 

336 version.psutil 

337 version.pycommons 

338 version.pyparsing 

339 version.pythondateutil 

340 version.scipy 

341 version.setuptools 

342 version.six 

343 version.threadpoolctl 

344 hardware.machine 

345 hardware.nPhysicalCpus 

346 hardware.nLogicalCpus 

347 hardware.cpuMhz 

348 hardware.byteOrder 

349 hardware.cpu 

350 hardware.memSize 

351 python.version 

352 python.implementation 

353 os.name 

354 os.release 

355 os.version 

356 """ 

357 the_object: Final[object] = get_sys_info 

358 the_attr: Final[str] = "__the_sysinfo" 

359 if hasattr(the_object, the_attr): 

360 return getattr(the_object, the_attr) 

361 sys_info: Final[str] = __make_sys_info() 

362 setattr(the_object, the_attr, sys_info) 

363 return sys_info 

364 

365 

366def update_sys_info_cpu_affinity() -> None: 

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

368 sys_info_str = get_sys_info() 

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

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

371 start_i = sys_info_str.find(start) 

372 if start_i < 0: 

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

374 start_i += len(start) 

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

376 if end_i <= start_i: 

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

378 affinity = __cpu_affinity() 

379 if affinity is None: 

380 raise ValueError( 

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

382 f" but second one is None?") 

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

384 

385 the_object: Final[object] = get_sys_info 

386 the_attr: Final[str] = "__the_sysinfo" 

387 setattr(the_object, the_attr, sys_info_str) 

388 

389 

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

391 r""" 

392 Write the system information section to a logger. 

393 

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

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

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

397 every single time. 

398 

399 :param logger: the logger 

400 

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

402 >>> with InMemoryLogger() as l: 

403 ... log_sys_info(l) 

404 ... log = l.get_log() 

405 >>> print(log[0]) 

406 BEGIN_SYS_INFO 

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

408 END_SYS_INFO 

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

410 True 

411 """ 

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

413 txt.write(get_sys_info())