librus-python-api 1.8.1__py3-none-any.whl → 1.8.3__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.3"
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.1"
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,
@@ -11,6 +11,7 @@ from librus_python_api.config import (
11
11
  ATTENDANCE_EMPTY_MARKERS,
12
12
  ATTENDANCE_MAX_RECORDS,
13
13
  ATTENDANCE_SEMESTER_LABELS,
14
+ ATTENDANCE_TOOLTIP_NOTICES,
14
15
  GRADE_MAX_METADATA_LENGTH,
15
16
  GRADE_MAX_VALUE_LENGTH,
16
17
  )
@@ -29,6 +30,17 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
29
30
  title = element.get("title", "")
30
31
  if len(title) > GRADE_MAX_METADATA_LENGTH:
31
32
  raise LibrusError(ErrorKind.LIMIT)
33
+ # A known Librus notice is a whole line of its own; other unlabelled lines
34
+ # still fail in tooltip_fields.
35
+ notices: list[tuple[str, str]] = []
36
+ lines = []
37
+ for line in re.split(r"<br\s*/?>|\n", title, flags=re.I):
38
+ notice = ATTENDANCE_TOOLTIP_NOTICES.get(markup.tooltip_line(line))
39
+ if notice is None or notice in notices:
40
+ lines.append(line)
41
+ else:
42
+ notices.append(notice)
43
+ title = "<br>".join(lines)
32
44
  holder = html.Element("span")
33
45
  holder.set("title", re.sub(r"</b>\s*(?=<b[ >])", "</b>\n", title, flags=re.I))
34
46
  fields = markup.tooltip_fields(holder)
@@ -57,7 +69,7 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
57
69
  fields.get("Temat zajęć"),
58
70
  fields.get("Lekcja"),
59
71
  markup.attendance_detail_id(element),
60
- tuple(fields.items()),
72
+ (*fields.items(), *notices),
61
73
  )
62
74
 
63
75
 
@@ -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",
@@ -786,6 +787,7 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
786
787
  # semester cells and its script loads the grades from the gateway routes above.
787
788
  DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
788
789
  DESCRIPTIVE_ROW_CLASS = "studentRow"
790
+ DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
789
791
  # Gateway account and student identifiers are opaque strings, unlike every
790
792
  # other `{id}` route, which takes a numeric ID.
791
793
  DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
@@ -837,6 +839,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
837
839
  }
838
840
  )
839
841
  ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
842
+ # Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
843
+ # metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
844
+ # absence excused through the e-Usprawiedliwienia module.
845
+ ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
846
+ {
847
+ "Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
848
+ "Usprawiedliwienie",
849
+ "dodane za pomocą modułu e-Usprawiedliwienia",
850
+ )
851
+ }
852
+ )
840
853
  ATTENDANCE_MAX_RECORDS = 2048
841
854
  ATTENDANCE_MAX_WINDOW_DAYS = 371
842
855
  # Whole-school gateway collections resolve attendance metadata in two requests.
@@ -848,6 +861,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
848
861
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
849
862
  # Recognition only: behaviour-point detail links are classified, never requested.
850
863
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
864
+ # Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
865
+ BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
851
866
  # Recognition only: point-grade detail links are classified, never requested.
852
867
  POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
853
868
  FORMATIVE_HEADERS = (
@@ -862,9 +877,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
862
877
  FORMATIVE_MAX_TEXT_LENGTH = 65536
863
878
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
864
879
  ATTENDANCE_METADATA_CACHE_SIZE = 256
865
- ATTENDANCE_RESULT_CACHE_SIZE = 64
866
880
  ATTENDANCE_METADATA_TTL_SECONDS = 3600
867
881
 
882
+ # Entries of every cached read of one account, oldest first out.
883
+ ACCOUNT_RESULT_CACHE_SIZE = 64
884
+
868
885
  ANNOUNCEMENT_MAX_ITEMS = 256
869
886
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
870
887
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
@@ -913,7 +930,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
913
930
  ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
914
931
  {"container", "border-red", "resizeable", "center"}
915
932
  )
