librus-python-api 1.8.0__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.8.0"
141
+ __version__ = "1.8.1"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -688,6 +688,21 @@ PROFILE_LABELS = MappingProxyType(
688
688
  "Szkoła": "school",
689
689
  }
690
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 = "?"
691
706
 
692
707
  # HTML summary headers omit the two leading body cells: expander and subject.
693
708
  # These labels/layout rules are source-informed requirements, not live evidence.
@@ -30,6 +30,16 @@ _ELEMENT_LOCALS = 4
30
30
  _DEPTH = 6
31
31
  _NODES = 120
32
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")
33
43
  _REPORT_BYTES = 32 * 1024
34
44
  # Markup observed on Synergia pages. Other class tokens, tags and attribute
35
45
  # names are shown only as lengths, since they could carry data.
@@ -45,6 +55,7 @@ _CLASSES = frozenset(
45
55
  "decorated",
46
56
  "detail-grades",
47
57
  "double",
58
+ "form",
48
59
  "gradesCell",
49
60
  "grade-box",
50
61
  "grade-box-mapping",
@@ -53,6 +64,7 @@ _CLASSES = frozenset(
53
64
  "legend",
54
65
  "line0",
55
66
  "line1",
67
+ "luckyNumber",
56
68
  "micro",
57
69
  "newSchemaGradesByStudentTable",
58
70
  "no-border-left",
@@ -65,6 +77,7 @@ _CLASSES = frozenset(
65
77
  "stretch",
66
78
  "studentRow",
67
79
  "tooltip",
80
+ "warning-content",
68
81
  }
69
82
  )
70
83
  _TAGS = frozenset(
@@ -164,6 +177,7 @@ def logging_sink(event: DiagnosticEvent) -> None:
164
177
  "budget_requests_dispatched": event.budget_requests_dispatched,
165
178
  "budget_response_bytes": event.budget_response_bytes,
166
179
  "frames": event.frames,
180
+ "http_status": event.http_status,
167
181
  },
168
182
  )
169
183
 
@@ -242,6 +256,9 @@ def _redaction_vocabulary() -> tuple[
242
256
  *config.GRADE_AVERAGE_HEADERS,
243
257
  }
244
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}
245
262
  prefixes = tuple(
246
263
  sorted(
247
264
  (
@@ -398,13 +415,91 @@ def _path(element: html.HtmlElement, redactor: _Redactor) -> str:
398
415
  return " > ".join(reversed(steps))
399
416
 
400
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
+
401
495
  def describe_failure(error: BaseException) -> str | None:
402
496
  """The page structure around a failure, for a bug report.
403
497
 
404
498
  Shows the HTML elements held by the innermost library frame that holds
405
499
  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.
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.
408
503
  """
409
504
  try:
410
505
  redactor = _Redactor()
@@ -425,8 +520,15 @@ def describe_failure(error: BaseException) -> str | None:
425
520
  if not elements and not sequences:
426
521
  continue
427
522
  lines = [f"at {_site(frame, line)}"]
428
- for name, value in elements:
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])):
429
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
430
532
  lines.append(f"{name}: {_path(value, redactor)}")
431
533
  rendered = redactor.element(value)
432
534
  if len(rendered) > _ELEMENT_BYTES:
@@ -7,6 +7,7 @@ __all__ = [
7
7
  "AccountActionRequiredError",
8
8
  "ClosedError",
9
9
  "CheckpointError",
10
+ "ConsentRequiredError",
10
11
  "ConnectionError",
11
12
  "CredentialsRejectedError",
12
13
  "ErrorKind",
@@ -31,6 +32,7 @@ __all__ = [
31
32
  class ErrorKind(StrEnum):
32
33
  CHECKPOINT = "checkpoint"
33
34
  INVALID_INPUT = "invalid_input"
35
+ CONSENT_REQUIRED = "consent_required"
34
36
  CREDENTIALS_REJECTED = "credentials_rejected"
35
37
  ACCOUNT_ACTION_REQUIRED = "account_action_required"
36
38
  SESSION_EXPIRED = "session_expired"
@@ -78,6 +80,13 @@ class InvalidInputError(LibrusError):
78
80
  pass
79
81
 
80
82
 
83
+ class ConsentRequiredError(InvalidInputError):
84
+ """A call with an effect the user must agree to was made without consent.
85
+
86
+ A subclass of InvalidInputError, which these calls raised before 1.8.1.
87
+ """
88
+
89
+
81
90
  class CredentialsRejectedError(LibrusError):
82
91
  pass
83
92
 
@@ -89,6 +98,9 @@ class AccountActionRequiredError(LibrusError):
89
98
  class SessionExpiredError(LibrusError):
90
99
  # Library-private provenance only; never attach a URL, body or credentials.
91
100
  _messages_origin = False
101
+ # Inferred from a page without the logged-in menu, not from a 401 or a
102
+ # login redirect; a fresh session getting the same page is a layout.
103
+ _logged_out_page = False
92
104
 
93
105
 
94
106
  class AccessDeniedError(LibrusError):
@@ -154,6 +166,7 @@ class StorageError(LibrusError):
154
166
  _ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
155
167
  ErrorKind.CHECKPOINT: CheckpointError,
156
168
  ErrorKind.INVALID_INPUT: InvalidInputError,
169
+ ErrorKind.CONSENT_REQUIRED: ConsentRequiredError,
157
170
  ErrorKind.CREDENTIALS_REJECTED: CredentialsRejectedError,
158
171
  ErrorKind.ACCOUNT_ACTION_REQUIRED: AccountActionRequiredError,
159
172
  ErrorKind.SESSION_EXPIRED: SessionExpiredError,
@@ -1,6 +1,7 @@
1
1
  """Run one parser on a saved Synergia page and report the outcome offline.
2
2
 
3
3
  python -m librus_python_api.inspect_page grades page.html
4
+ python -m librus_python_api.inspect_page profile informacja.html
4
5
 
5
6
  The report holds counts, code locations and page structure built from
6
7
  allowlists (see diagnostics.describe_failure), never page text or values.
@@ -20,6 +21,8 @@ from librus_python_api.diagnostics import describe_failure, failure_frames
20
21
  from librus_python_api.exceptions import LibrusError
21
22
  from librus_python_api.grade_parsers import parse_final_grades
22
23
  from librus_python_api.grade_records import parse_grade_records
24
+ from librus_python_api.models import Availability
25
+ from librus_python_api.parsers import parse_profile
23
26
 
24
27
  NOTICE = (
25
28
  "This report holds counts, code locations and page structure only, no page "
@@ -45,10 +48,19 @@ def _final_grades(body: bytes) -> dict[str, int]:
45
48
  return {"subjects": len(parse_final_grades(body))}
46
49
 
47
50
 
51
+ def _profile(body: bytes) -> dict[str, int]:
52
+ lucky = parse_profile(body).lucky_number.availability
53
+ return {"lucky_number": int(lucky == Availability.AVAILABLE)}
54
+
55
+
48
56
  PARSERS: dict[str, Callable[[bytes], dict[str, int]]] = {
49
57
  "grades": _grades,
50
58
  "final-grades": _final_grades,
59
+ "profile": _profile,
51
60
  }
61
+ # macOS TextEdit saves a viewed page source as a new document holding the
62
+ # source as text, not the page itself.
63
+ _TEXT_EXPORT = re.compile(rb'<meta name="Generator" content="Cocoa HTML Writer"', re.I)
52
64
 
53
65
 
54
66
  def inspect(parser: str, body: bytes) -> tuple[bool, list[str]]:
@@ -60,6 +72,12 @@ def inspect(parser: str, body: bytes) -> tuple[bool, list[str]]:
60
72
  # Synergia serves UTF-8; another encoding is a saving problem, not a layout.
61
73
  lines.append("FAILED input: the file is not UTF-8; save the page as UTF-8")
62
74
  return False, lines
75
+ if _TEXT_EXPORT.search(body[:4096]):
76
+ lines.append(
77
+ "FAILED input: the file is page source saved as a text document; "
78
+ "save the page itself (Safari: File > Save As, format Page Source)"
79
+ )
80
+ return False, lines
63
81
  if not re.search(rb"<html[\s>]", body[:4096], re.I):
64
82
  # A copied table (outerHTML) is a fragment; parsers expect a page.
65
83
  body = b"<!doctype html><html><body>" + body + b"</body></html>"
@@ -90,6 +90,8 @@ class DiagnosticEvent:
90
90
  budget_response_bytes: int
91
91
  # Code locations of a failure (see diagnostics.failure_frames), else empty.
92
92
  frames: tuple[str, ...] = ()
93
+ # HTTP status of the operation's last checked read response, if any.
94
+ http_status: int | None = None
93
95
 
94
96
 
95
97
  @dataclass(frozen=True, slots=True)
@@ -8,7 +8,14 @@ from typing import Any
8
8
  from lxml import etree, html
9
9
  from pydantic import ValidationError
10
10
 
11
- from librus_python_api.config import PAGE_NOTICES, PROFILE_LABELS
11
+ from librus_python_api.config import (
12
+ LOGGED_IN_PAGE_MARKER,
13
+ LOGGED_OUT_HEADING,
14
+ PAGE_NOTICES,
15
+ PROFILE_LABELS,
16
+ PROFILE_LUCKY_NUMBER_CLASS,
17
+ PROFILE_LUCKY_NUMBER_DISABLED,
18
+ )
12
19
  from librus_python_api.exceptions import ErrorKind, LibrusError
13
20
  from librus_python_api.models import (
14
21
  Availability,
@@ -178,6 +185,23 @@ def parse_page(body: bytes, *, repeatable_id: str | None = None) -> html.HtmlEle
178
185
  return document
179
186
 
180
187
 
188
+ def is_logged_out_page(body: bytes) -> bool:
189
+ """Synergia's page for a session it no longer knows; never raises."""
190
+ if LOGGED_OUT_HEADING.encode() not in body:
191
+ return False
192
+ try:
193
+ parser = html.HTMLParser(no_network=True, recover=True, huge_tree=False)
194
+ document = html.document_fromstring(body.decode("utf-8"), parser=parser)
195
+ except (UnicodeError, etree.LxmlError, ValueError):
196
+ return False
197
+ if document.get_element_by_id(LOGGED_IN_PAGE_MARKER, None) is not None:
198
+ return False
199
+ headings = [
200
+ " ".join(h.text_content().split()) for h in document.iter("h1", "h2", "h3")
201
+ ]
202
+ return headings == [LOGGED_OUT_HEADING]
203
+
204
+
181
205
  def parse_profile(body: bytes) -> ProfileFields:
182
206
  document = parse_page(body)
183
207
  required = {"name", "class_name", "register_number", "tutor", "school"}
@@ -210,15 +234,21 @@ def parse_profile(body: bytes) -> ProfileFields:
210
234
  raise LibrusError(ErrorKind.PARSE)
211
235
  if not re.fullmatch(r"[0-9]{1,4}", candidates[0]["register_number"]):
212
236
  raise LibrusError(ErrorKind.PARSE)
213
- markers = document.xpath('//*[@id="luckyNumber"]')
237
+ markers = document.xpath(
238
+ '//span[contains(concat(" ",normalize-space(@class)," "),$token)]',
239
+ token=f" {PROFILE_LUCKY_NUMBER_CLASS} ",
240
+ )
214
241
  if not markers:
215
242
  lucky = LuckyNumber(Availability.UNAVAILABLE)
216
243
  elif len(markers) == 1:
217
244
  value = " ".join(markers[0].text_content().split())
218
- if not re.fullmatch(r"[0-9]{1,4}", value):
245
+ if value in ("", PROFILE_LUCKY_NUMBER_DISABLED):
246
+ lucky = LuckyNumber(Availability.UNAVAILABLE)
247
+ elif re.fullmatch(r"[0-9]{1,4}", value):
248
+ # No evidenced civil date in this marker. Never fabricate today's date.
249
+ lucky = LuckyNumber(Availability.AVAILABLE, int(value))
250
+ else:
219
251
  raise LibrusError(ErrorKind.PARSE)
220
- # No evidenced civil date in this marker. Never fabricate today's date.
221
- lucky = LuckyNumber(Availability.AVAILABLE, int(value))
222
252
  else:
223
253
  raise LibrusError(ErrorKind.PARSE)
224
254
  fields = candidates[0]
@@ -1,6 +1,7 @@
1
1
  """Public login-scoped account service under one shared traffic boundary."""
2
2
 
3
3
  import asyncio
4
+ import contextvars
4
5
  import hashlib
5
6
  import hmac
6
7
  import json
@@ -52,6 +53,7 @@ from librus_python_api.config import (
52
53
  ConnectionSettings,
53
54
  OperationLimits,
54
55
  SchedulerLimits,
56
+ SideEffect,
55
57
  TransportLimits,
56
58
  agenda_form,
57
59
  attendance_view_form,
@@ -69,7 +71,12 @@ from librus_python_api.config import (
69
71
  )
70
72
  from librus_python_api.detail_fields import normalize_detail_fields
71
73
  from librus_python_api.diagnostics import DiagnosticSink, failure_frames
72
- from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
74
+ from librus_python_api.exceptions import (
75
+ ErrorKind,
76
+ LibrusError,
77
+ ParseError,
78
+ SessionExpiredError,
79
+ )
73
80
  from librus_python_api.gateway_grades import (
74
81
  DescriptiveTable,
75
82
  descriptive_grades,
@@ -198,7 +205,12 @@ from librus_python_api.notifications import (
198
205
  parse_schedule_events,
199
206
  validate_response,
200
207
  )
201
- from librus_python_api.parsers import parse_identity, parse_login, parse_profile
208
+ from librus_python_api.parsers import (
209
+ is_logged_out_page,
210
+ parse_identity,
211
+ parse_login,
212
+ parse_profile,
213
+ )
202
214
  from librus_python_api.parsing import ParserPool
203
215
  from librus_python_api.recipients import (
204
216
  parse_recipient_group_choices,
@@ -381,6 +393,24 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
381
393
  raise LibrusError(ErrorKind.INVALID_INPUT)
382
394
 
383
395
 
396
+ def _cooldown_due(error: LibrusError) -> bool:
397
+ """A denial, an unrecovered expiry, or a logged-out page that a session
398
+ just signed in also got: signing in again at once cannot help."""
399
+ if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
400
+ return True
401
+ cause = error.__cause__
402
+ return (
403
+ error.kind is ErrorKind.PARSE
404
+ and isinstance(cause, SessionExpiredError)
405
+ and cause._logged_out_page
406
+ )
407
+
408
+
409
+ # The running operation's last checked read status, for its diagnostic event.
410
+ # A holder per operation, so concurrent operations do not share one.
411
+ _READ_STATUS: contextvars.ContextVar[list[int | None] | None] = contextvars.ContextVar(
412
+ "librus_read_status", default=None
413
+ )
384
414
  _METADATA_MAP_FALLBACK = frozenset(
385
415
  {ErrorKind.ACCESS_DENIED, ErrorKind.CONNECTION, ErrorKind.LIMIT, ErrorKind.PARSE}
386
416
  )
@@ -894,10 +924,10 @@ class AccountClient:
894
924
  max_age_seconds: float = 0.0,
895
925
  ) -> ModernMessageContent:
896
926
  validate_modern_message_reference(reference, self._alias)
897
- if type(allow_mark_read) is not bool or (
898
- reference.folder is MessageFolder.RECEIVED and not allow_mark_read
899
- ):
927
+ if type(allow_mark_read) is not bool:
900
928
  raise LibrusError(ErrorKind.INVALID_INPUT)
929
+ if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
930
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
901
931
  if reference.archived:
902
932
  # No archived content route or mark-read effect is established yet.
903
933
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
@@ -1178,6 +1208,8 @@ class AccountClient:
1178
1208
  started = time.monotonic()
1179
1209
  outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
1180
1210
  frames: tuple[str, ...] = ()
1211
+ status: list[int | None] = [None]
1212
+ status_token = _READ_STATUS.set(status)
1181
1213
  try:
1182
1214
  if deadline is None:
1183
1215
  deadline = asyncio.timeout(None)
@@ -1195,10 +1227,7 @@ class AccountClient:
1195
1227
  outcome = error.kind
1196
1228
  if self._service._diagnostic_sink is not None:
1197
1229
  frames = failure_frames(error)
1198
- if authenticate and error.kind in (
1199
- ErrorKind.ACCESS_DENIED,
1200
- ErrorKind.SESSION_EXPIRED,
1201
- ):
1230
+ if authenticate and _cooldown_due(error):
1202
1231
  self._cooldowns[operation] = (
1203
1232
  time.monotonic() + self._service._transport_limits.cooldown_seconds,
1204
1233
  error.kind,
@@ -1215,6 +1244,7 @@ class AccountClient:
1215
1244
  # produce a misleading success diagnostic at this owner boundary.
1216
1245
  outcome = ErrorKind.CONNECTION
1217
1246
  finally:
1247
+ _READ_STATUS.reset(status_token)
1218
1248
  self._emit(
1219
1249
  DiagnosticEvent(
1220
1250
  operation,
@@ -1223,6 +1253,7 @@ class AccountClient:
1223
1253
  budget.requests_dispatched,
1224
1254
  budget.response_bytes,
1225
1255
  frames,
1256
+ status[0],
1226
1257
  )
1227
1258
  )
1228
1259
 
@@ -1761,10 +1792,10 @@ class AccountClient:
1761
1792
  ) -> MessageContent:
1762
1793
  """Open one message; received opens require explicit read-effect consent."""
1763
1794
  validate_reference(reference, self._alias)
1764
- if type(allow_mark_read) is not bool or (
1765
- reference.folder is MessageFolder.RECEIVED and not allow_mark_read
1766
- ):
1795
+ if type(allow_mark_read) is not bool:
1767
1796
  raise LibrusError(ErrorKind.INVALID_INPUT)
1797
+ if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
1798
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
1768
1799
  operation: Literal["message_content_received", "message_content_sent"] = (
1769
1800
  "message_content_received"
1770
1801
  if reference.folder is MessageFolder.RECEIVED
@@ -2044,6 +2075,8 @@ class AccountClient:
2044
2075
  started = time.monotonic()
2045
2076
  outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
2046
2077
  frames: tuple[str, ...] = ()
2078
+ status: list[int | None] = [None]
2079
+ status_token = _READ_STATUS.set(status)
2047
2080
  try:
2048
2081
  async with asyncio.timeout(budget.remaining_seconds()):
2049
2082
  async with self._lock:
@@ -2062,6 +2095,7 @@ class AccountClient:
2062
2095
  outcome = ErrorKind.TIMEOUT
2063
2096
  raise LibrusError(ErrorKind.TIMEOUT) from None
2064
2097
  finally:
2098
+ _READ_STATUS.reset(status_token)
2065
2099
  self._emit(
2066
2100
  DiagnosticEvent(
2067
2101
  operation,
@@ -2070,6 +2104,7 @@ class AccountClient:
2070
2104
  budget.requests_dispatched,
2071
2105
  budget.response_bytes,
2072
2106
  frames,
2107
+ status[0],
2073
2108
  )
2074
2109
  )
2075
2110
 
@@ -2101,7 +2136,7 @@ class AccountClient:
2101
2136
  try:
2102
2137
  result = await self._authenticated(fetch, budget, retry_safe)
2103
2138
  except LibrusError as error:
2104
- if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
2139
+ if _cooldown_due(error):
2105
2140
  self._cooldowns[operation] = (
2106
2141
  time.monotonic() + self._service._transport_limits.cooldown_seconds,
2107
2142
  error.kind,
@@ -2119,7 +2154,10 @@ class AccountClient:
2119
2154
 
2120
2155
  Initial login is never retried: a failed credential submission must not
2121
2156
  be replayed. A view-changing POST is never replayed either; expiry only
2122
- clears the session so a later explicit call can log in again.
2157
+ clears the session so a later explicit call can log in again. A
2158
+ logged-out page proves the request was not applied to the account, so
2159
+ a read without an account effect answered with one is repeated once in
2160
+ a fresh session; a session just signed in that gets it fails as PARSE.
2123
2161
  """
2124
2162
  fresh_login = self._identity is None
2125
2163
  if fresh_login:
@@ -2127,18 +2165,22 @@ class AccountClient:
2127
2165
  try:
2128
2166
  return await fetch(budget, fresh_login)
2129
2167
  except SessionExpiredError as error:
2168
+ if error._logged_out_page and fresh_login:
2169
+ raise LibrusError(ErrorKind.PARSE) from error
2130
2170
  modern_expired = error._messages_origin
2131
2171
  if modern_expired:
2132
2172
  self._invalidate_modern()
2133
2173
  else:
2134
2174
  self._invalidate()
2135
- if not retry_safe:
2175
+ if not retry_safe and not error._logged_out_page:
2136
2176
  raise
2137
2177
  if not modern_expired:
2138
2178
  await self._authenticate(budget)
2139
2179
  try:
2140
2180
  return await fetch(budget, not modern_expired)
2141
2181
  except SessionExpiredError as error:
2182
+ if error._logged_out_page:
2183
+ raise LibrusError(ErrorKind.PARSE) from error
2142
2184
  if error._messages_origin:
2143
2185
  self._invalidate_modern()
2144
2186
  else:
@@ -2167,7 +2209,26 @@ class AccountClient:
2167
2209
  endpoint, budget, form=form, reference_id=reference, query=query
2168
2210
  )
2169
2211
  self._validate_read_response(response, content_type)
2170
- return await self._service._parsers.run(parse, response.body, budget)
2212
+ try:
2213
+ return await self._service._parsers.run(parse, response.body, budget)
2214
+ except ParseError as error:
2215
+ if (
2216
+ content_type != HTML
2217
+ or ENDPOINTS[endpoint].origin != "synergia"
2218
+ or not await self._service._parsers.run(
2219
+ is_logged_out_page, response.body, budget
2220
+ )
2221
+ ):
2222
+ raise
2223
+ # Synergia's answer to a session it no longer knows. A read
2224
+ # with an account effect (mark-read) is not repeated: it fails
2225
+ # as an unrecovered expiry and the next call signs in.
2226
+ expired = SessionExpiredError(ErrorKind.SESSION_EXPIRED)
2227
+ expired._logged_out_page = ENDPOINTS[endpoint].side_effect in (
2228
+ SideEffect.NONE,
2229
+ SideEffect.SELECT_VIEW,
2230
+ )
2231
+ raise expired from error
2171
2232
  except BaseException as error:
2172
2233
  if ENDPOINTS[endpoint].origin == "messages":
2173
2234
  self._invalidate_modern()
@@ -2548,6 +2609,9 @@ class AccountClient:
2548
2609
  def _validate_read_response(
2549
2610
  self, response: TransportResponse, content_type: str
2550
2611
  ) -> None:
2612
+ holder = _READ_STATUS.get()
2613
+ if holder is not None:
2614
+ holder[0] = response.status
2551
2615
  if 300 <= response.status < 400:
2552
2616
  if self._is_module_unavailable_redirect(response):
2553
2617
  raise LibrusError(ErrorKind.MODULE_UNAVAILABLE)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.8.0
3
+ Version: 1.8.1
4
4
  Summary: Independent bounded async Librus Synergia client
5
5
  Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
6
6
  Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
@@ -42,9 +42,12 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
42
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
43
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
44
44
 
45
- **Status:** `1.8.0` reports where a failure happened (code locations and a
46
- redacted page structure, see [failure diagnostics](API.md#failure-diagnostics-180)),
47
- reads gateway descriptive grades for schools on the new descriptive schema
45
+ **Status:** `1.8.1` recovers a session Synergia no longer knows (it answers
46
+ with a "Brak dostępu" page, not a login redirect), reads the lucky number
47
+ from the page header and summarizes a page that fails as a whole in
48
+ diagnostics. `1.8.0` reports where a failure
49
+ happened (code locations and a redacted page structure, see
50
+ [failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
48
51
  (source-informed, first populated read live on one school) and keeps the 1.7.1 point
49
52
  grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
50
53
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
@@ -60,7 +63,7 @@ See [limitations](#supported-features-and-limitations) below.
60
63
  Install the exact release from PyPI after its gated publication completes:
61
64
 
62
65
  ```sh
63
- python -m pip install librus-python-api==1.8.0
66
+ python -m pip install librus-python-api==1.8.1
64
67
  ```
65
68
 
66
69
  Pin the exact version qualified by your application. Before publication, use a
@@ -76,7 +79,7 @@ python -m pip install .
76
79
  To install a locally built wheel instead:
77
80
 
78
81
  ```sh
79
- python -m pip install ./dist/librus_python_api-1.8.0-py3-none-any.whl
82
+ python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
80
83
  ```
81
84
 
82
85
  No console script or background process is installed: import the library in your
@@ -280,7 +283,10 @@ except LibrusError as error:
280
283
 
281
284
  To check a saved page offline (a page source saved with Ctrl+U, or a copied
282
285
  table), run `python -m librus_python_api.inspect_page grades page.html` (or
283
- `final-grades`). It prints counts, or the failure's code locations and masked
286
+ `final-grades`, or `profile` for the information page `/informacja`). Save the
287
+ page source itself: in Safari use File > Save As with format Page Source; in
288
+ Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
289
+ into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
284
290
  structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
285
291
  to receive one `DiagnosticEvent` per operation, with `frames` on failure.
286
292
  None of these print page text, values, logins or exception messages.
@@ -1,4 +1,4 @@
1
- librus_python_api/__init__.py,sha256=DmUSk3fOr0x7n27EcLzjJy-CQhMvjr1J_DPKkFCZ1CA,6513
1
+ librus_python_api/__init__.py,sha256=KnN3xexlc15CN1XQPtwLNhUHc9Y82MyiQrMZEs4TYl4,6513
2
2
  librus_python_api/_notification_bootstrap.py,sha256=18nzH0doXBNaZXCz-_ckIvRrlHTWV5Mm1t5m2-5eYGI,4669
3
3
  librus_python_api/_notification_codec.py,sha256=hU6G3zGJ5fR8wfPC6fV2OqBKs-7XpT9eRnKO3FGsTzY,13451
4
4
  librus_python_api/_storage.py,sha256=M_6ZAzN4zs7laOdKJBRH1I2rvy3pgqYgC_MZtsd2xtM,15893
@@ -12,22 +12,22 @@ librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2
12
12
  librus_python_api/checkpoint.py,sha256=QBmG9GtpyaGRaFq-FXuGA3yR1BjuMFN9V3RTvvi_FNQ,1930
13
13
  librus_python_api/class_free_days.py,sha256=IwoCgr-9ZgEYYQ6717s4cDpyFzbosHMzESMabXJLGqA,2422
14
14
  librus_python_api/completed_lessons.py,sha256=ngGfBD6K4B6dR4elPYvNRvp40Q-GlrhUsUKqBbYY1KE,10727
15
- librus_python_api/config.py,sha256=wGWZXg7CeISl9v_z8X759Yq3f4q7ril1kELazEhVt80,60184
15
+ librus_python_api/config.py,sha256=F4w0F5r6uCObgyzO-cXkUtbz9Okh8HQEyfObAdOysig,61132
16
16
  librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
17
- librus_python_api/diagnostics.py,sha256=_mWLbOj0HQrVSgTR_TrGybzN7OOYHLsdfLFI-matmww,14595
18
- librus_python_api/exceptions.py,sha256=adVcJuzfB8wGrUlF2SfiyIlE9ZZyTAvGdWRtQIjsA3I,4879
17
+ librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
18
+ librus_python_api/exceptions.py,sha256=3Bt_1WbW2blSoljGAa00TK2AzU68tgYuRDd2k1onLTc,5392
19
19
  librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
20
20
  librus_python_api/formative_grades.py,sha256=VykwtGvViZVOJMwPH3Re1RTDCHMr1_m9_xHjZyaXDIQ,6452
21
21
  librus_python_api/gateway_grades.py,sha256=HHXJqtC47v2BTP3ufak9i9Q68phOxwtzWjahKdxkyX4,16086
22
22
  librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
23
23
  librus_python_api/grade_records.py,sha256=YltZ_OQLsfxj0nvK8_xTuv8ifMOu2jJBnVn6TTQKzaY,22722
24
24
  librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
25
- librus_python_api/inspect_page.py,sha256=W7F0dt8qPbJnG6gMnA5kiVEFKXc6MbA7RLookR2IJ5Q,4457
25
+ librus_python_api/inspect_page.py,sha256=AUm-n2Ewe4cQUnlFnhttcGK5B9shvFde_7t1JF7mEm0,5285
26
26
  librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
27
27
  librus_python_api/markup.py,sha256=p97ZRPqhGYTEBbzJq_0j4j8AKihgQzJqTv__QUNm8Ws,6084
28
28
  librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
29
29
  librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
30
- librus_python_api/models.py,sha256=g6N5WBNU8ieZbxydihpbicwU3_Y8SuQZY_IXOZH7I9s,41843
30
+ librus_python_api/models.py,sha256=IO-SYpqpQeuTej6kqNjBRXDbAbKITRZ1njRUyoEZ8t0,41951
31
31
  librus_python_api/modern_body.py,sha256=SocXJTyGm1Q_2owzh1PE1zcCB2kqZTz1KPJ2R4yMyoc,3895
32
32
  librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
33
33
  librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
@@ -35,7 +35,7 @@ librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5
35
35
  librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
36
36
  librus_python_api/notification_workflow.py,sha256=MsBJdwn6J1UUGlz3Ldzi7WEBz5Qsama7eGmZvUQj25g,12924
37
37
  librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
38
- librus_python_api/parsers.py,sha256=OFztbUMDzCywq17BgfFT485bVjyOqLksFHQJthZCx5Q,8120
38
+ librus_python_api/parsers.py,sha256=8sdfPsOZYHNYVZ4rwX_de5Y5rPl0umoQPon3uflQFlY,9178
39
39
  librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
40
40
  librus_python_api/persistence.py,sha256=F35yBRu6D8aYgdwKTpzVdqVTyiUaLQludUmyyskb3Vo,15529
41
41
  librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -44,10 +44,10 @@ librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQm
44
44
  librus_python_api/school_reads.py,sha256=YHta7DmhjS5vSa4Djq8pzp01-2jhVfGOo1Mnc738rV4,15209
45
45
  librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
46
46
  librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
47
- librus_python_api/service.py,sha256=7s5NAtuKHIPL4u-ctu9RNEbd8ctGgvPzSXQ5XEOCeCg,99697
47
+ librus_python_api/service.py,sha256=JY-RStBHmNOgGHISaC-pB2La2pdVGlJXXn52_kwkd3U,102316
48
48
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
49
49
  librus_python_api/transport.py,sha256=n3goXE34xcsO-bilsLVYEUW3JkJR-9rbgmqPu7ff9J8,35036
50
- librus_python_api-1.8.0.dist-info/METADATA,sha256=sOS6jBJ9c6o1-1lnKEqPwcs5ILCx090B6CuxP0i9qDY,13572
51
- librus_python_api-1.8.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
- librus_python_api-1.8.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
53
- librus_python_api-1.8.0.dist-info/RECORD,,
50
+ librus_python_api-1.8.1.dist-info/METADATA,sha256=clDgcKIX4m1Q6xagOEeKjRrcTTdonbX5v37tI2Q1_7s,14071
51
+ librus_python_api-1.8.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
+ librus_python_api-1.8.1.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
53
+ librus_python_api-1.8.1.dist-info/RECORD,,