librus-python-api 1.2.1__py3-none-any.whl → 1.3.0__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.
@@ -16,6 +16,13 @@ from librus_python_api.models import (
16
16
  AgendaEvent,
17
17
  Announcement,
18
18
  Announcements,
19
+ ArchiveAbsences,
20
+ ArchiveAchievement,
21
+ ArchiveCounts,
22
+ ArchiveDescriptive,
23
+ ArchiveMarks,
24
+ ArchiveSubject,
25
+ ArchiveYear,
19
26
  AttachmentHeaders,
20
27
  AttachmentMetadata,
21
28
  Attendance,
@@ -110,6 +117,7 @@ from librus_python_api.models import (
110
117
  SchoolAverage,
111
118
  SchoolDetail,
112
119
  SchoolReference,
120
+ SchoolYearArchive,
113
121
  SendResult,
114
122
  SendStatus,
115
123
  SendSubmission,
@@ -127,9 +135,17 @@ from librus_python_api.models import (
127
135
  from librus_python_api.sending import SendAttempt
128
136
  from librus_python_api.service import AccountClient, LibrusService
129
137
 
130
- __version__ = "1.2.1"
138
+ __version__ = "1.3.0"
131
139
 
132
140
  __all__ = [
141
+ "ArchiveAbsences",
142
+ "ArchiveAchievement",
143
+ "ArchiveCounts",
144
+ "ArchiveDescriptive",
145
+ "ArchiveMarks",
146
+ "ArchiveSubject",
147
+ "ArchiveYear",
148
+ "SchoolYearArchive",
133
149
  "AccountContext",
134
150
  "MessagingBackend",
135
151
  "ModernAccountData",
@@ -505,6 +505,14 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
505
505
  True,
506
506
  Evidence.INDEPENDENTLY_OBSERVED,
507
507
  ),
508
+ Endpoint(
509
+ "school_year_archive",
510
+ "GET",
511
+ "/archiwum",
512
+ SideEffect.NONE,
513
+ True,
514
+ Evidence.INDEPENDENTLY_OBSERVED,
515
+ ),
508
516
  Endpoint(
509
517
  "agenda",
510
518
  "POST",
@@ -722,6 +730,35 @@ ANNOUNCEMENT_MAX_ITEMS = 256
722
730
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
723
731
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
724
732
  ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH = 262144
733
+
734
+ # School-year archive (/archiwum): labels as rendered upstream, including the
735
+ # absence spellings. Matching is case-insensitive and exact otherwise.
736
+ ARCHIVE_TABLE_CLASSES = frozenset({"decorated"})
737
+ ARCHIVE_ACHIEVEMENT_TABLE_CLASSES = frozenset({"decorated", "big", "center"})
738
+ ARCHIVE_YEAR_PATTERN = (
739
+ r"Klasa: (?P<class_name>\S(?:.*\S)?) Rok: (?P<first>[0-9]{4})/(?P<second>[0-9]{4})"
740
+ )
741
+ ARCHIVE_PERIOD_LABELS = ("okres 1", "okres 2", "koniec roku")
742
+ ARCHIVE_BEHAVIOUR_HEADING = "zachowanie"
743
+ ARCHIVE_ABSENCES_HEADING = "nieobecności"
744
+ ARCHIVE_ABSENCE_LABELS = MappingProxyType(
745
+ {
746
+ "nieusprawiedlione": "unexcused",
747
+ "usprawiedlione": "excused",
748
+ "spóźnienia": "late",
749
+ }
750
+ )
751
+ ARCHIVE_ACHIEVEMENT_HEADERS = ("data", "klasa", "kategoria", "osiągnięcie")
752
+ ARCHIVE_YEAR_HEADER_TAGS = frozenset({"span", "b"})
753
+ ARCHIVE_MAX_YEARS = 16
754
+ ARCHIVE_MAX_SUBJECTS = 128
755
+ ARCHIVE_MAX_DESCRIPTIVE = 32
756
+ ARCHIVE_MAX_ACHIEVEMENTS = 256
757
+ ARCHIVE_MAX_TEXT_LENGTH = 65536
758
+ ARCHIVE_MAX_TOTAL_TEXT_LENGTH = 262144
759
+ ARCHIVE_MAX_YEAR_HEADER_LENGTH = 1024 + len("Klasa: Rok: 0000/0000")
760
+ ARCHIVE_EMPTY_TITLE = "brak danych"
761
+ ARCHIVE_EMPTY_BOX_CLASSES = frozenset({"warning-box", "information", "medium"})
725
762
  ANNOUNCEMENT_TABLE_CLASSES = frozenset(
726
763
  {"decorated", "big", "center", "printable", "margin-top"}
727
764
  )
@@ -38,6 +38,7 @@ type OperationName = Literal[
38
38
  "attachment_download",
39
39
  "recipient_groups",
40
40
  "recipients",
41
+ "school_year_archive",
41
42
  "notification_counts",
42
43
  "consume_schedule_events",
43
44
  "decode_schedule_events",
@@ -778,6 +779,79 @@ class Announcements:
778
779
  observation: Observation
779
780
 
780
781
 
782
+ @dataclass(frozen=True, slots=True)
783
+ class ArchiveCounts:
784
+ """Counts per period as rendered; the year-end value is not checked as a sum."""
785
+
786
+ first_semester: int
787
+ second_semester: int
788
+ year_end: int
789
+
790
+
791
+ @dataclass(frozen=True, slots=True)
792
+ class ArchiveAbsences:
793
+ unexcused: ArchiveCounts
794
+ excused: ArchiveCounts
795
+ late: ArchiveCounts
796
+
797
+
798
+ @dataclass(frozen=True, slots=True)
799
+ class ArchiveMarks:
800
+ """Rendered marks; "-" and empty text are preserved, never invented.
801
+
802
+ `year_end` is the archive's year-end column, not asserted to equal the
803
+ published annual grade of `SubjectGradeSummary`.
804
+ """
805
+
806
+ first_semester: str = field(repr=False)
807
+ second_semester: str = field(repr=False)
808
+ year_end: str = field(repr=False)
809
+
810
+
811
+ @dataclass(frozen=True, slots=True)
812
+ class ArchiveSubject:
813
+ subject: str = field(repr=False)
814
+ marks: ArchiveMarks = field(repr=False)
815
+
816
+
817
+ @dataclass(frozen=True, slots=True)
818
+ class ArchiveDescriptive:
819
+ label: str = field(repr=False)
820
+ text: str = field(repr=False)
821
+
822
+
823
+ @dataclass(frozen=True, slots=True)
824
+ class ArchiveYear:
825
+ """One earlier school year. Behaviour cells are raw positional text: the
826
+ first-semester column and the cell spanning second semester and year end;
827
+ their populated meaning is not established."""
828
+
829
+ class_name: str = field(repr=False)
830
+ school_year: str = field(repr=False)
831
+ first_year: int = field(repr=False)
832
+ subjects: tuple[ArchiveSubject, ...] = field(repr=False)
833
+ descriptive: tuple[ArchiveDescriptive, ...] = field(repr=False)
834
+ behaviour_first_semester: str = field(repr=False)
835
+ behaviour_second_semester_and_year_end: str = field(repr=False)
836
+ absences: ArchiveAbsences = field(repr=False)
837
+
838
+
839
+ @dataclass(frozen=True, slots=True)
840
+ class ArchiveAchievement:
841
+ day: date = field(repr=False)
842
+ class_name: str = field(repr=False)
843
+ category: str = field(repr=False)
844
+ text: str = field(repr=False)
845
+
846
+
847
+ @dataclass(frozen=True, slots=True)
848
+ class SchoolYearArchive:
849
+ identity: Identity
850
+ years: tuple[ArchiveYear, ...] = field(repr=False)
851
+ achievements: tuple[ArchiveAchievement, ...] = field(repr=False)
852
+ observation: Observation
853
+
854
+
781
855
  @dataclass(frozen=True, slots=True)
782
856
  class AgendaEvent:
783
857
  day: date = field(repr=False)
@@ -0,0 +1,432 @@
1
+ """The school-year archive page (`/archiwum`), authored from an owner-authorized
2
+ structural observation; every fixture is synthetic.
3
+
4
+ One table holds every earlier school year: a year header row, a period row,
5
+ grade rows (subject marks or full-year descriptive text), then exactly one
6
+ behaviour section, one absences section and an empty footer, in that order.
7
+ A separate table lists achievements. Unknown layouts are unsupported;
8
+ contradictions inside the known layout are parse errors. The student's name in
9
+ the page header and the chart scripts are never read.
10
+ """
11
+
12
+ import re
13
+ from typing import NoReturn
14
+
15
+ from lxml import html
16
+
17
+ from librus_python_api import markup
18
+ from librus_python_api.config import (
19
+ ARCHIVE_ABSENCE_LABELS,
20
+ ARCHIVE_ABSENCES_HEADING,
21
+ ARCHIVE_ACHIEVEMENT_HEADERS,
22
+ ARCHIVE_ACHIEVEMENT_TABLE_CLASSES,
23
+ ARCHIVE_BEHAVIOUR_HEADING,
24
+ ARCHIVE_EMPTY_BOX_CLASSES,
25
+ ARCHIVE_EMPTY_TITLE,
26
+ ARCHIVE_MAX_ACHIEVEMENTS,
27
+ ARCHIVE_MAX_DESCRIPTIVE,
28
+ ARCHIVE_MAX_SUBJECTS,
29
+ ARCHIVE_MAX_TEXT_LENGTH,
30
+ ARCHIVE_MAX_TOTAL_TEXT_LENGTH,
31
+ ARCHIVE_MAX_YEAR_HEADER_LENGTH,
32
+ ARCHIVE_MAX_YEARS,
33
+ ARCHIVE_PERIOD_LABELS,
34
+ ARCHIVE_TABLE_CLASSES,
35
+ ARCHIVE_YEAR_HEADER_TAGS,
36
+ ARCHIVE_YEAR_PATTERN,
37
+ )
38
+ from librus_python_api.exceptions import ErrorKind, LibrusError
39
+ from librus_python_api.models import (
40
+ ArchiveAbsences,
41
+ ArchiveAchievement,
42
+ ArchiveCounts,
43
+ ArchiveDescriptive,
44
+ ArchiveMarks,
45
+ ArchiveSubject,
46
+ ArchiveYear,
47
+ )
48
+ from librus_python_api.parsers import parse_page
49
+
50
+ _YEAR = re.compile(ARCHIVE_YEAR_PATTERN)
51
+ _COUNT = re.compile(r"[0-9]{1,6}")
52
+
53
+
54
+ def _fail(kind: ErrorKind) -> NoReturn:
55
+ raise LibrusError(kind)
56
+
57
+
58
+ class _Text:
59
+ """Rendered cell text under one total budget for the whole page."""
60
+
61
+ def __init__(self) -> None:
62
+ self.used = 0
63
+
64
+ def __call__(
65
+ self,
66
+ cell: html.HtmlElement,
67
+ *,
68
+ multiline: bool = False,
69
+ limit: int = markup.FIELD_LENGTH,
70
+ ) -> str:
71
+ value = markup.text(cell, limit, multiline=multiline)
72
+ self.used += len(value)
73
+ if self.used > ARCHIVE_MAX_TOTAL_TEXT_LENGTH:
74
+ _fail(ErrorKind.LIMIT)
75
+ return value
76
+
77
+
78
+ def _plain(cell: html.HtmlElement, allowed: frozenset[str] = frozenset()) -> None:
79
+ """Only the observed child vocabulary; other markup is a new layout."""
80
+ for node in cell.iterdescendants():
81
+ if isinstance(node.tag, str) and node.tag not in allowed:
82
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
83
+ for node in cell.iter():
84
+ if any(name == "title" or name.startswith("on") for name in node.attrib):
85
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
86
+
87
+
88
+ def _td(cells: list[html.HtmlElement]) -> None:
89
+ if any(cell.tag != "td" for cell in cells):
90
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
91
+
92
+
93
+ def _classes(table: html.HtmlElement) -> set[str]:
94
+ return set(table.get("class", "").split())
95
+
96
+
97
+ def _first_cells(table: html.HtmlElement) -> list[html.HtmlElement]:
98
+ first = next(markup.rows(table), None)
99
+ return [] if first is None else markup.cells(first)
100
+
101
+
102
+ def _is_archive(table: html.HtmlElement) -> bool:
103
+ cells = _first_cells(table)
104
+ return (
105
+ len(cells) >= 2
106
+ and markup.text(cells[0]) == ""
107
+ and all(
108
+ _YEAR.fullmatch(markup.text(cell, ARCHIVE_MAX_YEAR_HEADER_LENGTH))
109
+ for cell in cells[1:]
110
+ )
111
+ )
112
+
113
+
114
+ def _is_achievement_list(table: html.HtmlElement) -> bool:
115
+ cells = _first_cells(table)
116
+ return [
117
+ markup.text(cell, ARCHIVE_MAX_YEAR_HEADER_LENGTH).casefold() for cell in cells
118
+ ] == list(ARCHIVE_ACHIEVEMENT_HEADERS)
119
+
120
+
121
+ def _in_footer(row: html.HtmlElement) -> bool:
122
+ return next(row.iterancestors("tfoot"), None) is not None
123
+
124
+
125
+ def _years(header: html.HtmlElement, text: _Text) -> list[tuple[str, str, int]]:
126
+ cells = markup.cells(header)
127
+ _td(cells)
128
+ _plain(cells[0])
129
+ if len(cells) - 1 > ARCHIVE_MAX_YEARS:
130
+ _fail(ErrorKind.LIMIT)
131
+ if markup.colspan(cells[0]) != 1:
132
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
133
+ years: list[tuple[str, str, int]] = []
134
+ for cell in cells[1:]:
135
+ if markup.colspan(cell) != 3:
136
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
137
+ _plain(cell, ARCHIVE_YEAR_HEADER_TAGS)
138
+ match = _YEAR.fullmatch(text(cell, limit=ARCHIVE_MAX_YEAR_HEADER_LENGTH))
139
+ if match is None:
140
+ _fail(ErrorKind.PARSE)
141
+ first, second = int(match["first"]), int(match["second"])
142
+ if len(match["class_name"]) > markup.FIELD_LENGTH:
143
+ _fail(ErrorKind.LIMIT)
144
+ if second != first + 1 or any(first == seen for _, _, seen in years):
145
+ _fail(ErrorKind.PARSE)
146
+ years.append((match["class_name"], f"{first}/{second}", first))
147
+ return years
148
+
149
+
150
+ def _check_periods(row: html.HtmlElement, count: int, text: _Text) -> None:
151
+ cells = markup.cells(row)
152
+ _td(cells)
153
+ for cell in cells:
154
+ _plain(cell)
155
+ if (
156
+ len(cells) != 1 + 3 * count
157
+ or any(markup.colspan(cell) != 1 for cell in cells)
158
+ or text(cells[0])
159
+ or [text(cell).casefold() for cell in cells[1:]]
160
+ != list(ARCHIVE_PERIOD_LABELS) * count
161
+ ):
162
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
163
+
164
+
165
+ class _Body:
166
+ """The section order: grades, behaviour, absences, footer."""
167
+
168
+ def __init__(self, count: int, text: _Text) -> None:
169
+ self.count = count
170
+ self.text = text
171
+ self.state = "grades"
172
+ self.labels: set[str] = set()
173
+ self.subjects: list[tuple[str, list[ArchiveMarks]]] = []
174
+ self.descriptive: list[tuple[str, list[str]]] = []
175
+ self.behaviour: list[tuple[str, str]] = []
176
+ self.absences: dict[str, list[ArchiveCounts]] = {}
177
+
178
+ def read(self, row: html.HtmlElement) -> None:
179
+ cells = markup.cells(row)
180
+ spans = [markup.colspan(cell) for cell in cells]
181
+ full = [1 + 3 * self.count]
182
+ heading = spans == full and "bolded" in row.get("class", "").split()
183
+ if self.state == "grades":
184
+ if heading:
185
+ self._heading(cells, ARCHIVE_BEHAVIOUR_HEADING, "behaviour")
186
+ else:
187
+ self._grade(cells, spans)
188
+ elif self.state == "behaviour":
189
+ self._behaviour(cells, spans, heading)
190
+ elif self.state == "absences_heading":
191
+ if not heading:
192
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
193
+ self._heading(cells, ARCHIVE_ABSENCES_HEADING, "absences")
194
+ elif self.state == "absences":
195
+ if spans == full and not heading:
196
+ self._footer(cells)
197
+ else:
198
+ self._absence(cells, spans)
199
+ else:
200
+ _fail(ErrorKind.PARSE)
201
+
202
+ def _heading(self, cells: list[html.HtmlElement], label: str, state: str) -> None:
203
+ _td(cells)
204
+ _plain(cells[0])
205
+ if self.text(cells[0]).casefold() != label:
206
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
207
+ self.state = state
208
+
209
+ def _label(self, cells: list[html.HtmlElement]) -> str:
210
+ if not cells or cells[0].tag != "th":
211
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
212
+ _plain(cells[0])
213
+ return self.text(cells[0])
214
+
215
+ def _values(
216
+ self,
217
+ cells: list[html.HtmlElement],
218
+ *,
219
+ multiline: bool = False,
220
+ limit: int = markup.FIELD_LENGTH,
221
+ ) -> list[str]:
222
+ _td(cells)
223
+ values = []
224
+ for cell in cells:
225
+ _plain(cell, frozenset({"br"}) if multiline else frozenset())
226
+ values.append(self.text(cell, multiline=multiline, limit=limit))
227
+ return values
228
+
229
+ def _grade(self, cells: list[html.HtmlElement], spans: list[int]) -> None:
230
+ label = self._label(cells)
231
+ if not label:
232
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
233
+ if spans == [1] * (1 + 3 * self.count):
234
+ values = self._values(cells[1:])
235
+ marks = [
236
+ ArchiveMarks(*values[3 * i : 3 * i + 3]) for i in range(self.count)
237
+ ]
238
+ self.subjects.append((label, marks))
239
+ limit, size = ARCHIVE_MAX_SUBJECTS, len(self.subjects)
240
+ elif spans == [1] + [3] * self.count:
241
+ texts = self._values(
242
+ cells[1:], multiline=True, limit=ARCHIVE_MAX_TEXT_LENGTH
243
+ )
244
+ self.descriptive.append((label, texts))
245
+ limit, size = ARCHIVE_MAX_DESCRIPTIVE, len(self.descriptive)
246
+ else:
247
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
248
+ if label in self.labels:
249
+ _fail(ErrorKind.PARSE)
250
+ self.labels.add(label)
251
+ if size > limit:
252
+ _fail(ErrorKind.LIMIT)
253
+
254
+ def _behaviour(
255
+ self, cells: list[html.HtmlElement], spans: list[int], heading: bool
256
+ ) -> None:
257
+ if heading or spans != [1] + [1, 2] * self.count or self._label(cells):
258
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
259
+ values = self._values(cells[1:])
260
+ self.behaviour = [(values[2 * i], values[2 * i + 1]) for i in range(self.count)]
261
+ self.state = "absences_heading"
262
+
263
+ def _absence(self, cells: list[html.HtmlElement], spans: list[int]) -> None:
264
+ field = ARCHIVE_ABSENCE_LABELS.get(self._label(cells).casefold())
265
+ if field is None or spans != [1] * (1 + 3 * self.count):
266
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
267
+ if field in self.absences:
268
+ _fail(ErrorKind.PARSE)
269
+ values = self._values(cells[1:])
270
+ if not all(_COUNT.fullmatch(value) for value in values):
271
+ _fail(ErrorKind.PARSE)
272
+ numbers = [int(value) for value in values]
273
+ self.absences[field] = [
274
+ ArchiveCounts(*numbers[3 * i : 3 * i + 3]) for i in range(self.count)
275
+ ]
276
+
277
+ def _footer(self, cells: list[html.HtmlElement]) -> None:
278
+ _td(cells)
279
+ _plain(cells[0])
280
+ if self.text(cells[0]) or len(self.absences) != len(ARCHIVE_ABSENCE_LABELS):
281
+ _fail(ErrorKind.PARSE)
282
+ self.state = "end"
283
+
284
+
285
+ def _archive(table: html.HtmlElement, text: _Text) -> tuple[ArchiveYear, ...]:
286
+ rows = list(markup.rows(table))
287
+ if len(rows) < 2:
288
+ _fail(ErrorKind.PARSE)
289
+ years = _years(rows[0], text)
290
+ _check_periods(rows[1], len(years), text)
291
+ body = _Body(len(years), text)
292
+ for row in rows[2:]:
293
+ body.read(row)
294
+ if body.state != "end":
295
+ _fail(ErrorKind.PARSE)
296
+ return tuple(
297
+ ArchiveYear(
298
+ class_name=class_name,
299
+ school_year=school_year,
300
+ first_year=first_year,
301
+ subjects=tuple(
302
+ ArchiveSubject(label, marks[i]) for label, marks in body.subjects
303
+ ),
304
+ descriptive=tuple(
305
+ ArchiveDescriptive(label, texts[i]) for label, texts in body.descriptive
306
+ ),
307
+ behaviour_first_semester=body.behaviour[i][0],
308
+ behaviour_second_semester_and_year_end=body.behaviour[i][1],
309
+ absences=ArchiveAbsences(
310
+ unexcused=body.absences["unexcused"][i],
311
+ excused=body.absences["excused"][i],
312
+ late=body.absences["late"][i],
313
+ ),
314
+ )
315
+ for i, (class_name, school_year, first_year) in enumerate(years)
316
+ )
317
+
318
+
319
+ def _achievements(
320
+ table: html.HtmlElement, text: _Text
321
+ ) -> tuple[ArchiveAchievement, ...]:
322
+ items: list[ArchiveAchievement] = []
323
+ rows = list(markup.rows(table))
324
+ headers = markup.cells(rows[0])
325
+ _td(headers)
326
+ for cell in headers:
327
+ _plain(cell)
328
+ if markup.colspan(cell) != 1:
329
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
330
+ text(cell)
331
+ finished = False
332
+ for row in rows[1:]:
333
+ if finished:
334
+ _fail(ErrorKind.PARSE)
335
+ cells = markup.cells(row)
336
+ _td(cells)
337
+ if _in_footer(row):
338
+ if len(cells) != 1 or text(cells[0]):
339
+ _fail(ErrorKind.PARSE)
340
+ _plain(cells[0])
341
+ if markup.colspan(cells[0]) != 4:
342
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
343
+ finished = True
344
+ continue
345
+ if len(cells) != 4 or any(markup.colspan(cell) != 1 for cell in cells):
346
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
347
+ for i, cell in enumerate(cells):
348
+ _plain(cell, frozenset({"br"}) if i == 3 else frozenset())
349
+ items.append(
350
+ ArchiveAchievement(
351
+ day=markup.civil_date(text(cells[0])),
352
+ class_name=text(cells[1]),
353
+ category=text(cells[2]),
354
+ text=text(cells[3], multiline=True, limit=ARCHIVE_MAX_TEXT_LENGTH),
355
+ )
356
+ )
357
+ if len(items) > ARCHIVE_MAX_ACHIEVEMENTS:
358
+ _fail(ErrorKind.LIMIT)
359
+ return tuple(items)
360
+
361
+
362
+ def _explicit_empty(document: html.HtmlElement) -> bool:
363
+ notices = []
364
+ for box in document.iter("div"):
365
+ if not ARCHIVE_EMPTY_BOX_CLASSES <= _classes(box):
366
+ continue
367
+ parent = box.getparent()
368
+ if parent is None or "container-background" not in _classes(parent):
369
+ continue
370
+ for head in box:
371
+ if head.tag != "div" or "warning-head" not in _classes(head):
372
+ continue
373
+ for title in head:
374
+ if title.tag == "span" and "warning-title" in _classes(title):
375
+ _plain(title)
376
+ if markup.text(title).casefold() != ARCHIVE_EMPTY_TITLE:
377
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
378
+ notices.append(title)
379
+ if len(notices) > 1:
380
+ _fail(ErrorKind.PARSE)
381
+ if notices and any("decorated" in _classes(t) for t in document.iter("table")):
382
+ _fail(ErrorKind.PARSE)
383
+ return bool(notices)
384
+
385
+
386
+ def _check_nesting(table: html.HtmlElement, *, chart_trailer: bool = False) -> None:
387
+ if next(table.iterancestors("table"), None) is not None:
388
+ _fail(ErrorKind.PARSE)
389
+ nested = [node for node in table.iter("table") if node is not table]
390
+ if not nested:
391
+ return
392
+ # Live HTML nests the post-footer charts in a stray opening table. Permit
393
+ # that single rowless trailing container, never nested tabular data.
394
+ if not chart_trailer or len(nested) != 1:
395
+ _fail(ErrorKind.PARSE)
396
+ trailer = nested[0]
397
+ previous = trailer.getprevious()
398
+ if (
399
+ trailer.getparent() is not table
400
+ or trailer.getnext() is not None
401
+ or previous is None
402
+ or previous.tag != "tfoot"
403
+ or trailer.attrib
404
+ or any(
405
+ node.tag in {"tr", "td", "th", "thead", "tbody", "tfoot"}
406
+ for node in trailer.iterdescendants()
407
+ )
408
+ ):
409
+ _fail(ErrorKind.PARSE)
410
+
411
+
412
+ def parse_school_year_archive(
413
+ body: bytes,
414
+ ) -> tuple[tuple[ArchiveYear, ...], tuple[ArchiveAchievement, ...]]:
415
+ document = parse_page(body)
416
+ archives = [t for t in document.iter("table") if _is_archive(t)]
417
+ lists = [t for t in document.iter("table") if _is_achievement_list(t)]
418
+ if _explicit_empty(document):
419
+ if archives or lists:
420
+ _fail(ErrorKind.PARSE)
421
+ return (), ()
422
+ if len(archives) != 1 or len(lists) != 1:
423
+ _fail(ErrorKind.PARSE)
424
+ archive, achievements = archives[0], lists[0]
425
+ _check_nesting(archive)
426
+ _check_nesting(achievements, chart_trailer=True)
427
+ if not ARCHIVE_TABLE_CLASSES <= _classes(archive) or not (
428
+ ARCHIVE_ACHIEVEMENT_TABLE_CLASSES <= _classes(achievements)
429
+ ):
430
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
431
+ text = _Text()
432
+ return _archive(archive, text), _achievements(achievements, text)
@@ -138,6 +138,7 @@ from librus_python_api.models import (
138
138
  SchedulerSnapshot,
139
139
  SchoolDetail,
140
140
  SchoolReference,
141
+ SchoolYearArchive,
141
142
  SendResult,
142
143
  SendSubmission,
143
144
  StudentInformation,
@@ -196,6 +197,7 @@ from librus_python_api.school_reads import (
196
197
  parse_homework,
197
198
  parse_school_detail,
198
199
  )
200
+ from librus_python_api.school_year_archive import parse_school_year_archive
199
201
  from librus_python_api.sending import SendAttempt, parse_send_acknowledgement
200
202
  from librus_python_api.timetable import parse_timetable
201
203
  from librus_python_api.transport import (
@@ -1486,6 +1488,30 @@ class AccountClient:
1486
1488
 
1487
1489
  return await self._read(("announcements",), fetch, budget, max_age_seconds)
1488
1490
 
1491
+ async def school_year_archive(
1492
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1493
+ ) -> SchoolYearArchive:
1494
+ """Read every earlier school year's summary and the achievements list.
1495
+
1496
+ One GET with no side effect; the page's student name and charts are
1497
+ never read. A layout other than the observed one is unsupported.
1498
+ """
1499
+
1500
+ async def fetch(budget: RequestBudget, _: bool) -> SchoolYearArchive:
1501
+ years, achievements = await self._page(
1502
+ "school_year_archive", budget, parse_school_year_archive
1503
+ )
1504
+ return SchoolYearArchive(
1505
+ self._session_identity(),
1506
+ years,
1507
+ achievements,
1508
+ self._observation("school_year_archive"),
1509
+ )
1510
+
1511
+ return await self._read(
1512
+ ("school_year_archive",), fetch, budget, max_age_seconds
1513
+ )
1514
+
1489
1515
  async def agenda(
1490
1516
  self,
1491
1517
  year: int,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.2.1
3
+ Version: 1.3.0
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
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # librus-python-api
28
28
 
29
+ [![Weekly live check](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml/badge.svg?branch=main)](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml)
30
+
29
31
  Read grades, attendance, homework, timetables and messages from **Librus Synergia**
30
32
  in Python. This independent, asynchronous client handles login, session recovery,
31
33
  pagination and request limits, returning typed Python objects rather than HTML.
@@ -40,9 +42,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
40
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
41
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
42
44
 
43
- **Status:** `1.0.x` freezes the documented native library API. It does not claim
44
- completed MCP cutover, production-state migration or universal live-school
45
- compatibility. Those consumer/live gates remain separate. School features depend
45
+ **Status:** `1.3.0` adds a read-only school-year archive.
46
+ Installed live checks passed for populated and empty
47
+ archives on two parent logins; see [verification](VERIFICATION.md).
48
+ The stable 1.x API does not guarantee every school layout. School features depend
46
49
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
47
50
  See [limitations](#supported-features-and-limitations) below.
48
51
 
@@ -51,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
51
54
  Install the exact release from PyPI after its gated publication completes:
52
55
 
53
56
  ```sh
54
- python -m pip install librus-python-api==1.2.1
57
+ python -m pip install librus-python-api==1.3.0
55
58
  ```
56
59
 
57
60
  Pin the exact version qualified by your application. Before publication, use a
@@ -67,7 +70,7 @@ python -m pip install .
67
70
  To install a locally built wheel instead:
68
71
 
69
72
  ```sh
70
- python -m pip install ./dist/librus_python_api-1.2.1-py3-none-any.whl
73
+ python -m pip install ./dist/librus_python_api-1.3.0-py3-none-any.whl
71
74
  ```
72
75
 
73
76
  No CLI or background process is installed: import the library in your own program.
@@ -148,6 +151,11 @@ attendance = await client.attendance_window(today.replace(day=1), today)
148
151
 
149
152
  # Permit reuse of this account's cached result for up to 60 seconds.
150
153
  announcements = await client.announcements(max_age_seconds=60)
154
+
155
+ # Earlier school years, separate from the message archive (1.3.0).
156
+ archive = await client.school_year_archive()
157
+ for year in archive.years:
158
+ print(year.school_year, year.subjects)
151
159
  ```
152
160
 
153
161
  Collections expose named record tuples, for example `homework.items`, rather than
@@ -233,7 +241,7 @@ removed in 0.7.
233
241
 
234
242
  | Area | Available |
235
243
  | --- | --- |
236
- | School data | Profile, grades, attendance, timetable, announcements, agenda, homework and completed lessons |
244
+ | School data | Profile, grades, school-year archive, attendance, timetable, announcements, agenda, homework and completed lessons |
237
245
  | Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
238
246
  | Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
239
247
 
@@ -1,4 +1,4 @@
1
- librus_python_api/__init__.py,sha256=cFnpyZw2vPzTFefw3pbhiP3f-CvFABI4rnGdpLye-LQ,6045
1
+ librus_python_api/__init__.py,sha256=XnV4fql56WvZHQuk3zrUHlQ4mtK0272dM07QFKyINU4,6393
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
@@ -11,7 +11,7 @@ librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7
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/completed_lessons.py,sha256=sR2oLSGb2q1580l-2upCBv9sQfIcHi7CQfWr1K0EFXI,7635
14
- librus_python_api/config.py,sha256=43tAalNG8_Em7Q-lXs1nmX-GKw7333CKpNAvJtGMg4E,53193
14
+ librus_python_api/config.py,sha256=8DckId5C13QH9KPelZwPfsci8upoqb_SJBtq7XviPPY,54628
15
15
  librus_python_api/detail_fields.py,sha256=K10Stx_ehDTttkUI7-G7a2Ko9exYEu5NJpR-3sBnQ60,1789
16
16
  librus_python_api/diagnostics.py,sha256=2l7wq6RA3it-Gwj-JRLcw5qbI37fl3xII6GZA2mZj0o,856
17
17
  librus_python_api/exceptions.py,sha256=yWWoo0xyrQAk1d13HOJZnQBsq4ulh_P0hRSI6ZiNQOE,4622
@@ -23,7 +23,7 @@ librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaA
23
23
  librus_python_api/markup.py,sha256=GZk7MmBBGB5SIVY_VngmQxQErI3eGHOpJs3mFVlMR3M,5298
24
24
  librus_python_api/message_content.py,sha256=sUPuqpjuavqDyn6QGM5aPH4mpoQV62bDLepSSQ2AsUE,8686
25
25
  librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
26
- librus_python_api/models.py,sha256=vqoQAL1IMgnOHRlNWxchMiOdkw7AnYDnyiYEQ2OAHko,36363
26
+ librus_python_api/models.py,sha256=wD4Wg7Jq0dYU2YA8FVztz0EguueN2cue0V0dbvxxJkw,38548
27
27
  librus_python_api/modern_body.py,sha256=3L4P_cIrHdIxZqiinFSQrDMhG6iklT1GoA-VEWMJUiI,2682
28
28
  librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
29
29
  librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
@@ -38,11 +38,12 @@ librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
38
  librus_python_api/recipients.py,sha256=6GQCKo-Zjg3VFSg1J8YXcDsy_U3r6-MUS4VCvz0xdaQ,10033
39
39
  librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQmA,10729
40
40
  librus_python_api/school_reads.py,sha256=PCnZ4lv44j-68FgJpQiMteaPSyfhbX8_3CUyE6zhFcQ,14760
41
+ librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
41
42
  librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
42
- librus_python_api/service.py,sha256=1hQkDjEIlOwF5_4aSAEkmb1iMdUU51bVyPTVc7BAKJU,93252
43
+ librus_python_api/service.py,sha256=0nu0XEVEnPFZ6gaeDN7Ao9WPmkDfuYbz8nW_Eg-y78I,94275
43
44
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
44
45
  librus_python_api/transport.py,sha256=QnYXnrv1lW4qToYoTv6HyZw-Lt4rDc-_SMTJpMp6_bc,35297
45
- librus_python_api-1.2.1.dist-info/METADATA,sha256=2EBX-fUeSn5fa9MG7Bgda1npQo-xnBbcgzhuFvnilJ0,11546
46
- librus_python_api-1.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
47
- librus_python_api-1.2.1.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
48
- librus_python_api-1.2.1.dist-info/RECORD,,
46
+ librus_python_api-1.3.0.dist-info/METADATA,sha256=7LcWlfUprMbkEQZbGWQHxj_p6mhrieVWk30-hAaClQ0,11990
47
+ librus_python_api-1.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
48
+ librus_python_api-1.3.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
49
+ librus_python_api-1.3.0.dist-info/RECORD,,