librus-python-api 1.0.0__tar.gz → 1.0.1__tar.gz

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.
Files changed (53) hide show
  1. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/CHANGELOG.md +20 -0
  2. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/PKG-INFO +4 -4
  3. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/README.md +3 -3
  4. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/__init__.py +1 -1
  5. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/_storage.py +4 -1
  6. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/attendance_frequency.py +65 -2
  7. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/config.py +19 -0
  8. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/files.py +7 -2
  9. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/models.py +20 -0
  10. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/service.py +53 -3
  11. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/.gitignore +0 -0
  12. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/API.md +0 -0
  13. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/LICENSE +0 -0
  14. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/SECURITY.md +0 -0
  15. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/pyproject.toml +0 -0
  16. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
  17. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/_notification_codec.py +0 -0
  18. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/_windows_filesystem.py +0 -0
  19. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/announcements.py +0 -0
  20. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/attachment_routes.py +0 -0
  21. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/attachments.py +0 -0
  22. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/attendance.py +0 -0
  23. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/budget.py +0 -0
  24. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/checkpoint.py +0 -0
  25. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/completed_lessons.py +0 -0
  26. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/detail_fields.py +0 -0
  27. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/diagnostics.py +0 -0
  28. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/exceptions.py +0 -0
  29. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/grade_parsers.py +0 -0
  30. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/grade_records.py +0 -0
  31. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/homework_range.py +0 -0
  32. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/lifecycle.py +0 -0
  33. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/markup.py +0 -0
  34. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/message_content.py +0 -0
  35. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/messages.py +0 -0
  36. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/modern_body.py +0 -0
  37. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/modern_mailbox.py +0 -0
  38. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/modern_messages.py +0 -0
  39. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/notification_models.py +0 -0
  40. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/notification_persistence.py +0 -0
  41. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/notification_workflow.py +0 -0
  42. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/notifications.py +0 -0
  43. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/parsers.py +0 -0
  44. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/parsing.py +0 -0
  45. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/persistence.py +0 -0
  46. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/py.typed +0 -0
  47. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/recipients.py +0 -0
  48. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/scheduler.py +0 -0
  49. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/school_reads.py +0 -0
  50. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/sending.py +0 -0
  51. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/timetable.py +0 -0
  52. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/src/librus_python_api/transport.py +0 -0
  53. {librus_python_api-1.0.0 → librus_python_api-1.0.1}/uv.lock +0 -0
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1 (2026-10-06) - Bounded subject frequency and reliability fixes
4
+
5
+ Compatible patch release. The public library API is unchanged.
6
+
7
+ - Resolve subject-frequency lesson and subject names from the gateway `Lessons`
8
+ and `Subjects` collections, read once per login session and cached for the
9
+ existing one-hour TTL. A cold call now costs three requests regardless of how
10
+ many lessons and subjects a student has. Before, it cost one request per unique
11
+ lesson plus one per subject, which exceeded the default 32-request operation
12
+ budget near 15 subjects and failed with `LIMIT`. References missing from a
13
+ collection keep the strict per-reference routes. An unavailable, denied,
14
+ oversized or malformed collection falls back to them; deadlines, budgets and
15
+ cancellation still propagate.
16
+ - Report a published attachment as published when removing the private temporary
17
+ name fails after the atomic link commit, instead of raising `STORAGE` for a
18
+ complete file. The inert temporary file may remain.
19
+ - Wait up to one second, not 100 ms, for another process's SQLite write lock in
20
+ ordinary store operations. Several local processes commonly share one store;
21
+ final saves after upstream side effects keep their five-second wait.
22
+
3
23
  ## 1.0.0 (2026-10-05) - Stable native library API
4
24
 
5
25
  Freeze the documented library API and compatibility policy for consumers moving
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Independent bounded async Librus Synergia client
5
5
  Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
6
6
  Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
@@ -40,7 +40,7 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
40
40
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
41
41
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
42
42
 
43
- **Status:** `1.0.0` freezes the documented native library API. It does not claim
43
+ **Status:** `1.0.x` freezes the documented native library API. It does not claim
44
44
  completed MCP cutover, production-state migration or universal live-school
45
45
  compatibility. Those consumer/live gates remain separate. School features depend
