librus-python-api 1.7.1__py3-none-any.whl → 1.8.1__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 +104 -0
- librus_python_api/diagnostics.py +522 -1
- librus_python_api/exceptions.py +13 -0
- librus_python_api/gateway_grades.py +458 -0
- librus_python_api/grade_records.py +5 -1
- librus_python_api/inspect_page.py +132 -0
- librus_python_api/models.py +4 -0
- librus_python_api/parsers.py +35 -5
- librus_python_api/service.py +174 -20
- librus_python_api/transport.py +20 -5
- {librus_python_api-1.7.1.dist-info → librus_python_api-1.8.1.dist-info}/METADATA +43 -6
- {librus_python_api-1.7.1.dist-info → librus_python_api-1.8.1.dist-info}/RECORD +15 -13
- {librus_python_api-1.7.1.dist-info → librus_python_api-1.8.1.dist-info}/WHEEL +0 -0
- {librus_python_api-1.7.1.dist-info → librus_python_api-1.8.1.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.1"
|
|
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",
|
|
@@ -643,6 +688,21 @@ PROFILE_LABELS = MappingProxyType(
|
|
|
643
688
|
"Szkoła": "school",
|
|
644
689
|
}
|
|
645
690
|
)
|
|
691
|
+
# Rows the information page lists for the login's own user after the
|
|
692
|
+
# student's rows (observed); never read, named by failure diagnostics.
|
|
693
|
+
PROFILE_OWNER_LABELS = ("Imię i nazwisko użytkownika", "Login", "Hasło")
|
|
694
|
+
# The page header shows the lucky number as `span.luckyNumber > b` (observed
|
|
695
|
+
# 2026-10-29). "?" there is reported where no number is drawn, for a school
|
|
696
|
+
# without lucky numbers and possibly on days without a draw; neither it nor
|
|
697
|
+
# an empty marker has been observed.
|
|
698
|
+
PROFILE_LUCKY_NUMBER_CLASS = "luckyNumber"
|
|
699
|
+
# For a session it no longer knows, Synergia answers every page read with 200
|
|
700
|
+
# and a page whose only heading is "Brak dostępu" (`h2.inside`), without the
|
|
701
|
+
# signed-in menu, not with a login redirect (observed 2026-10-29). A signed-in
|
|
702
|
+
# user's timetable page has no menu either, so the heading is required too.
|
|
703
|
+
LOGGED_IN_PAGE_MARKER = "graphic-menu"
|
|
704
|
+
LOGGED_OUT_HEADING = "Brak dostępu"
|
|
705
|
+
PROFILE_LUCKY_NUMBER_DISABLED = "?"
|
|
646
706
|
|
|
647
707
|
# HTML summary headers omit the two leading body cells: expander and subject.
|
|
648
708
|
# These labels/layout rules are source-informed requirements, not live evidence.
|
|
@@ -662,6 +722,35 @@ GRADE_INLINE_DETAIL_LABEL = "Ocena"
|
|
|
662
722
|
# Tooltip fields whose free text may continue on unlabelled lines. A teacher's
|
|
663
723
|
# multi-line grade comment was observed on 2026-10-27.
|
|
664
724
|
TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
|
|
725
|
+
# Tooltip labels observed on grade, attendance and point pages. Diagnostics
|
|
726
|
+
# show only these by name; any other label is masked as teacher-entered text.
|
|
727
|
+
TOOLTIP_LABELS = frozenset(
|
|
728
|
+
{
|
|
729
|
+
"Czy wycieczka",
|
|
730
|
+
"Data",
|
|
731
|
+
"Data realizacji",
|
|
732
|
+
"Data wystawienia",
|
|
733
|
+
"Data zapowiedzi",
|
|
734
|
+
"Dodał",
|
|
735
|
+
"Godzina lekcyjna",
|
|
736
|
+
"Kategoria",
|
|
737
|
+
"Komentarz",
|
|
738
|
+
"Lekcja",
|
|
739
|
+
"Licz do puli",
|
|
740
|
+
"Licz do wyniku",
|
|
741
|
+
"Licz do średniej",
|
|
742
|
+
"Maksymalna ilość punktów",
|
|
743
|
+
"Nauczyciel",
|
|
744
|
+
"Obowiązek wyk. zadania",
|
|
745
|
+
"Ocena",
|
|
746
|
+
"Rodzaj",
|
|
747
|
+
"Suma punktów",
|
|
748
|
+
"Temat zajęć",
|
|
749
|
+
"Waga",
|
|
750
|
+
"Zakres do",
|
|
751
|
+
"Zakres od",
|
|
752
|
+
}
|
|
753
|
+
)
|
|
665
754
|
# A point-grade ("Oceny punktowe") table marks each subject row's expander image
|
|
666
755
|
# with this ID; ordinary grade rows use `przedmioty_<id>_node`.
|
|
667
756
|
GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
|
|
@@ -693,6 +782,21 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
|
|
|
693
782
|
"Ocena roczna": 0,
|
|
694
783
|
}
|
|
695
784
|
)
|
|
785
|
+
# New-schema descriptive grading: the page renders this table with empty
|
|
786
|
+
# semester cells and its script loads the grades from the gateway routes above.
|
|
787
|
+
DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
|
|
788
|
+
DESCRIPTIVE_ROW_CLASS = "studentRow"
|
|
789
|
+
# Gateway account and student identifiers are opaque strings, unlike every
|
|
790
|
+
# other `{id}` route, which takes a numeric ID.
|
|
791
|
+
DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
792
|
+
DESCRIPTIVE_IDENTIFIER_OPERATIONS = frozenset(
|
|
793
|
+
{"descriptive_user_info", "descriptive_partial_grades"}
|
|
794
|
+
)
|
|
795
|
+
# The PartialGrades read is a POST whose only body is this fixed JSON object.
|
|
796
|
+
DESCRIPTIVE_PARTIAL_GRADES_BODY = b"{}"
|
|
797
|
+
DESCRIPTIVE_MAX_SUBJECTS = 1024
|
|
798
|
+
DESCRIPTIVE_MAX_USERS = 8192
|
|
799
|
+
DESCRIPTIVE_MAX_NAME_LENGTH = 1024
|
|
696
800
|
GRADE_PREDICTED_ANNUAL_HEADER = "Przewidywana ocena roczna"
|
|
697
801
|
GRADE_PREDICTED_PERIOD_HEADERS = MappingProxyType(
|
|
698
802
|
{
|
librus_python_api/diagnostics.py
CHANGED
|
@@ -1,10 +1,165 @@
|
|
|
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
|
+
_PAGE_TABLES = 12
|
|
34
|
+
_PAGE_ROWS = 40
|
|
35
|
+
_PAGE_CELLS = 8
|
|
36
|
+
_PAGE_NOTICES = 4
|
|
37
|
+
# Input types a login, search or settings form uses; others are shown as "?".
|
|
38
|
+
_INPUT_TYPES = frozenset(
|
|
39
|
+
{"button", "checkbox", "hidden", "password", "radio", "submit", "text"}
|
|
40
|
+
)
|
|
41
|
+
# Element IDs whose presence tells a Synergia page from a login or error page.
|
|
42
|
+
_PAGE_MARKERS = ("graphic-menu", "user-section")
|
|
43
|
+
_REPORT_BYTES = 32 * 1024
|
|
44
|
+
# Markup observed on Synergia pages. Other class tokens, tags and attribute
|
|
45
|
+
# names are shown only as lengths, since they could carry data.
|
|
46
|
+
_CLASSES = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"big",
|
|
49
|
+
"black",
|
|
50
|
+
"bolded",
|
|
51
|
+
"center",
|
|
52
|
+
"colspan",
|
|
53
|
+
"container-background",
|
|
54
|
+
"container-icon",
|
|
55
|
+
"decorated",
|
|
56
|
+
"detail-grades",
|
|
57
|
+
"double",
|
|
58
|
+
"form",
|
|
59
|
+
"gradesCell",
|
|
60
|
+
"grade-box",
|
|
61
|
+
"grade-box-mapping",
|
|
62
|
+
"helper-icon",
|
|
63
|
+
"left",
|
|
64
|
+
"legend",
|
|
65
|
+
"line0",
|
|
66
|
+
"line1",
|
|
67
|
+
"luckyNumber",
|
|
68
|
+
"micro",
|
|
69
|
+
"newSchemaGradesByStudentTable",
|
|
70
|
+
"no-border-left",
|
|
71
|
+
"no-border-top",
|
|
72
|
+
"ocena",
|
|
73
|
+
"right",
|
|
74
|
+
"screen-only",
|
|
75
|
+
"sort_box",
|
|
76
|
+
"spacing",
|
|
77
|
+
"stretch",
|
|
78
|
+
"studentRow",
|
|
79
|
+
"tooltip",
|
|
80
|
+
"warning-content",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
_TAGS = frozenset(
|
|
84
|
+
{
|
|
85
|
+
"a",
|
|
86
|
+
"b",
|
|
87
|
+
"body",
|
|
88
|
+
"br",
|
|
89
|
+
"center",
|
|
90
|
+
"div",
|
|
91
|
+
"em",
|
|
92
|
+
"font",
|
|
93
|
+
"form",
|
|
94
|
+
"h1",
|
|
95
|
+
"h2",
|
|
96
|
+
"h3",
|
|
97
|
+
"h4",
|
|
98
|
+
"head",
|
|
99
|
+
"hr",
|
|
100
|
+
"html",
|
|
101
|
+
"i",
|
|
102
|
+
"img",
|
|
103
|
+
"input",
|
|
104
|
+
"label",
|
|
105
|
+
"li",
|
|
106
|
+
"nobr",
|
|
107
|
+
"ol",
|
|
108
|
+
"option",
|
|
109
|
+
"p",
|
|
110
|
+
"select",
|
|
111
|
+
"small",
|
|
112
|
+
"span",
|
|
113
|
+
"strong",
|
|
114
|
+
"sub",
|
|
115
|
+
"sup",
|
|
116
|
+
"table",
|
|
117
|
+
"tbody",
|
|
118
|
+
"td",
|
|
119
|
+
"tfoot",
|
|
120
|
+
"th",
|
|
121
|
+
"thead",
|
|
122
|
+
"tr",
|
|
123
|
+
"u",
|
|
124
|
+
"ul",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
_ATTRIBUTES = frozenset(
|
|
128
|
+
{
|
|
129
|
+
"align",
|
|
130
|
+
"alt",
|
|
131
|
+
"border",
|
|
132
|
+
"class",
|
|
133
|
+
"colspan",
|
|
134
|
+
"data-semester",
|
|
135
|
+
"data-subject_id",
|
|
136
|
+
"data-user_id",
|
|
137
|
+
"height",
|
|
138
|
+
"href",
|
|
139
|
+
"id",
|
|
140
|
+
"name",
|
|
141
|
+
"onclick",
|
|
142
|
+
"rowspan",
|
|
143
|
+
"selected",
|
|
144
|
+
"src",
|
|
145
|
+
"style",
|
|
146
|
+
"title",
|
|
147
|
+
"type",
|
|
148
|
+
"valign",
|
|
149
|
+
"value",
|
|
150
|
+
"width",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
# Element IDs shown, with digit runs masked, only in these observed shapes.
|
|
154
|
+
_ID_SHAPES = re.compile(
|
|
155
|
+
r"(?:przedmioty(?:_OP|_oo)?_(?:<digits [0-9]+>|all|zachowanie)(?:_node)?"
|
|
156
|
+
r"|tabSource|przedmioty(?:_OP)?_all)"
|
|
157
|
+
)
|
|
158
|
+
_ONCLICK = re.compile(r"\s*([A-Za-z_][\w.]{0,63})\s*\(")
|
|
159
|
+
_ONCLICK_NAMES = frozenset(
|
|
160
|
+
{"otworz_w_nowym_oknie", "showHideOP.ShowHide", "showHideOO.ShowHide"}
|
|
161
|
+
)
|
|
162
|
+
|
|
8
163
|
|
|
9
164
|
class DiagnosticSink(Protocol):
|
|
10
165
|
def __call__(self, event: DiagnosticEvent, /) -> None: ...
|
|
@@ -21,5 +176,371 @@ def logging_sink(event: DiagnosticEvent) -> None:
|
|
|
21
176
|
"elapsed_seconds": event.elapsed_seconds,
|
|
22
177
|
"budget_requests_dispatched": event.budget_requests_dispatched,
|
|
23
178
|
"budget_response_bytes": event.budget_response_bytes,
|
|
179
|
+
"frames": event.frames,
|
|
180
|
+
"http_status": event.http_status,
|
|
24
181
|
},
|
|
25
182
|
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _chain(error: BaseException) -> Iterator[BaseException]:
|
|
186
|
+
"""The error, then the failures it replaced: an explicit cause or a
|
|
187
|
+
context suppressed with `from None`. Only their frames are read."""
|
|
188
|
+
seen: set[int] = set()
|
|
189
|
+
current: BaseException | None = error
|
|
190
|
+
while current is not None and id(current) not in seen and len(seen) < _CHAIN_LIMIT:
|
|
191
|
+
seen.add(id(current))
|
|
192
|
+
yield current
|
|
193
|
+
current = current.__cause__ or (
|
|
194
|
+
current.__context__ if current.__suppress_context__ else None
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _owned(module: str, packages: tuple[str, ...]) -> bool:
|
|
199
|
+
return any(module == name or module.startswith(name + ".") for name in packages)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _frames(
|
|
203
|
+
error: BaseException, packages: tuple[str, ...]
|
|
204
|
+
) -> list[tuple[FrameType, int]]:
|
|
205
|
+
"""Owned frames, outer call first; the original failure site is last."""
|
|
206
|
+
found: list[tuple[FrameType, int]] = []
|
|
207
|
+
for failure in _chain(error):
|
|
208
|
+
for frame, line in traceback.walk_tb(failure.__traceback__):
|
|
209
|
+
module = str(frame.f_globals.get("__name__", ""))
|
|
210
|
+
if not _owned(module, packages):
|
|
211
|
+
continue
|
|
212
|
+
if (module, frame.f_code.co_name) in RAISE_HELPERS:
|
|
213
|
+
continue
|
|
214
|
+
found.append((frame, line))
|
|
215
|
+
return found
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _site(frame: FrameType, line: int) -> str:
|
|
219
|
+
module = str(frame.f_globals.get("__name__", ""))
|
|
220
|
+
if module.startswith(_PACKAGE + "."):
|
|
221
|
+
module = module[len(_PACKAGE) + 1 :]
|
|
222
|
+
return f"{module}:{frame.f_code.co_qualname}:{line}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def failure_frames(
|
|
226
|
+
error: BaseException,
|
|
227
|
+
limit: int = 6,
|
|
228
|
+
packages: tuple[str, ...] = (_PACKAGE,),
|
|
229
|
+
) -> tuple[str, ...]:
|
|
230
|
+
"""Where `error` happened, as `module:function:line`, innermost last.
|
|
231
|
+
|
|
232
|
+
Code locations only, opaque and specific to a release. Never raises.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
return tuple(_site(frame, line) for frame, line in _frames(error, packages))[
|
|
236
|
+
-limit:
|
|
237
|
+
]
|
|
238
|
+
except Exception:
|
|
239
|
+
return ()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _redaction_vocabulary() -> tuple[
|
|
243
|
+
frozenset[str], frozenset[str], tuple[str, ...], tuple[re.Pattern[str], ...]
|
|
244
|
+
]:
|
|
245
|
+
# Lazy: config imports models, which this module also imports.
|
|
246
|
+
from librus_python_api import config
|
|
247
|
+
|
|
248
|
+
texts = set(config.GRADE_EMPTY_MARKERS) | {
|
|
249
|
+
config.GRADE_CURRENT_HEADER,
|
|
250
|
+
config.GRADE_INLINE_DETAIL_LABEL,
|
|
251
|
+
config.GRADE_PREDICTED_ANNUAL_HEADER,
|
|
252
|
+
*config.GRADE_MERGED_SUBJECTS,
|
|
253
|
+
*config.GRADE_SUMMARY_HEADERS,
|
|
254
|
+
*config.GRADE_PERIOD_HEADERS,
|
|
255
|
+
*config.GRADE_PREDICTED_PERIOD_HEADERS,
|
|
256
|
+
*config.GRADE_AVERAGE_HEADERS,
|
|
257
|
+
}
|
|
258
|
+
texts |= {label.casefold() for label in config.FORMATIVE_HEADERS}
|
|
259
|
+
profile = (*config.PROFILE_LABELS, *config.PROFILE_OWNER_LABELS)
|
|
260
|
+
texts |= set(profile) | {f"{label}:" for label in profile}
|
|
261
|
+
texts |= set(config.PAGE_NOTICES) | {config.LOGGED_OUT_HEADING}
|
|
262
|
+
prefixes = tuple(
|
|
263
|
+
sorted(
|
|
264
|
+
(
|
|
265
|
+
value
|
|
266
|
+
for name, value in vars(config).items()
|
|
267
|
+
if name.endswith("_PATH_PREFIX") and isinstance(value, str)
|
|
268
|
+
),
|
|
269
|
+
key=len,
|
|
270
|
+
reverse=True,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
patterns = (config.GRADE_POINT_ROW_NODE, config.GRADE_POINT_RESULT_HEADER)
|
|
274
|
+
return frozenset(texts), config.TOOLTIP_LABELS, prefixes, patterns
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _Redactor:
|
|
278
|
+
def __init__(self) -> None:
|
|
279
|
+
self.texts, self.labels, self.prefixes, self.patterns = _redaction_vocabulary()
|
|
280
|
+
self.nodes = 0
|
|
281
|
+
|
|
282
|
+
def text(self, value: str) -> str | None:
|
|
283
|
+
normalized = " ".join(value.split())
|
|
284
|
+
if not normalized:
|
|
285
|
+
return None
|
|
286
|
+
if (
|
|
287
|
+
normalized in self.texts
|
|
288
|
+
or normalized.casefold() in self.texts
|
|
289
|
+
or self.patterns[1].fullmatch(normalized)
|
|
290
|
+
):
|
|
291
|
+
return repr(normalized)
|
|
292
|
+
return f"TEXT(len={len(normalized)})"
|
|
293
|
+
|
|
294
|
+
def title(self, value: str) -> str:
|
|
295
|
+
from librus_python_api.grade_parsers import title_label
|
|
296
|
+
|
|
297
|
+
for points in (False, True):
|
|
298
|
+
if title_label(value, points=points) in self.texts:
|
|
299
|
+
return repr(title_label(value, points=points))
|
|
300
|
+
parts: list[str] = []
|
|
301
|
+
for chunk in re.split(r"<br\s*/?>|\n", value, flags=re.I):
|
|
302
|
+
rendered = " ".join(re.sub(r"<[^>]*>", " ", chunk).split())
|
|
303
|
+
if not rendered:
|
|
304
|
+
parts.append("EMPTY")
|
|
305
|
+
continue
|
|
306
|
+
key, separator, _ = rendered.partition(":")
|
|
307
|
+
key = key.strip()
|
|
308
|
+
if not separator:
|
|
309
|
+
parts.append(f"NO-COLON(len={len(rendered)})")
|
|
310
|
+
elif key in self.labels:
|
|
311
|
+
parts.append(f"[{key}]")
|
|
312
|
+
else:
|
|
313
|
+
parts.append(f"LABEL?(len={len(key)})")
|
|
314
|
+
return "[" + ", ".join(parts) + "]"
|
|
315
|
+
|
|
316
|
+
def href(self, value: str) -> str:
|
|
317
|
+
path = value.split("?", 1)[0].split("#", 1)[0]
|
|
318
|
+
for prefix in self.prefixes:
|
|
319
|
+
if path.startswith(prefix):
|
|
320
|
+
rest = path[len(prefix) :]
|
|
321
|
+
if re.fullmatch(r"[0-9]{1,64}", rest):
|
|
322
|
+
return f"{prefix}<digits {len(rest)}>"
|
|
323
|
+
return f"{prefix}<other len {len(rest)}>"
|
|
324
|
+
if path.lower().startswith("javascript:"):
|
|
325
|
+
return f"javascript:<len {len(path) - 11}>"
|
|
326
|
+
return f"<other len {len(value)}>"
|
|
327
|
+
|
|
328
|
+
def identifier(self, value: str) -> str:
|
|
329
|
+
if self.patterns[0].fullmatch(value):
|
|
330
|
+
return "<GRADE_POINT_ROW_NODE>"
|
|
331
|
+
shaped = re.sub(r"[0-9]+", lambda m: f"<digits {len(m.group())}>", value)
|
|
332
|
+
if len(value) <= 64 and _ID_SHAPES.fullmatch(shaped):
|
|
333
|
+
return shaped
|
|
334
|
+
return f"<len {len(value)}>"
|
|
335
|
+
|
|
336
|
+
def attribute(self, name: str, value: str) -> str | None:
|
|
337
|
+
if name not in _ATTRIBUTES:
|
|
338
|
+
return f"attr?{len(name)}=<len {len(value)}>"
|
|
339
|
+
if name == "class":
|
|
340
|
+
tokens = [
|
|
341
|
+
token if token in _CLASSES else f"?{len(token)}"
|
|
342
|
+
for token in value.split()
|
|
343
|
+
]
|
|
344
|
+
return f"class={'.'.join(tokens)}"
|
|
345
|
+
if name in ("colspan", "rowspan"):
|
|
346
|
+
shown = (
|
|
347
|
+
value if re.fullmatch(r"[0-9]{1,3}", value) else f"<len {len(value)}>"
|
|
348
|
+
)
|
|
349
|
+
return f"{name}={shown}"
|
|
350
|
+
if name in ("id", "name"):
|
|
351
|
+
return f"{name}={self.identifier(value)}"
|
|
352
|
+
if name == "href":
|
|
353
|
+
return f"href={self.href(value)}"
|
|
354
|
+
if name == "title":
|
|
355
|
+
return f"title={self.title(value)}"
|
|
356
|
+
if name == "style":
|
|
357
|
+
hidden = "display:none" in value.replace(" ", "").casefold()
|
|
358
|
+
return "style=display:none" if hidden else "style"
|
|
359
|
+
if name == "onclick":
|
|
360
|
+
call = _ONCLICK.match(value)
|
|
361
|
+
if call is not None and call.group(1) in _ONCLICK_NAMES:
|
|
362
|
+
return f"onclick={call.group(1)}(...)"
|
|
363
|
+
return f"onclick=<len {len(value)}>"
|
|
364
|
+
return f"{name}=<len {len(value)}>"
|
|
365
|
+
|
|
366
|
+
def element(self, element: html.HtmlElement, depth: int = 0) -> str:
|
|
367
|
+
self.nodes += 1
|
|
368
|
+
tag = element.tag if isinstance(element.tag, str) else "#comment"
|
|
369
|
+
if tag == "#comment" or tag in ("script", "style"):
|
|
370
|
+
return f"<{tag} len={len(element.text or '')}>"
|
|
371
|
+
tag = _tag(tag)
|
|
372
|
+
attributes = [
|
|
373
|
+
shown
|
|
374
|
+
for name, value in element.attrib.items()
|
|
375
|
+
if (shown := self.attribute(str(name), str(value))) is not None
|
|
376
|
+
]
|
|
377
|
+
opening = " ".join([tag, *attributes])
|
|
378
|
+
parts: list[str] = []
|
|
379
|
+
if (shown := self.text(element.text or "")) is not None:
|
|
380
|
+
parts.append(shown)
|
|
381
|
+
for child in element:
|
|
382
|
+
if depth + 1 >= _DEPTH or self.nodes >= _NODES:
|
|
383
|
+
parts.append("...")
|
|
384
|
+
break
|
|
385
|
+
parts.append(self.element(child, depth + 1))
|
|
386
|
+
if (shown := self.text(child.tail or "")) is not None:
|
|
387
|
+
parts.append(shown)
|
|
388
|
+
return f"<{opening}>{''.join(parts)}</{tag}>"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _tag(name: str) -> str:
|
|
392
|
+
return name if name in _TAGS else f"?tag{len(name)}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _path(element: html.HtmlElement, redactor: _Redactor) -> str:
|
|
396
|
+
steps: list[str] = []
|
|
397
|
+
node: html.HtmlElement | None = element
|
|
398
|
+
while node is not None and isinstance(node.tag, str) and len(steps) < 12:
|
|
399
|
+
parent = node.getparent()
|
|
400
|
+
index = (
|
|
401
|
+
[sibling for sibling in parent if sibling.tag == node.tag].index(node) + 1
|
|
402
|
+
if parent is not None
|
|
403
|
+
else 1
|
|
404
|
+
)
|
|
405
|
+
classes = node.get("class", "").split()
|
|
406
|
+
token = _tag(node.tag) + (
|
|
407
|
+
"." + str(redactor.attribute("class", " ".join(classes)))[6:]
|
|
408
|
+
if classes
|
|
409
|
+
else ""
|
|
410
|
+
)
|
|
411
|
+
steps.append(f"{token}[{index}]" if parent is not None else token)
|
|
412
|
+
if node.tag == "table" and "decorated" in node.get("class", "").split():
|
|
413
|
+
break
|
|
414
|
+
node = parent
|
|
415
|
+
return " > ".join(reversed(steps))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _cell(cell: html.HtmlElement, redactor: _Redactor) -> str:
|
|
419
|
+
spans = "".join(
|
|
420
|
+
f" {redactor.attribute(name, str(cell.get(name)))}"
|
|
421
|
+
for name in ("colspan", "rowspan")
|
|
422
|
+
if cell.get(name, "1") != "1"
|
|
423
|
+
)
|
|
424
|
+
text = redactor.text(cell.text_content()) or "EMPTY"
|
|
425
|
+
return f"{_tag(str(cell.tag))}{spans}[{text}]"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _shown(label: str, texts: list[str]) -> str:
|
|
429
|
+
return " ".join([f" {label}: {len(texts)}", *texts[:_PAGE_NOTICES]])
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _page_summary(document: html.HtmlElement, redactor: _Redactor) -> list[str]:
|
|
433
|
+
"""A whole page as its markers, forms, notices and tables, row by row.
|
|
434
|
+
|
|
435
|
+
A page that fails as a whole (no expected table, an unknown notice, a
|
|
436
|
+
login or error page) is told apart by these, not by its first elements.
|
|
437
|
+
Only the first tables, rows and cells are shown.
|
|
438
|
+
"""
|
|
439
|
+
body = document.find("body")
|
|
440
|
+
text = " ".join((body if body is not None else document).text_content().split())
|
|
441
|
+
present = {
|
|
442
|
+
marker: document.get_element_by_id(marker, None) is not None
|
|
443
|
+
for marker in _PAGE_MARKERS
|
|
444
|
+
}
|
|
445
|
+
markers = " ".join(
|
|
446
|
+
f"{marker}={'yes' if found else 'no'}" for marker, found in present.items()
|
|
447
|
+
)
|
|
448
|
+
lines = [f" body text len={len(text)} {markers}"]
|
|
449
|
+
title = document.find(".//title")
|
|
450
|
+
titles = [] if title is None else [redactor.text(title.text_content()) or "EMPTY"]
|
|
451
|
+
lines.append(_shown("title", titles))
|
|
452
|
+
headings = [
|
|
453
|
+
redactor.text(heading.text_content()) or "EMPTY"
|
|
454
|
+
for heading in document.iter("h1", "h2", "h3")
|
|
455
|
+
]
|
|
456
|
+
lines.append(_shown("headings", headings))
|
|
457
|
+
notices = document.xpath(
|
|
458
|
+
'//div[contains(concat(" ",normalize-space(@class)," ")," warning-content ")]'
|
|
459
|
+
)
|
|
460
|
+
shown = [redactor.text(notice.text_content()) or "EMPTY" for notice in notices]
|
|
461
|
+
lines.append(_shown("notices", shown))
|
|
462
|
+
forms = [redactor.href(form.get("action", "")) for form in document.iter("form")]
|
|
463
|
+
inputs: dict[str, int] = {}
|
|
464
|
+
for field in document.iter("input"):
|
|
465
|
+
kind = field.get("type", "text").casefold()
|
|
466
|
+
kind = kind if kind in _INPUT_TYPES else "?"
|
|
467
|
+
inputs[kind] = inputs.get(kind, 0) + 1
|
|
468
|
+
counted = " ".join(f"{kind}={count}" for kind, count in sorted(inputs.items()))
|
|
469
|
+
lines.append(_shown("forms", forms) + (f"; inputs {counted}" if counted else ""))
|
|
470
|
+
tables = list(document.iter("table"))
|
|
471
|
+
lines.append(f" tables: {len(tables)}")
|
|
472
|
+
for table in tables[:_PAGE_TABLES]:
|
|
473
|
+
rows = [
|
|
474
|
+
row
|
|
475
|
+
for row in table.iter("tr")
|
|
476
|
+
if next(row.iterancestors("table"), None) is table
|
|
477
|
+
]
|
|
478
|
+
lines.append(f" {_path(table, redactor)}: {len(rows)} rows")
|
|
479
|
+
for row in rows[:_PAGE_ROWS]:
|
|
480
|
+
cells = [cell for cell in row if cell.tag in ("td", "th")]
|
|
481
|
+
more = " ..." if len(cells) > _PAGE_CELLS else ""
|
|
482
|
+
rendered = " ".join(_cell(cell, redactor) for cell in cells[:_PAGE_CELLS])
|
|
483
|
+
lines.append(f" {rendered}{more}")
|
|
484
|
+
if len(rows) > _PAGE_ROWS:
|
|
485
|
+
lines.append(" ...")
|
|
486
|
+
if len(tables) > _PAGE_TABLES:
|
|
487
|
+
lines.append(" ...")
|
|
488
|
+
return lines
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _is_page(element: html.HtmlElement) -> bool:
|
|
492
|
+
return element.getparent() is None and element.tag == "html"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def describe_failure(error: BaseException) -> str | None:
|
|
496
|
+
"""The page structure around a failure, for a bug report.
|
|
497
|
+
|
|
498
|
+
Shows the HTML elements held by the innermost library frame that holds
|
|
499
|
+
any: tags, safe class tokens, known tooltip labels, known link families
|
|
500
|
+
and lengths. A whole page is summarized last (see _page_summary). Text
|
|
501
|
+
and attribute values are masked unless they equal a parser constant.
|
|
502
|
+
Returns None when there is nothing to show; never raises.
|
|
503
|
+
"""
|
|
504
|
+
try:
|
|
505
|
+
redactor = _Redactor()
|
|
506
|
+
for frame, line in reversed(_frames(error, (_PACKAGE,))):
|
|
507
|
+
local_items = list(frame.f_locals.items())
|
|
508
|
+
elements = [
|
|
509
|
+
(name, value)
|
|
510
|
+
for name, value in local_items
|
|
511
|
+
if isinstance(value, html.HtmlElement)
|
|
512
|
+
][:_ELEMENT_LOCALS]
|
|
513
|
+
sequences = [
|
|
514
|
+
(name, value)
|
|
515
|
+
for name, value in local_items
|
|
516
|
+
if isinstance(value, (list, tuple))
|
|
517
|
+
and value
|
|
518
|
+
and all(isinstance(item, html.HtmlElement) for item in value)
|
|
519
|
+
][:_ELEMENT_LOCALS]
|
|
520
|
+
if not elements and not sequences:
|
|
521
|
+
continue
|
|
522
|
+
lines = [f"at {_site(frame, line)}"]
|
|
523
|
+
# The elements nearest the failure first; a page summary last.
|
|
524
|
+
for name, value in sorted(elements, key=lambda item: _is_page(item[1])):
|
|
525
|
+
redactor.nodes = 0
|
|
526
|
+
if _is_page(value):
|
|
527
|
+
summary = "\n".join(_page_summary(value, redactor))
|
|
528
|
+
if len(summary) > _ELEMENT_BYTES:
|
|
529
|
+
summary = summary[:_ELEMENT_BYTES] + "...(truncated)"
|
|
530
|
+
lines += [f"{name}: page", summary]
|
|
531
|
+
continue
|
|
532
|
+
lines.append(f"{name}: {_path(value, redactor)}")
|
|
533
|
+
rendered = redactor.element(value)
|
|
534
|
+
if len(rendered) > _ELEMENT_BYTES:
|
|
535
|
+
rendered = rendered[:_ELEMENT_BYTES] + "...(truncated)"
|
|
536
|
+
lines.append(" " + rendered)
|
|
537
|
+
for name, items in sequences:
|
|
538
|
+
tags = ",".join(str(item.tag) for item in items[:20])
|
|
539
|
+
lines.append(f"{name}: {len(items)} elements: {tags}")
|
|
540
|
+
report = "\n".join(lines)
|
|
541
|
+
if len(report) > _REPORT_BYTES:
|
|
542
|
+
report = report[:_REPORT_BYTES] + "...(truncated)"
|
|
543
|
+
return report
|
|
544
|
+
return None
|
|
545
|
+
except Exception:
|
|
546
|
+
return None
|