librus-python-api 1.7.0__py3-none-any.whl → 1.8.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.
@@ -138,7 +138,7 @@ from librus_python_api.models import (
138
138
  from librus_python_api.sending import SendAttempt
139
139
  from librus_python_api.service import AccountClient, LibrusService
140
140
 
141
- __version__ = "1.7.0"
141
+ __version__ = "1.8.0"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -433,6 +433,51 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
433
433
  False,
434
434
  Evidence.INDEPENDENTLY_OBSERVED,
435
435
  ),
436
+ # Gateway reads behind the grades page's new-schema descriptive
437
+ # table. Routes and field names come from public client code and the
438
+ # official page script, not from an observed populated response.
439
+ Endpoint(
440
+ "descriptive_token_info",
441
+ "GET",
442
+ "/gateway/api/2.0/Auth/TokenInfo",
443
+ SideEffect.NONE,
444
+ True,
445
+ Evidence.SOURCE_INFORMED,
446
+ ),
447
+ Endpoint(
448
+ "descriptive_user_info",
449
+ "GET",
450
+ "/gateway/api/2.0/Auth/UserInfo/{id}",
451
+ SideEffect.NONE,
452
+ True,
453
+ Evidence.SOURCE_INFORMED,
454
+ ),
455
+ # A read sent as POST with the fixed JSON body `{}`; it is never
456
+ # replayed, although it changes nothing that is known.
457
+ Endpoint(
458
+ "descriptive_partial_grades",
459
+ "POST",
460
+ "/gateway/api/2.0/Auth/DescriptiveGradingSystem/PartialGrades/Student/{id}",
461
+ SideEffect.NONE,
462
+ False,
463
+ Evidence.SOURCE_INFORMED,
464
+ ),
465
+ Endpoint(
466
+ "descriptive_subjects",
467
+ "GET",
468
+ "/gateway/api/3.0/Auth/Subjects",
469
+ SideEffect.NONE,
470
+ True,
471
+ Evidence.SOURCE_INFORMED,
472
+ ),
473
+ Endpoint(
474
+ "descriptive_users",
475
+ "GET",
476
+ "/gateway/api/2.0/Users",
477
+ SideEffect.NONE,
478
+ True,
479
+ Evidence.SOURCE_INFORMED,
480
+ ),
436
481
  Endpoint(
437
482
  "attendance",
438
483
  "POST",
@@ -659,6 +704,38 @@ GRADE_MAX_SUBJECTS = 128
659
704
  GRADE_MAX_VALUE_LENGTH = 1024
660
705
  GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
661
706
  GRADE_INLINE_DETAIL_LABEL = "Ocena"
707
+ # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
708
+ # multi-line grade comment was observed on 2026-10-27.
709
+ TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
710
+ # Tooltip labels observed on grade, attendance and point pages. Diagnostics
711
+ # show only these by name; any other label is masked as teacher-entered text.
712
+ TOOLTIP_LABELS = frozenset(
713
+ {
714
+ "Czy wycieczka",
715
+ "Data",
716
+ "Data realizacji",
717
+ "Data wystawienia",
718
+ "Data zapowiedzi",
719
+ "Dodał",
720
+ "Godzina lekcyjna",
721
+ "Kategoria",
722
+ "Komentarz",
723
+ "Lekcja",
724
+ "Licz do puli",
725
+ "Licz do wyniku",
726
+ "Licz do średniej",
727
+ "Maksymalna ilość punktów",
728
+ "Nauczyciel",
729
+ "Obowiązek wyk. zadania",
730
+ "Ocena",
731
+ "Rodzaj",
732
+ "Suma punktów",
733
+ "Temat zajęć",
734
+ "Waga",
735
+ "Zakres do",
736
+ "Zakres od",
737
+ }
738
+ )
662
739
  # A point-grade ("Oceny punktowe") table marks each subject row's expander image
663
740
  # with this ID; ordinary grade rows use `przedmioty_<id>_node`.
664
741
  GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
@@ -690,6 +767,21 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
690
767
  "Ocena roczna": 0,
691
768
  }
