librus-python-api 1.8.2__py3-none-any.whl → 1.8.4__py3-none-any.whl

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.
@@ -1,7 +1,7 @@
1
1
  """Independent bounded async client. General live compatibility is unqualified."""
2
2
 
3
3
  # Before the imports: config reads it for the User-Agent.
4
- __version__ = "1.8.2"
4
+ __version__ = "1.8.4"
5
5
 
6
6
  from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
7
7
  from librus_python_api.budget import RequestBudget
@@ -11,6 +11,7 @@ from librus_python_api.config import (
11
11
  ATTENDANCE_EMPTY_MARKERS,
12
12
  ATTENDANCE_MAX_RECORDS,
13
13
  ATTENDANCE_SEMESTER_LABELS,
14
+ ATTENDANCE_TOOLTIP_NOTICES,
14
15
  GRADE_MAX_METADATA_LENGTH,
15
16
  GRADE_MAX_VALUE_LENGTH,
16
17
  )
@@ -29,6 +30,17 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
29
30
  title = element.get("title", "")
30
31
  if len(title) > GRADE_MAX_METADATA_LENGTH:
31
32
  raise LibrusError(ErrorKind.LIMIT)
33
+ # A known Librus notice is a whole line of its own; other unlabelled lines
34
+ # still fail in tooltip_fields.
35
+ notices: list[tuple[str, str]] = []
36
+ lines = []
37
+ for line in re.split(r"<br\s*/?>|\n", title, flags=re.I):
38
+ notice = ATTENDANCE_TOOLTIP_NOTICES.get(markup.tooltip_line(line))
39
+ if notice is None or notice in notices:
40
+ lines.append(line)
41
+ else:
42
+ notices.append(notice)
43
+ title = "<br>".join(lines)
32
44
  holder = html.Element("span")
33
45
  holder.set("title", re.sub(r"</b>\s*(?=<b[ >])", "</b>\n", title, flags=re.I))
34
46
  fields = markup.tooltip_fields(holder)
@@ -57,7 +69,7 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
57
69
  fields.get("Temat zajęć"),
58
70
  fields.get("Lekcja"),
59
71
  markup.attendance_detail_id(element),
60
- tuple(fields.items()),
72
+ (*fields.items(), *notices),
61
73
  )
62
74
 
63
75
 
@@ -721,8 +721,12 @@ GRADE_MAX_VALUE_LENGTH = 1024
721
721
  GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
722
722
  GRADE_INLINE_DETAIL_LABEL = "Ocena"
723
723
  # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
724
- # multi-line grade comment was observed on 2026-10-27.
724
+ # multi-line grade comment was observed on 2026-10-27. A formative mirror's
725
+ # assessment text after `Ocena` also spans lines (2026-11-08, markup.py).
725
726
  TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
727
+ # Labels a tooltip may repeat. A grade correcting two earlier grades lists one
728
+ # `Poprawa oceny` line per corrected grade in the week view (2026-11-08).
729
+ TOOLTIP_REPEATABLE_LABELS = frozenset({"Poprawa oceny"})
726
730
  # Tooltip labels observed on grade, attendance and point pages. Diagnostics
727
731
  # show only these by name; any other label is masked as teacher-entered text.
728
732
  TOOLTIP_LABELS = frozenset(
@@ -744,6 +748,7 @@ TOOLTIP_LABELS = frozenset(
744
748
  "Nauczyciel",
745
749
  "Obowiązek wyk. zadania",
746
750
  "Ocena",
751
+ "Poprawa oceny",
747
752
  "Rodzaj",
748
753
  "Suma punktów",
749
754
  "Temat zajęć",
@@ -783,10 +788,12 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
783
788
  "Ocena roczna": 0,
784
789
  }
785
790
  )
786
- # New-schema descriptive grading: the page renders this table with empty
787
- # semester cells and its script loads the grades from the gateway routes above.
791
+ # New-schema descriptive grading: the page renders this table either with empty
792
+ # `gradesCell` semester cells, which its script fills from the gateway routes
793
+ # above, or with bare semester text, which needs no gateway read.
788
794
  DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
789
795
  DESCRIPTIVE_ROW_CLASS = "studentRow"
796
+ DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
790
797
  # Gateway account and student identifiers are opaque strings, unlike every
791
798
  # other `{id}` route, which takes a numeric ID.
792
799
  DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
@@ -838,6 +845,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
838
845
  }
839
846
  )
840
847
  ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
