librus-python-api 1.7.0__tar.gz → 1.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/CHANGELOG.md +18 -0
  2. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/PKG-INFO +5 -5
  3. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/README.md +4 -4
  4. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/__init__.py +1 -1
  5. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/config.py +3 -0
  6. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/formative_grades.py +6 -1
  7. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/grade_parsers.py +28 -8
  8. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/grade_records.py +17 -11
  9. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/markup.py +11 -1
  10. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/.gitignore +0 -0
  11. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/API.md +0 -0
  12. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/LICENSE +0 -0
  13. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/SECURITY.md +0 -0
  14. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/pyproject.toml +0 -0
  15. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
  16. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/_notification_codec.py +0 -0
  17. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/_storage.py +0 -0
  18. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/_windows_filesystem.py +0 -0
  19. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/announcements.py +0 -0
  20. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/attachment_routes.py +0 -0
  21. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/attachments.py +0 -0
  22. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/attendance.py +0 -0
  23. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/attendance_frequency.py +0 -0
  24. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/budget.py +0 -0
  25. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/checkpoint.py +0 -0
  26. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/class_free_days.py +0 -0
  27. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/completed_lessons.py +0 -0
  28. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/detail_fields.py +0 -0
  29. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/diagnostics.py +0 -0
  30. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/exceptions.py +0 -0
  31. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/files.py +0 -0
  32. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/homework_range.py +0 -0
  33. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/lifecycle.py +0 -0
  34. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/message_content.py +0 -0
  35. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/messages.py +0 -0
  36. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/models.py +0 -0
  37. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/modern_body.py +0 -0
  38. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/modern_mailbox.py +0 -0
  39. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/modern_messages.py +0 -0
  40. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/notification_models.py +0 -0
  41. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/notification_persistence.py +0 -0
  42. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/notification_workflow.py +0 -0
  43. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/notifications.py +0 -0
  44. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/parsers.py +0 -0
  45. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/parsing.py +0 -0
  46. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/persistence.py +0 -0
  47. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/py.typed +0 -0
  48. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/recipients.py +0 -0
  49. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/scheduler.py +0 -0
  50. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/school_reads.py +0 -0
  51. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/school_year_archive.py +0 -0
  52. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/sending.py +0 -0
  53. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/service.py +0 -0
  54. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/timetable.py +0 -0
  55. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/src/librus_python_api/transport.py +0 -0
  56. {librus_python_api-1.7.0 → librus_python_api-1.7.1}/uv.lock +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.1 (2026-10-27) - Observed point-grade page and multi-line comments
4
+
5
+ Fixes for layouts 1.7.0 rejected with `ParseError`, established from an
6
+ affected school's real point-grade page and a live grade read. Public types
7
+ and signatures are unchanged.
8
+
9
+ - Grades: a teacher's multi-line `Komentarz` continues on unlabelled tooltip
10
+ lines and is kept with its line breaks. Every grade read of an account with
11
+ such a comment failed in all releases up to 1.7.0; any other unlabelled
12
+ tooltip line still fails.
13
+ - Point grades: a formative entry among the point entries is accepted as the
14
+ formative mirror it is; the empty footer row wider than the table is
15
+ skipped; titles that break their own label (`Ocena śródroczna <br> z
16
+ pierwszego okresu`) name the period columns, so point midterm grades are
17
+ read. These rules apply to point tables only.
18
+ - Formative assessments: assessment text may contain line breaks. 1.7.0 and
19
+ earlier failed such a table with `UnsupportedCapabilityError`.
20
+
3
21
  ## 1.7.0 (2026-10-27) - Point grades
4
22
 
5
23
  Reads the "Oceny punktowe" table that point-grade schools show on the grades
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.7.0
3
+ Version: 1.7.1
4
4
  Summary: Independent bounded async Librus Synergia client
5
5
  Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
6
6
  Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
@@ -42,8 +42,8 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
42
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
43
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
44
44
 
45
- **Status:** `1.7.0` reads point grades ("Oceny punktowe"), which made both grade
46
- reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
45
+ **Status:** `1.7.1` reads point grades ("Oceny punktowe") as an affected
46
+ school's page shows them, and multi-line grade comments. It keeps the 1.6.4 linear-time message body
47
47
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
48
48
  points, which are validated and not reported as grades.
49
49
  Class free days, typed capability outcomes and the live-check freshness watchdog
