librus-python-api 1.6.0__py3-none-any.whl → 1.6.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.
@@ -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.6.0"
141
+ __version__ = "1.6.2"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -9,10 +9,12 @@ from lxml import html
9
9
 
10
10
  from librus_python_api import markup
11
11
  from librus_python_api.config import (
12
+ COMPLETED_LESSONS_COLUMNS,
12
13
  COMPLETED_LESSONS_MAX_BATCH_ITEMS,
13
14
  COMPLETED_LESSONS_MAX_BATCH_PAGES,
14
15
  COMPLETED_LESSONS_MAX_PAGE_COUNT,
15
16
  COMPLETED_LESSONS_MAX_PAGE_ITEMS,
17
+ COMPLETED_LESSONS_OPTIONAL_HEADERS,
16
18
  SCHOOL_MAX_CONTENT_LENGTH,
17
19
  SCHOOL_MAX_FIELD_LENGTH,
18
20
  SCHOOL_MAX_TOTAL_TEXT_LENGTH,
@@ -65,7 +67,8 @@ def validate_selection(
65
67
  completed_lessons_form(cursor.start, cursor.end, cursor.page)
66
68
 
67
69
 
68
- def _pagination(document: html.HtmlElement, page: int) -> int:
70
+ def _pagination(document: html.HtmlElement, page: int) -> tuple[int, int | None]:
71
+ """The page count and, when shown, the number of rows the page reports."""
69
72
  containers = [
70
73
  node
71
74
  for node in document.iter("div")
@@ -74,14 +77,19 @@ def _pagination(document: html.HtmlElement, page: int) -> int:
74
77
  if not containers:
75
78
  if page != 0:
76
79
  raise LibrusError(ErrorKind.PARSE)
77
- return 1
80
+ return 1, None
78
81
  if len(containers) != 1:
79
82
  raise LibrusError(ErrorKind.PARSE)
80
83
  spans = [node for node in containers[0] if node.tag == "span"]
81
84
  if len(spans) != 1:
82
85
  raise LibrusError(ErrorKind.PARSE)
83
86
  value = markup.text(spans[0], SCHOOL_MAX_FIELD_LENGTH)
84
- match = re.fullmatch(r"(?:Strona\s+)?([0-9]{1,4})\s+z\s+([0-9]{1,4})", value, re.I)
87
+ match = re.fullmatch(
88
+ r"(?:Strona\s+)?([0-9]{1,4})\s+z\s+([0-9]{1,4})"
89
+ r"(?:\s+Pozycje\s+([0-9]{1,6})\.\.([0-9]{1,6})\s+z\s+([0-9]{1,6}))?",
90
+ value,
91
+ re.I,
92
+ )
85
93
  if match is None:
86
94
  raise LibrusError(ErrorKind.PARSE)
87
95
  current, count = int(match[1]), int(match[2])
@@ -89,7 +97,53 @@ def _pagination(document: html.HtmlElement, page: int) -> int:
89
97
  raise LibrusError(ErrorKind.LIMIT)
90
98
  if not 1 <= current <= count or current != page + 1:
91
99
  raise LibrusError(ErrorKind.PARSE)
92
- return count
100
+ if match[3] is None:
101
+ return count, None
102
+ # Some schools append the item range: "Pozycje first..last z total".
103
+ first, last, total = int(match[3]), int(match[4]), int(match[5])
104
+ if not 1 <= first <= last <= total:
105
+ raise LibrusError(ErrorKind.PARSE)
106
+ return count, last - first + 1
107
+
108
+
109
+ def _optional_columns(table: html.HtmlElement) -> frozenset[int]:
110
+ """Row positions of optional columns, located by their header labels."""
111
+ positions: set[int] = set()
112
+ for row in markup.rows(table):
113
+ if not markup.in_header(row):
114
+ continue
115
+ position = 0
116
+ labels = [
117
+ (cell, markup.text(cell, SCHOOL_MAX_FIELD_LENGTH))
118
+ for cell in markup.cells(row)
119
+ ]
120
+ if not any(label in COMPLETED_LESSONS_OPTIONAL_HEADERS for _, label in labels):
121
+ continue
122
+ if positions:
123
+ raise LibrusError(ErrorKind.PARSE)
124
+ for cell, label in labels:
125
+ span = markup.colspan(cell)
126
+ if label in COMPLETED_LESSONS_OPTIONAL_HEADERS:
127
+ if span != 1:
128
+ raise LibrusError(ErrorKind.PARSE)
129
+ positions.add(position)
130
+ position += span
131
+ # A labelled header must describe exactly the row layout it extends.
132
+ if position != COMPLETED_LESSONS_COLUMNS + len(positions):
133
+ raise LibrusError(ErrorKind.PARSE)
134
+ return frozenset(positions)
135
+
136
+
137
+ def _closing_row(row: html.HtmlElement, width: int) -> bool:
138
+ """A blank full-width row that closes some schools' tables, not a lesson."""
139
+ cells = markup.cells(row)
140
+ return (
141
+ len(cells) == 1
142
+ and cells[0].tag == "td"
143
+ and markup.colspan(cells[0]) == width
144
+ and not any(isinstance(node.tag, str) for node in cells[0])
145
+ and not markup.text(cells[0], SCHOOL_MAX_FIELD_LENGTH)
146
+ )
93
147
 
94
148
 
95
149
  def _day(value: str) -> date:
@@ -109,15 +163,24 @@ def _day(value: str) -> date:
109
163
  return result
110
164
 
111
165
 
112
- def _lesson(row: html.HtmlElement, start: date, end: date) -> CompletedLesson:
166
+ def _lesson(
167
+ row: html.HtmlElement, start: date, end: date, optional: frozenset[int]
168
+ ) -> CompletedLesson:
113
169
  cells = markup.cells(row)
114
- if len(cells) != 7 or any(
170
+ if len(cells) != COMPLETED_LESSONS_COLUMNS + len(optional) or any(
115
171
  cell.tag != "td"
116
172
  or cell.get("rowspan", "1") != "1"
117
173
  or cell.get("colspan", "1") != "1"
118
174
  for cell in cells
119
175
  ):
120
176
  raise LibrusError(ErrorKind.PARSE)
177
+ for index in optional:
178
+ # Content in an optional column is not modelled; never drop it silently.
179
+ if any(isinstance(node.tag, str) for node in cells[index]) or markup.text(
180
+ cells[index], SCHOOL_MAX_FIELD_LENGTH
181
+ ):
182
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
183
+ cells = [cell for index, cell in enumerate(cells) if index not in optional]
121
184
  date_cells = [
122
185
  c for c in cells if {"center", "small"} <= set(c.get("class", "").split())
123
186
  ]
@@ -140,7 +203,8 @@ def _lesson(row: html.HtmlElement, start: date, end: date) -> CompletedLesson:
140
203
  raw_number, combined, topic, z_value, symbol = values
141
204
  if raw_number not in {"", "-"} and re.fullmatch(r"[0-9]{1,2}", raw_number) is None:
142
205
  raise LibrusError(ErrorKind.PARSE)
143
- names = combined.split(", ")
206
+ # Some schools render "<b>Subject</b>, <br>Teacher" or pad the comma.
207
+ names = [name.strip() for name in combined.split(",")]
144
208
  subject, teacher = (names[0], names[1]) if len(names) == 2 else (combined, None)
145
209
  if not subject or teacher == "":
146
210
  raise LibrusError(ErrorKind.PARSE)
@@ -174,9 +238,13 @@ def parse_completed_lessons(
174
238
  ) -> tuple[tuple[CompletedLesson, ...], int, str]:
175
239
  completed_lessons_form(start, end, page)
176
240
  document = parse_page(body)
177
- count = _pagination(document, page)
241
+ count, reported = _pagination(document, page)
242
+ # A date-filter form table shares the "decorated" class with the data table.
178
243
  tables = [
179
- t for t in document.iter("table") if "decorated" in t.get("class", "").split()
244
+ t
245
+ for t in document.iter("table")
246
+ if "decorated" in (classes := t.get("class", "").split())
247
+ and "filters" not in classes
180
248
  ]
181
249
  empty = [
182
250
  n
@@ -190,10 +258,12 @@ def parse_completed_lessons(
190
258
  if tables:
191
259
  if list(tables[0].iterdescendants("table")):
192
260
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
193
- for row in markup.rows(tables[0]):
194
- if next(row.iterancestors("thead"), None) is not None:
195
- continue
196
- item = _lesson(row, start, end)
261
+ optional = _optional_columns(tables[0])
262
+ lines = [row for row in markup.rows(tables[0]) if not markup.in_header(row)]
263
+ if lines and _closing_row(lines[-1], COMPLETED_LESSONS_COLUMNS + len(optional)):
264
+ lines.pop()
265
+ for row in lines:
266
+ item = _lesson(row, start, end, optional)
197
267
  total += sum(len(v) for v in astuple(item) if isinstance(v, str))
198
268
  items.append(item)
199
269
  if (
@@ -201,6 +271,8 @@ def parse_completed_lessons(
201
271
  or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH
202
272
  ):
203
273
  raise LibrusError(ErrorKind.LIMIT)
274
+ if reported is not None and reported != len(items):
275
+ raise LibrusError(ErrorKind.PARSE)
204
276
  if (items and empty) or (not items and (not empty or count != 1 or page != 0)):
205
277
  raise LibrusError(ErrorKind.PARSE)
206
278
  # An unrecognized notice next to an empty marker is not a valid empty page.
@@ -731,6 +731,8 @@ ATTENDANCE_MAX_SUBJECTS = 1024
731
731
  ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
732
732
  # Recognition only: formative detail links are classified, never requested.
733
733
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
734
+ # Recognition only: behaviour-point detail links are classified, never requested.
735
+ BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
734
736
  FORMATIVE_HEADERS = (
735
737
  "przedmiot",
736
738
  "ocena kształtująca",
@@ -860,6 +862,8 @@ HOMEWORK_MARK_DONE_HANDLER = (
860
862
  r"\s*showConfirmQuestion\(\s*[0-9]{1,64}\s*,\s*[0-9]{1,64}\s*\);?\s*"
861
863
  )
862
864
  WEEKDAY_LABELS = ("pon.", "wt.", "śr.", "czw.", "pt.", "sob.", "ndz.")
865
+ # Grade tooltips also use the short Sunday display label.
866
+ CIVIL_DATE_WEEKDAY_LABELS = frozenset((*WEEKDAY_LABELS, "nd."))
863
867
  # Observed page-level notices shown instead of the requested content.
864
868
  PAGE_NOTICES = MappingProxyType(
865
869
  {
@@ -880,7 +884,13 @@ COMPLETED_LESSONS_MAX_PAGE_COUNT = 1000
880
884
  COMPLETED_LESSONS_MAX_PAGE_ITEMS = 256
881
885
  COMPLETED_LESSONS_MAX_BATCH_PAGES = 8
882
886
  COMPLETED_LESSONS_MAX_BATCH_ITEMS = 256
887
+ # Header labels of columns some schools add to the completed-lesson table. Their
888
+ # content is not modelled: such a column must be empty in every lesson row.
889
+ COMPLETED_LESSONS_OPTIONAL_HEADERS = frozenset({"e-Tablica"})
890
+ COMPLETED_LESSONS_COLUMNS = 7
883
891
  AGENDA_DETAIL_PATH_PREFIX = "/terminarz/szczegoly/"
892
+ # Recognition only: closure details are not ordinary agenda-detail references.
893
+ AGENDA_CLOSURE_DETAIL_PATH_PREFIX = "/terminarz/szczegoly_wolne/"
884
894
  HOMEWORK_DETAIL_PATH_PREFIX = "/moje_zadania/podglad/"
885
895
  MESSAGE_MAX_PAGE_COUNT = 1000
886
896
  MESSAGE_MAX_PAGE_ITEMS = 50
@@ -927,7 +937,6 @@ MODERN_DIRECTORY_QUERIES = MappingProxyType(
927
937
  "modern_recipients": (("receiverType", "parentsCouncil"),),
928
938
  }
929
939
  )
930
- MODERN_SUPPORTED_RECIPIENT_TYPE = "parentsCouncil"
931
940
  MODERN_RECIPIENT_OPERATIONS = MappingProxyType(
932
941
  {
933
942
  **{
@@ -34,7 +34,6 @@ def normalize_detail_fields(
34
34
  fields: tuple[tuple[str, str], ...],
35
35
  kind: Literal["agenda", "homework", "attendance"],
36
36
  ) -> tuple[DetailField, ...]:
37
- """Run after bounded HTML parsing, before publishing or caching a result."""
38
37
  labels: set[str] = set()
39
38
  keys: set[DetailFieldKey] = set()
40
39
  result = []
@@ -31,7 +31,6 @@ def _fail(kind: ErrorKind) -> NoReturn:
31
31
 
32
32
 
33
33
  def is_formative_table(table: html.HtmlElement) -> bool:
34
- # Cheap structural gates first: other tables' header text is never read.
35
34
  if not {"stretch", "decorated"} <= set(table.get("class", "").split()):
36
35
  return False
37
36
  heads = [row for row in markup.rows(table) if markup.in_header(row)]
@@ -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_POINTS_DETAIL_PATH_PREFIX,
11
12
  FORMATIVE_DETAIL_PATH_PREFIX,
12
13
  GRADE_AVERAGE_HEADERS,
13
14
  GRADE_BODY_PREFIX_COLUMNS,
@@ -16,6 +17,7 @@ from librus_python_api.config import (
16
17
  GRADE_MAX_METADATA_LENGTH,
17
18
  GRADE_MAX_RECORDS,
18
19
  GRADE_MAX_SUBJECTS,
20
+ GRADE_MERGED_SUBJECTS,
19
21
  GRADE_PERIOD_HEADERS,
20
22
  GRADE_PREDICTED_ANNUAL_HEADER,
21
23
  GRADE_PREDICTED_PERIOD_HEADERS,
@@ -174,6 +176,50 @@ def _boxes(cell: html.HtmlElement) -> list[html.HtmlElement]:
174
176
  ]
175
177
 
176
178
 
179
+ def _behaviour_points(cell: html.HtmlElement, collection: _Collection) -> bool:
180
+ """Recognize and validate a behaviour-points cell; points are not grades.
181
+
182
+ The cell holds one points tooltip (starting points or a running total; its
183
+ title is not interpreted) and point entries linking to behaviour details,
184
+ each with its own unlabelled tooltip. Any other content next to the points
185
+ tooltip fails instead of being skipped.
186
+ """
187
+ tallies = [
188
+ node
189
+ for node in cell
190
+ if node.tag == "span" and "tooltip" in node.get("class", "").split()
191
+ ]
192
+ if not tallies:
193
+ return False
194
+ elements = [node for node in cell if isinstance(node.tag, str)]
195
+ boxes = [node for node in elements if node not in tallies]
196
+ if (
197
+ len(tallies) != 1
198
+ or len(tallies[0])
199
+ or not re.fullmatch(r"-?[0-9]{1,4}", markup.text(tallies[0]))
200
+ or (cell.text or "").strip()
201
+ or any((node.tail or "").strip() for node in cell)
202
+ or _boxes(cell) != boxes
203
+ ):
204
+ raise LibrusError(ErrorKind.PARSE)
205
+ for box in boxes:
206
+ anchors = [node for node in box if isinstance(node.tag, str)]
207
+ if (
208
+ box.getparent() is not cell
209
+ or len(anchors) != 1
210
+ or anchors[0].tag != "a"
211
+ or len(anchors[0])
212
+ or markup.detail_id(
213
+ anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
214
+ )
215
+ is None
216
+ or not re.fullmatch(r"[+-]?[0-9]{1,4}", markup.text(anchors[0]))
217
+ ):
218
+ raise LibrusError(ErrorKind.PARSE)
219
+ collection.consumed.add(box)
220
+ return True
221
+
222
+
177
223
  def _entries(
178
224
  cell: html.HtmlElement,
179
225
  subject: str,
@@ -320,9 +366,13 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
320
366
  seen: set[html.HtmlElement] = set()
321
367
  for period, index in enumerate(current, start=1):
322
368
  if columns[index] not in seen:
323
- _entries(
324
- columns[index], subject, cast(Literal[1, 2], period), collection
325
- )
369
+ if not (
370
+ subject in GRADE_MERGED_SUBJECTS
371
+ and _behaviour_points(columns[index], collection)
372
+ ):
373
+ _entries(
374
+ columns[index], subject, cast(Literal[1, 2], period), collection
375
+ )
326
376
  seen.add(columns[index])
327
377
  for (period, kind), index in periods.items():
328
378
  cell = columns[index]
@@ -9,11 +9,11 @@ from lxml import etree, html
9
9
 
10
10
  from librus_python_api.config import (
11
11
  ATTENDANCE_DETAIL_PATH_PREFIX,
12
+ CIVIL_DATE_WEEKDAY_LABELS,
12
13
  GRADE_MAX_COLUMNS,
13
14
  GRADE_MAX_METADATA_FIELDS,
14
15
  GRADE_MAX_METADATA_LENGTH,
15
16
  UPSTREAM_ORIGINS,
16
- WEEKDAY_LABELS,
17
17
  )
18
18
  from librus_python_api.exceptions import ErrorKind, LibrusError
19
19
  from librus_python_api.parsers import parse_html_document
@@ -109,7 +109,9 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
109
109
  def civil_date(value: str | None) -> date:
110
110
  """An ISO date, optionally followed by its weekday label in parentheses."""
111
111
  match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
112
- if match is None or (match[2] is not None and match[2] not in WEEKDAY_LABELS):
112
+ if match is None or (
113
+ match[2] is not None and match[2] not in CIVIL_DATE_WEEKDAY_LABELS
114
+ ):
113
115
  raise LibrusError(ErrorKind.PARSE)
114
116
  try:
115
117
  return date.fromisoformat(match[1])
@@ -144,7 +144,6 @@ def _seen_after(
144
144
  class NotificationStore(_SQLiteStore):
145
145
  """Private native recovery store. No MCP files, auto migration or live access.
146
146
 
147
- Context locks require POSIX flock. Unsupported platforms fail explicitly.
148
147
  Raw checkpoints contain private account/content metadata, never HTTP cookies.
149
148
  """
150
149
 
@@ -1,4 +1,4 @@
1
- """Explicit optional durable sending, independent of MCP and core transport."""
1
+ """Durable sending and the notification store and workflow."""
2
2
 
3
3
  import hashlib
4
4
  import json
@@ -9,6 +9,7 @@ from lxml import html
9
9
 
10
10
  from librus_python_api import markup
11
11
  from librus_python_api.config import (
12
+ AGENDA_CLOSURE_DETAIL_PATH_PREFIX,
12
13
  AGENDA_DESCRIPTION_LABEL,
13
14
  AGENDA_DETAIL_PATH_PREFIX,
14
15
  AGENDA_TOOLTIP_LABELS,
@@ -42,13 +43,14 @@ def school_reference(
42
43
  ) -> SchoolReference | None:
43
44
  """Find the single detail link.
44
45
 
45
- Agenda cells must not carry any other handler. Homework rows also carry a
46
- mark-done button; the library never follows handlers, so it is inert here.
46
+ Closure handlers stay inert without an ordinary agenda-detail reference.
47
+ Homework rows also carry a mark-done button; handlers are never followed.
47
48
  """
48
49
  prefix = (
49
50
  AGENDA_DETAIL_PATH_PREFIX if kind == "agenda" else HOMEWORK_DETAIL_PATH_PREFIX
50
51
  )
51
52
  found: set[str] = set()
53
+ closure = False
52
54
  for node in nodes:
53
55
  if not isinstance(node.tag, str):
54
56
  continue
@@ -57,6 +59,19 @@ def school_reference(
57
59
  raise LibrusError(ErrorKind.LIMIT)
58
60
  if not action:
59
61
  continue
62
+ if (
63
+ kind == "agenda"
64
+ and node is nodes[0]
65
+ and node.tag == "td"
66
+ and re.fullmatch(
67
+ r"location\.href='"
68
+ + re.escape(AGENDA_CLOSURE_DETAIL_PATH_PREFIX)
69
+ + r"[0-9]{1,64}'",
70
+ action,
71
+ )
72
+ ):
73
+ closure = True
74
+ continue
60
75
  if kind == "homework" and prefix not in action:
61
76
  # Only the observed mark-done button is inert; anything else is new.
62
77
  if re.fullmatch(HOMEWORK_MARK_DONE_HANDLER, action):
@@ -66,7 +81,7 @@ def school_reference(
66
81
  if len(paths) != 1:
67
82
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
68
83
  found.add(paths[0].removeprefix(prefix))
69
- if len(found) > 1:
84
+ if len(found) > 1 or (closure and found):
70
85
  raise LibrusError(ErrorKind.PARSE)
71
86
  return SchoolReference(kind, next(iter(found)), account) if found else None
72
87
 
@@ -210,51 +210,51 @@ class AiohttpTransport:
210
210
  self._modern_session: aiohttp.ClientSession | None = None
211
211
  self._closed = False
212
212
 
213
+ def _open_session(
214
+ self,
215
+ *,
216
+ cookie_jar: aiohttp.abc.AbstractCookieJar | None = None,
217
+ read_bufsize: int = 16 * 1024,
218
+ per_request_timeout: bool = False,
219
+ ) -> aiohttp.ClientSession:
220
+ _require_no_connection_retry()
221
+ timeout = (
222
+ None
223
+ if per_request_timeout
224
+ else aiohttp.ClientTimeout(
225
+ total=self._limits.request_timeout_seconds,
226
+ connect=self._limits.connect_timeout_seconds,
227
+ )
228
+ )
229
+ session = aiohttp.ClientSession(
230
+ connector=aiohttp.TCPConnector(
231
+ limit=1,
232
+ ssl=self._connection.ssl_context or True,
233
+ ),
234
+ cookie_jar=aiohttp.CookieJar() if cookie_jar is None else cookie_jar,
235
+ trust_env=False,
236
+ auto_decompress=False,
237
+ read_bufsize=read_bufsize,
238
+ timeout=timeout,
239
+ headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
240
+ )
241
+ # aiohttp otherwise silently replays GET on a stale keepalive
242
+ # connection. Some GETs mark read; every attempt must be budgeted.
243
+ session._retry_connection = False
244
+ return session
245
+
213
246
  def _get_session(self) -> aiohttp.ClientSession:
214
247
  if self._closed:
215
248
  raise LibrusError(ErrorKind.CLOSED)
216
249
  if self._session is None:
217
- _require_no_connection_retry()
218
- self._session = aiohttp.ClientSession(
219
- connector=aiohttp.TCPConnector(
220
- limit=1,
221
- ssl=self._connection.ssl_context or True,
222
- ),
223
- cookie_jar=aiohttp.CookieJar(),
224
- trust_env=False,
225
- auto_decompress=False,
226
- read_bufsize=16 * 1024,
227
- timeout=aiohttp.ClientTimeout(
228
- total=self._limits.request_timeout_seconds,
229
- connect=self._limits.connect_timeout_seconds,
230
- ),
231
- headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
232
- )
233
- # aiohttp otherwise silently replays GET on a stale keepalive
234
- # connection. Some GETs mark read; every attempt must be budgeted.
235
- self._session._retry_connection = False
250
+ self._session = self._open_session()
236
251
  return self._session
237
252
 
238
253
  def _get_modern_session(self) -> aiohttp.ClientSession:
239
254
  if self._closed:
240
255
  raise LibrusError(ErrorKind.CLOSED)
241
256
  if self._modern_session is None:
242
- _require_no_connection_retry()
243
- self._modern_session = aiohttp.ClientSession(
244
- connector=aiohttp.TCPConnector(
245
- limit=1, ssl=self._connection.ssl_context or True
246
- ),
247
- cookie_jar=aiohttp.CookieJar(),
248
- trust_env=False,
249
- auto_decompress=False,
250
- read_bufsize=16 * 1024,
251
- timeout=aiohttp.ClientTimeout(
252
- total=self._limits.request_timeout_seconds,
253
- connect=self._limits.connect_timeout_seconds,
254
- ),
255
- headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
256
- )
257
- self._modern_session._retry_connection = False
257
+ self._modern_session = self._open_session()
258
258
  return self._modern_session
259
259
 
260
260
  def _url(self, endpoint: Endpoint) -> str:
@@ -392,25 +392,12 @@ class AiohttpTransport:
392
392
  budget: RequestBudget,
393
393
  dispatched: Callable[[], None],
394
394
  ) -> TransportResponse:
395
- payload = encode_modern_send(submission, self._account)
396
- if self._closed:
397
- raise LibrusError(ErrorKind.CLOSED)
398
- kind: ErrorKind | None = None
399
- try:
400
- return await self._scheduler.run(
401
- self._account,
402
- budget,
403
- partial(self._send_exchange, payload, budget, dispatched, modern=True),
404
- preserve_completed=True,
405
- )
406
- except aiohttp.ClientError:
407
- kind = ErrorKind.CONNECTION
408
- except TimeoutError:
409
- kind = ErrorKind.TIMEOUT
410
- except (ValueError, OverflowError, zlib.error):
411
- kind = ErrorKind.PARSE
412
- assert kind is not None
413
- raise LibrusError(kind)
395
+ return await self._send_exchange(
396
+ encode_modern_send(submission, self._account),
397
+ budget,
398
+ dispatched,
399
+ modern=True,
400
+ )
414
401
 
415
402
  async def send_message(
416
403
  self,
@@ -418,15 +405,61 @@ class AiohttpTransport:
418
405
  budget: RequestBudget,
419
406
  dispatched: Callable[[], None],
420
407
  ) -> TransportResponse:
421
- payload = encode_send_form(submission, self._account)
408
+ return await self._send_exchange(
409
+ encode_send_form(submission, self._account),
410
+ budget,
411
+ dispatched,
412
+ )
413
+
414
+ async def _send_exchange(
415
+ self,
416
+ payload: bytes,
417
+ budget: RequestBudget,
418
+ dispatched: Callable[[], None],
419
+ *,
420
+ modern: bool = False,
421
+ ) -> TransportResponse:
422
422
  if self._closed:
423
423
  raise LibrusError(ErrorKind.CLOSED)
424
+
425
+ async def exchange() -> TransportResponse:
426
+ endpoint = ENDPOINTS["modern_send_message" if modern else "send_message"]
427
+ session = self._get_modern_session() if modern else self._get_session()
428
+ proxy = self._connection.proxy_url
429
+ dispatched()
430
+ async with session.post(
431
+ self._url(endpoint),
432
+ data=payload,
433
+ headers={
434
+ "Content-Type": "application/json"
435
+ if modern
436
+ else "application/x-www-form-urlencoded; charset=utf-8"
437
+ },
438
+ allow_redirects=False,
439
+ proxy=proxy.get_secret_value() if proxy is not None else None,
440
+ ) as response:
441
+ self._check_headers(response)
442
+ if len(session.cookie_jar) > self._limits.max_cookies:
443
+ session.cookie_jar.clear()
444
+ raise LibrusError(ErrorKind.LIMIT)
445
+ if not modern or response.status not in (400, 422):
446
+ self._check_status(response)
447
+ body = await self._read_body(response, budget)
448
+ return TransportResponse(
449
+ response.status,
450
+ body,
451
+ str(response.url),
452
+ MappingProxyType(
453
+ {k.lower(): v for k, v in response.headers.items()}
454
+ ),
455
+ )
456
+
424
457
  kind: ErrorKind | None = None
425
458
  try:
426
459
  return await self._scheduler.run(
427
460
  self._account,
428
461
  budget,
429
- partial(self._send_exchange, payload, budget, dispatched),
462
+ exchange,
430
463
  preserve_completed=True,
431
464
  )
432
465
  except aiohttp.ClientError:
@@ -438,43 +471,6 @@ class AiohttpTransport:
438
471
  assert kind is not None
439
472
  raise LibrusError(kind)
440
473
 
441
- async def _send_exchange(
442
- self,
443
- payload: bytes,
444
- budget: RequestBudget,
445
- dispatched: Callable[[], None],
446
- *,
447
- modern: bool = False,
448
- ) -> TransportResponse:
449
- endpoint = ENDPOINTS["modern_send_message" if modern else "send_message"]
450
- session = self._get_modern_session() if modern else self._get_session()
451
- proxy = self._connection.proxy_url
452
- dispatched()
453
- async with session.post(
454
- self._url(endpoint),
455
- data=payload,
456
- headers={
457
- "Content-Type": "application/json"
458
- if modern
459
- else "application/x-www-form-urlencoded; charset=utf-8"
460
- },
461
- allow_redirects=False,
462
- proxy=proxy.get_secret_value() if proxy is not None else None,
463
- ) as response:
464
- self._check_headers(response)
465
- if len(session.cookie_jar) > self._limits.max_cookies:
466
- session.cookie_jar.clear()
467
- raise LibrusError(ErrorKind.LIMIT)
468
- if not modern or response.status not in (400, 422):
469
- self._check_status(response)
470
- body = await self._read_body(response, budget)
471
- return TransportResponse(
472
- response.status,
473
- body,
474
- str(response.url),
475
- MappingProxyType({k.lower(): v for k, v in response.headers.items()}),
476
- )
477
-
478
474
  async def consume_schedule_events(
479
475
  self,
480
476
  budget: RequestBudget,
@@ -607,18 +603,11 @@ class AiohttpTransport:
607
603
  if self._closed:
608
604
  raise LibrusError(ErrorKind.CLOSED)
609
605
  if self._download_session is None:
610
- _require_no_connection_retry()
611
- self._download_session = aiohttp.ClientSession(
612
- connector=aiohttp.TCPConnector(
613
- limit=1, ssl=self._connection.ssl_context or True
614
- ),
606
+ self._download_session = self._open_session(
615
607
  cookie_jar=aiohttp.DummyCookieJar(),
616
- trust_env=False,
617
- auto_decompress=False,
618
608
  read_bufsize=ATTACHMENT_CHUNK_BYTES,
619
- headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
609
+ per_request_timeout=True,
620
610
  )
621
- self._download_session._retry_connection = False
622
611
  return self._download_session
623
612
 
624
613
  async def stream_download(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.6.0
3
+ Version: 1.6.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,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.6.0` adds class free days and a typed module-unavailable outcome,
46
- plus an independent live-check freshness watchdog and targeted mutation/load
47
- evidence. See [verification](VERIFICATION.md).
45
+ **Status:** `1.6.2` accepts more completed-lesson and grade layouts, including
46
+ behaviour points, which are validated and not reported as grades.
47
+ Class free days, typed capability outcomes and the live-check freshness watchdog
48
+ remain available. See [verification](VERIFICATION.md).
48
49
  The stable 1.x API does not guarantee every school layout. School features depend
49
50
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
50
51
  See [limitations](#supported-features-and-limitations) below.
@@ -54,7 +55,7 @@ See [limitations](#supported-features-and-limitations) below.
54
55
  Install the exact release from PyPI after its gated publication completes:
55
56
 
56
57
  ```sh
57
- python -m pip install librus-python-api==1.6.0
58
+ python -m pip install librus-python-api==1.6.2
58
59
  ```
59
60
 
60
61
  Pin the exact version qualified by your application. Before publication, use a
@@ -70,7 +71,7 @@ python -m pip install .
70
71
  To install a locally built wheel instead:
71
72
 
72
73
  ```sh
73
- python -m pip install ./dist/librus_python_api-1.6.0-py3-none-any.whl
74
+ python -m pip install ./dist/librus_python_api-1.6.2-py3-none-any.whl
74
75
  ```
75
76
 
76
77
  No CLI or background process is installed: import the library in your own program.
@@ -1,4 +1,4 @@
1
- librus_python_api/__init__.py,sha256=DhWaUkE_sGZgqB1aWZh5ZUtyjfIeMJl1KhUPV5WpzLA,6513
1
+ librus_python_api/__init__.py,sha256=ZLWweFJqQFf-SdkQ7pHclXOKI0b-bfqNbNN1u14333w,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
@@ -11,18 +11,18 @@ librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7
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
- librus_python_api/completed_lessons.py,sha256=sR2oLSGb2q1580l-2upCBv9sQfIcHi7CQfWr1K0EFXI,7635
15
- librus_python_api/config.py,sha256=A5UNZ0oc8Oxt13y0To1REdQLWPgLrH-VRRpcuwodElc,55482
16
- librus_python_api/detail_fields.py,sha256=K10Stx_ehDTttkUI7-G7a2Ko9exYEu5NJpR-3sBnQ60,1789
14
+ librus_python_api/completed_lessons.py,sha256=ngGfBD6K4B6dR4elPYvNRvp40Q-GlrhUsUKqBbYY1KE,10727
15
+ librus_python_api/config.py,sha256=KEDe6KgS6XMjMViUexgUqlQnBtFVskVLd8NrVADD7BU,56109
16
+ librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
17
17
  librus_python_api/diagnostics.py,sha256=2l7wq6RA3it-Gwj-JRLcw5qbI37fl3xII6GZA2mZj0o,856
18
18
  librus_python_api/exceptions.py,sha256=adVcJuzfB8wGrUlF2SfiyIlE9ZZyTAvGdWRtQIjsA3I,4879
19
19
  librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
20
- librus_python_api/formative_grades.py,sha256=-F-d1rnFVAIGwg1mN0cryCIBR76OW3v2L4mCWUZ3M7Q,6227
20
+ librus_python_api/formative_grades.py,sha256=K_Qj13XMowYsIR10PjQqZ0MMta5eXIzLgQKCPL6yFJM,6150
21
21
  librus_python_api/grade_parsers.py,sha256=91Kr1-TArzqO-b94ipeIR0a98OV7tRCZQdBWsd9QLZ0,6141
22
- librus_python_api/grade_records.py,sha256=K0ZzmU1KsA8Jo1MXh30mStApgrigaOKk5ldoBnwRrH8,17901
22
+ librus_python_api/grade_records.py,sha256=tGi1037deTacvfDYmiKf-N7McyKcyos6NPuBk_H_M3U,19794
23
23
  librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
24
24
  librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
25
- librus_python_api/markup.py,sha256=mjCZf6KJTWUz2vjfXbJ4zM17hnev7a5AFIOW3ip3gsE,5677
25
+ librus_python_api/markup.py,sha256=vd483NSUQbVYvSV2IaUmsyGcRhpWtbyDHMeEhbc7siA,5713
26
26
  librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
27
27
  librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
28
28
  librus_python_api/models.py,sha256=Z6eV-ftjaN8YGUW8SntVTLjGCqe1EWRpd0P1Va8EwDs,40991
@@ -30,22 +30,22 @@ librus_python_api/modern_body.py,sha256=3L4P_cIrHdIxZqiinFSQrDMhG6iklT1GoA-VEWMJ
30
30
  librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
31
31
  librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
32
32
  librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5kOSW7Jjt7J0,4199
33
- librus_python_api/notification_persistence.py,sha256=hXKmzAh0yNYG_n5CLlZOcoYOZdQnlrSeNtjjXiDmLm4,49532
33
+ librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
34
34
  librus_python_api/notification_workflow.py,sha256=MsBJdwn6J1UUGlz3Ldzi7WEBz5Qsama7eGmZvUQj25g,12924
35
35
  librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
36
36
  librus_python_api/parsers.py,sha256=OFztbUMDzCywq17BgfFT485bVjyOqLksFHQJthZCx5Q,8120
37
37
  librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
38
- librus_python_api/persistence.py,sha256=C5oDHQg1Xzy9Jb9obVsRNndCiXps-QQQtQ_M7_l5BBI,15546
38
+ librus_python_api/persistence.py,sha256=F35yBRu6D8aYgdwKTpzVdqVTyiUaLQludUmyyskb3Vo,15529
39
39
  librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
40
  librus_python_api/recipients.py,sha256=6GQCKo-Zjg3VFSg1J8YXcDsy_U3r6-MUS4VCvz0xdaQ,10033
41
41
  librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQmA,10729
42
- librus_python_api/school_reads.py,sha256=PCnZ4lv44j-68FgJpQiMteaPSyfhbX8_3CUyE6zhFcQ,14760
42
+ librus_python_api/school_reads.py,sha256=YHta7DmhjS5vSa4Djq8pzp01-2jhVfGOo1Mnc738rV4,15209
43
43
  librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
44
44
  librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
45
45
  librus_python_api/service.py,sha256=7ioLx-8C-I0Qla8YUyu5UqhjBPlcUvEFpQSYH-9yjzs,96232
46
46
  librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
47
- librus_python_api/transport.py,sha256=QnYXnrv1lW4qToYoTv6HyZw-Lt4rDc-_SMTJpMp6_bc,35297
48
- librus_python_api-1.6.0.dist-info/METADATA,sha256=O_HfTBq306xfvGOiGLwpWeWdHb2G9m_2c2PN9HmY4VY,11994
49
- librus_python_api-1.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
- librus_python_api-1.6.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
51
- librus_python_api-1.6.0.dist-info/RECORD,,
47
+ librus_python_api/transport.py,sha256=VslHio0FrOiTkISfLNDlGVV_MeGAYidHDKZnoVUIwrI,34310
48
+ librus_python_api-1.6.2.dist-info/METADATA,sha256=l317hh6HLcjPf2_EHZN2pVOMuoJvBNqaU8RcBtdhaaU,12070
49
+ librus_python_api-1.6.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
+ librus_python_api-1.6.2.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
51
+ librus_python_api-1.6.2.dist-info/RECORD,,