916
- ANNOUNCEMENT_EMPTY_MARKERS = frozenset({"brak ogłoszeń", "nie ma ogłoszeń"})
933
+ # "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
934
+ ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
935
+ {"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
936
+ )
917
937
  TIMETABLE_MAX_PERIODS = 32
918
938
  TIMETABLE_MAX_LESSONS_PER_SLOT = 16
919
939
  TIMETABLE_MAX_CHANGES_PER_SLOT = 16
@@ -136,7 +136,9 @@ class OperationTimeoutError(LibrusError):
136
136
 
137
137
 
138
138
  class LimitError(LibrusError):
139
- 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
140
142
 
141
143
 
142
144
  class ParseError(LibrusError):
@@ -3,8 +3,14 @@
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.
9
+
10
+ Another school renders the same table with plain text in its semester cells and
11
+ no script hooks (#115, from a reporter's synthetic example). The grade record
12
+ parser reads those cells like any descriptive row, so that table needs no
13
+ gateway read.
8
14
  """
9
15
 
10
16
  import re
@@ -19,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
19
25
 
20
26
  from librus_python_api import markup
21
27
  from librus_python_api.config import (
28
+ DESCRIPTIVE_DETAILS_ROW_CLASS,
22
29
  DESCRIPTIVE_IDENTIFIER,
23
30
  DESCRIPTIVE_MAX_NAME_LENGTH,
24
31
  DESCRIPTIVE_MAX_SUBJECTS,
@@ -330,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
330
337
 
331
338
  @dataclass(frozen=True, slots=True)
332
339
  class DescriptiveTable:
333
- """Numeric subject ID to the subject name the grades page shows."""
340
+ """Numeric subject ID to the subject name the grades page shows.
341
+
342
+ A filled table already holds its grades and has no gateway grades.
343
+ """
334
344
 
335
345
  subjects: Mapping[int, str] = field(repr=False)
346
+ filled: bool = False
336
347
 
337
348
 
338
349
  def _classes(element: html.HtmlElement) -> set[str]:
@@ -342,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
342
353
  def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
343
354
  """The new-schema subject table, or None when the page has none.
344
355
 
345
- Its rows hold an expander, the subject and two empty semester cells that
346
- the page script fills from the gateway. A cell the page already filled
347
- would duplicate the gateway records, so it is an unsupported layout.
356
+ Its rows hold an expander, the subject and two semester cells. Either
357
+ every row has empty `gradesCell` cells that the page script fills from
358
+ the gateway, or every row has plain cells the page filled itself, which
359
+ `read_grade_records` reads and validates. A script cell the page already
360
+ filled would duplicate the gateway records, so it is unsupported.
348
361
  """
349
362
  tables = [
350
363
  table
@@ -368,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
368
381
  tables = populated
369
382
  subjects: dict[int, str] = {}
370
383
  students: set[str | None] = set()
384
+ layouts: set[bool] = set()
371
385
  for row in markup.rows(tables[0]):
372
386
  if DESCRIPTIVE_ROW_CLASS not in _classes(row):
373
387
  continue
374
388
  cells = markup.cells(row)
375
- if (
376
- len(cells) != 4
377
- or not {"micro", "center", "screen-only"} <= _classes(cells[0])
378
- or [cell.get("data-semester") for cell in cells[2:]] != ["1", "2"]
379
- or not all("gradesCell" in _classes(cell) for cell in cells[2:])
389
+ if len(cells) != 4 or not {"micro", "center", "screen-only"} <= _classes(
390
+ cells[0]
380
391
  ):
381
392
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
382
- if any(
393
+ semesters = cells[2:]
394
+ # A filled cell is bare text: no attributes and no markup, so a renamed
395
+ # or moved script hook never passes as a filled table.
396
+ filled = all(
397
+ not cell.attrib
398
+ and markup.text(cell)
399
+ and not any(isinstance(node.tag, str) for node in cell)
400
+ for cell in semesters
401
+ )
402
+ if not filled and (
403
+ [cell.get("data-semester") for cell in semesters] != ["1", "2"]
404
+ or not all("gradesCell" in _classes(cell) for cell in semesters)
405
+ ):
406
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
407
+ layouts.add(filled)
408
+ if not filled and any(
383
409
  markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
384
- for cell in cells[2:]
410
+ for cell in semesters
385
411
  ):
386
412
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
387
413
  identifier = row.get("data-subject_id", "")
@@ -397,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
397
423
  if len(students) > 1:
398
424
  # One page shows one student; rows of several students are unknown.
399
425
  raise LibrusError(ErrorKind.PARSE)
400
- return DescriptiveTable(MappingProxyType(subjects))
426
+ if len(layouts) > 1:
427
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
428
+ if layouts == {True} and any(
429
+ DESCRIPTIVE_DETAILS_ROW_CLASS in _classes(row) for row in markup.rows(tables[0])
430
+ ):
431
+ # Detail rows are what the page script fills from the gateway; a filled
432
+ # table that still has them may also have gateway grades.
433
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
434
+ return DescriptiveTable(MappingProxyType(subjects), layouts == {True})
401
435
 
402
436
 
403
437
  def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
@@ -8,6 +8,7 @@ from lxml import html
8
8
 
9
9
  from librus_python_api import markup
10
10
  from librus_python_api.config import (
11
+ BEHAVIOUR_GRADE_LABELS,
11
12
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
12
13
  FORMATIVE_DETAIL_PATH_PREFIX,
13
14
  GRADE_AVERAGE_HEADERS,
@@ -26,6 +27,7 @@ from librus_python_api.config import (
26
27
  GRADE_PUBLICATION_DATE_LABEL,
27
28
  GRADE_PUBLICATION_PERIOD_LABELS,
28
29
  GRADE_PUBLICATION_TEACHER_LABEL,
30
+ TOOLTIP_LABELS,
29
31
  )
30
32
  from librus_python_api.exceptions import ErrorKind, LibrusError
31
33
  from librus_python_api.formative_grades import is_formative_table, read_formative_table
@@ -126,6 +128,51 @@ def _numeric(
126
128
  )
127
129
 
128
130
 
131
+ def _behaviour_grade(
132
+ element: html.HtmlElement,
133
+ subject: str,
134
+ semester: Literal[0, 1, 2],
135
+ kind: GradeKind = GradeKind.CURRENT,
136
+ ) -> NumericGrade:
137
+ """A behaviour entry with a grade symbol, such as `wz`, instead of points.
138
+
139
+ Its tooltip has the point entries' layout: an unlabelled first line, then
140
+ `Data wystawienia` and `Dodał`. The first line names the entry, as the
141
+ point category does, so it is the category. `Dodał` stays in metadata. A
142
+ label with digits, a point entry's range labels or any other label fails.
143
+ """
144
+ heading: list[str] = []
145
+ metadata = markup.tooltip_fields(element, heading)
146
+ raw = markup.text(element)
147
+ if (
148
+ not heading
149
+ # A labelled first line means the heading is missing.
150
+ or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
151
+ # Symbols carry no digits; points or a range are a point entry.
152
+ or not raw
153
+ or re.search(r"[0-9]", raw)
154
+ or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
155
+ ):
156
+ raise LibrusError(ErrorKind.PARSE)
157
+ href = element.get("href")
158
+ if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
159
+ raise LibrusError(ErrorKind.LIMIT)
160
+ return NumericGrade(
161
+ subject,
162
+ raw,
163
+ markup.civil_date(metadata.get("Data wystawienia")),
164
+ semester,
165
+ None,
166
+ None,
167
+ heading[0],
168
+ None,
169
+ metadata.get("Komentarz"),
170
+ href,
171
+ tuple(metadata.items()),
172
+ kind,
173
+ )
174
+
175
+
129
176
  def _descriptive(
130
177
  box: html.HtmlElement,
131
178
  element: html.HtmlElement,
@@ -284,7 +331,21 @@ def _entries(
284
331
  if len(anchors) + len(descriptions) != 1:
285
332
  raise LibrusError(ErrorKind.PARSE)
286
333
  if anchors:
287
- record = _numeric(anchors[0], subject, period, kind)
334
+ behaviour = (
335
+ subject in GRADE_MERGED_SUBJECTS
336
+ and markup.detail_id(
337
+ anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
338
+ )
339
+ is not None
340
+ )
341
+ if behaviour and descriptive:
342
+ # A descriptive record has no category for the first line.
343
+ raise LibrusError(ErrorKind.PARSE)
344
+ record = (
345
+ _behaviour_grade(anchors[0], subject, period, kind)
346
+ if behaviour
347
+ else _numeric(anchors[0], subject, period, kind)
348
+ )
288
349
  if descriptive:
289
350
  href = record.href
290
351
  if href is not None and re.sub(
@@ -540,6 +601,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
540
601
  _read_numeric_table(table, collection, points=True)
541
602
  else:
542
603
  for row in markup.rows(table):
604
+ if markup.in_header(row):
605
+ # The new-schema table's column labels, not a subject.
606
+ continue
543
607
  cells = markup.cells(row)
544
608
  if _descriptive_row(cells):
545
609
  _read_descriptive_row(cells, collection)
@@ -19,8 +19,8 @@ from librus_python_api import __version__
19
19
  from librus_python_api.config import TransportLimits
20
20
  from librus_python_api.diagnostics import describe_failure, failure_frames
21
21
  from librus_python_api.exceptions import LibrusError
22
+ from librus_python_api.gateway_grades import parse_grade_page
22
23
  from librus_python_api.grade_parsers import parse_final_grades
23
- from librus_python_api.grade_records import parse_grade_records
24
24
  from librus_python_api.models import Availability
25
25
  from librus_python_api.parsers import parse_profile
26
26
 
@@ -31,7 +31,8 @@ NOTICE = (
31
31
 
32
32
 
33
33
  def _grades(body: bytes) -> dict[str, int]:
34
- records = parse_grade_records(body)
34
+ # The all view also checks the new-schema table, as `grades()` does.
35
+ records, table = parse_grade_page(body)
35
36
  return {
36
37
  "numeric": sum(1 for g in records.numeric if g.point_grade_id is None),
37
38
  "point": sum(1 for g in records.numeric if g.point_grade_id is not None),
@@ -41,6 +42,8 @@ def _grades(body: bytes) -> dict[str, int]:
41
42
  "averages": len(records.averages),
42
43
  "point_results": len(records.point_results),
43
44
  "formative": len(records.formative),
45
+ "descriptive_table": int(table is not None),
46
+ "descriptive_table_filled": int(table is not None and table.filled),
44
47
  }
45
48
 
46
49
 
@@ -81,11 +81,14 @@ def colspan(cell: html.HtmlElement) -> int:
81
81
  return span
82
82
 
83
83
 
84
- def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
84
+ def tooltip_fields(
85
+ element: html.HtmlElement, heading: list[str] | None = None
86
+ ) -> dict[str, str]:
85
87
  """Parse a 'Label: value<br>' title attribute into unique labelled fields.
86
88
 
87
89
  A free-text field may span lines; an unlabelled line after it continues
88
- its value. Any other unlabelled line is an unknown layout.
90
+ its value. Any other unlabelled line is an unknown layout. With `heading`,
91
+ the first line is taken whole, colon included, and appended to it.
89
92
  """
90
93
  title = element.get("title", "")
91
94
  if len(title) > GRADE_MAX_METADATA_LENGTH:
@@ -98,12 +101,10 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
98
101
  for chunk in chunks:
99
102
  if not chunk.strip():
100
103
  continue
101
- # Tooltips are HTML inside an attribute; parse markup only where present.
102
- rendered = (
103
- text(parse_html_document(chunk.encode()))
104
- if "<" in chunk
105
- else " ".join(chunk.split())
106
- )
104
+ rendered = tooltip_line(chunk)
105
+ if heading is not None and not heading:
106
+ heading.append(rendered)
107
+ continue
107
108
  key, separator, value = rendered.partition(":")
108
109
  key, value = key.strip(), value.strip()
109
110
  if not separator and last in TOOLTIP_MULTILINE_LABELS:
@@ -116,6 +117,14 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
116
117
  return result
117
118
 
118
119
 
120
+ def tooltip_line(chunk: str) -> str:
121
+ """One tooltip line's text, whitespace normalized."""
122
+ # Tooltips are HTML inside an attribute; parse markup only where present.
123
+ if "<" in chunk:
124
+ return text(parse_html_document(chunk.encode()))
125
+ return " ".join(chunk.split())
126
+
127
+
119
128
  def civil_date(value: str | None) -> date:
120
129
  """An ISO date, optionally followed by its weekday label in parentheses."""
121
130
  match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
@@ -34,11 +34,11 @@ from librus_python_api.completed_lessons import (
34
34
  validate_selection,
35
35
  )
36
36
  from librus_python_api.config import (
37
+ ACCOUNT_RESULT_CACHE_SIZE,
37
38
  ATTACHMENT_MAX_BYTES,
38
39
  ATTENDANCE_MAX_WINDOW_DAYS,
39
40
  ATTENDANCE_METADATA_CACHE_SIZE,
40
41
  ATTENDANCE_METADATA_TTL_SECONDS,
41
- ATTENDANCE_RESULT_CACHE_SIZE,
42
42
  CHECKPOINT_TIMEOUT_SECONDS,
43
43
  ENDPOINTS,
44
44
  GRADE_MAX_RECORDS,
@@ -74,6 +74,7 @@ from librus_python_api.diagnostics import DiagnosticSink, failure_frames
74
74
  from librus_python_api.exceptions import (
75
75
  ErrorKind,
76
76
  LibrusError,
77
+ LimitError,
77
78
  ParseError,
78
79
  SessionExpiredError,
79
80
  )
@@ -393,6 +394,18 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
393
394
  raise LibrusError(ErrorKind.INVALID_INPUT)
394
395
 
395
396
 
397
+ def _check_record_count(records: GradeRecords, added: int) -> None:
398
+ if (
399
+ len(records.numeric)
400
+ + len(records.descriptive)
401
+ + len(records.descriptive_summaries)
402
+ + len(records.formative)
403
+ + added
404
+ > GRADE_MAX_RECORDS
405
+ ):
406
+ raise LibrusError(ErrorKind.LIMIT)
407
+
408
+
396
409
  def _cooldown_due(error: LibrusError) -> bool:
397
410
  """A denial, an unrecovered expiry, or a logged-out page that a session
398
411
  just signed in also got: signing in again at once cannot help."""
@@ -1308,7 +1321,7 @@ class AccountClient:
1308
1321
  budget: RequestBudget | None = None,
1309
1322
  max_age_seconds: float = 0.0,
1310
1323
  ) -> Grades:
1311
- """Select a grade view with one non-replayed view-changing POST.
1324
+ """Select a grade view with one view-changing POST.
1312
1325
 
1313
1326
  Changes the selected grade filter, not school records. A cache hit makes
1314
1327
  no POST. Missing inline weight/count data remains unknown. In the all
@@ -1327,7 +1340,7 @@ class AccountClient:
1327
1340
  records, table = await self._page(
1328
1341
  "grades", budget, parse_grade_page, form=form
1329
1342
  )
1330
- if table is not None:
1343
+ if table is not None and not table.filled:
1331
1344
  records = await self._gateway_descriptive(records, table, budget)
1332
1345
  return Grades(
1333
1346
  self._session_identity(), records, self._observation("grades"), view
@@ -1401,7 +1414,7 @@ class AccountClient:
1401
1414
  budget: RequestBudget | None = None,
1402
1415
  max_age_seconds: float = 0.0,
1403
1416
  ) -> Attendance:
1404
- """Read a fixed upstream attendance view without replaying its POST."""
1417
+ """Read a fixed upstream attendance view with one view-changing POST."""
1405
1418
  form = attendance_view_form(view)
1406
1419
 
1407
1420
  async def fetch(budget: RequestBudget, _: bool) -> Attendance:
@@ -1540,7 +1553,7 @@ class AccountClient:
1540
1553
  budget: RequestBudget | None = None,
1541
1554
  max_age_seconds: float = 0.0,
1542
1555
  ) -> Timetable:
