librus-python-api 1.3.0__py3-none-any.whl → 1.5.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.
@@ -44,6 +44,7 @@ from librus_python_api.models import (
44
44
  DetailField,
45
45
  DetailFieldKey,
46
46
  FinalGrades,
47
+ FormativeGrade,
47
48
  FrequencyMeasure,
48
49
  GatewayAttendance,
49
50
  GatewayAttendanceRecord,
@@ -135,7 +136,7 @@ from librus_python_api.models import (
135
136
  from librus_python_api.sending import SendAttempt
136
137
  from librus_python_api.service import AccountClient, LibrusService
137
138
 
138
- __version__ = "1.3.0"
139
+ __version__ = "1.5.0"
139
140
 
140
141
  __all__ = [
141
142
  "ArchiveAbsences",
@@ -251,6 +252,7 @@ __all__ = [
251
252
  "DescriptiveGrade",
252
253
  "DescriptiveGradeSummary",
253
254
  "FinalGrades",
255
+ "FormativeGrade",
254
256
  "GradeKind",
255
257
  "GradeRecords",
256
258
  "Grades",
@@ -721,6 +721,18 @@ ATTENDANCE_MAX_LESSONS = 4096
721
721
  ATTENDANCE_MAX_SUBJECTS = 1024
722
722
  # Collection recognition and detail retrieval share this central path family.
723
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
724
736
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
725
737
  ATTENDANCE_METADATA_CACHE_SIZE = 256
726
738
  ATTENDANCE_RESULT_CACHE_SIZE = 64
@@ -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
@@ -57,26 +57,33 @@ def _recipient_receipts(
57
57
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
58
58
  result = []
59
59
  total = 0
60
+ # Rows are recipient/status, or recipient/class/status when the page adds
61
+ # the class-label column; one table never mixes the two.
62
+ width = len(cells(table_rows[1]))
60
63
  for row in table_rows[1:]:
61
64
  columns = cells(row)
62
- if len(columns) != 2 or any(
63
- c.tag != "td"
64
- or c.get("colspan", "1") != "1"
65
- or c.get("rowspan", "1") != "1"
66
- or next(c.iterdescendants("table"), None) is not None
67
- for c in columns
65
+ if (
66
+ len(columns) != width
67
+ or width not in (2, 3)
68
+ or any(
69
+ c.tag != "td"
70
+ or c.get("colspan", "1") != "1"
71
+ or c.get("rowspan", "1") != "1"
72
+ or next(c.iterdescendants("table"), None) is not None
73
+ for c in columns
74
+ )
68
75
  ):
69
76
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
70
- recipient, status = [
71
- text(c, MESSAGE_MAX_FIELD_LENGTH, multiline=True) for c in columns
72
- ]
77
+ values = [text(c, MESSAGE_MAX_FIELD_LENGTH, multiline=True) for c in columns]
78
+ recipient, status = values[0], values[-1]
73
79
  if not recipient or not status:
74
80
  raise LibrusError(ErrorKind.PARSE)
75
- total += len(recipient) + len(status)
81
+ total += sum(map(len, values))
76
82
  if total > MESSAGE_MAX_RECEIPT_TEXT_LENGTH:
77
83
  raise LibrusError(ErrorKind.LIMIT)
78
84
  stamp = None if status == "NIE" else _timestamp(status)
79
- result.append(MessageRecipientReceipt(recipient, status, stamp))
85
+ label = values[1] if width == 3 and values[1] else None
86
+ result.append(MessageRecipientReceipt(recipient, status, stamp, label))
80
87
  return tuple(result)
81
88
 
82
89
 
@@ -210,6 +210,8 @@ class MessageRecipientReceipt:
210
210
  recipient: str = field(repr=False)
211
211
  raw_status: str = field(repr=False)
212
212
  read_timestamp: MessageTimestamp | None = field(repr=False)
213
+ # Displayed class label of the optional middle column; None without it.
214
+ recipient_class: str | None = field(default=None, repr=False)
213
215
 
214
216
 
215
217
  @dataclass(frozen=True, slots=True)
@@ -1019,6 +1021,19 @@ class NumericGrade:
1019
1021
  metadata: tuple[tuple[str, str], ...] = field(repr=False)
1020
1022
  kind: GradeKind = GradeKind.CURRENT
1021
1023
 
1024
+ @property
1025
+ def formative_id(self) -> str | None:
1026
+ """The formative-assessment ID when `href` is a formative detail link.
1027
+
1028
+ Computed, not a field: canonical notification IDs hash the record's
1029
+ fields, so stored identities stay unchanged.
1030
+ """
1031
+ # Lazy import: config imports this module.
1032
+ from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
1033
+ from librus_python_api.markup import detail_id
1034
+
1035
+ return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
1036
+
1022
1037
 
1023
1038
  @dataclass(frozen=True, slots=True)
1024
1039
  class DescriptiveGrade:
@@ -1032,6 +1047,19 @@ class DescriptiveGrade:
1032
1047
  kind: GradeKind = GradeKind.CURRENT
1033
1048
  href: str | None = field(default=None, repr=False)
1034
1049
 
1050
+ @property
1051
+ def formative_id(self) -> str | None:
1052
+ """The formative-assessment ID when `href` is a formative detail link.
1053
+
1054
+ Computed, not a field: canonical notification IDs hash the record's
1055
+ fields, so stored identities stay unchanged.
1056
+ """
1057
+ # Lazy import: config imports this module.
1058
+ from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
1059
+ from librus_python_api.markup import detail_id
1060
+
1061
+ return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
1062
+
1035
1063
 
1036
1064
  @dataclass(frozen=True, slots=True)
1037
1065
  class SchoolAverage:
@@ -1051,6 +1079,22 @@ class DescriptiveGradeSummary:
1051
1079
  raw: str = field(repr=False)
1052
1080
 
1053
1081
 
1082
+ @dataclass(frozen=True, slots=True)
1083
+ class FormativeGrade:
1084
+ """One row of the grades page's formative-assessment ("Oceny kształtujące")
1085
+ table. "KARTA SPOSTRZEŻEŃ" (observation card) appears as a subject value.
1086
+ The same item may also appear in the grid with a matching `formative_id`.
1087
+ """
1088
+
1089
+ subject: str = field(repr=False)
1090
+ text: str = field(repr=False)
1091
+ category: str = field(repr=False)
1092
+ semester: Literal[1, 2]
1093
+ day: date = field(repr=False)
1094
+ assessment_type: str = field(repr=False)
1095
+ detail_id: str = field(repr=False)
1096
+
1097
+
1054
1098
  @dataclass(frozen=True, slots=True)
1055
1099
  class GradeRecords:
1056
1100
  numeric: tuple[NumericGrade, ...] = field(repr=False)
@@ -1059,6 +1103,7 @@ class GradeRecords:
1059
1103
  descriptive_summaries: tuple[DescriptiveGradeSummary, ...] = field(
1060
1104
  default=(), repr=False
1061
1105
  )
1106
+ formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
1062
1107
 
1063
1108
 
1064
1109
  @dataclass(frozen=True, slots=True)
@@ -1078,6 +1123,7 @@ class GradeWindow:
1078
1123
  descriptive: tuple[DescriptiveGrade, ...] = field(repr=False)
1079
1124
  observation: Observation
1080
1125
  view: GradeView = GradeView.ALL
1126
+ formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
1081
1127
 
1082
1128
 
1083
1129
  @dataclass(frozen=True, slots=True)
@@ -1307,6 +1307,11 @@ class AccountClient:
1307
1307
  ),
1308
1308
  result.observation,
1309
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
+ ),
1310
1315
  )
1311
1316
 
1312
1317
  async def attendance(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.3.0
3
+ Version: 1.5.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
@@ -42,9 +42,9 @@ 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.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).
45
+ **Status:** `1.5.0` reads sent-message receipt tables that show a recipient
46
+ class column. `1.4.0` added formative assessments, including the observation
47
+ card, to grade results; see [verification](VERIFICATION.md).
48
48
  The stable 1.x API does not guarantee every school layout. School features depend
49
49
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
50
50
  See [limitations](#supported-features-and-limitations) below.
@@ -54,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
54
54
  Install the exact release from PyPI after its gated publication completes:
55
55
 
56
56
  ```sh
57
- python -m pip install librus-python-api==1.3.0
57
+ python -m pip install librus-python-api==1.5.0
58
58
  ```
59
59
 
60
60
  Pin the exact version qualified by your application. Before publication, use a
@@ -70,7 +70,7 @@ python -m pip install .
70
70
  To install a locally built wheel instead:
71
71
 
72
72
  ```sh
73
- python -m pip install ./dist/librus_python_api-1.3.0-py3-none-any.whl
73
+ python -m pip install ./dist/librus_python_api-1.5.0-py3-none-any.whl
74
74
  ```
75
75
 
76
76
  No CLI or background process is installed: import the library in your own program.
@@ -245,8 +245,8 @@ removed in 0.7.
245
245
  | Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
246
246
  | Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
247
247
 
248
- Completed lessons may be disabled by the school. Behaviour notes and observation
249
- 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
250
250
  unqualified; backend acceptance is not proof of delivery. Tests cover supported
251
251
  contracts, not every school or role. Detailed coverage is in the
252
252
  [verification log][verification] and [roadmap][roadmap].
@@ -1,4 +1,4 @@
1
- librus_python_api/__init__.py,sha256=XnV4fql56WvZHQuk3zrUHlQ4mtK0272dM07QFKyINU4,6393
1
+ librus_python_api/__init__.py,sha256=I1Qf4LlyITCJWMgO6Ce4Ujf7EP8VmHXitvRk07kKCVw,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=8DckId5C13QH9KPelZwPfsci8upoqb_SJBtq7XviPPY,54628
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/message_content.py,sha256=sUPuqpjuavqDyn6QGM5aPH4mpoQV62bDLepSSQ2AsUE,8686
24
+ librus_python_api/markup.py,sha256=mjCZf6KJTWUz2vjfXbJ4zM17hnev7a5AFIOW3ip3gsE,5677
25
+ librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
25
26
  librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
26
- librus_python_api/models.py,sha256=wD4Wg7Jq0dYU2YA8FVztz0EguueN2cue0V0dbvxxJkw,38548
27
+ librus_python_api/models.py,sha256=Mv7V2dg96aw41zO8flpOBLOnaNWETr4IJMxRa-RMxDk,40452
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
@@ -40,10 +41,10 @@ librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQm
40
41
  librus_python_api/school_reads.py,sha256=PCnZ4lv44j-68FgJpQiMteaPSyfhbX8_3CUyE6zhFcQ,14760
41
42
  librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
42
43
  librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
43
- librus_python_api/service.py,sha256=0nu0XEVEnPFZ6gaeDN7Ao9WPmkDfuYbz8nW_Eg-y78I,94275
44
+ librus_python_api/service.py,sha256=yjdKc4-psMDlIHVLud-WA2YDXafKPuTT-hLdVmdWsIM,94464
44
45
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
45
46
  librus_python_api/transport.py,sha256=QnYXnrv1lW4qToYoTv6HyZw-Lt4rDc-_SMTJpMp6_bc,35297
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,,
47
+ librus_python_api-1.5.0.dist-info/METADATA,sha256=vVhNXk_P3l95cfa2_dM5yabNZTBTf0FZL_M3VGHZrxU,12003
48
+ librus_python_api-1.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
49
+ librus_python_api-1.5.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
50
+ librus_python_api-1.5.0.dist-info/RECORD,,