librus-python-api 1.2.1__py3-none-any.whl → 1.4.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,
@@ -37,6 +44,7 @@ from librus_python_api.models import (
37
44
  DetailField,
38
45
  DetailFieldKey,
39
46
  FinalGrades,
47
+ FormativeGrade,
40
48
  FrequencyMeasure,
41
49
  GatewayAttendance,
42
50
  GatewayAttendanceRecord,
@@ -110,6 +118,7 @@ from librus_python_api.models import (
110
118
  SchoolAverage,
111
119
  SchoolDetail,
112
120
  SchoolReference,
121
+ SchoolYearArchive,
113
122
  SendResult,
114
123
  SendStatus,
115
124
  SendSubmission,
@@ -127,9 +136,17 @@ from librus_python_api.models import (
127
136
  from librus_python_api.sending import SendAttempt
128
137
  from librus_python_api.service import AccountClient, LibrusService
129
138
 
130
- __version__ = "1.2.1"
139
+ __version__ = "1.4.0"
131
140
 
132
141
  __all__ = [
142
+ "ArchiveAbsences",
143
+ "ArchiveAchievement",
144
+ "ArchiveCounts",
145
+ "ArchiveDescriptive",
146
+ "ArchiveMarks",
147
+ "ArchiveSubject",
148
+ "ArchiveYear",
149
+ "SchoolYearArchive",
133
150
  "AccountContext",
134
151
  "MessagingBackend",
135
152
  "ModernAccountData",
@@ -235,6 +252,7 @@ __all__ = [
235
252
  "DescriptiveGrade",
236
253
  "DescriptiveGradeSummary",
237
254
  "FinalGrades",
255
+ "FormativeGrade",
238
256
  "GradeKind",
239
257
  "GradeRecords",
240
258
  "Grades",
@@ -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",
@@ -713,6 +721,18 @@ ATTENDANCE_MAX_LESSONS = 4096
713
721
  ATTENDANCE_MAX_SUBJECTS = 1024
714
722
  # Collection recognition and detail retrieval share this central path family.
715
723
  ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
724
+ # Recognition only: formative detail links are classified, never requested.
725
+ FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
726
+ FORMATIVE_HEADERS = (
727
+ "przedmiot",
728
+ "ocena kształtująca",
729
+ "kategoria",
730
+ "okres",
731
+ "data",
732
+ "typ",
733
+ )
734
+ FORMATIVE_TEMPLATE_ID = "000000"
735
+ FORMATIVE_MAX_TEXT_LENGTH = 65536
716
736
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
717
737
  ATTENDANCE_METADATA_CACHE_SIZE = 256
718
738
  ATTENDANCE_RESULT_CACHE_SIZE = 64
@@ -722,6 +742,35 @@ ANNOUNCEMENT_MAX_ITEMS = 256
722
742
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
723
743
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
724
744
  ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH = 262144
745
+
746
+ # School-year archive (/archiwum): labels as rendered upstream, including the
747
+ # absence spellings. Matching is case-insensitive and exact otherwise.
748
+ ARCHIVE_TABLE_CLASSES = frozenset({"decorated"})
749
+ ARCHIVE_ACHIEVEMENT_TABLE_CLASSES = frozenset({"decorated", "big", "center"})
750
+ ARCHIVE_YEAR_PATTERN = (
751
+ r"Klasa: (?P<class_name>\S(?:.*\S)?) Rok: (?P<first>[0-9]{4})/(?P<second>[0-9]{4})"
752
+ )
753
+ ARCHIVE_PERIOD_LABELS = ("okres 1", "okres 2", "koniec roku")
754
+ ARCHIVE_BEHAVIOUR_HEADING = "zachowanie"
755
+ ARCHIVE_ABSENCES_HEADING = "nieobecności"
756
+ ARCHIVE_ABSENCE_LABELS = MappingProxyType(
757
+ {
758
+ "nieusprawiedlione": "unexcused",
759
+ "usprawiedlione": "excused",
760
+ "spóźnienia": "late",
761
+ }
762
+ )
763
+ ARCHIVE_ACHIEVEMENT_HEADERS = ("data", "klasa", "kategoria", "osiągnięcie")
764
+ ARCHIVE_YEAR_HEADER_TAGS = frozenset({"span", "b"})
765
+ ARCHIVE_MAX_YEARS = 16
766
+ ARCHIVE_MAX_SUBJECTS = 128
767
+ ARCHIVE_MAX_DESCRIPTIVE = 32
768
+ ARCHIVE_MAX_ACHIEVEMENTS = 256
769
+ ARCHIVE_MAX_TEXT_LENGTH = 65536
770
+ ARCHIVE_MAX_TOTAL_TEXT_LENGTH = 262144
771
+ ARCHIVE_MAX_YEAR_HEADER_LENGTH = 1024 + len("Klasa: Rok: 0000/0000")
772
+ ARCHIVE_EMPTY_TITLE = "brak danych"
773
+ ARCHIVE_EMPTY_BOX_CLASSES = frozenset({"warning-box", "information", "medium"})
725
774
  ANNOUNCEMENT_TABLE_CLASSES = frozenset(
726
775
  {"decorated", "big", "center", "printable", "margin-top"}
727
776
  )
@@ -0,0 +1,173 @@
1
+ """The grades page's formative-assessment table ("Oceny kształtujące").
2
+
3
+ Authored from an owner-authorized structural observation; fixtures are
4
+ synthetic. The table sits on the page `grades()` already reads, so no request
5
+ is added. Its subject may be the pseudo-subject "KARTA SPOSTRZEŻEŃ". Unknown
6
+ markup is unsupported; contradictions inside the known layout are parse errors.
7
+ """
8
+
9
+ import re
10
+ from collections.abc import Callable
11
+ from typing import Literal, NoReturn, cast
12
+
13
+ from lxml import html
14
+
15
+ from librus_python_api import markup
16
+ from librus_python_api.config import (
17
+ FORMATIVE_DETAIL_PATH_PREFIX,
18
+ FORMATIVE_HEADERS,
19
+ FORMATIVE_MAX_TEXT_LENGTH,
20
+ FORMATIVE_TEMPLATE_ID,
21
+ GRADE_MAX_RECORDS,
22
+ )
23
+ from librus_python_api.exceptions import ErrorKind, LibrusError
24
+ from librus_python_api.models import FormativeGrade
25
+
26
+ _SPAN = re.compile(r"[1-9][0-9]{0,5}")
27
+
28
+
29
+ def _fail(kind: ErrorKind) -> NoReturn:
30
+ raise LibrusError(kind)
31
+
32
+
33
+ def is_formative_table(table: html.HtmlElement) -> bool:
34
+ # Cheap structural gates first: other tables' header text is never read.
35
+ if not {"stretch", "decorated"} <= set(table.get("class", "").split()):
36
+ return False
37
+ heads = [row for row in markup.rows(table) if markup.in_header(row)]
38
+ if len(heads) != 1:
39
+ return False
40
+ cells = markup.cells(heads[0])
41
+ return len(cells) == len(FORMATIVE_HEADERS) and [
42
+ markup.text(cell).casefold() for cell in cells
43
+ ] == list(FORMATIVE_HEADERS)
44
+
45
+
46
+ def _single(cell: html.HtmlElement) -> None:
47
+ if cell.get("colspan", "1") != "1" or cell.get("rowspan", "1") != "1":
48
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
49
+
50
+
51
+ def _plain(cell: html.HtmlElement) -> str:
52
+ _single(cell)
53
+ if any(isinstance(node.tag, str) for node in cell.iterdescendants()):
54
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
55
+ return markup.text(cell)
56
+
57
+
58
+ def _link(cell: html.HtmlElement) -> tuple[str, str]:
59
+ """The detail ID and full text of the assessment cell.
60
+
61
+ Observed: one link whose optional first child is an empty coloured
62
+ `span.grade-box` marker, followed by the text. Anything else is new markup.
63
+ """
64
+ _single(cell)
65
+ anchors = [node for node in cell if isinstance(node.tag, str)]
66
+ if len(anchors) != 1 or anchors[0].tag != "a":
67
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
68
+ anchor = anchors[0]
69
+ children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
70
+ if children:
71
+ marker = children[0]
72
+ if (
73
+ len(children) != 1
74
+ or marker.getparent() is not anchor
75
+ or anchor.index(marker) != 0
76
+ or marker.tag != "span"
77
+ or "grade-box" not in marker.get("class", "").split()
78
+ or markup.text(marker)
79
+ ):
80
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
81
+ identifier = markup.detail_id(anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX)
82
+ if identifier is None:
83
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
84
+ return identifier, markup.text(anchor, FORMATIVE_MAX_TEXT_LENGTH)
85
+
86
+
87
+ def _template(row: html.HtmlElement) -> bool:
88
+ """A hidden, empty template entry (ID 000000) rendered for scripts.
89
+
90
+ Not observed inside this table (the page's observed template is an ordinary
91
+ grade link in a separate hidden table); kept narrow on purpose.
92
+ """
93
+ hidden = "display:none" in row.get("style", "").replace(" ", "").casefold()
94
+ links = [
95
+ markup.detail_id(anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX)
96
+ for anchor in row.iter("a")
97
+ ]
98
+ if not hidden or links != [FORMATIVE_TEMPLATE_ID]:
99
+ return False
100
+ if any(markup.text(anchor) for anchor in row.iter("a")):
101
+ _fail(ErrorKind.PARSE)
102
+ return True
103
+
104
+
105
+ def _item(subject: str, cells: list[html.HtmlElement]) -> FormativeGrade:
106
+ if len(cells) != 5:
107
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
108
+ identifier, text = _link(cells[0])
109
+ category = _plain(cells[1])
110
+ period = _plain(cells[2])
111
+ day = markup.civil_date(_plain(cells[3]))
112
+ kind = _plain(cells[4])
113
+ if not text or period not in ("1", "2") or identifier == FORMATIVE_TEMPLATE_ID:
114
+ _fail(ErrorKind.PARSE)
115
+ return FormativeGrade(
116
+ subject,
117
+ text,
118
+ category,
119
+ cast(Literal[1, 2], int(period)),
120
+ day,
121
+ kind,
122
+ identifier,
123
+ )
124
+
125
+
126
+ def read_formative_table(
127
+ table: html.HtmlElement, check_size: Callable[[int], None]
128
+ ) -> tuple[FormativeGrade, ...]:
129
+ """Rows in page order; a subject TH may span the rows of its group."""
130
+ if any(nested is not table for nested in table.iter("table")):
131
+ _fail(ErrorKind.PARSE)
132
+ items: list[FormativeGrade] = []
133
+ seen: set[str] = set()
134
+ subject, remaining = "", 0
135
+ for row in markup.rows(table):
136
+ if markup.in_header(row):
137
+ continue
138
+ cells = markup.cells(row)
139
+ if next(row.iterancestors("tfoot"), None) is not None:
140
+ if len(cells) != 1 or markup.text(cells[0]):
141
+ _fail(ErrorKind.PARSE)
142
+ continue
143
+ if _template(row):
144
+ continue
145
+ if remaining == 0:
146
+ if not cells or cells[0].tag != "th":
147
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
148
+ head = cells[0]
149
+ span = head.get("rowspan", "1")
150
+ if head.get("colspan", "1") != "1" or not _SPAN.fullmatch(span):
151
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
152
+ remaining = int(span)
153
+ if remaining > GRADE_MAX_RECORDS:
154
+ _fail(ErrorKind.LIMIT)
155
+ if any(isinstance(node.tag, str) for node in head.iterdescendants()):
156
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
157
+ subject = markup.text(head)
158
+ if not subject:
159
+ _fail(ErrorKind.PARSE)
160
+ cells = cells[1:]
161
+ elif cells and cells[0].tag == "th":
162
+ # A new subject before the spanning group ended.
163
+ _fail(ErrorKind.PARSE)
164
+ remaining -= 1
165
+ item = _item(subject, cells)
166
+ if item.detail_id in seen:
167
+ _fail(ErrorKind.PARSE)
168
+ seen.add(item.detail_id)
169
+ items.append(item)
170
+ check_size(len(items))
171
+ if remaining:
172
+ _fail(ErrorKind.PARSE)
173
+ return tuple(items)
@@ -8,6 +8,7 @@ from lxml import html
8
8
 
9
9
  from librus_python_api import markup
10
10
  from librus_python_api.config import (
11
+ FORMATIVE_DETAIL_PATH_PREFIX,
11
12
  GRADE_AVERAGE_HEADERS,
12
13
  GRADE_BODY_PREFIX_COLUMNS,
13
14
  GRADE_CURRENT_HEADER,
@@ -23,11 +24,13 @@ from librus_python_api.config import (
23
24
  GRADE_PUBLICATION_TEACHER_LABEL,
24
25
  )
25
26
  from librus_python_api.exceptions import ErrorKind, LibrusError
27
+ from librus_python_api.formative_grades import is_formative_table, read_formative_table
26
28
  from librus_python_api.grade_parsers import _locate, _subject_rows, _summary
27
29
  from librus_python_api.models import (
28
30
  Availability,
29
31
  DescriptiveGrade,
30
32
  DescriptiveGradeSummary,
33
+ FormativeGrade,
31
34
  GradeKind,
32
35
  GradeRecords,
33
36
  GradeSummaryValue,
@@ -138,14 +141,22 @@ class _Collection:
138
141
  subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
139
142
  consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
140
143
  publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
144
+ formative: int = 0
141
145
 
142
146
  def check_size(self) -> None:
143
147
  if (
144
- len(self.numeric) + len(self.descriptive) + len(self.descriptive_summaries)
148
+ len(self.numeric)
149
+ + len(self.descriptive)
150
+ + len(self.descriptive_summaries)
151
+ + self.formative
145
152
  > GRADE_MAX_RECORDS
146
153
  ):
147
154
  raise LibrusError(ErrorKind.LIMIT)
148
155
 
156
+ def count_formative(self, count: int) -> None:
157
+ self.formative = count
158
+ self.check_size()
159
+
149
160
  def subject(self, value: str, *, descriptive: bool = False) -> None:
150
161
  key = (descriptive, value)
151
162
  if not value or key in self.subjects:
@@ -400,7 +411,14 @@ def parse_grade_records(body: bytes) -> GradeRecords:
400
411
  numeric_tables.append(table)
401
412
  if len(numeric_tables) > 1:
402
413
  raise LibrusError(ErrorKind.PARSE)
414
+ formative_tables = [table for table in tables if is_formative_table(table)]
415
+ if len(formative_tables) > 1:
416
+ raise LibrusError(ErrorKind.PARSE)
417
+ formative: tuple[FormativeGrade, ...] = ()
403
418
  for table in tables:
419
+ if table in formative_tables:
420
+ formative = read_formative_table(table, collection.count_formative)
421
+ continue
404
422
  _read_publications(table, collection)
405
423
  if table in numeric_tables:
406
424
  _read_numeric_table(table, collection)
@@ -434,6 +452,15 @@ def parse_grade_records(body: bytes) -> GradeRecords:
434
452
  ):
435
453
  continue
436
454
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
455
+ for anchor in document.iter("a"):
456
+ # A formative link outside both known tables is a new layout.
457
+ if markup.detail_id(
458
+ anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX
459
+ ) and not any(
460
+ parent in numeric_tables or parent in formative_tables
461
+ for parent in anchor.iterancestors("table")
462
+ ):
463
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
437
464
  if not collection.subjects and not collection.descriptive:
438
465
  raise LibrusError(ErrorKind.PARSE)
439
466
  for descriptive, subject in sorted(collection.subjects):
@@ -451,4 +478,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
451
478
  tuple(collection.descriptive),
452
479
  tuple(collection.averages),
453
480
  tuple(collection.descriptive_summaries),
481
+ formative,
454
482
  )
@@ -131,8 +131,19 @@ def attendance_detail_id(element: html.HtmlElement) -> str | None:
131
131
  )
132
132
  if match is None:
133
133
  return None
134
+ return detail_id(match[2], ATTENDANCE_DETAIL_PATH_PREFIX)
135
+
136
+
137
+ def detail_id(link: str | None, prefix: str) -> str | None:
138
+ """The numeric ID of a relative or same-origin Synergia detail link.
139
+
140
+ Recognition only: the link is never followed. Queries, fragments, other
141
+ origins, credentials and non-digit IDs are not detail links.
142
+ """
143
+ if link is None or len(link) > GRADE_MAX_METADATA_LENGTH:
144
+ return None
134
145
  try:
135
- target = urlsplit(match[2])
146
+ target = urlsplit(link)
136
147
  except ValueError:
137
148
  return None
138
149
  if target.query or target.fragment or target.username or target.password:
@@ -141,7 +152,7 @@ def attendance_detail_id(element: html.HtmlElement) -> str | None:
141
152
  expected = urlsplit(UPSTREAM_ORIGINS["synergia"])
142
153
  if (target.scheme, target.netloc) != (expected.scheme, expected.netloc):
143
154
  return None
144
- if not target.path.startswith(ATTENDANCE_DETAIL_PATH_PREFIX):
155
+ if not target.path.startswith(prefix):
145
156
  return None
146
- identifier = target.path[len(ATTENDANCE_DETAIL_PATH_PREFIX) :]
157
+ identifier = target.path[len(prefix) :]
147
158
  return identifier if re.fullmatch(r"[0-9]{1,64}", identifier) else None
@@ -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)
@@ -945,6 +1019,19 @@ class NumericGrade:
945
1019
  metadata: tuple[tuple[str, str], ...] = field(repr=False)
946
1020
  kind: GradeKind = GradeKind.CURRENT
947
1021
 
1022
+ @property
1023
+ def formative_id(self) -> str | None:
1024
+ """The formative-assessment ID when `href` is a formative detail link.
1025
+
1026
+ Computed, not a field: canonical notification IDs hash the record's
1027
+ fields, so stored identities stay unchanged.
1028
+ """
1029
+ # Lazy import: config imports this module.
1030
+ from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
1031
+ from librus_python_api.markup import detail_id
1032
+
1033
+ return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
1034
+
948
1035
 
949
1036
  @dataclass(frozen=True, slots=True)
950
1037
  class DescriptiveGrade:
@@ -958,6 +1045,19 @@ class DescriptiveGrade:
958
1045
  kind: GradeKind = GradeKind.CURRENT
959
1046
  href: str | None = field(default=None, repr=False)
960
1047
 
1048
+ @property
1049
+ def formative_id(self) -> str | None:
1050
+ """The formative-assessment ID when `href` is a formative detail link.
1051
+
1052
+ Computed, not a field: canonical notification IDs hash the record's
1053
+ fields, so stored identities stay unchanged.
1054
+ """
1055
+ # Lazy import: config imports this module.
1056
+ from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
1057
+ from librus_python_api.markup import detail_id
1058
+
1059
+ return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
1060
+
961
1061
 
962
1062
  @dataclass(frozen=True, slots=True)
963
1063
  class SchoolAverage:
@@ -977,6 +1077,22 @@ class DescriptiveGradeSummary:
977
1077
  raw: str = field(repr=False)
978
1078
 
979
1079
 
1080
+ @dataclass(frozen=True, slots=True)
1081
+ class FormativeGrade:
1082
+ """One row of the grades page's formative-assessment ("Oceny kształtujące")
1083
+ table. "KARTA SPOSTRZEŻEŃ" (observation card) appears as a subject value.
1084
+ The same item may also appear in the grid with a matching `formative_id`.
1085
+ """
1086
+
1087
+ subject: str = field(repr=False)
1088
+ text: str = field(repr=False)
1089
+ category: str = field(repr=False)
1090
+ semester: Literal[1, 2]
1091
+ day: date = field(repr=False)
1092
+ assessment_type: str = field(repr=False)
1093
+ detail_id: str = field(repr=False)
1094
+
1095
+
980
1096
  @dataclass(frozen=True, slots=True)
981
1097
  class GradeRecords:
982
1098
  numeric: tuple[NumericGrade, ...] = field(repr=False)
@@ -985,6 +1101,7 @@ class GradeRecords:
985
1101
  descriptive_summaries: tuple[DescriptiveGradeSummary, ...] = field(
986
1102
  default=(), repr=False
987
1103
  )
1104
+ formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
988
1105
 
989
1106
 
990
1107
  @dataclass(frozen=True, slots=True)
@@ -1004,6 +1121,7 @@ class GradeWindow:
1004
1121
  descriptive: tuple[DescriptiveGrade, ...] = field(repr=False)
1005
1122
  observation: Observation
1006
1123
  view: GradeView = GradeView.ALL
1124
+ formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
1007
1125
 
1008
1126
 
1009
1127
  @dataclass(frozen=True, slots=True)
@@ -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 (
@@ -1305,6 +1307,11 @@ class AccountClient:
1305
1307
  ),
1306
1308
  result.observation,
1307
1309
  result.view,
1310
+ tuple(
1311
+ g
1312
+ for g in result.records.formative
1313
+ if (start is None or start <= g.day) and (end is None or g.day <= end)
1314
+ ),
1308
1315
  )
1309
1316
 
1310
1317
  async def attendance(
@@ -1486,6 +1493,30 @@ class AccountClient:
1486
1493
 
1487
1494
  return await self._read(("announcements",), fetch, budget, max_age_seconds)
1488
1495
 
1496
+ async def school_year_archive(
1497
+ self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
1498
+ ) -> SchoolYearArchive:
1499
+ """Read every earlier school year's summary and the achievements list.
1500
+
1501
+ One GET with no side effect; the page's student name and charts are
1502
+ never read. A layout other than the observed one is unsupported.
1503
+ """
1504
+
1505
+ async def fetch(budget: RequestBudget, _: bool) -> SchoolYearArchive:
1506
+ years, achievements = await self._page(
1507
+ "school_year_archive", budget, parse_school_year_archive
1508
+ )
1509
+ return SchoolYearArchive(
1510
+ self._session_identity(),
1511
+ years,
1512
+ achievements,
1513
+ self._observation("school_year_archive"),
1514
+ )
1515
+
1516
+ return await self._read(
1517
+ ("school_year_archive",), fetch, budget, max_age_seconds
1518
+ )
1519
+
1489
1520
  async def agenda(
1490
1521
  self,
1491
1522
  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.4.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.4.0` adds formative assessments, including the observation card,
46
+ to grade results. The 1.3.0 archive read passed installed live checks for
47
+ populated and empty 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.4.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.4.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,12 +241,12 @@ 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
 
240
- Completed lessons may be disabled by the school. Behaviour notes and observation
241
- cards are not implemented. Some recipient, archive and receipt layouts remain
248
+ Completed lessons may be disabled by the school. Behaviour notes are not
249
+ implemented. Some recipient, archive and receipt layouts remain
242
250
  unqualified; backend acceptance is not proof of delivery. Tests cover supported
243
251
  contracts, not every school or role. Detailed coverage is in the
244
252
  [verification log][verification] and [roadmap][roadmap].
@@ -1,4 +1,4 @@
1
- librus_python_api/__init__.py,sha256=cFnpyZw2vPzTFefw3pbhiP3f-CvFABI4rnGdpLye-LQ,6045
1
+ librus_python_api/__init__.py,sha256=Ka6_hAV-fnh3QrjAPyqhWPvaG2eh5w8VT8a5aB_RBNU,6435
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,19 +11,20 @@ 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=zhE2-v3FehMvvsbxMtK1J6iliOp8jYwb4g-e-rFCMEQ,54968
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
18
18
  librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
19
+ librus_python_api/formative_grades.py,sha256=-F-d1rnFVAIGwg1mN0cryCIBR76OW3v2L4mCWUZ3M7Q,6227
19
20
  librus_python_api/grade_parsers.py,sha256=91Kr1-TArzqO-b94ipeIR0a98OV7tRCZQdBWsd9QLZ0,6141
20
- librus_python_api/grade_records.py,sha256=HvGHQ8Fyx_1Ne5k6N8cBeTqrDCzM5P6saj8eijDbRA4,16793
21
+ librus_python_api/grade_records.py,sha256=K0ZzmU1KsA8Jo1MXh30mStApgrigaOKk5ldoBnwRrH8,17901
21
22
  librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
22
23
  librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
23
- librus_python_api/markup.py,sha256=GZk7MmBBGB5SIVY_VngmQxQErI3eGHOpJs3mFVlMR3M,5298
24
+ librus_python_api/markup.py,sha256=mjCZf6KJTWUz2vjfXbJ4zM17hnev7a5AFIOW3ip3gsE,5677
24
25
  librus_python_api/message_content.py,sha256=sUPuqpjuavqDyn6QGM5aPH4mpoQV62bDLepSSQ2AsUE,8686
25
26
  librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
26
- librus_python_api/models.py,sha256=vqoQAL1IMgnOHRlNWxchMiOdkw7AnYDnyiYEQ2OAHko,36363
27
+ librus_python_api/models.py,sha256=wWMw2TXmeRWP3yJLSDmdVDYsbXRIztm3mEhCYPH1gco,40310
27
28
  librus_python_api/modern_body.py,sha256=3L4P_cIrHdIxZqiinFSQrDMhG6iklT1GoA-VEWMJUiI,2682
28
29
  librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
29
30
  librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
@@ -38,11 +39,12 @@ librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
39
  librus_python_api/recipients.py,sha256=6GQCKo-Zjg3VFSg1J8YXcDsy_U3r6-MUS4VCvz0xdaQ,10033
39
40
  librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQmA,10729
40
41
  librus_python_api/school_reads.py,sha256=PCnZ4lv44j-68FgJpQiMteaPSyfhbX8_3CUyE6zhFcQ,14760
42
+ librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
41
43
  librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
42
- librus_python_api/service.py,sha256=1hQkDjEIlOwF5_4aSAEkmb1iMdUU51bVyPTVc7BAKJU,93252
44
+ librus_python_api/service.py,sha256=yjdKc4-psMDlIHVLud-WA2YDXafKPuTT-hLdVmdWsIM,94464
43
45
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
44
46
  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,,
47
+ librus_python_api-1.4.0.dist-info/METADATA,sha256=VKqlisucgyKpgIO7aGudJPX8-gKpm5mB3ZEm_59jJzE,12031
48
+ librus_python_api-1.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
49
+ librus_python_api-1.4.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
50
+ librus_python_api-1.4.0.dist-info/RECORD,,