848
+ # Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
849
+ # metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
850
+ # absence excused through the e-Usprawiedliwienia module.
851
+ ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
852
+ {
853
+ "Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
854
+ "Usprawiedliwienie",
855
+ "dodane za pomocą modułu e-Usprawiedliwienia",
856
+ )
857
+ }
858
+ )
841
859
  ATTENDANCE_MAX_RECORDS = 2048
842
860
  ATTENDANCE_MAX_WINDOW_DAYS = 371
843
861
  # Whole-school gateway collections resolve attendance metadata in two requests.
@@ -849,6 +867,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
849
867
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
850
868
  # Recognition only: behaviour-point detail links are classified, never requested.
851
869
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
870
+ # Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
871
+ BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
852
872
  # Recognition only: point-grade detail links are classified, never requested.
853
873
  POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
854
874
  FORMATIVE_HEADERS = (
@@ -916,7 +936,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
916
936
  ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
917
937
  {"container", "border-red", "resizeable", "center"}
918
938
  )
919
- ANNOUNCEMENT_EMPTY_MARKERS = frozenset({"brak ogłoszeń", "nie ma ogłoszeń"})
939
+ # "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
940
+ ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
941
+ {"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
942
+ )
920
943
  TIMETABLE_MAX_PERIODS = 32
921
944
  TIMETABLE_MAX_LESSONS_PER_SLOT = 16
922
945
  TIMETABLE_MAX_CHANGES_PER_SLOT = 16
@@ -6,6 +6,11 @@ grades from gateway JSON routes. Routes and field names come from public client
6
6
  code and the official page script. One authorized read on 2026-10-28 parsed four
7
7
  PartialGrades records on one school; pagination keys are still unknown, so every
8
8
  unexpected shape fails instead of producing a partial collection.
