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.
@@ -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.1"
141
+ __version__ = "1.8.1"
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",
@@ -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
  {
@@ -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