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.
- librus_python_api/__init__.py +1 -1
- librus_python_api/config.py +92 -0
- librus_python_api/diagnostics.py +420 -1
- librus_python_api/formative_grades.py +6 -1
- librus_python_api/gateway_grades.py +458 -0
- librus_python_api/grade_parsers.py +28 -8
- librus_python_api/grade_records.py +22 -12
- librus_python_api/inspect_page.py +114 -0
- librus_python_api/markup.py +11 -1
- librus_python_api/models.py +2 -0
- librus_python_api/service.py +94 -4
- librus_python_api/transport.py +20 -5
- {librus_python_api-1.7.0.dist-info → librus_python_api-1.8.0.dist-info}/METADATA +37 -6
- {librus_python_api-1.7.0.dist-info → librus_python_api-1.8.0.dist-info}/RECORD +16 -14
- {librus_python_api-1.7.0.dist-info → librus_python_api-1.8.0.dist-info}/WHEEL +0 -0
- {librus_python_api-1.7.0.dist-info → librus_python_api-1.8.0.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -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.
|
|
141
|
+
__version__ = "1.8.0"
|
|
142
142
|
|
|
143
143
|
__all__ = [
|
|
144
144
|
"ArchiveAbsences",
|
librus_python_api/config.py
CHANGED
|
@@ -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
|
{
|
librus_python_api/diagnostics.py
CHANGED
|
@@ -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
|
|
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 (
|