9
+
10
+ Another school renders the same table with plain text in its semester cells and
11
+ no script hooks (#115, from a reporter's synthetic example). The grade record
12
+ parser reads those cells like any descriptive row, so that table needs no
13
+ gateway read.
9
14
  """
10
15
 
11
16
  import re
@@ -20,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
20
25
 
21
26
  from librus_python_api import markup
22
27
  from librus_python_api.config import (
28
+ DESCRIPTIVE_DETAILS_ROW_CLASS,
23
29
  DESCRIPTIVE_IDENTIFIER,
24
30
  DESCRIPTIVE_MAX_NAME_LENGTH,
25
31
  DESCRIPTIVE_MAX_SUBJECTS,
@@ -331,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
331
337
 
332
338
  @dataclass(frozen=True, slots=True)
333
339
  class DescriptiveTable:
334
- """Numeric subject ID to the subject name the grades page shows."""
340
+ """Numeric subject ID to the subject name the grades page shows.
341
+
342
+ A filled table already holds its grades and has no gateway grades.
343
+ """
335
344
 
336
345
  subjects: Mapping[int, str] = field(repr=False)
346
+ filled: bool = False
337
347
 
338
348
 
339
349
  def _classes(element: html.HtmlElement) -> set[str]:
@@ -343,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
343
353
  def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
344
354
  """The new-schema subject table, or None when the page has none.
345
355
 
346
- Its rows hold an expander, the subject and two empty semester cells that
347
- the page script fills from the gateway. A cell the page already filled
348
- would duplicate the gateway records, so it is an unsupported layout.
356
+ Its rows hold an expander, the subject and two semester cells. Either
357
+ every row has empty `gradesCell` cells that the page script fills from
358
+ the gateway, or every row has plain cells the page filled itself, which
359
+ `read_grade_records` reads and validates. A script cell the page already
360
+ filled would duplicate the gateway records, so it is unsupported.
349
361
  """
350
362
  tables = [
351
363
  table
@@ -369,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
369
381
  tables = populated
370
382
  subjects: dict[int, str] = {}
371
383
  students: set[str | None] = set()
384
+ layouts: set[bool] = set()
372
385
  for row in markup.rows(tables[0]):
373
386
  if DESCRIPTIVE_ROW_CLASS not in _classes(row):
374
387
  continue
375
388
  cells = markup.cells(row)
376
- if (
377
- len(cells) != 4
378
- or not {"micro", "center", "screen-only"} <= _classes(cells[0])
379
- or [cell.get("data-semester") for cell in cells[2:]] != ["1", "2"]
380
- or not all("gradesCell" in _classes(cell) for cell in cells[2:])
389
+ if len(cells) != 4 or not {"micro", "center", "screen-only"} <= _classes(
390
+ cells[0]
391
+ ):
392
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
393
+ semesters = cells[2:]
394
+ # A filled cell is bare text: no attributes and no markup, so a renamed
395
+ # or moved script hook never passes as a filled table.
396
+ filled = all(
397
+ not cell.attrib
398
+ and markup.text(cell)
399
+ and not any(isinstance(node.tag, str) for node in cell)
400
+ for cell in semesters
401
+ )
402
+ if not filled and (
403
+ [cell.get("data-semester") for cell in semesters] != ["1", "2"]
404
+ or not all("gradesCell" in _classes(cell) for cell in semesters)
381
405
  ):
382
406
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
383
- if any(
407
+ layouts.add(filled)
408
+ if not filled and any(
384
409
  markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
385
- for cell in cells[2:]
410
+ for cell in semesters
386
411
  ):
387
412
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
388
413
  identifier = row.get("data-subject_id", "")
@@ -398,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
398
423
  if len(students) > 1:
399
424
  # One page shows one student; rows of several students are unknown.
400
425
  raise LibrusError(ErrorKind.PARSE)
401
- return DescriptiveTable(MappingProxyType(subjects))
426
+ if len(layouts) > 1:
427
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
428
+ if layouts == {True} and any(
429
+ DESCRIPTIVE_DETAILS_ROW_CLASS in _classes(row) for row in markup.rows(tables[0])
430
+ ):
431
+ # Detail rows are what the page script fills from the gateway; a filled
432
+ # table that still has them may also have gateway grades.
433
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
434
+ return DescriptiveTable(MappingProxyType(subjects), layouts == {True})
402
435
 
403
436
 
404
437
  def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
@@ -8,12 +8,14 @@ from lxml import html
8
8
 
9
9
  from librus_python_api import markup
10
10
  from librus_python_api.config import (
11
+ BEHAVIOUR_GRADE_LABELS,
11
12
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
12
13
  FORMATIVE_DETAIL_PATH_PREFIX,
13
14
  GRADE_AVERAGE_HEADERS,
14
15
  GRADE_BODY_PREFIX_COLUMNS,
15
16
  GRADE_CURRENT_HEADER,
16
17
  GRADE_EMPTY_MARKERS,
18
+ GRADE_INLINE_DETAIL_LABEL,
17
19
  GRADE_MAX_METADATA_LENGTH,
18
20
  GRADE_MAX_RECORDS,
19
21
  GRADE_MAX_SUBJECTS,
@@ -26,6 +28,8 @@ from librus_python_api.config import (
26
28
  GRADE_PUBLICATION_DATE_LABEL,
27
29
  GRADE_PUBLICATION_PERIOD_LABELS,
28
30
  GRADE_PUBLICATION_TEACHER_LABEL,
31
+ TOOLTIP_LABELS,
32
+ TOOLTIP_MULTILINE_LABELS,
29
33
  )
30
34
  from librus_python_api.exceptions import ErrorKind, LibrusError
31
35
  from librus_python_api.formative_grades import is_formative_table, read_formative_table
@@ -97,7 +101,16 @@ def _numeric(
97
101
  semester: Literal[0, 1, 2],
98
102
  kind: GradeKind = GradeKind.CURRENT,
99
103
  ) -> NumericGrade:
100
- metadata = markup.tooltip_fields(element)
104
+ if markup.detail_id(element.get("href"), FORMATIVE_DETAIL_PATH_PREFIX) is None:
105
+ metadata = markup.tooltip_fields(element)
106
+ else:
107
+ # A formative mirror's assessment text after `Ocena` is free text that
108
+ # may span lines and hold colons (observed live 2026-11-08).
109
+ metadata = markup.tooltip_fields(
110
+ element,
111
+ multiline=TOOLTIP_MULTILINE_LABELS | {GRADE_INLINE_DETAIL_LABEL},
112
+ unknown_multiline=True,
113
+ )
101
114
  counts = metadata.get("Licz do średniej")
102
115
  if counts is not None and counts.casefold() not in ("tak", "nie"):
103
116
  raise LibrusError(ErrorKind.PARSE)
@@ -126,6 +139,51 @@ def _numeric(
126
139
  )
127
140
 
128
141
 
142
+ def _behaviour_grade(
143
+ element: html.HtmlElement,
144
+ subject: str,
145
+ semester: Literal[0, 1, 2],
146
+ kind: GradeKind = GradeKind.CURRENT,
147
+ ) -> NumericGrade:
148
+ """A behaviour entry with a grade symbol, such as `wz`, instead of points.
149
+
150
+ Its tooltip has the point entries' layout: an unlabelled first line, then
151
+ `Data wystawienia` and `Dodał`. The first line names the entry, as the
152
+ point category does, so it is the category. `Dodał` stays in metadata. A
153
+ label with digits, a point entry's range labels or any other label fails.
154
+ """
155
+ heading: list[str] = []
156
+ metadata = markup.tooltip_fields(element, heading)
157
+ raw = markup.text(element)
158
+ if (
159
+ not heading
160
+ # A labelled first line means the heading is missing.
161
+ or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
162
+ # Symbols carry no digits; points or a range are a point entry.
163
+ or not raw
164
+ or re.search(r"[0-9]", raw)
165
+ or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
166
+ ):
167
+ raise LibrusError(ErrorKind.PARSE)
168
+ href = element.get("href")
169
+ if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
170
+ raise LibrusError(ErrorKind.LIMIT)
171
+ return NumericGrade(
172
+ subject,
173
+ raw,
174
+ markup.civil_date(metadata.get("Data wystawienia")),
175
+ semester,
176
+ None,
177
+ None,
178
+ heading[0],
179
+ None,
180
+ metadata.get("Komentarz"),
181
+ href,
182
+ tuple(metadata.items()),
183
+ kind,
184
+ )
185
+
186
+
129
187
  def _descriptive(
130
188
  box: html.HtmlElement,
131
189
  element: html.HtmlElement,
@@ -284,7 +342,21 @@ def _entries(
284
342
  if len(anchors) + len(descriptions) != 1:
285
343
  raise LibrusError(ErrorKind.PARSE)
286
344
  if anchors:
287
- record = _numeric(anchors[0], subject, period, kind)
345
+ behaviour = (
346
+ subject in GRADE_MERGED_SUBJECTS
347
+ and markup.detail_id(
348
+ anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
349
+ )
350
+ is not None
351
+ )
352
+ if behaviour and descriptive:
353
+ # A descriptive record has no category for the first line.
354
+ raise LibrusError(ErrorKind.PARSE)
355
+ record = (
356
+ _behaviour_grade(anchors[0], subject, period, kind)
357
+ if behaviour
358
+ else _numeric(anchors[0], subject, period, kind)
359
+ )
288
360
  if descriptive:
289
361
  href = record.href
290
362
  if href is not None and re.sub(
@@ -540,6 +612,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
540
612
  _read_numeric_table(table, collection, points=True)
541
613
  else:
542
614
  for row in markup.rows(table):
615
+ if markup.in_header(row):
616
+ # The new-schema table's column labels, not a subject.
617
+ continue
543
618
  cells = markup.cells(row)
544
619
  if _descriptive_row(cells):
545
620
  _read_descriptive_row(cells, collection)
@@ -19,8 +19,8 @@ from librus_python_api import __version__
19
19
  from librus_python_api.config import TransportLimits
20
20
  from librus_python_api.diagnostics import describe_failure, failure_frames
21
21
  from librus_python_api.exceptions import LibrusError
22
+ from librus_python_api.gateway_grades import parse_grade_page
22
23
  from librus_python_api.grade_parsers import parse_final_grades
23
- from librus_python_api.grade_records import parse_grade_records
24
24
  from librus_python_api.models import Availability
25
25
  from librus_python_api.parsers import parse_profile
26
26
 
@@ -31,7 +31,8 @@ NOTICE = (
31
31
 
32
32
 
33
33
  def _grades(body: bytes) -> dict[str, int]:
34
- records = parse_grade_records(body)
34
+ # Every grade view also checks the new-schema table, as `grades()` does.
35
+ records, table = parse_grade_page(body)
35
36
  return {
36
37
  "numeric": sum(1 for g in records.numeric if g.point_grade_id is None),
37
38
  "point": sum(1 for g in records.numeric if g.point_grade_id is not None),
@@ -41,6 +42,8 @@ def _grades(body: bytes) -> dict[str, int]:
41
42
  "averages": len(records.averages),
42
43
  "point_results": len(records.point_results),
43
44
  "formative": len(records.formative),
45
+ "descriptive_table": int(table is not None),
46
+ "descriptive_table_filled": int(table is not None and table.filled),
44
47
  }
45
48
 
46
49
 
@@ -13,7 +13,9 @@ 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_LABELS,
16
17
  TOOLTIP_MULTILINE_LABELS,
18
+ TOOLTIP_REPEATABLE_LABELS,
17
19
  UPSTREAM_ORIGINS,
18
20
  )
19
21
  from librus_python_api.exceptions import ErrorKind, LibrusError
@@ -81,12 +83,28 @@ def colspan(cell: html.HtmlElement) -> int:
81
83
  return span
82
84
 
83
85
 
84
- def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
86
+ def tooltip_fields(
87
+ element: html.HtmlElement,
88
+ heading: list[str] | None = None,
89
+ *,
90
+ multiline: frozenset[str] | None = None,
91
+ unknown_multiline: bool = False,
92
+ ) -> dict[str, str]:
85
93
  """Parse a 'Label: value<br>' title attribute into unique labelled fields.
86
94
 
87
95
  A free-text field may span lines; an unlabelled line after it continues
88
- its value. Any other unlabelled line is an unknown layout.
96
+ its value. Any other unlabelled line is an unknown layout. With `heading`,
97
+ the first line is taken whole, colon included, and appended to it.
98
+ `multiline` names the free-text labels (default `Komentarz`); with
99
+ `unknown_multiline`, a field whose label is not a known one (teacher text
100
+ holding a colon) is free text too, and inside such free text a line
101
+ starting with a known label is text unless the tooltip's closing run of
102
+ known-label lines begins there. Otherwise a known label such as `Data`
103
+ never continues. A repeatable label (`Poprawa oceny`) adds its value as
104
+ one more line of the same field.
89
105
  """
106
+ if multiline is None:
107
+ multiline = TOOLTIP_MULTILINE_LABELS
90
108
  title = element.get("title", "")
91
109
  if len(title) > GRADE_MAX_METADATA_LENGTH:
92
110
  raise LibrusError(ErrorKind.LIMIT)
@@ -95,20 +113,34 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
95
113
  chunks = re.split(r"<br\s*/?>|\n", title, flags=re.I)
96
114
  if len(chunks) > GRADE_MAX_METADATA_FIELDS:
97
115
  raise LibrusError(ErrorKind.LIMIT)
98
- for chunk in chunks:
99
- if not chunk.strip():
116
+ lines = [tooltip_line(chunk) for chunk in chunks if chunk.strip()]
117
+ # Start of the closing run of known-label lines, such as Kategoria, Data.
118
+ closing = len(lines)
119
+ while closing and _known_label(lines[closing - 1]):
120
+ closing -= 1
121
+ for index, rendered in enumerate(lines):
122
+ if heading is not None and not heading:
123
+ heading.append(rendered)
100
124
  continue
101
- # Tooltips are HTML inside an attribute; parse markup only where present.
102
- rendered = (
103
- text(parse_html_document(chunk.encode()))
104
- if "<" in chunk
105
- else " ".join(chunk.split())
106
- )
107
125
  key, separator, value = rendered.partition(":")
108
126
  key, value = key.strip(), value.strip()
109
- if not separator and last in TOOLTIP_MULTILINE_LABELS:
127
+ if (
128
+ last is not None
129
+ and (
130
+ last in multiline or (unknown_multiline and last not in TOOLTIP_LABELS)
131
+ )
132
+ and (
133
+ not separator
134
+ or (unknown_multiline and index < closing and key in TOOLTIP_LABELS)
135
+ )
136
+ ):
110
137
  result[last] = f"{result[last]}\n{rendered}".strip()
111
138
  continue
139
+ if separator and key in result and key in TOOLTIP_REPEATABLE_LABELS:
140
+ # Each repeat keeps its value, in order, as one more line.
141
+ result[key] = f"{result[key]}\n{value}"
142
+ last = key
143
+ continue
112
144
  if not separator or not key or key in result:
113
145
  raise LibrusError(ErrorKind.PARSE)
114
146
  result[key] = value
@@ -116,6 +148,19 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
116
148
  return result
117
149
 
118
150
 
151
+ def _known_label(line: str) -> bool:
152
+ key, separator, _ = line.partition(":")
153
+ return bool(separator) and key.strip() in TOOLTIP_LABELS
154
+
155
+
156
+ def tooltip_line(chunk: str) -> str:
157
+ """One tooltip line's text, whitespace normalized."""
158
+ # Tooltips are HTML inside an attribute; parse markup only where present.
159
+ if "<" in chunk:
160
+ return text(parse_html_document(chunk.encode()))
161
+ return " ".join(chunk.split())
162
+
163
+
119
164
  def civil_date(value: str | None) -> date:
120
165
  """An ISO date, optionally followed by its weekday label in parentheses."""
121
166
  match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
@@ -130,7 +130,8 @@ class NotificationWorkflow:
130
130
  if uncertain:
131
131
  raise LibrusError(ErrorKind.CHECKPOINT)
132
132
  if not consume:
133
- raise LibrusError(ErrorKind.INVALID_INPUT)
133
+ # Nothing is read before the caller agrees to consume new events.
134
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
134
135
  state = await store._io(lambda: store._read_state(context.identifier))
135
136
  ordinary = await self._ordinary(categories, window, budget)
136
137
  # Validate whole ordinary results and state capacity before consuming.
@@ -89,7 +89,6 @@ from librus_python_api.gateway_grades import (
89
89
  parse_user_info,
90
90
  )
91
91
  from librus_python_api.grade_parsers import parse_final_grades
92
- from librus_python_api.grade_records import parse_grade_records
93
92
  from librus_python_api.homework_range import HomeworkAccumulator
94
93
  from librus_python_api.lifecycle import join_owned
95
94
  from librus_python_api.message_content import parse_message_content, validate_reference
@@ -394,6 +393,18 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
394
393
  raise LibrusError(ErrorKind.INVALID_INPUT)
395
394
 
396
395
 
396
+ def _check_record_count(records: GradeRecords, added: int) -> None:
397
+ if (
398
+ len(records.numeric)
399
+ + len(records.descriptive)
400
+ + len(records.descriptive_summaries)
401
+ + len(records.formative)
402
+ + added
403
+ > GRADE_MAX_RECORDS
404
+ ):
405
+ raise LibrusError(ErrorKind.LIMIT)
406
+
407
+
397
408
  def _cooldown_due(error: LibrusError) -> bool:
398
409
  """A denial, an unrecovered expiry, or a logged-out page that a session
399
410
  just signed in also got: signing in again at once cannot help."""
@@ -1072,11 +1083,11 @@ class AccountClient:
1072
1083
  Callback success acknowledges consumer-owned durable storage. Callback
1073
1084
  failure/timeout means acknowledgement unknown, never automatic replay.
1074
1085
  """
1075
- if (
1076
- type(allow_consume_events) is not bool
1077
- or not allow_consume_events
1078
- or self._consuming_schedule
1079
- ):
1086
+ if type(allow_consume_events) is not bool:
1087
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1088
+ if not allow_consume_events:
1089
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
1090
+ if self._consuming_schedule:
1080
1091
  raise LibrusError(ErrorKind.INVALID_INPUT)
1081
1092
  validate_checkpoint(checkpoint, checkpoint_timeout_seconds)
1082
1093
  self._consuming_schedule = True
@@ -1312,24 +1323,22 @@ class AccountClient:
1312
1323
  """Select a grade view with one view-changing POST.
1313
1324
 
1314
1325
  Changes the selected grade filter, not school records. A cache hit makes
1315
- no POST. Missing inline weight/count data remains unknown. In the all
1316
- view, a page with the new-schema descriptive table adds that table's
1326
+ no POST. Missing inline weight/count data remains unknown.
1327
+
1328
+ Every view checks a new-schema descriptive table the same way. Its
1329
+ semester cells either are empty script hooks or hold bare text, which
1330
+ becomes undated summaries with no extra request; any other layout
1331
+ fails the read. Only the all view with empty hooks adds that table's
1317
1332
  grades from the gateway: three more reads, five when there are grades.
1318
- Other views do not request them.
1319
1333
  """
1320
1334
  form = grade_view_form(view)
1321
1335
 
1322
1336
  async def fetch(budget: RequestBudget, _: bool) -> Grades:
1323
- if view is not GradeView.ALL:
1324
- records = await self._page(
1325
- "grades", budget, parse_grade_records, form=form
1326
- )
1327
- else:
1328
- records, table = await self._page(
1329
- "grades", budget, parse_grade_page, form=form
1330
- )
1331
- if table is not None:
1332
- records = await self._gateway_descriptive(records, table, budget)
1337
+ records, table = await self._page(
1338
+ "grades", budget, parse_grade_page, form=form
1339
+ )
1340
+ if view is GradeView.ALL and table is not None and not table.filled:
1341
+ records = await self._gateway_descriptive(records, table, budget)
1333
1342
  return Grades(
1334
1343
  self._session_identity(), records, self._observation("grades"), view
1335
1344
  )
@@ -2508,15 +2517,7 @@ class AccountClient:
2508
2517
  "descriptive_users", budget, parse_gateway_users, content_type=JSON
2509
2518
  )
2510
2519
  added = descriptive_grades(items, subjects, users, table)
2511
- if (
2512
- len(records.numeric)
2513
- + len(records.descriptive)
2514
- + len(records.descriptive_summaries)
2515
- + len(records.formative)
2516
- + len(added)
2517
- > GRADE_MAX_RECORDS
2518
- ):
2519
- raise LibrusError(ErrorKind.LIMIT)
2520
+ _check_record_count(records, len(added))
2520
2521
  return replace(records, descriptive=records.descriptive + added)
2521
2522
 
2522
2523
  async def _metadata_value(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.8.2
3
+ Version: 1.8.4
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,7 +42,12 @@ 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.8.2` keeps the modern mailbox session across a cancelled read
45
+ **Status:** `1.8.4` reads multi-line formative assessment text and repeated
46
+ corrections in grade tooltips, checks the new-schema grade table in every grade view and reports
47
+ missing calendar-consumption consent as `ConsentRequiredError`.
48
+ `1.8.3` reads behaviour grade symbols, filled descriptive
49
+ grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
50
+ through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
46
51
  or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
47
52
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
48
53
  from the page header and summarizes a page that fails as a whole in
@@ -64,7 +69,7 @@ See [limitations](#supported-features-and-limitations) below.
64
69
  Install the exact release from PyPI after its gated publication completes:
65
70
 
66
71
  ```sh
67
- python -m pip install librus-python-api==1.8.2
72
+ python -m pip install librus-python-api==1.8.4
68
73
  ```
69
74
 
70
75
  Pin the exact version qualified by your application. Before publication, use a
@@ -80,7 +85,7 @@ python -m pip install .
80
85
  To install a locally built wheel instead:
81
86
 
82
87
  ```sh
83
- python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
88
+ python -m pip install ./dist/librus_python_api-1.8.4-py3-none-any.whl
84
89
  ```
85
90
 
86
91
  No console script or background process is installed: import the library in your
@@ -1,4 +1,4 @@
1
- librus_python_api/__init__.py,sha256=CDi6pTko967g3RViVcZCIWhE-Lzof5VN6E4NkQJ4aUU,6571
1
+ librus_python_api/__init__.py,sha256=A9OaGlvvc-wAqzAp7925LUh9EnmcEx_WncmUXoaZFWU,6571
2
2
  librus_python_api/_notification_bootstrap.py,sha256=18nzH0doXBNaZXCz-_ckIvRrlHTWV5Mm1t5m2-5eYGI,4669
3
3
  librus_python_api/_notification_codec.py,sha256=hU6G3zGJ5fR8wfPC6fV2OqBKs-7XpT9eRnKO3FGsTzY,13451
4
4
  librus_python_api/_storage.py,sha256=M_6ZAzN4zs7laOdKJBRH1I2rvy3pgqYgC_MZtsd2xtM,15893
@@ -6,25 +6,25 @@ librus_python_api/_windows_filesystem.py,sha256=pQANIGkYrb4Q75gz5mdr5mqYhM_PFblW
6
6
  librus_python_api/announcements.py,sha256=13FoOR1sq7tt9w6aLf0vclcFKDqLUiIYaO5iYD2QVLA,5928
7
7
  librus_python_api/attachment_routes.py,sha256=uSg5wCQqHJiphF0KdI0GatQxZr5SLUx6xWQRsl_U4_U,4123
8
8
  librus_python_api/attachments.py,sha256=Iq-ERZWc3gYXu6Lcv6womT2EXCc-c1Q7r_Q6VyaCm2E,10818
9
- librus_python_api/attendance.py,sha256=HeFAfnIUn4N__yp1zr635Dve55S2jmwtkeIy_6mV5B4,6924
9
+ librus_python_api/attendance.py,sha256=Dq2IC8WERO-9x-LZIN5Nl0PTLYmL1Iq7BZtOz6nssOI,7428
10
10
  librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7eL4dU3mif1EY,5309
11
11
  librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2878
12
12
  librus_python_api/checkpoint.py,sha256=QBmG9GtpyaGRaFq-FXuGA3yR1BjuMFN9V3RTvvi_FNQ,1930
13
13
  librus_python_api/class_free_days.py,sha256=IwoCgr-9ZgEYYQ6717s4cDpyFzbosHMzESMabXJLGqA,2422
14
14
  librus_python_api/completed_lessons.py,sha256=ngGfBD6K4B6dR4elPYvNRvp40Q-GlrhUsUKqBbYY1KE,10727
15
- librus_python_api/config.py,sha256=CBMwuuACfrB7TzTVkTP8QAuPz5hv43YOsbto81-uEJc,61248
15
+ librus_python_api/config.py,sha256=rAZK4YfQbhAftSj56tkLJ52DrrhWHzYoSz-ZdwjKUKM,62420
16
16
  librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
17
17
  librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
18
18
  librus_python_api/exceptions.py,sha256=x8_z5UTNmuRINTK4buLYiKdYcFA2w6GZP9ZXOL-OnRU,5531
19
19
  librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
20
20
  librus_python_api/formative_grades.py,sha256=VykwtGvViZVOJMwPH3Re1RTDCHMr1_m9_xHjZyaXDIQ,6452
21
- librus_python_api/gateway_grades.py,sha256=T-G5KN0kpROi6xSGwAUba2qWDGNhg8TeGmQN140iLxc,16163
21
+ librus_python_api/gateway_grades.py,sha256=uaCCz743aPlymAa6rDfRb0w2JFC2hJ4yis0hbKdqTeM,17663
22
22
  librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
23
- librus_python_api/grade_records.py,sha256=YltZ_OQLsfxj0nvK8_xTuv8ifMOu2jJBnVn6TTQKzaY,22722
23
+ librus_python_api/grade_records.py,sha256=xxQ8HrlIMXPtp8oEd3-TUewBhIIBc6Qu7V9Pu2ASxqk,25472
24
24
  librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
25
- librus_python_api/inspect_page.py,sha256=AUm-n2Ewe4cQUnlFnhttcGK5B9shvFde_7t1JF7mEm0,5285
25
+ librus_python_api/inspect_page.py,sha256=zSo9SZVWz2POUWC0mNBnwhgHk5arrEOs3l209cxkPSg,5494
26
26
  librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
27
- librus_python_api/markup.py,sha256=p97ZRPqhGYTEBbzJq_0j4j8AKihgQzJqTv__QUNm8Ws,6084
27
+ librus_python_api/markup.py,sha256=TzGt8pRed2n--BpxFtLY0eq1wzdOhvKhehS50uPBi1o,7935
28
28
  librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
29
29
  librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
30
30
  librus_python_api/models.py,sha256=IO-SYpqpQeuTej6kqNjBRXDbAbKITRZ1njRUyoEZ8t0,41951
@@ -33,7 +33,7 @@ librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeX
33
33
  librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
34
34
  librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5kOSW7Jjt7J0,4199
35
35
  librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
36
- librus_python_api/notification_workflow.py,sha256=MsBJdwn6J1UUGlz3Ldzi7WEBz5Qsama7eGmZvUQj25g,12924
36
+ librus_python_api/notification_workflow.py,sha256=XpWNGWbRsqtpTJinPsw65bjnnEnNpm-qzn0QPxz43Xg,13009
37
37
  librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
38
38
  librus_python_api/parsers.py,sha256=8sdfPsOZYHNYVZ4rwX_de5Y5rPl0umoQPon3uflQFlY,9178
39
39
  librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
@@ -44,10 +44,10 @@ librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQm
44
44
  librus_python_api/school_reads.py,sha256=YHta7DmhjS5vSa4Djq8pzp01-2jhVfGOo1Mnc738rV4,15209
45
45
  librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
46
46
  librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
47
- librus_python_api/service.py,sha256=34o-EQSuERhSHZAme-RDhVznhd_y1-GT0SPfduJ5LoM,102964
47
+ librus_python_api/service.py,sha256=A24_70Doqk0mh9fAOxOUSNMtuPG-7qaDvUMlBSE8Lrw,103073
48
48
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
49
49
  librus_python_api/transport.py,sha256=yF-DnFy4xGYS_RWQ9161b9WJXOlYF8cFRgdjXmVVUIk,34988
50
- librus_python_api-1.8.2.dist-info/METADATA,sha256=78cqj_uSkFU-4NhnUV0FCj3U8-QmJRK51bvIzIRVask,14202
51
- librus_python_api-1.8.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
- librus_python_api-1.8.2.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
53
- librus_python_api-1.8.2.dist-info/RECORD,,
50
+ librus_python_api-1.8.4.dist-info/METADATA,sha256=M-xBnjR60MNy2jfQSjQsmwDPnwWUNVnIhaoi8dXRSe4,14586
51
+ librus_python_api-1.8.4.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
52
+ librus_python_api-1.8.4.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
53
+ librus_python_api-1.8.4.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any