692
769
  )
770
+ # New-schema descriptive grading: the page renders this table with empty
771
+ # semester cells and its script loads the grades from the gateway routes above.
772
+ DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
773
+ DESCRIPTIVE_ROW_CLASS = "studentRow"
774
+ # Gateway account and student identifiers are opaque strings, unlike every
775
+ # other `{id}` route, which takes a numeric ID.
776
+ DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
777
+ DESCRIPTIVE_IDENTIFIER_OPERATIONS = frozenset(
778
+ {"descriptive_user_info", "descriptive_partial_grades"}
779
+ )
780
+ # The PartialGrades read is a POST whose only body is this fixed JSON object.
781
+ DESCRIPTIVE_PARTIAL_GRADES_BODY = b"{}"
782
+ DESCRIPTIVE_MAX_SUBJECTS = 1024
783
+ DESCRIPTIVE_MAX_USERS = 8192
784
+ DESCRIPTIVE_MAX_NAME_LENGTH = 1024
693
785
  GRADE_PREDICTED_ANNUAL_HEADER = "Przewidywana ocena roczna"
694
786
  GRADE_PREDICTED_PERIOD_HEADERS = MappingProxyType(
695
787
  {
@@ -1,10 +1,152 @@
1
- """Opt-in allowlisted structured diagnostics; no global logging configuration."""
1
+ """Opt-in allowlisted structured diagnostics; no global logging configuration.
2
+
3
+ `failure_frames` and `describe_failure` read where a failure happened from the
4
+ exception's own traceback. Their output holds code locations and page
5
+ structure built from allowlists, never page text, attribute values or
6
+ exception messages.
7
+ """
2
8
 
3
9
  import logging
10
+ import re
11
+ import traceback
12
+ from collections.abc import Iterator
13
+ from types import FrameType
4
14
  from typing import Protocol
5
15
 
16
+ from lxml import html
17
+
6
18
  from librus_python_api.models import DiagnosticEvent
7
19
 
20
+ _PACKAGE = "librus_python_api"
21
+ # `-> NoReturn` helpers that only raise; their caller is the failure site.
22
+ RAISE_HELPERS = frozenset(
23
+ {
24
+ ("librus_python_api.formative_grades", "_fail"),
25
+ ("librus_python_api.school_year_archive", "_fail"),
26
+ }
27
+ )
28
+ _CHAIN_LIMIT = 8
29
+ _ELEMENT_LOCALS = 4
30
+ _DEPTH = 6
31
+ _NODES = 120
32
+ _ELEMENT_BYTES = 8 * 1024
33
+ _REPORT_BYTES = 32 * 1024
34
+ # Markup observed on Synergia pages. Other class tokens, tags and attribute
35
+ # names are shown only as lengths, since they could carry data.
36
+ _CLASSES = frozenset(
37
+ {
38
+ "big",
39
+ "black",
40
+ "bolded",
41
+ "center",
42
+ "colspan",
43
+ "container-background",
44
+ "container-icon",
45
+ "decorated",
46
+ "detail-grades",
47
+ "double",
48
+ "gradesCell",
49
+ "grade-box",
50
+ "grade-box-mapping",
51
+ "helper-icon",
52
+ "left",
53
+ "legend",
54
+ "line0",
55
+ "line1",
56
+ "micro",
57
+ "newSchemaGradesByStudentTable",
58
+ "no-border-left",
59
+ "no-border-top",
60
+ "ocena",
61
+ "right",
62
+ "screen-only",
63
+ "sort_box",
64
+ "spacing",
65
+ "stretch",
66
+ "studentRow",
67
+ "tooltip",
68
+ }
69
+ )
70
+ _TAGS = frozenset(
71
+ {
72
+ "a",
73
+ "b",
74
+ "body",
75
+ "br",
76
+ "center",
77
+ "div",
78
+ "em",
79
+ "font",
80
+ "form",
81
+ "h1",
82
+ "h2",
83
+ "h3",
84
+ "h4",
85
+ "head",
86
+ "hr",
87
+ "html",
88
+ "i",
89
+ "img",
90
+ "input",
91
+ "label",
92
+ "li",
93
+ "nobr",
94
+ "ol",
95
+ "option",
96
+ "p",
97
+ "select",
98
+ "small",
99
+ "span",
100
+ "strong",
101
+ "sub",
102
+ "sup",
103
+ "table",
104
+ "tbody",
105
+ "td",
106
+ "tfoot",
107
+ "th",
108
+ "thead",
109
+ "tr",
110
+ "u",
111
+ "ul",
112
+ }
113
+ )
114
+ _ATTRIBUTES = frozenset(
115
+ {
116
+ "align",
117
+ "alt",
118
+ "border",
119
+ "class",
120
+ "colspan",
121
+ "data-semester",
122
+ "data-subject_id",
123
+ "data-user_id",
124
+ "height",
125
+ "href",
126
+ "id",
127
+ "name",
128
+ "onclick",
129
+ "rowspan",
130
+ "selected",
131
+ "src",
132
+ "style",
133
+ "title",
134
+ "type",
135
+ "valign",
136
+ "value",
137
+ "width",
138
+ }
139
+ )
140
+ # Element IDs shown, with digit runs masked, only in these observed shapes.
141
+ _ID_SHAPES = re.compile(
142
+ r"(?:przedmioty(?:_OP|_oo)?_(?:<digits [0-9]+>|all|zachowanie)(?:_node)?"
143
+ r"|tabSource|przedmioty(?:_OP)?_all)"
144
+ )
145
+ _ONCLICK = re.compile(r"\s*([A-Za-z_][\w.]{0,63})\s*\(")
146
+ _ONCLICK_NAMES = frozenset(
147
+ {"otworz_w_nowym_oknie", "showHideOP.ShowHide", "showHideOO.ShowHide"}
148
+ )
149
+
8
150
 
9
151
  class DiagnosticSink(Protocol):
10
152
  def __call__(self, event: DiagnosticEvent, /) -> None: ...
@@ -21,5 +163,282 @@ def logging_sink(event: DiagnosticEvent) -> None:
21
163
  "elapsed_seconds": event.elapsed_seconds,
22
164
  "budget_requests_dispatched": event.budget_requests_dispatched,
23
165
  "budget_response_bytes": event.budget_response_bytes,
166
+ "frames": event.frames,
24
167
  },
25
168
  )