@@ -57,7 +57,7 @@ See [limitations](#supported-features-and-limitations) below.
57
57
  Install the exact release from PyPI after its gated publication completes:
58
58
 
59
59
  ```sh
60
- python -m pip install librus-python-api==1.7.0
60
+ python -m pip install librus-python-api==1.7.1
61
61
  ```
62
62
 
63
63
  Pin the exact version qualified by your application. Before publication, use a
@@ -73,7 +73,7 @@ python -m pip install .
73
73
  To install a locally built wheel instead:
74
74
 
75
75
  ```sh
76
- python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
76
+ python -m pip install ./dist/librus_python_api-1.7.1-py3-none-any.whl
77
77
  ```
78
78
 
79
79
  No CLI or background process is installed: import the library in your own program.
@@ -16,8 +16,8 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
16
16
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
17
17
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
18
18
 
19
- **Status:** `1.7.0` reads point grades ("Oceny punktowe"), which made both grade
20
- reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
19
+ **Status:** `1.7.1` reads point grades ("Oceny punktowe") as an affected
20
+ school's page shows them, and multi-line grade comments. It keeps the 1.6.4 linear-time message body
21
21
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
22
22
  points, which are validated and not reported as grades.
23
23
  Class free days, typed capability outcomes and the live-check freshness watchdog
@@ -31,7 +31,7 @@ See [limitations](#supported-features-and-limitations) below.
31
31
  Install the exact release from PyPI after its gated publication completes:
32
32
 
33
33
  ```sh
34
- python -m pip install librus-python-api==1.7.0
34
+ python -m pip install librus-python-api==1.7.1
35
35
  ```
36
36
 
37
37
  Pin the exact version qualified by your application. Before publication, use a
@@ -47,7 +47,7 @@ python -m pip install .
47
47
  To install a locally built wheel instead:
48
48
 
49
49
  ```sh
50
- python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
50
+ python -m pip install ./dist/librus_python_api-1.7.1-py3-none-any.whl
51
51
  ```
52
52
 
53
53
  No CLI or background process is installed: import the library in your own program.
@@ -138,7 +138,7 @@ from librus_python_api.models import (
138
138
  from librus_python_api.sending import SendAttempt
139
139
  from librus_python_api.service import AccountClient, LibrusService
140
140
 
141
- __version__ = "1.7.0"
141
+ __version__ = "1.7.1"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -659,6 +659,9 @@ GRADE_MAX_SUBJECTS = 128
659
659
  GRADE_MAX_VALUE_LENGTH = 1024
660
660
  GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
661
661
  GRADE_INLINE_DETAIL_LABEL = "Ocena"
662
+ # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
663
+ # multi-line grade comment was observed on 2026-10-27.
664
+ TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
662
665
  # A point-grade ("Oceny punktowe") table marks each subject row's expander image
663
666
  # with this ID; ordinary grade rows use `przedmioty_<id>_node`.
664
667
  GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
@@ -58,7 +58,8 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
58
58
  """The detail ID and full text of the assessment cell.
59
59
 
60
60
  Observed: one link whose optional first child is an empty coloured
61
- `span.grade-box` marker, followed by the text. Anything else is new markup.
61
+ `span.grade-box` marker, followed by the text, which may contain empty
62
+ line breaks (2026-10-27). Anything else is new markup.
62
63
  """
63
64
  _single(cell)
64
65
  anchors = [node for node in cell if isinstance(node.tag, str)]
@@ -66,6 +67,10 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
66
67
  _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
67
68
  anchor = anchors[0]
68
69
  children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
70
+ breaks = [node for node in children if node.tag == "br"]
71
+ if any(node.getparent() is not anchor or len(node) for node in breaks):
72
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
73
+ children = [node for node in children if node.tag != "br"]
69
74
  if children:
70
75
  marker = children[0]
71
76
  if (
@@ -27,22 +27,34 @@ from librus_python_api.models import (
27
27
  from librus_python_api.parsers import parse_page
28
28
 
29
29
 
30
- def _summary_field(cell: html.HtmlElement) -> str | None:
30
+ def title_label(title: str, *, points: bool = False) -> str:
31
+ """A header title's label: its first line, or for a point table all of it.
32
+
33
+ Ordinary titles append an explanation after a line break. Point tables
34
+ break the label itself ("Ocena śródroczna <br> z pierwszego okresu").
35
+ """
36
+ if points:
37
+ return " ".join(re.sub(r"<br\s*/?>", " ", title, flags=re.I).split())
38
+ return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
39
+
40
+
41
+ def _summary_field(cell: html.HtmlElement, *, points: bool = False) -> str | None:
31
42
  title = cell.get("title", "")
32
43
  if len(title) > GRADE_MAX_VALUE_LENGTH:
33
44
  raise LibrusError(ErrorKind.LIMIT)
34
- label = " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
35
- return GRADE_SUMMARY_HEADERS.get(label)
45
+ return GRADE_SUMMARY_HEADERS.get(title_label(title, points=points))
36
46
 
37
47
 
38
- def _header(row: html.HtmlElement) -> tuple[dict[str, int], int]:
48
+ def _header(
49
+ row: html.HtmlElement, *, points: bool = False
50
+ ) -> tuple[dict[str, int], int]:
39
51
  fields: dict[str, int] = {}
40
52
  position = GRADE_BODY_PREFIX_COLUMNS
41
53
  for cell in markup.cells(row):
42
54
  span = markup.colspan(cell)
43
55
  if position + span > GRADE_MAX_COLUMNS:
44
56
  raise LibrusError(ErrorKind.LIMIT)
45
- field = _summary_field(cell)
57
+ field = _summary_field(cell, points=points)
46
58
  if field is not None:
47
59
  if field in fields:
48
60
  raise LibrusError(ErrorKind.PARSE)
@@ -86,7 +98,7 @@ def point_layout(table: html.HtmlElement) -> tuple[dict[str, int], int]:
86
98
  ]
87
99
  if len(headers) != 1:
88
100
  raise LibrusError(ErrorKind.PARSE)
89
- return _header(headers[0])
101
+ return _header(headers[0], points=True)
90
102
 
91
103
 
92
104
  def _candidates(
@@ -181,7 +193,7 @@ def _summary(
181
193
 
182
194
 
183
195
  def _subject_rows(
184
- table: html.HtmlElement, width: int
196
+ table: html.HtmlElement, width: int, *, points: bool = False
185
197
  ) -> Iterator[list[html.HtmlElement]]:
186
198
  for row in markup.rows(table):
187
199
  if next(row.iterancestors("thead"), None) is not None:
@@ -189,6 +201,14 @@ def _subject_rows(
189
201
  cells = markup.cells(row)
190
202
  if not cells:
191
203
  raise LibrusError(ErrorKind.PARSE)
204
+ # The observed point table closes with an empty footer cell wider than
205
+ # the table; only an empty footer row is skipped.
206
+ if (
207
+ points
208
+ and next(row.iterancestors("tfoot"), None) is not None
209
+ and not any(markup.text(cell) for cell in cells)
210
+ ):
211
+ continue
192
212
  if list(row.iter("table")):
193
213
  if _outside_nested_tables(row):
194
214
  raise LibrusError(ErrorKind.PARSE)
@@ -252,7 +272,7 @@ def parse_final_grades(body: bytes) -> tuple[SubjectGradeSummary, ...]:
252
272
  items: dict[str, SubjectGradeSummary] = {}
253
273
  for table, fields, width in sources:
254
274
  family_subjects: set[str] = set()
255
- for cells in _subject_rows(table, width):
275
+ for cells in _subject_rows(table, width, points=table in point_tables):
256
276
  item = _summary(cells, fields, width)
257
277
  if item.subject in family_subjects:
258
278
  raise LibrusError(ErrorKind.PARSE)
@@ -36,6 +36,7 @@ from librus_python_api.grade_parsers import (
36
36
  _summary,
37
37
  is_point_table,
38
38
  point_layout,
39
+ title_label,
39
40
  )
40
41
  from librus_python_api.models import (
41
42
  Availability,
@@ -51,8 +52,8 @@ from librus_python_api.models import (
51
52
  from librus_python_api.parsers import parse_page
52
53
 
53
54
 
54
- def _label(title: str) -> str:
55
- return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
55
+ def _label(title: str, *, points: bool = False) -> str:
56
+ return title_label(title, points=points)
56
57
 
57
58
 
58
59
  def _layout(
@@ -74,7 +75,9 @@ def _layout(
74
75
  if markup.colspan(cell) != 1:
75
76
  raise LibrusError(ErrorKind.PARSE)
76
77
  current.append(position)
77
- period = GRADE_AVERAGE_HEADERS.get(_label(cell.get("title", "")))
78
+ period = GRADE_AVERAGE_HEADERS.get(
79
+ _label(cell.get("title", ""), points=points)
80
+ )
78
81
  result = GRADE_POINT_RESULT_HEADER.fullmatch(markup.text(cell))
79
82
  if points and period is None and result is not None:
80
83
  period = GRADE_POINT_RESULT_PERIODS[result.group(1)]
@@ -333,7 +336,9 @@ def _read_descriptive_row(
333
336
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
334
337
 
335
338
 
336
- def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]:
339
+ def _period_columns(
340
+ table: html.HtmlElement, *, points: bool = False
341
+ ) -> dict[tuple[int, GradeKind], int]:
337
342
  result: dict[tuple[int, GradeKind], int] = {}
338
343
  for row in markup.rows(table):
339
344
  cells = markup.cells(row)
@@ -343,7 +348,7 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
343
348
  continue
344
349
  position = GRADE_BODY_PREFIX_COLUMNS
345
350
  for cell in cells:
346
- label = _label(cell.get("title", ""))
351
+ label = _label(cell.get("title", ""), points=points)
347
352
  period = GRADE_PERIOD_HEADERS.get(label)
348
353
  kind = GradeKind.ANNUAL if period == 0 else GradeKind.PERIOD
349
354
  if label == GRADE_PREDICTED_ANNUAL_HEADER:
@@ -371,11 +376,11 @@ def _read_numeric_table(
371
376
  else:
372
377
  _, fields, width = _locate(table)
373
378
  current, means = _layout(table, points=points)
374
- periods = _period_columns(table)
379
+ periods = _period_columns(table, points=points)
375
380
  averages = collection.point_results if points else collection.averages
376
381
  # Period grades shown by both tables are one record, kept from the first.
377
382
  prior = set(collection.numeric) if points else set()
378
- for cells in _subject_rows(table, width):
383
+ for cells in _subject_rows(table, width, points=points):
379
384
  if cells[0].getparent() in collection.publication_rows:
380
385
  continue
381
386
  if points:
@@ -412,9 +417,10 @@ def _read_numeric_table(
412
417
  columns[index], subject, cast(Literal[1, 2], period), collection
413
418
  )
414
419
  # A point entry without its detail link would pass as a
415
- # grade symbol; an unseen link layout fails instead.
420
+ # grade symbol; an unseen link layout fails instead. The
421
+ # observed point table also shows formative entries.
416
422
  if points and any(
417
- record.point_grade_id is None
423
+ record.point_grade_id is None and record.formative_id is None
418
424
  for record in collection.numeric[start:]
419
425
  ):
420
426
  raise LibrusError(ErrorKind.PARSE)
@@ -558,11 +564,11 @@ def parse_grade_records(body: bytes) -> GradeRecords:
558
564
  continue
559
565
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
560
566
  for anchor in document.iter("a"):
561
- # A formative link outside both known tables is a new layout.
567
+ # A formative link outside the known tables is a new layout.
562
568
  if markup.detail_id(
563
569
  anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX
564
570
  ) and not any(
565
- parent in numeric_tables or parent in formative_tables
571
+ parent in grade_tables or parent in formative_tables
566
572
  for parent in anchor.iterancestors("table")
567
573
  ):
568
574
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
@@ -13,6 +13,7 @@ from librus_python_api.config import (
13
13
  GRADE_MAX_COLUMNS,
14
14
  GRADE_MAX_METADATA_FIELDS,
15
15
  GRADE_MAX_METADATA_LENGTH,
16
+ TOOLTIP_MULTILINE_LABELS,
16
17
  UPSTREAM_ORIGINS,
17
18
  )
18
19
  from librus_python_api.exceptions import ErrorKind, LibrusError
@@ -81,11 +82,16 @@ def colspan(cell: html.HtmlElement) -> int:
81
82
 
82
83
 
83
84
  def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
84
- """Parse a 'Label: value<br>' title attribute into unique labelled fields."""
85
+ """Parse a 'Label: value<br>' title attribute into unique labelled fields.
86
+
87
+ A free-text field may span lines; an unlabelled line after it continues
88
+ its value. Any other unlabelled line is an unknown layout.
89
+ """
85
90
  title = element.get("title", "")
86
91
  if len(title) > GRADE_MAX_METADATA_LENGTH:
87
92
  raise LibrusError(ErrorKind.LIMIT)
88
93
  result: dict[str, str] = {}
94
+ last: str | None = None
89
95
  chunks = re.split(r"<br\s*/?>|\n", title, flags=re.I)
90
96
  if len(chunks) > GRADE_MAX_METADATA_FIELDS:
91
97
  raise LibrusError(ErrorKind.LIMIT)
@@ -100,9 +106,13 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
100
106
  )
101
107
  key, separator, value = rendered.partition(":")
102
108
  key, value = key.strip(), value.strip()
109
+ if not separator and last in TOOLTIP_MULTILINE_LABELS:
110
+ result[last] = f"{result[last]}\n{rendered}".strip()
111
+ continue
103
112
  if not separator or not key or key in result:
104
113
  raise LibrusError(ErrorKind.PARSE)
105
114
  result[key] = value
115
+ last = key
106
116
  return result
107
117
 
108
118