librus-python-api 1.8.1__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.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,
@@ -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",
@@ -862,9 +863,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
862
863
  FORMATIVE_MAX_TEXT_LENGTH = 65536
863
864
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
864
865
  ATTENDANCE_METADATA_CACHE_SIZE = 256
865
- ATTENDANCE_RESULT_CACHE_SIZE = 64
866
866
  ATTENDANCE_METADATA_TTL_SECONDS = 3600
867
867
 
868
+ # Entries of every cached read of one account, oldest first out.
869
+ ACCOUNT_RESULT_CACHE_SIZE = 64
870
+
868
871
  ANNOUNCEMENT_MAX_ITEMS = 256
869
872
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
870
873
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
@@ -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,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
@@ -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
  )
@@ -1308,7 +1309,7 @@ class AccountClient:
1308
1309
  budget: RequestBudget | None = None,
1309
1310
  max_age_seconds: float = 0.0,
1310
1311
  ) -> Grades:
1311
- """Select a grade view with one non-replayed view-changing POST.
1312
+ """Select a grade view with one view-changing POST.
1312
1313
 
1313
1314
  Changes the selected grade filter, not school records. A cache hit makes
1314
1315
  no POST. Missing inline weight/count data remains unknown. In the all
@@ -1401,7 +1402,7 @@ class AccountClient:
1401
1402
  budget: RequestBudget | None = None,
1402
1403
  max_age_seconds: float = 0.0,
1403
1404
  ) -> Attendance:
1404
- """Read a fixed upstream attendance view without replaying its POST."""
1405
+ """Read a fixed upstream attendance view with one view-changing POST."""
1405
1406
  form = attendance_view_form(view)
1406
1407
 
1407
1408
  async def fetch(budget: RequestBudget, _: bool) -> Attendance:
@@ -1540,7 +1541,7 @@ class AccountClient:
1540
1541
  budget: RequestBudget | None = None,
1541
1542
  max_age_seconds: float = 0.0,
1542
1543
  ) -> Timetable:
1543
- """Select an explicit school civil week; never replay its selection POST."""
1544
+ """Select an explicit school civil week with one view-changing POST."""
1544
1545
  form = timetable_form(monday)
1545
1546
 
1546
1547
  async def fetch(budget: RequestBudget, _: bool) -> Timetable:
@@ -2143,21 +2144,22 @@ class AccountClient:
2143
2144
  )
2144
2145
  raise
2145
2146
  self._cache[key] = (time.monotonic(), result)
2146
- if len(self._cache) > ATTENDANCE_RESULT_CACHE_SIZE:
2147
+ if len(self._cache) > ACCOUNT_RESULT_CACHE_SIZE:
2147
2148
  del self._cache[next(iter(self._cache))]
2148
2149
  return result
2149
2150
 
2150
2151
  async def _authenticated[T](
2151
2152
  self, fetch: Fetch[T], budget: RequestBudget, retry_safe: bool
2152
2153
  ) -> T:
2153
- """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.
2154
2155
 
2155
2156
  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.
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.
2161
2163
  """
2162
2164
  fresh_login = self._identity is None
2163
2165
  if fresh_login:
@@ -2231,9 +2233,7 @@ class AccountClient:
2231
2233
  raise expired from error
2232
2234
  except BaseException as error:
2233
2235
  if ENDPOINTS[endpoint].origin == "messages":
2234
- self._invalidate_modern()
2235
- if isinstance(error, SessionExpiredError):
2236
- error._messages_origin = True
2236
+ self._modern_read_failed(error)
2237
2237
  raise
2238
2238
 
2239
2239
  def _check_cooldown(self, key: str) -> None:
@@ -2249,6 +2249,22 @@ class AccountClient:
2249
2249
  self._metadata_maps.clear()
2250
2250
  self._transport.clear_auth()
2251
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
+
2252
2268
  def _invalidate_modern(self) -> None:
2253
2269
  self._modern_account = None
2254
2270
  for key in tuple(self._cache):
@@ -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.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,7 +42,8 @@ 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.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
46
47
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
47
48
  from the page header and summarizes a page that fails as a whole in
48
49
  diagnostics. `1.8.0` reports where a failure
@@ -63,7 +64,7 @@ See [limitations](#supported-features-and-limitations) below.
63
64
  Install the exact release from PyPI after its gated publication completes:
64
65
 
65
66
  ```sh
66
- python -m pip install librus-python-api==1.8.1
67
+ python -m pip install librus-python-api==1.8.2
67
68
  ```
68
69
 
69
70
  Pin the exact version qualified by your application. Before publication, use a
@@ -79,7 +80,7 @@ python -m pip install .
79
80
  To install a locally built wheel instead:
80
81
 
81
82
  ```sh
82
- python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
83
+ python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
83
84
  ```
84
85
 
85
86
  No console script or background process is installed: import the library in your
@@ -1,24 +1,24 @@
1
- librus_python_api/__init__.py,sha256=KnN3xexlc15CN1XQPtwLNhUHc9Y82MyiQrMZEs4TYl4,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=F4w0F5r6uCObgyzO-cXkUtbz9Okh8HQEyfObAdOysig,61132
15
+ librus_python_api/config.py,sha256=CBMwuuACfrB7TzTVkTP8QAuPz5hv43YOsbto81-uEJc,61248
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=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
@@ -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=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.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.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,,