1543
- """Select an explicit school civil week; never replay its selection POST."""
1556
+ """Select an explicit school civil week with one view-changing POST."""
1544
1557
  form = timetable_form(monday)
1545
1558
 
1546
1559
  async def fetch(budget: RequestBudget, _: bool) -> Timetable:
@@ -2143,21 +2156,22 @@ class AccountClient:
2143
2156
  )
2144
2157
  raise
2145
2158
  self._cache[key] = (time.monotonic(), result)
2146
- if len(self._cache) > ATTENDANCE_RESULT_CACHE_SIZE:
2159
+ if len(self._cache) > ACCOUNT_RESULT_CACHE_SIZE:
2147
2160
  del self._cache[next(iter(self._cache))]
2148
2161
  return result
2149
2162
 
2150
2163
  async def _authenticated[T](
2151
2164
  self, fetch: Fetch[T], budget: RequestBudget, retry_safe: bool
2152
2165
  ) -> T:
2153
- """Run a read in a session; recover proven expiry once for safe reads.
2166
+ """Run a read in a session; recover expiry once where a repeat is safe.
2154
2167
 
2155
2168
  Initial login is never retried: a failed credential submission must not
2156
- be replayed. A view-changing POST is never replayed either; expiry only
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.
2169
+ be replayed. A retry-safe read is repeated once after a new login, and
2170
+ so is a read without an account effect (`NONE` or `SELECT_VIEW`, never
2171
+ mark-read) answered with a logged-out page, which proves the request
2172
+ was not applied. Other expiry only clears the session so a later
2173
+ explicit call can log in again. A session just signed in that gets the
2174
+ logged-out page fails as PARSE.
2161
2175
  """
2162
2176
  fresh_login = self._identity is None
2163
2177
  if fresh_login:
@@ -2231,9 +2245,7 @@ class AccountClient:
2231
2245
  raise expired from error
2232
2246
  except BaseException as error:
2233
2247
  if ENDPOINTS[endpoint].origin == "messages":
2234
- self._invalidate_modern()
2235
- if isinstance(error, SessionExpiredError):
2236
- error._messages_origin = True
2248
+ self._modern_read_failed(error)
2237
2249
  raise
2238
2250
 
2239
2251
  def _check_cooldown(self, key: str) -> None:
@@ -2249,6 +2261,22 @@ class AccountClient:
2249
2261
  self._metadata_maps.clear()
2250
2262
  self._transport.clear_auth()
2251
2263
 
2264
+ def _modern_read_failed(self, error: BaseException) -> None:
2265
+ """Drop the modern session unless the failure says nothing about it.
2266
+
2267
+ A cancel or a local bound keeps it. Every other failure drops it, an
2268
+ unrecognized body included: a session the upstream no longer knows may
2269
+ be answered with one, and keeping it would fail every later read, while
2270
+ dropping it costs one launch.
2271
+ """
2272
+ if isinstance(error, asyncio.CancelledError) or (
2273
+ isinstance(error, LimitError) and not error._cookies_cleared
2274
+ ):
2275
+ return
2276
+ self._invalidate_modern()
2277
+ if isinstance(error, SessionExpiredError):
2278
+ error._messages_origin = True
2279
+
2252
2280
  def _invalidate_modern(self) -> None:
2253
2281
  self._modern_account = None
2254
2282
  for key in tuple(self._cache):
@@ -2492,15 +2520,7 @@ class AccountClient:
2492
2520
  "descriptive_users", budget, parse_gateway_users, content_type=JSON
2493
2521
  )
2494
2522
  added = descriptive_grades(items, subjects, users, table)
2495
- if (
2496
- len(records.numeric)
2497
- + len(records.descriptive)
2498
- + len(records.descriptive_summaries)
2499
- + len(records.formative)
2500
- + len(added)
2501
- > GRADE_MAX_RECORDS
2502
- ):
2503
- raise LibrusError(ErrorKind.LIMIT)
2523
+ _check_record_count(records, len(added))
2504
2524
  return replace(records, descriptive=records.descriptive + added)
2505
2525
 
2506
2526
  async def _metadata_value(
@@ -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.1
3
+ Version: 1.8.3
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,7 +42,10 @@ 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.1` recovers a session Synergia no longer knows (it answers
45
+ **Status:** `1.8.3` reads behaviour grade symbols, filled descriptive
46
+ grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
47
+ through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
48
+ 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
46
49
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
47
50
  from the page header and summarizes a page that fails as a whole in
48
51
  diagnostics. `1.8.0` reports where a failure
@@ -63,7 +66,7 @@ See [limitations](#supported-features-and-limitations) below.
63
66
  Install the exact release from PyPI after its gated publication completes:
64
67
 
65
68
  ```sh
66
- python -m pip install librus-python-api==1.8.1
69
+ python -m pip install librus-python-api==1.8.3
67
70
  ```
68
71
 
69
72
  Pin the exact version qualified by your application. Before publication, use a
@@ -79,7 +82,7 @@ python -m pip install .
79
82
  To install a locally built wheel instead:
80
83
 
81
84
  ```sh
82
- python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
85
+ python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
83
86
  ```
84
87
 
85
88
  No console script or background process is installed: import the library in your
@@ -1,30 +1,30 @@
1
- librus_python_api/__init__.py,sha256=KnN3xexlc15CN1XQPtwLNhUHc9Y82MyiQrMZEs4TYl4,6513
1
+ librus_python_api/__init__.py,sha256=Ts6HZg9LRMp-MLMbjajwEdNlxVsmW8TnnGKbnzytP1k,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
9
- librus_python_api/attendance.py,sha256=HeFAfnIUn4N__yp1zr635Dve55S2jmwtkeIy_6mV5B4,6924
8
+ librus_python_api/attachments.py,sha256=Iq-ERZWc3gYXu6Lcv6womT2EXCc-c1Q7r_Q6VyaCm2E,10818
9
+ librus_python_api/attendance.py,sha256=Dq2IC8WERO-9x-LZIN5Nl0PTLYmL1Iq7BZtOz6nssOI,7428
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=F4w0F5r6uCObgyzO-cXkUtbz9Okh8HQEyfObAdOysig,61132
15
+ librus_python_api/config.py,sha256=Sz6b5lWllzsKSbCbfGKqN6z-CZfwbCt9MkD2y-OG9CU,62019
16
16
  librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
17
17
  librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
18
- librus_python_api/exceptions.py,sha256=3Bt_1WbW2blSoljGAa00TK2AzU68tgYuRDd2k1onLTc,5392
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=uaCCz743aPlymAa6rDfRb0w2JFC2hJ4yis0hbKdqTeM,17663
22
22
  librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
23
- librus_python_api/grade_records.py,sha256=YltZ_OQLsfxj0nvK8_xTuv8ifMOu2jJBnVn6TTQKzaY,22722
23
+ librus_python_api/grade_records.py,sha256=IT6A5_09Zu5h03g_p3pHVNepFCMoWag0WIJKg4A9MlQ,24978
24
24
  librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
25
- librus_python_api/inspect_page.py,sha256=AUm-n2Ewe4cQUnlFnhttcGK5B9shvFde_7t1JF7mEm0,5285
25
+ librus_python_api/inspect_page.py,sha256=W5M21v0DlFw3eaqzwGG6hsI6fkTZVQTwkO2x-NNk4y0,5490
26
26
  librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
27
- librus_python_api/markup.py,sha256=p97ZRPqhGYTEBbzJq_0j4j8AKihgQzJqTv__QUNm8Ws,6084
27
+ librus_python_api/markup.py,sha256=AS0hfJS83lnOUpyfgoNHwUKf8MKBR3WN3p_4aeZ7BOk,6408
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
30
  librus_python_api/models.py,sha256=IO-SYpqpQeuTej6kqNjBRXDbAbKITRZ1njRUyoEZ8t0,41951
@@ -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=JY-RStBHmNOgGHISaC-pB2La2pdVGlJXXn52_kwkd3U,102316
47
+ librus_python_api/service.py,sha256=GaavbI6117fcbVuWDzZXDbbgQUczZXXFVNKEMXsFfaw,103063
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.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,,
49
+ librus_python_api/transport.py,sha256=yF-DnFy4xGYS_RWQ9161b9WJXOlYF8cFRgdjXmVVUIk,34988
50
+ librus_python_api-1.8.3.dist-info/METADATA,sha256=PwGb0FBnKt-moy91gG0veDWgZZzMcEtymgI-BMFU75k,14361
51
+ librus_python_api-1.8.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
52
+ librus_python_api-1.8.3.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
53
+ librus_python_api-1.8.3.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any