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

1""" 

2Classes for writing structured log files. 

3 

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). 

9 

10A :class:`~Logger` can produce output in three formats: 

11 

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. 

23 

24The beginning and ending of section named `XXX` are `BEGIN_XXX` and `END_XXX`. 

25 

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

32 

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 

38 

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 

44 

45from moptipy.utils.strings import ( 

46 PART_SEPARATOR, 

47 sanitize_name, 

48) 

49 

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)" 

60 

61 

62class Logger(AbstractContextManager): 

63 """ 

64 An abstract base class for logging data in a structured way. 

65 

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

71 

72 def __init__(self, name: str, writer: Callable[[str], Any], 

73 closer: Callable[[], Any] = lambda: None) -> None: 

74 """ 

75 Create a new logger. 

76 

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) 

87 

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 

95 

96 def __enter__(self): 

97 """ 

98 Enter the logger in a `with` statement. 

99 

100 :return: `self` 

101 """ 

102 return self 

103 

104 def _error(self, message: str) -> None: 

105 """ 

106 Raise a :class:`ValueError` with context information. 

107 

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}.") 

116 

117 def __exit__(self, exception_type, exception_value, traceback) -> None: 

118 """ 

119 Close the logger after leaving the `with` statement. 

120 

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 

131 

132 def _open_section(self, title: str) -> None: 

133 """ 

134 Open a new section. 

135 

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") 

141 

142 if self.__section is not None: 

143 self._error(f"Cannot open section {title!r} because " 

144 "another one is open") 

145 

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") 

150 

151 if not self.__sections(title): 

152 self._error(f"Section {title!r} already done") 

153 

154 self._writer(f"{SECTION_START}{title}") 

155 self.__closer = f"{SECTION_END}{title}" 

156 self.__section = title 

157 

158 def _close_section(self, title: str) -> None: 

159 """ 

160 Close a section. 

161 

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 

169 

170 def _comment(self, comment: str) -> None: 

171 """ 

172 Write a comment line. 

173 

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}") 

182 

183 def _write(self, text: str) -> None: 

184 """ 

185 Write a string. 

186 

187 :param text: the text to write 

188 """ 

189 if self.__section is None: 

190 self._error("Cannot write if not inside section") 

191 

192 if len(text) <= 0: 

193 return 

194 

195 if self.__closer in text: 

196 self._error(f"String {self.__closer!r} " 

197 "must not be contained in output") 

198 

199 if COMMENT_START in text: 

200 raise ValueError( 

201 f"{COMMENT_START!r} not permitted in text {text!r}.") 

202 self._writer(text) 

203 

204 def key_values(self, title: str) -> "KeyValueLogSection": 

205 r""" 

206 Create a log section for key-value pairs. 

207 

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). 

213 

214 :param title: the title of the new section 

215 :return: the new logger 

216 

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) 

236 

237 def csv(self, title: str, header: list[str]) -> "CsvLogSection": 

238 """ 

239 Create a log section for CSV data with `;` as column separator. 

240 

241 The first line will be the headline with the column names. 

242 

243 :param title: the title of the new section 

244 :param header: the list of column titles 

245 :return: the new logger 

246 

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) 

267 

268 def text(self, title: str) -> "TextLogSection": 

269 r""" 

270 Create a log section for unstructured text. 

271 

272 :param title: the title of the new section 

273 :return: the new logger 

274 

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) 

286 

287 

288class FileLogger(Logger): 

289 """A logger logging to a file.""" 

290 

291 def __init__(self, path: str) -> None: 

292 """ 

293 Initialize the logger. 

294 

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) 

302 

303 

304class PrintLogger(Logger): 

305 """A logger logging to stdout.""" 

306 

307 def __init__(self) -> None: 

308 """Initialize the logger.""" 

309 super().__init__("printer", print) 

310 

311 

312class InMemoryLogger(Logger): 

313 """A logger logging to a string in memory.""" 

314 

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

320 

321 def get_log(self) -> list[str]: 

322 """ 

323 Obtain all the lines logged to this logger. 

324 

325 :return: a list of strings with the logged lines 

326 """ 

327 return cast("StringIO", self.__stream).getvalue().splitlines() 

328 

329 

330class LogSection(AbstractContextManager): 

331 """An internal base class for logger sections.""" 

332 

333 def __init__(self, title: str | None, logger: Logger) -> None: 

334 """ 

335 Perform internal construction. Do not call directly. 

336 

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) 

345 

346 def __enter__(self): 

347 """ 

348 Enter the context: needed for the `with` statement. 

349 

350 :return: `self` 

351 """ 

352 return self 

353 

354 def __exit__(self, exception_type, exception_value, traceback) -> None: 

355 """ 

356 Exit the `with` statement. 

357 

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 

368 

369 def comment(self, comment: str) -> None: 

370 """ 

371 Write a comment line. 

372 

373 A comment starts with `#` and is followed by text. 

374 

375 :param comment: the comment to write 

376 

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) 

387 

388 

389class CsvLogSection(LogSection): 

390 """ 

391 A logger that is designed to output CSV data. 

392 

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: 

400 

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

408 

409 def __init__(self, title: str, logger: Logger, header: list[str]) -> None: 

410 """ 

411 Perform internal construction. Do not call directly. 

412 

413 :param title: the title 

414 :param logger: the owning logger 

415 :param header: the header 

416 """ 

417 super().__init__(title, logger) 

418 

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") 

423 

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}") 

428 

429 # noinspection PyProtectedMember 

430 logger._writer(CSV_SEPARATOR.join(c.strip() for c in header)) 

431 

432 def row(self, row: tuple[int | float | bool, ...] 

433 | list[int | float | bool]) -> None: 

434 """ 

435 Write a row of csv data. 

436 

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)}") 

444 

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) 

452 

453 # noinspection PyProtectedMember 

454 self._logger._write(CSV_SEPARATOR.join(txt)) 

455 

456 

457class KeyValueLogSection(LogSection): 

458 """ 

459 A logger for key-value pairs. 

460 

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. 

475 

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

487 

488 def __init__(self, title: str | None, 

489 logger: Logger, prefix: str, done) -> None: 

490 """ 

491 Perform internal construction, do not call directly. 

492 

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") 

511 

512 def key_value(self, key: str, value, 

513 also_hex: bool = False) -> None: 

514 """ 

515 Write a key-value pair. 

516 

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`). 

520 

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") 

529 

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) 

542 

543 txt = KEY_VALUE_SEPARATOR.join([key, txt]) 

544 txt = f"{txt}" 

545 

546 if the_hex: 

547 tmp = KEY_VALUE_SEPARATOR.join( 

548 [key + KEY_HEX_VALUE, the_hex]) 

549 txt = f"{txt}\n{tmp}" 

550 

551 # noinspection PyProtectedMember 

552 self._logger._writer(txt) 

553 

554 def scope(self, prefix: str) -> "KeyValueLogSection": 

555 """ 

556 Create a new scope for key prefixes. 

557 

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. 

572 

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'] 

588 

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) 

597 

598 

599class TextLogSection(LogSection): 

600 """ 

601 A logger for raw, unprocessed text. 

602 

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`. 

608 

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

615 

616 def __init__(self, title: str, logger: Logger) -> None: 

617 """ 

618 Perform internal construction. Do not call it directly. 

619 

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 

626 

627 

628def parse_key_values(lines: Iterable[str]) -> dict[str, str]: 

629 """ 

630 Parse a :meth:`~moptipy.utils.logger.Logger.key_values` section's text. 

631 

632 :param lines: the lines with the key-values pairs 

633 :return: the dictionary with the 

634 

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 

673 

674 return dct