169
+
170
+
171
+ def _chain(error: BaseException) -> Iterator[BaseException]:
172
+ """The error, then the failures it replaced: an explicit cause or a
173
+ context suppressed with `from None`. Only their frames are read."""
174
+ seen: set[int] = set()
175
+ current: BaseException | None = error
176
+ while current is not None and id(current) not in seen and len(seen) < _CHAIN_LIMIT:
177
+ seen.add(id(current))
178
+ yield current
179
+ current = current.__cause__ or (
180
+ current.__context__ if current.__suppress_context__ else None
181
+ )
182
+
183
+
184
+ def _owned(module: str, packages: tuple[str, ...]) -> bool:
185
+ return any(module == name or module.startswith(name + ".") for name in packages)
186
+
187
+
188
+ def _frames(
189
+ error: BaseException, packages: tuple[str, ...]
190
+ ) -> list[tuple[FrameType, int]]:
191
+ """Owned frames, outer call first; the original failure site is last."""
192
+ found: list[tuple[FrameType, int]] = []
193
+ for failure in _chain(error):
194
+ for frame, line in traceback.walk_tb(failure.__traceback__):
195
+ module = str(frame.f_globals.get("__name__", ""))
196
+ if not _owned(module, packages):
197
+ continue
198
+ if (module, frame.f_code.co_name) in RAISE_HELPERS:
199
+ continue
200
+ found.append((frame, line))
201
+ return found
202
+
203
+
204
+ def _site(frame: FrameType, line: int) -> str:
205
+ module = str(frame.f_globals.get("__name__", ""))
206
+ if module.startswith(_PACKAGE + "."):
207
+ module = module[len(_PACKAGE) + 1 :]
208
+ return f"{module}:{frame.f_code.co_qualname}:{line}"
209
+
210
+
211
+ def failure_frames(
212
+ error: BaseException,
213
+ limit: int = 6,
214
+ packages: tuple[str, ...] = (_PACKAGE,),
215
+ ) -> tuple[str, ...]:
216
+ """Where `error` happened, as `module:function:line`, innermost last.
217
+
218
+ Code locations only, opaque and specific to a release. Never raises.
219
+ """
220
+ try:
221
+ return tuple(_site(frame, line) for frame, line in _frames(error, packages))[
222
+ -limit:
223
+ ]
224
+ except Exception:
225
+ return ()
226
+
227
+
228
+ def _redaction_vocabulary() -> tuple[
229
+ frozenset[str], frozenset[str], tuple[str, ...], tuple[re.Pattern[str], ...]
230
+ ]:
231
+ # Lazy: config imports models, which this module also imports.
232
+ from librus_python_api import config
233
+
234
+ texts = set(config.GRADE_EMPTY_MARKERS) | {
235
+ config.GRADE_CURRENT_HEADER,
236
+ config.GRADE_INLINE_DETAIL_LABEL,
237
+ config.GRADE_PREDICTED_ANNUAL_HEADER,
238
+ *config.GRADE_MERGED_SUBJECTS,
239
+ *config.GRADE_SUMMARY_HEADERS,
240
+ *config.GRADE_PERIOD_HEADERS,
241
+ *config.GRADE_PREDICTED_PERIOD_HEADERS,
242
+ *config.GRADE_AVERAGE_HEADERS,
243
+ }
244
+ texts |= {label.casefold() for label in config.FORMATIVE_HEADERS}
245
+ prefixes = tuple(
246
+ sorted(
247
+ (
248
+ value
249
+ for name, value in vars(config).items()
250
+ if name.endswith("_PATH_PREFIX") and isinstance(value, str)
251
+ ),
252
+ key=len,
253
+ reverse=True,
254
+ )
255
+ )
256
+ patterns = (config.GRADE_POINT_ROW_NODE, config.GRADE_POINT_RESULT_HEADER)
257
+ return frozenset(texts), config.TOOLTIP_LABELS, prefixes, patterns
258
+
259
+
260
+ class _Redactor:
261
+ def __init__(self) -> None:
262
+ self.texts, self.labels, self.prefixes, self.patterns = _redaction_vocabulary()
263
+ self.nodes = 0
264
+
265
+ def text(self, value: str) -> str | None:
266
+ normalized = " ".join(value.split())
267
+ if not normalized:
268
+ return None
269
+ if (
270
+ normalized in self.texts
271
+ or normalized.casefold() in self.texts
272
+ or self.patterns[1].fullmatch(normalized)
273
+ ):
274
+ return repr(normalized)
275
+ return f"TEXT(len={len(normalized)})"
276
+
277
+ def title(self, value: str) -> str:
278
+ from librus_python_api.grade_parsers import title_label
279
+
280
+ for points in (False, True):
281
+ if title_label(value, points=points) in self.texts:
282
+ return repr(title_label(value, points=points))
283
+ parts: list[str] = []
284
+ for chunk in re.split(r"<br\s*/?>|\n", value, flags=re.I):
285
+ rendered = " ".join(re.sub(r"<[^>]*>", " ", chunk).split())
286
+ if not rendered:
287
+ parts.append("EMPTY")
288
+ continue
289
+ key, separator, _ = rendered.partition(":")
290
+ key = key.strip()
291
+ if not separator:
292
+ parts.append(f"NO-COLON(len={len(rendered)})")
293
+ elif key in self.labels:
294
+ parts.append(f"[{key}]")
295
+ else:
296
+ parts.append(f"LABEL?(len={len(key)})")
297
+ return "[" + ", ".join(parts) + "]"
298
+
299
+ def href(self, value: str) -> str:
300
+ path = value.split("?", 1)[0].split("#", 1)[0]
301
+ for prefix in self.prefixes:
302
+ if path.startswith(prefix):
303
+ rest = path[len(prefix) :]
304
+ if re.fullmatch(r"[0-9]{1,64}", rest):
305
+ return f"{prefix}<digits {len(rest)}>"
306
+ return f"{prefix}<other len {len(rest)}>"
307
+ if path.lower().startswith("javascript:"):
308
+ return f"javascript:<len {len(path) - 11}>"
309
+ return f"<other len {len(value)}>"
310
+
311
+ def identifier(self, value: str) -> str:
312
+ if self.patterns[0].fullmatch(value):
313
+ return "<GRADE_POINT_ROW_NODE>"
314
+ shaped = re.sub(r"[0-9]+", lambda m: f"<digits {len(m.group())}>", value)
315
+ if len(value) <= 64 and _ID_SHAPES.fullmatch(shaped):
316
+ return shaped
317
+ return f"<len {len(value)}>"
318
+
319
+ def attribute(self, name: str, value: str) -> str | None:
320
+ if name not in _ATTRIBUTES:
321
+ return f"attr?{len(name)}=<len {len(value)}>"
322
+ if name == "class":
323
+ tokens = [
324
+ token if token in _CLASSES else f"?{len(token)}"
325
+ for token in value.split()
326
+ ]
327
+ return f"class={'.'.join(tokens)}"
328
+ if name in ("colspan", "rowspan"):
329
+ shown = (
330
+ value if re.fullmatch(r"[0-9]{1,3}", value) else f"<len {len(value)}>"
331
+ )
332
+ return f"{name}={shown}"
333
+ if name in ("id", "name"):
334
+ return f"{name}={self.identifier(value)}"
335
+ if name == "href":
336
+ return f"href={self.href(value)}"
337
+ if name == "title":
338
+ return f"title={self.title(value)}"
339
+ if name == "style":
340
+ hidden = "display:none" in value.replace(" ", "").casefold()
341
+ return "style=display:none" if hidden else "style"
342
+ if name == "onclick":
343
+ call = _ONCLICK.match(value)
344
+ if call is not None and call.group(1) in _ONCLICK_NAMES:
345
+ return f"onclick={call.group(1)}(...)"
346
+ return f"onclick=<len {len(value)}>"
347
+ return f"{name}=<len {len(value)}>"
348
+
349
+ def element(self, element: html.HtmlElement, depth: int = 0) -> str:
350
+ self.nodes += 1
351
+ tag = element.tag if isinstance(element.tag, str) else "#comment"
352
+ if tag == "#comment" or tag in ("script", "style"):
353
+ return f"<{tag} len={len(element.text or '')}>"
354
+ tag = _tag(tag)
355
+ attributes = [
356
+ shown
357
+ for name, value in element.attrib.items()
358
+ if (shown := self.attribute(str(name), str(value))) is not None
359
+ ]
360
+ opening = " ".join([tag, *attributes])
361
+ parts: list[str] = []
362
+ if (shown := self.text(element.text or "")) is not None:
363
+ parts.append(shown)
364
+ for child in element:
365
+ if depth + 1 >= _DEPTH or self.nodes >= _NODES:
366
+ parts.append("...")
367
+ break
368
+ parts.append(self.element(child, depth + 1))
369
+ if (shown := self.text(child.tail or "")) is not None:
370
+ parts.append(shown)
371
+ return f"<{opening}>{''.join(parts)}</{tag}>"
372
+
373
+
374
+ def _tag(name: str) -> str:
375
+ return name if name in _TAGS else f"?tag{len(name)}"
376
+
377
+
378
+ def _path(element: html.HtmlElement, redactor: _Redactor) -> str:
379
+ steps: list[str] = []
380
+ node: html.HtmlElement | None = element
381
+ while node is not None and isinstance(node.tag, str) and len(steps) < 12:
382
+ parent = node.getparent()
383
+ index = (
384
+ [sibling for sibling in parent if sibling.tag == node.tag].index(node) + 1
385
+ if parent is not None
386
+ else 1
387
+ )
388
+ classes = node.get("class", "").split()
389
+ token = _tag(node.tag) + (
390
+ "." + str(redactor.attribute("class", " ".join(classes)))[6:]
391
+ if classes
392
+ else ""
393
+ )
394
+ steps.append(f"{token}[{index}]" if parent is not None else token)
395
+ if node.tag == "table" and "decorated" in node.get("class", "").split():
396
+ break
397
+ node = parent
398
+ return " > ".join(reversed(steps))
399
+
400
+
401
+ def describe_failure(error: BaseException) -> str | None:
402
+ """The page structure around a failure, for a bug report.
403
+
404
+ Shows the HTML elements held by the innermost library frame that holds
405
+ any: tags, safe class tokens, known tooltip labels, known link families
406
+ and lengths. Text and attribute values are masked unless they equal a
407
+ parser constant. Returns None when there is nothing to show; never raises.
408
+ """
409
+ try:
410
+ redactor = _Redactor()
411
+ for frame, line in reversed(_frames(error, (_PACKAGE,))):
412
+ local_items = list(frame.f_locals.items())
413
+ elements = [
414
+ (name, value)
415
+ for name, value in local_items
416
+ if isinstance(value, html.HtmlElement)
417
+ ][:_ELEMENT_LOCALS]
418
+ sequences = [
419
+ (name, value)
420
+ for name, value in local_items
421
+ if isinstance(value, (list, tuple))
422
+ and value
423
+ and all(isinstance(item, html.HtmlElement) for item in value)
424
+ ][:_ELEMENT_LOCALS]
425
+ if not elements and not sequences:
426
+ continue
427
+ lines = [f"at {_site(frame, line)}"]
428
+ for name, value in elements:
429
+ redactor.nodes = 0
430
+ lines.append(f"{name}: {_path(value, redactor)}")
431
+ rendered = redactor.element(value)
432
+ if len(rendered) > _ELEMENT_BYTES:
433
+ rendered = rendered[:_ELEMENT_BYTES] + "...(truncated)"
434
+ lines.append(" " + rendered)
435
+ for name, items in sequences:
436
+ tags = ",".join(str(item.tag) for item in items[:20])
437
+ lines.append(f"{name}: {len(items)} elements: {tags}")
438
+ report = "\n".join(lines)
439
+ if len(report) > _REPORT_BYTES:
440
+ report = report[:_REPORT_BYTES] + "...(truncated)"
441
+ return report
442
+ return None
443
+ except Exception:
444
+ return None
@@ -58,7 +58,8 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
58
58
  """The detail ID and full text of the assessment cell.
59
59
 
60
60
  Observed: one link whose optional first child is an empty coloured
61
- `span.grade-box` marker, followed by the text. Anything else is new markup.
61
+ `span.grade-box` marker, followed by the text, which may contain empty
62
+ line breaks (2026-10-27). Anything else is new markup.
62
63
  """
63
64
  _single(cell)
64
65
  anchors = [node for node in cell if isinstance(node.tag, str)]
@@ -66,6 +67,10 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
66
67
  _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
67
68
  anchor = anchors[0]
68
69
  children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
70
+ breaks = [node for node in children if node.tag == "br"]
71
+ if any(node.getparent() is not anchor or len(node) for node in breaks):
72
+ _fail(ErrorKind.UNSUPPORTED_CAPABILITY)
73
+ children = [node for node in children if node.tag != "br"]
69
74
  if children:
70
75
  marker = children[0]
71
76
  if (