Coverage for moptipy / utils / logger.py: 84%
186 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-24 08:49 +0000
1"""
2Classes for writing structured log files.
4A :class:`~Logger` offers functionality to write structured, text-based log
5files that can hold a variety of information. It is implemented in two
6flavors, :class:`~FileLogger`, which writes data to a file, and
7:class:`~InMemoryLogger`, which writes data to a buffer in memory (which is
8mainly useful for testing).
10A :class:`~Logger` can produce output in three formats:
12- :meth:`~Logger.csv` creates a section of semicolon-separated-values data
13 (:class:`~CsvLogSection`), which we call `csv` because it structured
14 basically exactly as the well-known comma-separated-values data, just
15 using semicolons.
16- :meth:`~Logger.key_values` creates a key-values section
17 (:class:`~KeyValueLogSection`) in YAML format. The specialty of this
18 section is that it permits hierarchically structuring of data by spawning
19 out sub-sections that are signified by a key prefix via
20 :meth:`~KeyValueLogSection.scope`.
21- :meth:`~Logger.text` creates a raw text section (:class:`~TextLogSection`),
22 into which raw text can be written.
24The beginning and ending of section named `XXX` are `BEGIN_XXX` and `END_XXX`.
26This class is used by the :class:`~moptipy.api.execution.Execution` and
27experiment-running facility (:func:`~moptipy.api.experiment.run_experiment`)
28to produce log files complying with
29https://thomasweise.github.io/moptipy/#log-files.
30Such log files can be parsed via :mod:`~moptipy.evaluation.log_parser`.
31"""
33from contextlib import AbstractContextManager
34from io import StringIO, TextIOBase
35from math import isfinite
36from re import sub
37from typing import Any, Callable, Final, Iterable, cast
39from pycommons.ds.cache import str_is_new
40from pycommons.io.csv import COMMENT_START, CSV_SEPARATOR
41from pycommons.io.path import Path, line_writer
42from pycommons.strings.string_conv import bool_to_str, float_to_str
43from pycommons.types import type_error
45from moptipy.utils.strings import (
46 PART_SEPARATOR,
47 sanitize_name,
48)
50#: the indicator of the start of a log section
51SECTION_START: Final[str] = "BEGIN_"
52#: the indicator of the end of a log section
53SECTION_END: Final[str] = "END_"
54#: the replacement for special characters
55SPECIAL_CHAR_REPLACEMENT: Final[str] = PART_SEPARATOR
56#: the YAML-conform separator between a key and a value
57KEY_VALUE_SEPARATOR: Final[str] = ": "
58#: the hexadecimal version of a value
59KEY_HEX_VALUE: Final[str] = "(hex)"
62class Logger(AbstractContextManager):
63 """
64 An abstract base class for logging data in a structured way.
66 There are two implementations of this, :class:`InMemoryLogger`, which logs
67 data in memory and is mainly there for testing and debugging, an
68 :class:`FileLogger` which logs to a text file and is to be used in
69 experiments with `moptipy`.
70 """
72 def __init__(self, name: str, writer: Callable[[str], Any],
73 closer: Callable[[], Any] = lambda: None) -> None:
74 """
75 Create a new logger.
77 :param writer: the string writing function
78 :param closer: the function to be invoked when the stream is closed
79 :param name: the name of the logger
80 """
81 if not isinstance(name, str):
82 raise type_error(name, "name", str)
83 if not callable(writer):
84 raise type_error(writer, "writer", call=True)
85 if not callable(closer):
86 raise type_error(closer, "closer", call=True)
88 #: The internal stream
89 self._writer: Callable[[str], Any] | None = writer
90 self._closer: Callable[[], Any] | None = closer
91 self.__section: str | None = None
92 self.__log_name: str = name
93 self.__sections: Callable = str_is_new()
94 self.__closer: str | None = None
96 def __enter__(self):
97 """
98 Enter the logger in a `with` statement.
100 :return: `self`
101 """
102 return self
104 def _error(self, message: str) -> None:
105 """
106 Raise a :class:`ValueError` with context information.
108 :param message: the message elements to merge
109 :raises ValueError: an error with the message and some context
110 information
111 """
112 raise ValueError(f"{message} in logger {self.__log_name!r}."
113 if self.__section is None else
114 f"{message} in section {self.__section!r} "
115 f"of logger {self.__log_name!r}.")
117 def __exit__(self, exception_type, exception_value, traceback) -> None:
118 """
119 Close the logger after leaving the `with` statement.
121 :param exception_type: ignored
122 :param exception_value: ignored
123 :param traceback: ignored
124 """
125 if self.__section is not None:
126 self._error("Cannot close logger, because section still open")
127 if self._closer is not None:
128 self._closer()
129 self._closer = None
130 self._writer = None
132 def _open_section(self, title: str) -> None:
133 """
134 Open a new section.
136 :param title: the section title
137 """
138 if self._writer is None:
139 self._error(f"Cannot open section {title!r} "
140 "because logger already closed")
142 if self.__section is not None:
143 self._error(f"Cannot open section {title!r} because "
144 "another one is open")
146 new_title = title.strip().upper()
147 if new_title != title:
148 self._error(f"Cannot open section {title!r} because "
149 "title is invalid")
151 if not self.__sections(title):
152 self._error(f"Section {title!r} already done")
154 self._writer(f"{SECTION_START}{title}")
155 self.__closer = f"{SECTION_END}{title}"
156 self.__section = title
158 def _close_section(self, title: str) -> None:
159 """
160 Close a section.
162 :param title: the section title
163 """
164 if (self.__section is None) or (self.__section != title):
165 self._error(f"Cannot open section {title!r} since it is not open")
166 self._writer(self.__closer)
167 self.__closer = None
168 self.__section = None
170 def _comment(self, comment: str) -> None:
171 """
172 Write a comment line.
174 :param comment: the comment
175 """
176 if self.__section is None:
177 self._error("Cannot write if not inside section")
178 if len(comment) <= 0:
179 return
180 comment = sub(r"\s+", " ", comment.strip())
181 self._writer(f"{COMMENT_START} {comment}")
183 def _write(self, text: str) -> None:
184 """
185 Write a string.
187 :param text: the text to write
188 """
189 if self.__section is None:
190 self._error("Cannot write if not inside section")
192 if len(text) <= 0:
193 return
195 if self.__closer in text:
196 self._error(f"String {self.__closer!r} "
197 "must not be contained in output")
199 if COMMENT_START in text:
200 raise ValueError(
201 f"{COMMENT_START!r} not permitted in text {text!r}.")
202 self._writer(text)
204 def key_values(self, title: str) -> "KeyValueLogSection":
205 r"""
206 Create a log section for key-value pairs.
208 The contents of such a section will be valid YAML mappings, i.w.,
209 conform to
210 https://yaml.org/spec/1.2/spec.html#mapping.
211 This means they can be parsed with a YAML parser (after removing the
212 section start and end marker, of course).
214 :param title: the title of the new section
215 :return: the new logger
217 >>> from pycommons.io.temp import temp_file
218 >>> with temp_file() as t:
219 ... with FileLogger(str(t)) as l:
220 ... with l.key_values("B") as kv:
221 ... kv.key_value("a", "b")
222 ... with kv.scope("c") as kvc:
223 ... kvc.key_value("d", 12)
224 ... kvc.key_value("e", True)
225 ... kv.key_value("f", 3)
226 ... text = open(str(t), "r").read().splitlines()
227 >>> print(text)
228 ['BEGIN_B', 'a: b', 'c.d: 12', 'c.e: T', 'f: 3', 'END_B']
229 >>> import yaml
230 >>> dic = yaml.safe_load("\n".join(text[1:5]))
231 >>> print(list(dic.keys()))
232 ['a', 'c.d', 'c.e', 'f']
233 """
234 return KeyValueLogSection(title=title, logger=self, prefix="",
235 done=None)
237 def csv(self, title: str, header: list[str]) -> "CsvLogSection":
238 """
239 Create a log section for CSV data with `;` as column separator.
241 The first line will be the headline with the column names.
243 :param title: the title of the new section
244 :param header: the list of column titles
245 :return: the new logger
247 >>> from moptipy.utils.logger import FileLogger
248 >>> from pycommons.io.temp import temp_file
249 >>> with temp_file() as t:
250 ... with FileLogger(str(t)) as l:
251 ... with l.csv("A", ["x", "y"]) as csv:
252 ... csv.row([1,2])
253 ... csv.row([3,4])
254 ... csv.row([4, 12])
255 ... text = open(str(t), "r").read().splitlines()
256 ... print(text)
257 ['BEGIN_A', 'x;y', '1;2', '3;4', '4;12', 'END_A']
258 >>> import csv
259 >>> for r in csv.reader(text[1:5], delimiter=";"):
260 ... print(r)
261 ['x', 'y']
262 ['1', '2']
263 ['3', '4']
264 ['4', '12']
265 """
266 return CsvLogSection(title=title, logger=self, header=header)
268 def text(self, title: str) -> "TextLogSection":
269 r"""
270 Create a log section for unstructured text.
272 :param title: the title of the new section
273 :return: the new logger
275 >>> from moptipy.utils.logger import InMemoryLogger
276 >>> with InMemoryLogger() as l:
277 ... with l.text("C") as tx:
278 ... tx.write("aaaaaa")
279 ... tx.write("bbbbb")
280 ... tx.write("\n")
281 ... tx.write("ccccc")
282 ... print(l.get_log())
283 ['BEGIN_C', 'aaaaaa', 'bbbbb', '', 'ccccc', 'END_C']
284 """
285 return TextLogSection(title=title, logger=self)
288class FileLogger(Logger):
289 """A logger logging to a file."""
291 def __init__(self, path: str) -> None:
292 """
293 Initialize the logger.
295 :param path: the path to the file to open
296 """
297 if not isinstance(path, str):
298 raise type_error(path, "path", str)
299 name = path
300 tio: TextIOBase = Path(path).open_for_write()
301 super().__init__(name, line_writer(tio), tio.close)
304class PrintLogger(Logger):
305 """A logger logging to stdout."""
307 def __init__(self) -> None:
308 """Initialize the logger."""
309 super().__init__("printer", print)
312class InMemoryLogger(Logger):
313 """A logger logging to a string in memory."""
315 def __init__(self) -> None:
316 """Initialize the logger."""
317 #: the internal stream
318 self.__stream: Final[StringIO] = StringIO()
319 super().__init__("in-memory-logger", line_writer(self.__stream))
321 def get_log(self) -> list[str]:
322 """
323 Obtain all the lines logged to this logger.
325 :return: a list of strings with the logged lines
326 """
327 return cast("StringIO", self.__stream).getvalue().splitlines()
330class LogSection(AbstractContextManager):
331 """An internal base class for logger sections."""
333 def __init__(self, title: str | None, logger: Logger) -> None:
334 """
335 Perform internal construction. Do not call directly.
337 :param title: the section title
338 :param logger: the logger
339 """
340 self._logger: Logger = logger
341 self._title: str | None = title
342 if title is not None:
343 # noinspection PyProtectedMember
344 logger._open_section(title)
346 def __enter__(self):
347 """
348 Enter the context: needed for the `with` statement.
350 :return: `self`
351 """
352 return self
354 def __exit__(self, exception_type, exception_value, traceback) -> None:
355 """
356 Exit the `with` statement.
358 :param exception_type: ignored
359 :param exception_value: ignored
360 :param traceback: ignored
361 :return: ignored
362 """
363 if self._title is not None:
364 # noinspection PyProtectedMember
365 self._logger._close_section(self._title)
366 self._title = None
367 self._logger = None
369 def comment(self, comment: str) -> None:
370 """
371 Write a comment line.
373 A comment starts with `#` and is followed by text.
375 :param comment: the comment to write
377 >>> from moptipy.utils.logger import InMemoryLogger
378 >>> with InMemoryLogger() as l:
379 ... with l.text("A") as tx:
380 ... tx.write("aaaaaa")
381 ... tx.comment("hello")
382 ... print(l.get_log())
383 ['BEGIN_A', 'aaaaaa', '# hello', 'END_A']
384 """
385 # noinspection PyProtectedMember
386 self._logger._comment(comment)
389class CsvLogSection(LogSection):
390 """
391 A logger that is designed to output CSV data.
393 The coma-separated-values log is actually a semicolon-separated-values
394 log. This form of logging is used to store progress information or
395 time series data, as captured by the optimization
396 :class:`~moptipy.api.process.Process` and activated by, e.g., the methods
397 :meth:`~moptipy.api.execution.Execution.set_log_improvements` and
398 :meth:`~moptipy.api.execution.Execution.set_log_all_fes`. It will look
399 like this:
401 >>> with InMemoryLogger() as logger:
402 ... with logger.csv("CSV", ["A", "B", "C"]) as csv:
403 ... csv.row((1, 2, 3))
404 ... csv.row([1.5, 2.0, 3.5])
405 ... print(logger.get_log())
406 ['BEGIN_CSV', 'A;B;C', '1;2;3', '1.5;2;3.5', 'END_CSV']
407 """
409 def __init__(self, title: str, logger: Logger, header: list[str]) -> None:
410 """
411 Perform internal construction. Do not call directly.
413 :param title: the title
414 :param logger: the owning logger
415 :param header: the header
416 """
417 super().__init__(title, logger)
419 self.__header_len: Final[int] = len(header)
420 if self.__header_len <= 0:
421 # noinspection PyProtectedMember
422 logger._error(f"Empty header {header} invalid for a CSV section")
424 for c in header:
425 if (not (isinstance(c, str))) or CSV_SEPARATOR in c:
426 # noinspection PyProtectedMember
427 logger._error(f"Invalid column {c}")
429 # noinspection PyProtectedMember
430 logger._writer(CSV_SEPARATOR.join(c.strip() for c in header))
432 def row(self, row: tuple[int | float | bool, ...]
433 | list[int | float | bool]) -> None:
434 """
435 Write a row of csv data.
437 :param row: the row of data
438 """
439 if self.__header_len != len(row):
440 # noinspection PyProtectedMember
441 self._logger._error(
442 f"Header of CSV section demands {self.__header_len} columns, "
443 f"but row {row} has {len(row)}")
445 # noinspection PyProtectedMember
446 txt = (bool_to_str(c) if isinstance(c, bool) # type: ignore
447 else str(c) if isinstance(c, int)
448 else (float_to_str(c) if isinstance(c, float) else
449 cast("None", self._logger._error(
450 f"Invalid log value {c} in row {row}")))
451 for c in row)
453 # noinspection PyProtectedMember
454 self._logger._write(CSV_SEPARATOR.join(txt))
457class KeyValueLogSection(LogSection):
458 """
459 A logger for key-value pairs.
461 The key-values section `XXX` starts with the line `BEGIN_XXX` and ends
462 with the line `END_XXX`. On every line in between, there is a key-value
463 pair of the form `key: value`. Key-values sections support so-called
464 scopes. Key-values pairs belong to a scope `Y` if the key starts with `Y.`
465 followed by the actual key, e.g., `a.g: 5` denotes that the key `g` of
466 scope `a` has value `5`. Such scopes can be arbitrarily nested: The
467 key-value pair `x.y.z: 2` denotes a key `z` in the scope `y` nested within
468 scope `x` having the value `2`. This system of nested scopes allows you
469 to recursively invoke the method
470 :meth:`~moptipy.api.component.Component.log_parameters_to` without
471 worrying of key clashes. Just wrap the call to the `log_parameters_to`
472 method of a sub-component into a unique scope. At the same time, this
473 avoids the need of any more complex hierarchical data structures in our
474 log files.
476 >>> with InMemoryLogger() as logger:
477 ... with logger.key_values("A") as kv:
478 ... kv.key_value("x", 1)
479 ... with kv.scope("b") as sc1:
480 ... sc1.key_value("i", "j")
481 ... with sc1.scope("c") as sc2:
482 ... sc2.key_value("l", 5)
483 ... kv.key_value("y", True)
484 ... print(logger.get_log())
485 ['BEGIN_A', 'x: 1', 'b.i: j', 'b.c.l: 5', 'y: T', 'END_A']
486 """
488 def __init__(self, title: str | None,
489 logger: Logger, prefix: str, done) -> None:
490 """
491 Perform internal construction, do not call directly.
493 :param title: the section title, or `None` for nested scopes.
494 :param logger: the owning logger
495 :param prefix: the prefix
496 :param done: the set of already done keys and prefixes
497 """
498 if not isinstance(prefix, str):
499 raise type_error(prefix, "prefix", str)
500 super().__init__(title=title, logger=logger)
501 self._prefix: Final[str] = prefix
502 self.__done: Callable
503 if done is None:
504 self.__done = str_is_new()
505 self.__done(prefix)
506 else:
507 self.__done = done
508 if not done(prefix):
509 # noinspection PyProtectedMember
510 logger._error(f"Prefix {prefix!r} already done")
512 def key_value(self, key: str, value,
513 also_hex: bool = False) -> None:
514 """
515 Write a key-value pair.
517 Given key `A` and value `B`, the line `A: B` will be added to the log.
518 If `value` (`B`) happens to be a floating point number, the value will
519 also be stored in hexadecimal notation (:meth:`float.hex`).
521 :param key: the key
522 :param value: the value
523 :param also_hex: also store the value as hexadecimal version
524 """
525 key = self._prefix + sanitize_name(key)
526 if not self.__done(key):
527 # noinspection PyProtectedMember
528 self._logger._error(f"Key {key!r} already used")
530 the_hex = None
531 if isinstance(value, float):
532 txt = float_to_str(value)
533 if isfinite(value) and (also_hex
534 or (("e" in txt) or ("." in txt))):
535 the_hex = float.hex(value)
536 elif isinstance(value, bool):
537 txt = bool_to_str(value)
538 else:
539 txt = str(value)
540 if also_hex and isinstance(value, int):
541 the_hex = hex(value)
543 txt = KEY_VALUE_SEPARATOR.join([key, txt])
544 txt = f"{txt}"
546 if the_hex:
547 tmp = KEY_VALUE_SEPARATOR.join(
548 [key + KEY_HEX_VALUE, the_hex])
549 txt = f"{txt}\n{tmp}"
551 # noinspection PyProtectedMember
552 self._logger._writer(txt)
554 def scope(self, prefix: str) -> "KeyValueLogSection":
555 """
556 Create a new scope for key prefixes.
558 :class:`KeyValueLogSection` only allows you to store flat key-value
559 pairs where each key must be unique. However, what do we do if we
560 have two components of an algorithm that have parameters with the
561 same name (key)?
562 We can hierarchically nest :class:`KeyValueLogSection` sections via
563 prefix scopes. The idea is as follows: If one component has
564 sub-components, instead of invoking their
565 :meth:`~moptipy.api.component.Component.log_parameters_to` methods
566 directly, which could lead to key-clashes, it will create a
567 :meth:`scope` for each one and then pass these scopes to their
568 :meth:`~moptipy.api.component.Component.log_parameters_to`.
569 Each scope basically appends a prefix and a "." to the keys.
570 If the prefixes are unique, this ensures that all prefix+"."+keys are
571 unique, too.
573 >>> from moptipy.utils.logger import InMemoryLogger
574 >>> with InMemoryLogger() as l:
575 ... with l.key_values("A") as kv:
576 ... kv.key_value("x", "y")
577 ... with kv.scope("b") as sc1:
578 ... sc1.key_value("x", "y")
579 ... with sc1.scope("c") as sc2:
580 ... sc2.key_value("x", "y")
581 ... with kv.scope("d") as sc3:
582 ... sc3.key_value("x", "y")
583 ... with sc3.scope("c") as sc4:
584 ... sc4.key_value("x", "y")
585 ... print(l.get_log())
586 ['BEGIN_A', 'x: y', 'b.x: y', 'b.c.x: y', 'd.x: y', 'd.c.x: y', \
587'END_A']
589 :param prefix: the key prefix
590 :return: the new logger
591 """
592 return KeyValueLogSection(
593 title=None, logger=self._logger,
594 prefix=(prefix if (self._prefix is None) else
595 f"{self._prefix}{sanitize_name(prefix)}."),
596 done=self.__done)
599class TextLogSection(LogSection):
600 """
601 A logger for raw, unprocessed text.
603 Such a log section is used to capture the raw contents of the solutions
604 discovered by the optimization :class:`~moptipy.api.process.Process`. For
605 this purpose, our system will use the method
606 :meth:`~moptipy.api.space.Space.to_str` of the search and/or solution
607 :class:`~moptipy.api.space.Space`.
609 >>> with InMemoryLogger() as logger:
610 ... with logger.text("T") as txt:
611 ... txt.write("Hello World!")
612 ... print(logger.get_log())
613 ['BEGIN_T', 'Hello World!', 'END_T']
614 """
616 def __init__(self, title: str, logger: Logger) -> None:
617 """
618 Perform internal construction. Do not call it directly.
620 :param title: the title
621 :param logger: the logger
622 """
623 super().__init__(title, logger)
624 # noinspection PyProtectedMember
625 self.write = self._logger._writer # type: ignore
628def parse_key_values(lines: Iterable[str]) -> dict[str, str]:
629 """
630 Parse a :meth:`~moptipy.utils.logger.Logger.key_values` section's text.
632 :param lines: the lines with the key-values pairs
633 :return: the dictionary with the
635 >>> from moptipy.utils.logger import InMemoryLogger
636 >>> with InMemoryLogger() as l:
637 ... with l.key_values("B") as kv:
638 ... kv.key_value("a", "b")
639 ... with kv.scope("c") as kvc:
640 ... kvc.key_value("d", 12)
641 ... kvc.key_value("e", True)
642 ... kv.key_value("f", 3)
643 ... txt = l.get_log()
644 >>> print(txt)
645 ['BEGIN_B', 'a: b', 'c.d: 12', 'c.e: T', 'f: 3', 'END_B']
646 >>> dic = parse_key_values(txt[1:5])
647 >>> keys = list(dic.keys())
648 >>> keys.sort()
649 >>> print(keys)
650 ['a', 'c.d', 'c.e', 'f']
651 """
652 if not isinstance(lines, Iterable):
653 raise type_error(lines, "lines", Iterable)
654 dct = {}
655 for i, line in enumerate(lines):
656 if not isinstance(line, str):
657 raise type_error(line, f"lines[{i}]", str)
658 splt = line.split(KEY_VALUE_SEPARATOR)
659 if len(splt) != 2:
660 raise ValueError(
661 f"Two strings separated by {KEY_VALUE_SEPARATOR!r} "
662 f"expected, but encountered {len(splt)} in {i}th "
663 f"line {line!r}.")
664 key = splt[0].strip()
665 if len(key) <= 0:
666 raise ValueError(
667 f"Empty key encountered in {i}th line {line!r}.")
668 value = splt[1].strip()
669 if len(value) <= 0:
670 raise ValueError(
671 f"Empty value encountered in {i}th line {line!r}.")
672 dct[key] = value
674 return dct