46
46
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
@@ -51,7 +51,7 @@ See [limitations](#supported-features-and-limitations) below.
51
51
  Install the exact release from PyPI after its gated publication completes:
52
52
 
53
53
  ```sh
54
- python -m pip install librus-python-api==1.0.0
54
+ python -m pip install librus-python-api==1.0.1
55
55
  ```
56
56
 
57
57
  Pin the exact version qualified by your application. Before publication, use a
@@ -67,7 +67,7 @@ python -m pip install .
67
67
  To install a locally built wheel instead:
68
68
 
69
69
  ```sh
70
- python -m pip install ./dist/librus_python_api-1.0.0-py3-none-any.whl
70
+ python -m pip install ./dist/librus_python_api-1.0.1-py3-none-any.whl
71
71
  ```
72
72
 
73
73
  No CLI or background process is installed: import the library in your own program.
@@ -14,7 +14,7 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
14
14
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
15
15
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
16
16
 
17
- **Status:** `1.0.0` freezes the documented native library API. It does not claim
17
+ **Status:** `1.0.x` freezes the documented native library API. It does not claim
18
18
  completed MCP cutover, production-state migration or universal live-school
19
19
  compatibility. Those consumer/live gates remain separate. School features depend
20
20
  on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
@@ -25,7 +25,7 @@ See [limitations](#supported-features-and-limitations) below.
25
25
  Install the exact release from PyPI after its gated publication completes:
26
26
 
27
27
  ```sh
28
- python -m pip install librus-python-api==1.0.0
28
+ python -m pip install librus-python-api==1.0.1
29
29
  ```
30
30
 
31
31
  Pin the exact version qualified by your application. Before publication, use a
@@ -41,7 +41,7 @@ python -m pip install .
41
41
  To install a locally built wheel instead:
42
42
 
43
43
  ```sh
44
- python -m pip install ./dist/librus_python_api-1.0.0-py3-none-any.whl
44
+ python -m pip install ./dist/librus_python_api-1.0.1-py3-none-any.whl
45
45
  ```
46
46
 
47
47
  No CLI or background process is installed: import the library in your own program.
@@ -120,7 +120,7 @@ from librus_python_api.models import (
120
120
  from librus_python_api.sending import SendAttempt
121
121
  from librus_python_api.service import AccountClient, LibrusService
122
122
 
123
- __version__ = "1.0.0"
123
+ __version__ = "1.0.1"
124
124
 
125
125
  __all__ = [
126
126
  "AccountContext",
@@ -26,7 +26,10 @@ if TYPE_CHECKING:
26
26
 
27
27
  class _StorageLimits(_ValidatedConfig):
28
28
  operations: int = Field(default=8, ge=1, le=64)
29
- busy_timeout_seconds: float = Field(default=0.1, gt=0, le=5, allow_inf_nan=False)
29
+ # Several local processes (one per agent host session) commonly share one
30
+ # store. Each transaction is short, but FULL synchronous commits can exceed
31
+ # 100 ms on slow disks, so ordinary operations wait up to one second.
32
+ busy_timeout_seconds: float = Field(default=1.0, gt=0, le=5, allow_inf_nan=False)
30
33
  # Final saves follow upstream side effects (a dispatched send or a consumed
31
34
  # read-once page), so they wait longer for other contexts' write locks.
32
35
  final_busy_timeout_seconds: float = Field(
@@ -1,9 +1,14 @@
1
1
  """Original validated gateway parsing and explicit attendance ratio policies."""
2
2
 
3
- from pydantic import ValidationError
3
+ from pydantic import BaseModel, ValidationError
4
4
 
5
5
  from librus_python_api import markup
6
- from librus_python_api.config import ATTENDANCE_MAX_RECORDS, ATTENDANCE_TYPE_KINDS
6
+ from librus_python_api.config import (
7
+ ATTENDANCE_MAX_LESSONS,
8
+ ATTENDANCE_MAX_RECORDS,
9
+ ATTENDANCE_MAX_SUBJECTS,
10
+ ATTENDANCE_TYPE_KINDS,
11
+ )
7
12
  from librus_python_api.exceptions import ErrorKind, LibrusError
8
13
  from librus_python_api.models import (
9
14
  AttendanceKind,
@@ -11,7 +16,9 @@ from librus_python_api.models import (
11
16
  GatewayAttendanceRecord,
12
17
  _AttendanceEnvelopeWire,
13
18
  _LessonEnvelopeWire,
19
+ _LessonsEnvelopeWire,
14
20
  _SubjectEnvelopeWire,
21
+ _SubjectsEnvelopeWire,
15
22
  )
16
23
  from librus_python_api.parsers import decode_json
17
24
 
@@ -84,6 +91,62 @@ def parse_subject_name(body: bytes, identifier: str) -> str:
84
91
  return subject.Name.strip()
85
92
 
86
93
 
94
+ def _collection[M: BaseModel](data: object, key: str, maximum: int, wire: type[M]) -> M:
95
+ if (
96
+ isinstance(data, dict)
97
+ and isinstance(data.get(key), list)
98
+ and len(data[key]) > maximum
99
+ ):
100
+ raise LibrusError(ErrorKind.LIMIT)
101
+ failed = False
102
+ envelope = None
103
+ try:
104
+ envelope = wire.model_validate(data)
105
+ except ValidationError:
106
+ failed = True
107
+ if failed or envelope is None:
108
+ raise LibrusError(ErrorKind.PARSE)
109
+ return envelope
110
+
111
+
112
+ def _unique(pairs: list[tuple[str, str]]) -> dict[str, str]:
113
+ result: dict[str, str] = {}
114
+ for identifier, value in pairs:
115
+ # Conflicting rows are never resolved by choosing one of them.
116
+ if identifier in result and result[identifier] != value:
117
+ raise LibrusError(ErrorKind.PARSE)
118
+ result[identifier] = value
119
+ return result
120
+
121
+
122
+ def parse_lesson_subjects(body: bytes) -> dict[str, str]:
123
+ """Map lesson IDs to subject IDs; rows without a subject are omitted."""
124
+ envelope = _collection(
125
+ decode_json(body), "Lessons", ATTENDANCE_MAX_LESSONS, _LessonsEnvelopeWire
126
+ )
127
+ return _unique(
128
+ [
129
+ (lesson.Id, lesson.Subject.Id)
130
+ for lesson in envelope.Lessons
131
+ if lesson.Subject is not None
132
+ ]
133
+ )
134
+
135
+
136
+ def parse_subject_names(body: bytes) -> dict[str, str]:
137
+ """Map subject IDs to nonblank names; blank names are omitted."""
138
+ envelope = _collection(
139
+ decode_json(body), "Subjects", ATTENDANCE_MAX_SUBJECTS, _SubjectsEnvelopeWire
140
+ )
141
+ return _unique(
142
+ [
143
+ (subject.Id, subject.Name.strip())
144
+ for subject in envelope.Subjects
145
+ if subject.Name is not None and subject.Name.strip()
146
+ ]
147
+ )
148
+
149
+
87
150
  def summarize_frequency(
88
151
  rows: tuple[GatewayAttendanceRecord, ...],
89
152
  *,
@@ -401,6 +401,22 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
401
401
  True,
402
402
  Evidence.INDEPENDENTLY_OBSERVED,
403
403
  ),
404
+ Endpoint(
405
+ "attendance_lessons",
406
+ "GET",
407
+ "/gateway/api/2.0/Lessons",
408
+ SideEffect.NONE,
409
+ True,
410
+ Evidence.INDEPENDENTLY_OBSERVED,
411
+ ),
412
+ Endpoint(
413
+ "attendance_subjects",
414
+ "GET",
415
+ "/gateway/api/2.0/Subjects",
416
+ SideEffect.NONE,
417
+ True,
418
+ Evidence.INDEPENDENTLY_OBSERVED,
419
+ ),
404
420
  Endpoint(
405
421
  "attendance_lesson",
406
422
  "GET",
@@ -636,6 +652,9 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
636
652
  ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
637
653
  ATTENDANCE_MAX_RECORDS = 2048
638
654
  ATTENDANCE_MAX_WINDOW_DAYS = 371
655
+ # Whole-school gateway collections resolve attendance metadata in two requests.
656
+ ATTENDANCE_MAX_LESSONS = 4096
657
+ ATTENDANCE_MAX_SUBJECTS = 1024
639
658
  # Collection recognition and detail retrieval share this central path family.
640
659
  ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
641
660
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
@@ -166,6 +166,7 @@ async def publish_attachment(
166
166
  size = 0
167
167
  digest = hashlib.sha256()
168
168
  failed = False
169
+ published: PublishedAttachment | None = None
169
170
  try:
170
171
  if sys.platform == "win32":
171
172
  from librus_python_api._windows_filesystem import (
@@ -206,9 +207,10 @@ async def publish_attachment(
206
207
  final = await _disk(
207
208
  lambda: _publish(directory_fd, descriptor, temporary, name)
208
209
  )
209
- return PublishedAttachment(
210
+ published = PublishedAttachment(
210
211
  directory / final, size, digest.hexdigest(), content_type
211
212
  )
213
+ return published
212
214
  except OSError:
213
215
  failed = True
214
216
  finally:
@@ -237,6 +239,9 @@ async def publish_attachment(
237
239
  windows_directory.close()
238
240
  except OSError:
239
241
  failed = True
240
- if failed:
242
+ # The atomic link is the commit point. Failing to remove our random
243
+ # temporary name afterwards leaves an inert private file, but must not
244
+ # report a complete, published attachment as a failed transfer.
245
+ if failed and published is None:
241
246
  raise LibrusError(ErrorKind.STORAGE) from None
242
247
  raise LibrusError(ErrorKind.STORAGE)
@@ -1104,6 +1104,26 @@ class _SubjectEnvelopeWire(BaseModel):
1104
1104
  Subject: _SubjectWire
1105
1105
 
1106
1106
 
1107
+ class _CollectionLessonWire(_NumericReferenceWire):
1108
+ # Collection rows may omit a subject; such lessons resolve individually.
1109
+ Subject: _NumericReferenceWire | None = None
1110
+
1111
+
1112
+ class _LessonsEnvelopeWire(BaseModel):
1113
+ model_config = ConfigDict(strict=True, extra="ignore", hide_input_in_errors=True)
1114
+ Lessons: list[_CollectionLessonWire]
1115
+
1116
+
1117
+ class _CollectionSubjectWire(_NumericReferenceWire):
1118
+ # Blank names resolve individually, where the strict subject contract applies.
1119
+ Name: Annotated[str | None, Field(max_length=1024)] = None
1120
+
1121
+
1122
+ class _SubjectsEnvelopeWire(BaseModel):
1123
+ model_config = ConfigDict(strict=True, extra="ignore", hide_input_in_errors=True)
1124
+ Subjects: list[_CollectionSubjectWire]
1125
+
1126
+
1107
1127
  @dataclass(frozen=True, slots=True)
1108
1128
  class ProfileFields:
1109
1129
  name: str = field(repr=False)
@@ -10,7 +10,7 @@ import time
10
10
  from collections.abc import Awaitable, Callable, Hashable, Mapping
11
11
  from dataclasses import dataclass
12
12
  from datetime import UTC, date, datetime
13
- from types import TracebackType
13
+ from types import MappingProxyType, TracebackType
14
14
  from typing import Any, Literal, Self, cast
15
15
  from urllib.parse import urljoin, urlsplit
16
16
 
@@ -20,7 +20,9 @@ from librus_python_api.attendance import parse_attendance, parse_attendance_deta
20
20
  from librus_python_api.attendance_frequency import (
21
21
  parse_gateway_attendance,
22
22
  parse_lesson_subject,
23
+ parse_lesson_subjects,
23
24
  parse_subject_name,
25
+ parse_subject_names,
24
26
  summarize_frequency,
25
27
  )
26
28
  from librus_python_api.budget import RequestBudget
@@ -345,6 +347,11 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
345
347
  raise LibrusError(ErrorKind.INVALID_INPUT)
346
348
 
347
349
 
350
+ _METADATA_MAP_FALLBACK = frozenset(
351
+ {ErrorKind.ACCESS_DENIED, ErrorKind.CONNECTION, ErrorKind.LIMIT, ErrorKind.PARSE}
352
+ )
353
+
354
+
348
355
  class AccountClient:
349
356
  """Service-owned account context. Fresh reads are the default.
350
357
 
@@ -388,6 +395,7 @@ class AccountClient:
388
395
  self._identity: Identity | None = None
389
396
  self._cache: dict[Hashable, tuple[float, Any]] = {}
390
397
  self._metadata: dict[tuple[str, str], tuple[float, str]] = {}
398
+ self._metadata_maps: dict[str, tuple[float, Mapping[str, str]]] = {}
391
399
  self._cooldowns: dict[str, tuple[float, ErrorKind]] = {}
392
400
  self._consuming_schedule = False
393
401
  self._modern_account: ModernAccountData | None = None
@@ -1910,6 +1918,7 @@ class AccountClient:
1910
1918
  self._modern_account = None
1911
1919
  self._cache.clear()
1912
1920
  self._metadata.clear()
1921
+ self._metadata_maps.clear()
1913
1922
  self._transport.clear_auth()
1914
1923
 
1915
1924
  def _invalidate_modern(self) -> None:
@@ -2132,6 +2141,36 @@ class AccountClient:
2132
2141
  del self._metadata[next(iter(self._metadata))]
2133
2142
  return value
2134
2143
 
2144
+ async def _metadata_map(
2145
+ self, endpoint: str, budget: RequestBudget
2146
+ ) -> Mapping[str, str]:
2147
+ """One login-scoped collection read replaces per-reference lookups."""
2148
+ cached = self._metadata_maps.get(endpoint)
2149
+ if cached and time.monotonic() - cached[0] <= ATTENDANCE_METADATA_TTL_SECONDS:
2150
+ return cached[1]
2151
+ parser = (
2152
+ parse_lesson_subjects
2153
+ if endpoint == "attendance_lessons"
2154
+ else parse_subject_names
2155
+ )
2156
+ try:
2157
+ value: Mapping[str, str] = MappingProxyType(
2158
+ await self._page(endpoint, budget, parser, content_type=JSON)
2159
+ )
2160
+ except LibrusError as error:
2161
+ # An unavailable or unexpected collection degrades to the original
2162
+ # per-reference lookups. Deadlines, budgets, cancellation and session
2163
+ # failures still propagate. Only deterministic outcomes are cached.
2164
+ if error.kind not in _METADATA_MAP_FALLBACK:
2165
+ raise
2166
+ if error.kind is ErrorKind.LIMIT:
2167
+ budget.check() # An exhausted caller budget is not a large collection.
2168
+ if error.kind is ErrorKind.CONNECTION:
2169
+ return MappingProxyType({})
2170
+ value = MappingProxyType({})
2171
+ self._metadata_maps[endpoint] = time.monotonic(), value
2172
+ return value
2173
+
2135
2174
  async def _subject_frequencies(
2136
2175
  self, budget: RequestBudget, start: date | None, end: date | None
2137
2176
  ) -> SubjectFrequencies:
@@ -2147,8 +2186,15 @@ class AccountClient:
2147
2186
  lessons = dict.fromkeys(row.lesson_id for row in rows)
2148
2187
  if len(lessons) > ATTENDANCE_METADATA_CACHE_SIZE:
2149
2188
  raise LibrusError(ErrorKind.LIMIT)
2189
+ # Two collection reads keep the request count independent of how many
2190
+ # lessons and subjects a student has. References absent from a
2191
+ # collection keep the strict per-reference contract.
2192
+ lesson_map = (
2193
+ await self._metadata_map("attendance_lessons", budget) if lessons else {}
2194
+ )
2150
2195
  lesson_subjects = {
2151
- lesson: await self._metadata_value("attendance_lesson", lesson, budget)
2196
+ lesson: lesson_map.get(lesson)
2197
+ or await self._metadata_value("attendance_lesson", lesson, budget)
2152
2198
  for lesson in lessons
2153
2199
  }
2154
2200
  subjects: dict[str, list[GatewayAttendanceRecord]] = {}
@@ -2156,10 +2202,14 @@ class AccountClient:
2156
2202
  subjects.setdefault(lesson_subjects[row.lesson_id], []).append(row)
2157
2203
  if len(subjects) > ATTENDANCE_METADATA_CACHE_SIZE:
2158
2204
  raise LibrusError(ErrorKind.LIMIT)
2205
+ subject_map = (
2206
+ await self._metadata_map("attendance_subjects", budget) if subjects else {}
2207
+ )
2159
2208
  items = [
2160
2209
  SubjectFrequency(
2161
2210
  subject,
2162
- await self._metadata_value("attendance_subject", subject, budget),
2211
+ subject_map.get(subject)
2212
+ or await self._metadata_value("attendance_subject", subject, budget),
2163
2213
  summarize_frequency(tuple(records), subject_policy=True),
2164
2214
  )
2165
2215
  for subject, records in subjects.items()