librus-python-api 1.8.0__py3-none-any.whl → 1.8.2__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.
@@ -1,5 +1,8 @@
1
1
  """Independent bounded async client. General live compatibility is unqualified."""
2
2
 
3
+ # Before the imports: config reads it for the User-Agent.
4
+ __version__ = "1.8.2"
5
+
3
6
  from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
4
7
  from librus_python_api.budget import RequestBudget
5
8
  from librus_python_api.config import (
@@ -138,8 +141,6 @@ from librus_python_api.models import (
138
141
  from librus_python_api.sending import SendAttempt
139
142
  from librus_python_api.service import AccountClient, LibrusService
140
143
 
141
- __version__ = "1.8.0"
142
-
143
144
  __all__ = [
144
145
  "ArchiveAbsences",
145
146
  "ArchiveAchievement",
@@ -15,7 +15,10 @@ from librus_python_api.attachment_routes import (
15
15
  )
16
16
  from librus_python_api.budget import RequestBudget
17
17
  from librus_python_api.config import ATTACHMENT_MAX_BYTES
18
- from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
18
+ from librus_python_api.exceptions import (
19
+ ErrorKind,
20
+ LibrusError,
21
+ )
19
22
  from librus_python_api.lifecycle import join_owned
20
23
  from librus_python_api.models import (
21
24
  AttachmentHeaders,
@@ -283,9 +286,7 @@ class ModernAttachmentStream(AttachmentStream):
283
286
  budget,
284
287
  )
285
288
  except BaseException as error:
286
- client._invalidate_modern()
287
- if isinstance(error, SessionExpiredError):
288
- error._messages_origin = True
289
+ client._modern_read_failed(error)
289
290
  raise
290
291
  await client._transport.stream_download(
291
292
  key,
@@ -31,6 +31,7 @@ from pydantic import (
31
31
  model_validator,
32
32
  )
33
33
 
34
+ from librus_python_api import __version__
34
35
  from librus_python_api.exceptions import ErrorKind, LibrusError
35
36
  from librus_python_api.models import (
36
37
  AttendanceView,
@@ -113,7 +114,7 @@ UPSTREAM_ORIGINS = MappingProxyType(
113
114
  OAUTH_QUERY = (("client_id", "46"),)
114
115
  SESSION_COOKIE = "oauth_token"
115
116
  AUTH_COOKIES = frozenset({SESSION_COOKIE, "DZIENNIKSID", "SDZIENNIKSID"})
116
- USER_AGENT = "librus-python-api/0.4 (independent client)"
117
+ USER_AGENT = f"librus-python-api/{__version__} (independent client)"
117
118
  ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
118
119
  {
119
120
  item.operation_id: item
@@ -435,7 +436,7 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
435
436
  ),
436
437
  # Gateway reads behind the grades page's new-schema descriptive
437
438
  # table. Routes and field names come from public client code and the
438
- # official page script, not from an observed populated response.
439
+ # official page script; one authorized read parsed four records.
439
440
  Endpoint(
440
441
  "descriptive_token_info",
441
442
  "GET",
@@ -688,6 +689,21 @@ PROFILE_LABELS = MappingProxyType(
688
689
  "Szkoła": "school",
689
690
  }
690
691
  )
692
+ # Rows the information page lists for the login's own user after the
693
+ # student's rows (observed); never read, named by failure diagnostics.
694
+ PROFILE_OWNER_LABELS = ("Imię i nazwisko użytkownika", "Login", "Hasło")
695
+ # The page header shows the lucky number as `span.luckyNumber > b` (observed
696
+ # 2026-10-29). "?" there is reported where no number is drawn, for a school
697
+ # without lucky numbers and possibly on days without a draw; neither it nor
698
+ # an empty marker has been observed.
699
+ PROFILE_LUCKY_NUMBER_CLASS = "luckyNumber"
700
+ # For a session it no longer knows, Synergia answers every page read with 200
701
+ # and a page whose only heading is "Brak dostępu" (`h2.inside`), without the
702
+ # signed-in menu, not with a login redirect (observed 2026-10-29). A signed-in
703
+ # user's timetable page has no menu either, so the heading is required too.
704
+ LOGGED_IN_PAGE_MARKER = "graphic-menu"
705
+ LOGGED_OUT_HEADING = "Brak dostępu"
706
+ PROFILE_LUCKY_NUMBER_DISABLED = "?"
691
707
 
692
708
  # HTML summary headers omit the two leading body cells: expander and subject.
693
709
  # These labels/layout rules are source-informed requirements, not live evidence.
@@ -847,9 +863,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
847
863
  FORMATIVE_MAX_TEXT_LENGTH = 65536
848
864
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
849
865
  ATTENDANCE_METADATA_CACHE_SIZE = 256
850
- ATTENDANCE_RESULT_CACHE_SIZE = 64
851
866
  ATTENDANCE_METADATA_TTL_SECONDS = 3600
852
867
 
868
+ # Entries of every cached read of one account, oldest first out.
869
+ ACCOUNT_RESULT_CACHE_SIZE = 64
870
+
853
871
  ANNOUNCEMENT_MAX_ITEMS = 256
854
872
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
855
873
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
@@ -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):
@@ -124,7 +136,9 @@ class OperationTimeoutError(LibrusError):
124
136
 
125
137
 
126
138
  class LimitError(LibrusError):
127
- pass
139
+ # Library-private: the bound was the cookie count and the jar was emptied,
140
+ # so the session behind it is gone.
141
+ _cookies_cleared = False
128
142
 
129
143
 
130
144
  class ParseError(LibrusError):
@@ -154,6 +168,7 @@ class StorageError(LibrusError):
154
168
  _ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
155
169
  ErrorKind.CHECKPOINT: CheckpointError,
156
170
  ErrorKind.INVALID_INPUT: InvalidInputError,
171
+ ErrorKind.CONSENT_REQUIRED: ConsentRequiredError,
157
172
  ErrorKind.CREDENTIALS_REJECTED: CredentialsRejectedError,
158
173
  ErrorKind.ACCOUNT_ACTION_REQUIRED: AccountActionRequiredError,
159
174
  ErrorKind.SESSION_EXPIRED: SessionExpiredError,
@@ -3,8 +3,9 @@
3
3
  The grades page of a school on the new descriptive grading schema renders a
4
4
  subject table whose semester cells are empty; the page script then loads the
5
5
  grades from gateway JSON routes. Routes and field names come from public client
6
- code and the official page script, not from an observed populated response,
7
- so every unexpected shape fails instead of producing a partial collection.
6
+ code and the official page script. One authorized read on 2026-10-28 parsed four
7
+ PartialGrades records on one school; pagination keys are still unknown, so every
8
+ unexpected shape fails instead of producing a partial collection.
8
9
  """
9
10
 
10
11
  import re
@@ -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
@@ -33,11 +34,11 @@ from librus_python_api.completed_lessons import (
33
34
  validate_selection,
34
35
  )
35
36
  from librus_python_api.config import (
37
+ ACCOUNT_RESULT_CACHE_SIZE,
36
38
  ATTACHMENT_MAX_BYTES,
37
39
  ATTENDANCE_MAX_WINDOW_DAYS,
38
40
  ATTENDANCE_METADATA_CACHE_SIZE,
39
41
  ATTENDANCE_METADATA_TTL_SECONDS,
40
- ATTENDANCE_RESULT_CACHE_SIZE,
41
42
  CHECKPOINT_TIMEOUT_SECONDS,
42
43
  ENDPOINTS,
43
44
  GRADE_MAX_RECORDS,
@@ -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,13 @@ 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
+ LimitError,
78
+ ParseError,
79
+ SessionExpiredError,
80
+ )
73
81
  from librus_python_api.gateway_grades import (
74
82
  DescriptiveTable,
75
83
  descriptive_grades,
@@ -198,7 +206,12 @@ from librus_python_api.notifications import (
198
206
  parse_schedule_events,
199
207
  validate_response,
200
208
  )
201
- from librus_python_api.parsers import parse_identity, parse_login, parse_profile
209
+ from librus_python_api.parsers import (
210
+ is_logged_out_page,
211
+ parse_identity,
212
+ parse_login,
213
+ parse_profile,
214
+ )
202
215
  from librus_python_api.parsing import ParserPool
203
216
  from librus_python_api.recipients import (
204
217
  parse_recipient_group_choices,
@@ -381,6 +394,24 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
381
394
  raise LibrusError(ErrorKind.INVALID_INPUT)
382
395
 
383
396
 
397
+ def _cooldown_due(error: LibrusError) -> bool:
398
+ """A denial, an unrecovered expiry, or a logged-out page that a session
399
+ just signed in also got: signing in again at once cannot help."""
400
+ if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
401
+ return True
402
+ cause = error.__cause__
403
+ return (
404
+ error.kind is ErrorKind.PARSE
405
+ and isinstance(cause, SessionExpiredError)
406
+ and cause._logged_out_page
407
+ )
408
+
409
+
410
+ # The running operation's last checked read status, for its diagnostic event.
411
+ # A holder per operation, so concurrent operations do not share one.
412
+ _READ_STATUS: contextvars.ContextVar[list[int | None] | None] = contextvars.ContextVar(
413
+ "librus_read_status", default=None
414
+ )
384
415
  _METADATA_MAP_FALLBACK = frozenset(
385
416
  {ErrorKind.ACCESS_DENIED, ErrorKind.CONNECTION, ErrorKind.LIMIT, ErrorKind.PARSE}
386
417
  )
@@ -894,10 +925,10 @@ class AccountClient:
894
925
  max_age_seconds: float = 0.0,
895
926
  ) -> ModernMessageContent:
896
927
  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
- ):
928
+ if type(allow_mark_read) is not bool:
900
929
  raise LibrusError(ErrorKind.INVALID_INPUT)
930
+ if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
931
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
901
932
  if reference.archived:
902
933
  # No archived content route or mark-read effect is established yet.
903
934
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
@@ -1178,6 +1209,8 @@ class AccountClient:
1178
1209
  started = time.monotonic()
1179
1210
  outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
1180
1211
  frames: tuple[str, ...] = ()
1212
+ status: list[int | None] = [None]
1213
+ status_token = _READ_STATUS.set(status)
1181
1214
  try:
1182
1215
  if deadline is None:
1183
1216
  deadline = asyncio.timeout(None)
@@ -1195,10 +1228,7 @@ class AccountClient:
1195
1228
  outcome = error.kind
1196
1229
  if self._service._diagnostic_sink is not None:
1197
1230
  frames = failure_frames(error)
1198
- if authenticate and error.kind in (
1199
- ErrorKind.ACCESS_DENIED,
1200
- ErrorKind.SESSION_EXPIRED,
1201
- ):
1231
+ if authenticate and _cooldown_due(error):
1202
1232
  self._cooldowns[operation] = (
1203
1233
  time.monotonic() + self._service._transport_limits.cooldown_seconds,
1204
1234
  error.kind,
@@ -1215,6 +1245,7 @@ class AccountClient:
1215
1245
  # produce a misleading success diagnostic at this owner boundary.
1216
1246
  outcome = ErrorKind.CONNECTION
1217
1247
  finally:
1248
+ _READ_STATUS.reset(status_token)
1218
1249
  self._emit(
1219
1250
  DiagnosticEvent(
1220
1251
  operation,
@@ -1223,6 +1254,7 @@ class AccountClient:
1223
1254
  budget.requests_dispatched,
1224
1255
  budget.response_bytes,
1225
1256
  frames,
1257
+ status[0],
1226
1258
  )
1227
1259
  )
1228
1260
 
@@ -1277,7 +1309,7 @@ class AccountClient:
1277
1309
  budget: RequestBudget | None = None,
1278
1310
  max_age_seconds: float = 0.0,
1279
1311
  ) -> Grades:
1280
- """Select a grade view with one non-replayed view-changing POST.
1312
+ """Select a grade view with one view-changing POST.
1281
1313
 
1282
1314
  Changes the selected grade filter, not school records. A cache hit makes
1283
1315
  no POST. Missing inline weight/count data remains unknown. In the all
@@ -1370,7 +1402,7 @@ class AccountClient:
1370
1402
  budget: RequestBudget | None = None,
1371
1403
  max_age_seconds: float = 0.0,
1372
1404
  ) -> Attendance:
1373
- """Read a fixed upstream attendance view without replaying its POST."""
1405
+ """Read a fixed upstream attendance view with one view-changing POST."""
1374
1406
  form = attendance_view_form(view)
1375
1407
 
1376
1408
  async def fetch(budget: RequestBudget, _: bool) -> Attendance:
@@ -1509,7 +1541,7 @@ class AccountClient:
1509
1541
  budget: RequestBudget | None = None,
1510
1542
  max_age_seconds: float = 0.0,
1511
1543
  ) -> Timetable:
1512
- """Select an explicit school civil week; never replay its selection POST."""
1544
+ """Select an explicit school civil week with one view-changing POST."""
1513
1545
  form = timetable_form(monday)
1514
1546
 
1515
1547
  async def fetch(budget: RequestBudget, _: bool) -> Timetable:
@@ -1761,10 +1793,10 @@ class AccountClient:
1761
1793
  ) -> MessageContent:
1762
1794
  """Open one message; received opens require explicit read-effect consent."""
1763
1795
  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
- ):
1796
+ if type(allow_mark_read) is not bool:
1767
1797
  raise LibrusError(ErrorKind.INVALID_INPUT)
1798
+ if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
1799
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
1768
1800
  operation: Literal["message_content_received", "message_content_sent"] = (
1769
1801
  "message_content_received"
1770
1802
  if reference.folder is MessageFolder.RECEIVED
@@ -2044,6 +2076,8 @@ class AccountClient:
2044
2076
  started = time.monotonic()
2045
2077
  outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
2046
2078
  frames: tuple[str, ...] = ()
2079
+ status: list[int | None] = [None]
2080
+ status_token = _READ_STATUS.set(status)
2047
2081
  try:
2048
2082
  async with asyncio.timeout(budget.remaining_seconds()):
2049
2083
  async with self._lock:
@@ -2062,6 +2096,7 @@ class AccountClient:
2062
2096
  outcome = ErrorKind.TIMEOUT
2063
2097
  raise LibrusError(ErrorKind.TIMEOUT) from None
2064
2098
  finally:
2099
+ _READ_STATUS.reset(status_token)
2065
2100
  self._emit(
2066
2101
  DiagnosticEvent(
2067
2102
  operation,
@@ -2070,6 +2105,7 @@ class AccountClient:
2070
2105
  budget.requests_dispatched,
2071
2106
  budget.response_bytes,
2072
2107
  frames,
2108
+ status[0],
2073
2109
  )
2074
2110
  )
2075
2111
 
@@ -2101,25 +2137,29 @@ class AccountClient:
2101
2137
  try:
2102
2138
  result = await self._authenticated(fetch, budget, retry_safe)
2103
2139
  except LibrusError as error:
2104
- if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
2140
+ if _cooldown_due(error):
2105
2141
  self._cooldowns[operation] = (
2106
2142
  time.monotonic() + self._service._transport_limits.cooldown_seconds,
2107
2143
  error.kind,
2108
2144
  )
2109
2145
  raise
2110
2146
  self._cache[key] = (time.monotonic(), result)
2111
- if len(self._cache) > ATTENDANCE_RESULT_CACHE_SIZE:
2147
+ if len(self._cache) > ACCOUNT_RESULT_CACHE_SIZE:
2112
2148
  del self._cache[next(iter(self._cache))]
2113
2149
  return result
2114
2150
 
2115
2151
  async def _authenticated[T](
2116
2152
  self, fetch: Fetch[T], budget: RequestBudget, retry_safe: bool
2117
2153
  ) -> T:
2118
- """Run a read in a session; recover proven expiry once for safe reads.
2154
+ """Run a read in a session; recover expiry once where a repeat is safe.
2119
2155
 
2120
2156
  Initial login is never retried: a failed credential submission must not
2121
- 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
+ be replayed. A retry-safe read is repeated once after a new login, and
2158
+ so is a read without an account effect (`NONE` or `SELECT_VIEW`, never
2159
+ mark-read) answered with a logged-out page, which proves the request
2160
+ was not applied. Other expiry only clears the session so a later
2161
+ explicit call can log in again. A session just signed in that gets the
2162
+ logged-out page fails as PARSE.
2123
2163
  """
2124
2164
  fresh_login = self._identity is None
2125
2165
  if fresh_login:
@@ -2127,18 +2167,22 @@ class AccountClient:
2127
2167
  try:
2128
2168
  return await fetch(budget, fresh_login)
2129
2169
  except SessionExpiredError as error:
2170
+ if error._logged_out_page and fresh_login:
2171
+ raise LibrusError(ErrorKind.PARSE) from error
2130
2172
  modern_expired = error._messages_origin
2131
2173
  if modern_expired:
2132
2174
  self._invalidate_modern()
2133
2175
  else:
2134
2176
  self._invalidate()
2135
- if not retry_safe:
2177
+ if not retry_safe and not error._logged_out_page:
2136
2178
  raise
2137
2179
  if not modern_expired:
2138
2180
  await self._authenticate(budget)
2139
2181
  try:
2140
2182
  return await fetch(budget, not modern_expired)
2141
2183
  except SessionExpiredError as error:
2184
+ if error._logged_out_page:
2185
+ raise LibrusError(ErrorKind.PARSE) from error
2142
2186
  if error._messages_origin:
2143
2187
  self._invalidate_modern()
2144
2188
  else:
@@ -2167,12 +2211,29 @@ class AccountClient:
2167
2211
  endpoint, budget, form=form, reference_id=reference, query=query
2168
2212
  )
2169
2213
  self._validate_read_response(response, content_type)
2170
- return await self._service._parsers.run(parse, response.body, budget)
2214
+ try:
2215
+ return await self._service._parsers.run(parse, response.body, budget)
2216
+ except ParseError as error:
2217
+ if (
2218
+ content_type != HTML
2219
+ or ENDPOINTS[endpoint].origin != "synergia"
2220
+ or not await self._service._parsers.run(
2221
+ is_logged_out_page, response.body, budget
2222
+ )
2223
+ ):
2224
+ raise
2225
+ # Synergia's answer to a session it no longer knows. A read
2226
+ # with an account effect (mark-read) is not repeated: it fails
2227
+ # as an unrecovered expiry and the next call signs in.
2228
+ expired = SessionExpiredError(ErrorKind.SESSION_EXPIRED)
2229
+ expired._logged_out_page = ENDPOINTS[endpoint].side_effect in (
2230
+ SideEffect.NONE,
2231
+ SideEffect.SELECT_VIEW,
2232
+ )
2233
+ raise expired from error
2171
2234
  except BaseException as error:
2172
2235
  if ENDPOINTS[endpoint].origin == "messages":
2173
- self._invalidate_modern()
2174
- if isinstance(error, SessionExpiredError):
2175
- error._messages_origin = True
2236
+ self._modern_read_failed(error)
2176
2237
  raise
2177
2238
 
2178
2239
  def _check_cooldown(self, key: str) -> None:
@@ -2188,6 +2249,22 @@ class AccountClient:
2188
2249
  self._metadata_maps.clear()
2189
2250
  self._transport.clear_auth()
2190
2251
 
2252
+ def _modern_read_failed(self, error: BaseException) -> None:
2253
+ """Drop the modern session unless the failure says nothing about it.
2254
+
2255
+ A cancel or a local bound keeps it. Every other failure drops it, an
2256
+ unrecognized body included: a session the upstream no longer knows may
2257
+ be answered with one, and keeping it would fail every later read, while
2258
+ dropping it costs one launch.
2259
+ """
2260
+ if isinstance(error, asyncio.CancelledError) or (
2261
+ isinstance(error, LimitError) and not error._cookies_cleared
2262
+ ):
2263
+ return
2264
+ self._invalidate_modern()
2265
+ if isinstance(error, SessionExpiredError):
2266
+ error._messages_origin = True
2267
+
2191
2268
  def _invalidate_modern(self) -> None:
2192
2269
  self._modern_account = None
2193
2270
  for key in tuple(self._cache):
@@ -2548,6 +2625,9 @@ class AccountClient:
2548
2625
  def _validate_read_response(
2549
2626
  self, response: TransportResponse, content_type: str
2550
2627
  ) -> None:
2628
+ holder = _READ_STATUS.get()
2629
+ if holder is not None:
2630
+ holder[0] = response.status
2551
2631
  if 300 <= response.status < 400:
2552
2632
  if self._is_module_unavailable_redirect(response):
2553
2633
  raise LibrusError(ErrorKind.MODULE_UNAVAILABLE)
@@ -50,7 +50,12 @@ from librus_python_api.config import (
50
50
  recipient_form,
51
51
  validate_modern_query,
52
52
  )
53
- from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
53
+ from librus_python_api.exceptions import (
54
+ ErrorKind,
55
+ LibrusError,
56
+ LimitError,
57
+ SessionExpiredError,
58
+ )
54
59
  from librus_python_api.models import (
55
60
  AttachmentHeaders,
56
61
  LoginSubmission,
@@ -451,9 +456,7 @@ class AiohttpTransport:
451
456
  proxy=proxy.get_secret_value() if proxy is not None else None,
452
457
  ) as response:
453
458
  self._check_headers(response)
454
- if len(session.cookie_jar) > self._limits.max_cookies:
455
- session.cookie_jar.clear()
456
- raise LibrusError(ErrorKind.LIMIT)
459
+ self._check_cookies(session)
457
460
  if not modern or response.status not in (400, 422):
458
461
  self._check_status(response)
459
462
  body = await self._read_body(response, budget)
@@ -542,9 +545,7 @@ class AiohttpTransport:
542
545
  proxy=proxy.get_secret_value() if proxy is not None else None,
543
546
  ) as response:
544
547
  self._check_headers(response)
545
- if len(session.cookie_jar) > self._limits.max_cookies:
546
- session.cookie_jar.clear()
547
- raise LibrusError(ErrorKind.LIMIT)
548
+ self._check_cookies(session)
548
549
  self._check_status(response)
549
550
  body = await self._read_payload(response, budget)
550
551
  if response.status == 200:
@@ -843,9 +844,7 @@ class AiohttpTransport:
843
844
  proxy=proxy.get_secret_value() if proxy is not None else None,
844
845
  ) as response:
845
846
  self._check_headers(response)
846
- if len(session.cookie_jar) > self._limits.max_cookies:
847
- session.cookie_jar.clear()
848
- raise LibrusError(ErrorKind.LIMIT)
847
+ self._check_cookies(session)
849
848
  self._check_status(response)
850
849
  body = await self._read_body(response, budget)
851
850
  return TransportResponse(
@@ -863,6 +862,13 @@ class AiohttpTransport:
863
862
  ):
864
863
  raise LibrusError(ErrorKind.LIMIT)
865
864
 
865
+ def _check_cookies(self, session: aiohttp.ClientSession) -> None:
866
+ if len(session.cookie_jar) > self._limits.max_cookies:
867
+ session.cookie_jar.clear()
868
+ error = LimitError(ErrorKind.LIMIT)
869
+ error._cookies_cleared = True
870
+ raise error
871
+
866
872
  def _check_status(self, response: aiohttp.ClientResponse) -> None:
867
873
  if response.status in (429, 503):
868
874
  pause = self._limits.cooldown_seconds
@@ -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.2
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,13 @@ 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.2` keeps the modern mailbox session across a cancelled read
46
+ or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
47
+ with a "Brak dostępu" page, not a login redirect), reads the lucky number
48
+ from the page header and summarizes a page that fails as a whole in
49
+ diagnostics. `1.8.0` reports where a failure
50
+ happened (code locations and a redacted page structure, see
51
+ [failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
48
52
  (source-informed, first populated read live on one school) and keeps the 1.7.1 point
49
53
  grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
50
54
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
@@ -60,7 +64,7 @@ See [limitations](#supported-features-and-limitations) below.
60
64
  Install the exact release from PyPI after its gated publication completes:
61
65
 
62
66
  ```sh
63
- python -m pip install librus-python-api==1.8.0
67
+ python -m pip install librus-python-api==1.8.2
64
68
  ```
65
69
 
66
70
  Pin the exact version qualified by your application. Before publication, use a
@@ -76,7 +80,7 @@ python -m pip install .
76
80
  To install a locally built wheel instead:
77
81
 
78
82
  ```sh
79
- python -m pip install ./dist/librus_python_api-1.8.0-py3-none-any.whl
83
+ python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
80
84
  ```
81
85
 
82
86
  No console script or background process is installed: import the library in your
@@ -280,7 +284,10 @@ except LibrusError as error:
280
284
 
281
285
  To check a saved page offline (a page source saved with Ctrl+U, or a copied
282
286
  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
287
+ `final-grades`, or `profile` for the information page `/informacja`). Save the
288
+ page source itself: in Safari use File > Save As with format Page Source; in
289
+ Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
290
+ into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
284
291
  structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
285
292
  to receive one `DiagnosticEvent` per operation, with `frames` on failure.
286
293
  None of these print page text, values, logins or exception messages.
@@ -1,33 +1,33 @@
1
- librus_python_api/__init__.py,sha256=DmUSk3fOr0x7n27EcLzjJy-CQhMvjr1J_DPKkFCZ1CA,6513
1
+ librus_python_api/__init__.py,sha256=CDi6pTko967g3RViVcZCIWhE-Lzof5VN6E4NkQJ4aUU,6571
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
5
5
  librus_python_api/_windows_filesystem.py,sha256=pQANIGkYrb4Q75gz5mdr5mqYhM_PFblWwTxPGM0bQ78,14063
6
6
  librus_python_api/announcements.py,sha256=13FoOR1sq7tt9w6aLf0vclcFKDqLUiIYaO5iYD2QVLA,5928
7
7
  librus_python_api/attachment_routes.py,sha256=uSg5wCQqHJiphF0KdI0GatQxZr5SLUx6xWQRsl_U4_U,4123
8
- librus_python_api/attachments.py,sha256=kyIBpFxeK34Al5_jCkxYKpZnsSLa5DYaGS6vHADpy_g,10921
8
+ librus_python_api/attachments.py,sha256=Iq-ERZWc3gYXu6Lcv6womT2EXCc-c1Q7r_Q6VyaCm2E,10818
9
9
  librus_python_api/attendance.py,sha256=HeFAfnIUn4N__yp1zr635Dve55S2jmwtkeIy_6mV5B4,6924
10
10
  librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7eL4dU3mif1EY,5309
11
11
  librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2878
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=CBMwuuACfrB7TzTVkTP8QAuPz5hv43YOsbto81-uEJc,61248
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=x8_z5UTNmuRINTK4buLYiKdYcFA2w6GZP9ZXOL-OnRU,5531
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
- librus_python_api/gateway_grades.py,sha256=HHXJqtC47v2BTP3ufak9i9Q68phOxwtzWjahKdxkyX4,16086
21
+ librus_python_api/gateway_grades.py,sha256=T-G5KN0kpROi6xSGwAUba2qWDGNhg8TeGmQN140iLxc,16163
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=34o-EQSuERhSHZAme-RDhVznhd_y1-GT0SPfduJ5LoM,102964
48
48
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
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,,
49
+ librus_python_api/transport.py,sha256=yF-DnFy4xGYS_RWQ9161b9WJXOlYF8cFRgdjXmVVUIk,34988
50
+ librus_python_api-1.8.2.dist-info/METADATA,sha256=78cqj_uSkFU-4NhnUV0FCj3U8-QmJRK51bvIzIRVask,14202
51
+ librus_python_api-1.8.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
52
+ librus_python_api-1.8.2.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
53
+ librus_python_api-1.8.2.dist-info/RECORD,,