librus-python-api 1.8.0__py3-none-any.whl → 1.8.1__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.
- librus_python_api/__init__.py +1 -1
- librus_python_api/config.py +15 -0
- librus_python_api/diagnostics.py +105 -3
- librus_python_api/exceptions.py +13 -0
- librus_python_api/inspect_page.py +18 -0
- librus_python_api/models.py +2 -0
- librus_python_api/parsers.py +35 -5
- librus_python_api/service.py +80 -16
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.1.dist-info}/METADATA +13 -7
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.1.dist-info}/RECORD +12 -12
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.1.dist-info}/WHEEL +0 -0
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.1.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -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.8.
|
|
141
|
+
__version__ = "1.8.1"
|
|
142
142
|
|
|
143
143
|
__all__ = [
|
|
144
144
|
"ArchiveAbsences",
|
librus_python_api/config.py
CHANGED
|
@@ -688,6 +688,21 @@ PROFILE_LABELS = MappingProxyType(
|
|
|
688
688
|
"Szkoła": "school",
|
|
689
689
|
}
|
|
690
690
|
)
|
|
691
|
+
# Rows the information page lists for the login's own user after the
|
|
692
|
+
# student's rows (observed); never read, named by failure diagnostics.
|
|
693
|
+
PROFILE_OWNER_LABELS = ("Imię i nazwisko użytkownika", "Login", "Hasło")
|
|
694
|
+
# The page header shows the lucky number as `span.luckyNumber > b` (observed
|
|
695
|
+
# 2026-10-29). "?" there is reported where no number is drawn, for a school
|
|
696
|
+
# without lucky numbers and possibly on days without a draw; neither it nor
|
|
697
|
+
# an empty marker has been observed.
|
|
698
|
+
PROFILE_LUCKY_NUMBER_CLASS = "luckyNumber"
|
|
699
|
+
# For a session it no longer knows, Synergia answers every page read with 200
|
|
700
|
+
# and a page whose only heading is "Brak dostępu" (`h2.inside`), without the
|
|
701
|
+
# signed-in menu, not with a login redirect (observed 2026-10-29). A signed-in
|
|
702
|
+
# user's timetable page has no menu either, so the heading is required too.
|
|
703
|
+
LOGGED_IN_PAGE_MARKER = "graphic-menu"
|
|
704
|
+
LOGGED_OUT_HEADING = "Brak dostępu"
|
|
705
|
+
PROFILE_LUCKY_NUMBER_DISABLED = "?"
|
|
691
706
|
|
|
692
707
|
# HTML summary headers omit the two leading body cells: expander and subject.
|
|
693
708
|
# These labels/layout rules are source-informed requirements, not live evidence.
|
librus_python_api/diagnostics.py
CHANGED
|
@@ -30,6 +30,16 @@ _ELEMENT_LOCALS = 4
|
|
|
30
30
|
_DEPTH = 6
|
|
31
31
|
_NODES = 120
|
|
32
32
|
_ELEMENT_BYTES = 8 * 1024
|
|
33
|
+
_PAGE_TABLES = 12
|
|
34
|
+
_PAGE_ROWS = 40
|
|
35
|
+
_PAGE_CELLS = 8
|
|
36
|
+
_PAGE_NOTICES = 4
|
|
37
|
+
# Input types a login, search or settings form uses; others are shown as "?".
|
|
38
|
+
_INPUT_TYPES = frozenset(
|
|
39
|
+
{"button", "checkbox", "hidden", "password", "radio", "submit", "text"}
|
|
40
|
+
)
|
|
41
|
+
# Element IDs whose presence tells a Synergia page from a login or error page.
|
|
42
|
+
_PAGE_MARKERS = ("graphic-menu", "user-section")
|
|
33
43
|
_REPORT_BYTES = 32 * 1024
|
|
34
44
|
# Markup observed on Synergia pages. Other class tokens, tags and attribute
|
|
35
45
|
# names are shown only as lengths, since they could carry data.
|
|
@@ -45,6 +55,7 @@ _CLASSES = frozenset(
|
|
|
45
55
|
"decorated",
|
|
46
56
|
"detail-grades",
|
|
47
57
|
"double",
|
|
58
|
+
"form",
|
|
48
59
|
"gradesCell",
|
|
49
60
|
"grade-box",
|
|
50
61
|
"grade-box-mapping",
|
|
@@ -53,6 +64,7 @@ _CLASSES = frozenset(
|
|
|
53
64
|
"legend",
|
|
54
65
|
"line0",
|
|
55
66
|
"line1",
|
|
67
|
+
"luckyNumber",
|
|
56
68
|
"micro",
|
|
57
69
|
"newSchemaGradesByStudentTable",
|
|
58
70
|
"no-border-left",
|
|
@@ -65,6 +77,7 @@ _CLASSES = frozenset(
|
|
|
65
77
|
"stretch",
|
|
66
78
|
"studentRow",
|
|
67
79
|
"tooltip",
|
|
80
|
+
"warning-content",
|
|
68
81
|
}
|
|
69
82
|
)
|
|
70
83
|
_TAGS = frozenset(
|
|
@@ -164,6 +177,7 @@ def logging_sink(event: DiagnosticEvent) -> None:
|
|
|
164
177
|
"budget_requests_dispatched": event.budget_requests_dispatched,
|
|
165
178
|
"budget_response_bytes": event.budget_response_bytes,
|
|
166
179
|
"frames": event.frames,
|
|
180
|
+
"http_status": event.http_status,
|
|
167
181
|
},
|
|
168
182
|
)
|
|
169
183
|
|
|
@@ -242,6 +256,9 @@ def _redaction_vocabulary() -> tuple[
|
|
|
242
256
|
*config.GRADE_AVERAGE_HEADERS,
|
|
243
257
|
}
|
|
244
258
|
texts |= {label.casefold() for label in config.FORMATIVE_HEADERS}
|
|
259
|
+
profile = (*config.PROFILE_LABELS, *config.PROFILE_OWNER_LABELS)
|
|
260
|
+
texts |= set(profile) | {f"{label}:" for label in profile}
|
|
261
|
+
texts |= set(config.PAGE_NOTICES) | {config.LOGGED_OUT_HEADING}
|
|
245
262
|
prefixes = tuple(
|
|
246
263
|
sorted(
|
|
247
264
|
(
|
|
@@ -398,13 +415,91 @@ def _path(element: html.HtmlElement, redactor: _Redactor) -> str:
|
|
|
398
415
|
return " > ".join(reversed(steps))
|
|
399
416
|
|
|
400
417
|
|
|
418
|
+
def _cell(cell: html.HtmlElement, redactor: _Redactor) -> str:
|
|
419
|
+
spans = "".join(
|
|
420
|
+
f" {redactor.attribute(name, str(cell.get(name)))}"
|
|
421
|
+
for name in ("colspan", "rowspan")
|
|
422
|
+
if cell.get(name, "1") != "1"
|
|
423
|
+
)
|
|
424
|
+
text = redactor.text(cell.text_content()) or "EMPTY"
|
|
425
|
+
return f"{_tag(str(cell.tag))}{spans}[{text}]"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _shown(label: str, texts: list[str]) -> str:
|
|
429
|
+
return " ".join([f" {label}: {len(texts)}", *texts[:_PAGE_NOTICES]])
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _page_summary(document: html.HtmlElement, redactor: _Redactor) -> list[str]:
|
|
433
|
+
"""A whole page as its markers, forms, notices and tables, row by row.
|
|
434
|
+
|
|
435
|
+
A page that fails as a whole (no expected table, an unknown notice, a
|
|
436
|
+
login or error page) is told apart by these, not by its first elements.
|
|
437
|
+
Only the first tables, rows and cells are shown.
|
|
438
|
+
"""
|
|
439
|
+
body = document.find("body")
|
|
440
|
+
text = " ".join((body if body is not None else document).text_content().split())
|
|
441
|
+
present = {
|
|
442
|
+
marker: document.get_element_by_id(marker, None) is not None
|
|
443
|
+
for marker in _PAGE_MARKERS
|
|
444
|
+
}
|
|
445
|
+
markers = " ".join(
|
|
446
|
+
f"{marker}={'yes' if found else 'no'}" for marker, found in present.items()
|
|
447
|
+
)
|
|
448
|
+
lines = [f" body text len={len(text)} {markers}"]
|
|
449
|
+
title = document.find(".//title")
|
|
450
|
+
titles = [] if title is None else [redactor.text(title.text_content()) or "EMPTY"]
|
|
451
|
+
lines.append(_shown("title", titles))
|
|
452
|
+
headings = [
|
|
453
|
+
redactor.text(heading.text_content()) or "EMPTY"
|
|
454
|
+
for heading in document.iter("h1", "h2", "h3")
|
|
455
|
+
]
|
|
456
|
+
lines.append(_shown("headings", headings))
|
|
457
|
+
notices = document.xpath(
|
|
458
|
+
'//div[contains(concat(" ",normalize-space(@class)," ")," warning-content ")]'
|
|
459
|
+
)
|
|
460
|
+
shown = [redactor.text(notice.text_content()) or "EMPTY" for notice in notices]
|
|
461
|
+
lines.append(_shown("notices", shown))
|
|
462
|
+
forms = [redactor.href(form.get("action", "")) for form in document.iter("form")]
|
|
463
|
+
inputs: dict[str, int] = {}
|
|
464
|
+
for field in document.iter("input"):
|
|
465
|
+
kind = field.get("type", "text").casefold()
|
|
466
|
+
kind = kind if kind in _INPUT_TYPES else "?"
|
|
467
|
+
inputs[kind] = inputs.get(kind, 0) + 1
|
|
468
|
+
counted = " ".join(f"{kind}={count}" for kind, count in sorted(inputs.items()))
|
|
469
|
+
lines.append(_shown("forms", forms) + (f"; inputs {counted}" if counted else ""))
|
|
470
|
+
tables = list(document.iter("table"))
|
|
471
|
+
lines.append(f" tables: {len(tables)}")
|
|
472
|
+
for table in tables[:_PAGE_TABLES]:
|
|
473
|
+
rows = [
|
|
474
|
+
row
|
|
475
|
+
for row in table.iter("tr")
|
|
476
|
+
if next(row.iterancestors("table"), None) is table
|
|
477
|
+
]
|
|
478
|
+
lines.append(f" {_path(table, redactor)}: {len(rows)} rows")
|
|
479
|
+
for row in rows[:_PAGE_ROWS]:
|
|
480
|
+
cells = [cell for cell in row if cell.tag in ("td", "th")]
|
|
481
|
+
more = " ..." if len(cells) > _PAGE_CELLS else ""
|
|
482
|
+
rendered = " ".join(_cell(cell, redactor) for cell in cells[:_PAGE_CELLS])
|
|
483
|
+
lines.append(f" {rendered}{more}")
|
|
484
|
+
if len(rows) > _PAGE_ROWS:
|
|
485
|
+
lines.append(" ...")
|
|
486
|
+
if len(tables) > _PAGE_TABLES:
|
|
487
|
+
lines.append(" ...")
|
|
488
|
+
return lines
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _is_page(element: html.HtmlElement) -> bool:
|
|
492
|
+
return element.getparent() is None and element.tag == "html"
|
|
493
|
+
|
|
494
|
+
|
|
401
495
|
def describe_failure(error: BaseException) -> str | None:
|
|
402
496
|
"""The page structure around a failure, for a bug report.
|
|
403
497
|
|
|
404
498
|
Shows the HTML elements held by the innermost library frame that holds
|
|
405
499
|
any: tags, safe class tokens, known tooltip labels, known link families
|
|
406
|
-
and lengths.
|
|
407
|
-
|
|
500
|
+
and lengths. A whole page is summarized last (see _page_summary). Text
|
|
501
|
+
and attribute values are masked unless they equal a parser constant.
|
|
502
|
+
Returns None when there is nothing to show; never raises.
|
|
408
503
|
"""
|
|
409
504
|
try:
|
|
410
505
|
redactor = _Redactor()
|
|
@@ -425,8 +520,15 @@ def describe_failure(error: BaseException) -> str | None:
|
|
|
425
520
|
if not elements and not sequences:
|
|
426
521
|
continue
|
|
427
522
|
lines = [f"at {_site(frame, line)}"]
|
|
428
|
-
|
|
523
|
+
# The elements nearest the failure first; a page summary last.
|
|
524
|
+
for name, value in sorted(elements, key=lambda item: _is_page(item[1])):
|
|
429
525
|
redactor.nodes = 0
|
|
526
|
+
if _is_page(value):
|
|
527
|
+
summary = "\n".join(_page_summary(value, redactor))
|
|
528
|
+
if len(summary) > _ELEMENT_BYTES:
|
|
529
|
+
summary = summary[:_ELEMENT_BYTES] + "...(truncated)"
|
|
530
|
+
lines += [f"{name}: page", summary]
|
|
531
|
+
continue
|
|
430
532
|
lines.append(f"{name}: {_path(value, redactor)}")
|
|
431
533
|
rendered = redactor.element(value)
|
|
432
534
|
if len(rendered) > _ELEMENT_BYTES:
|
librus_python_api/exceptions.py
CHANGED
|
@@ -7,6 +7,7 @@ __all__ = [
|
|
|
7
7
|
"AccountActionRequiredError",
|
|
8
8
|
"ClosedError",
|
|
9
9
|
"CheckpointError",
|
|
10
|
+
"ConsentRequiredError",
|
|
10
11
|
"ConnectionError",
|
|
11
12
|
"CredentialsRejectedError",
|
|
12
13
|
"ErrorKind",
|
|
@@ -31,6 +32,7 @@ __all__ = [
|
|
|
31
32
|
class ErrorKind(StrEnum):
|
|
32
33
|
CHECKPOINT = "checkpoint"
|
|
33
34
|
INVALID_INPUT = "invalid_input"
|
|
35
|
+
CONSENT_REQUIRED = "consent_required"
|
|
34
36
|
CREDENTIALS_REJECTED = "credentials_rejected"
|
|
35
37
|
ACCOUNT_ACTION_REQUIRED = "account_action_required"
|
|
36
38
|
SESSION_EXPIRED = "session_expired"
|
|
@@ -78,6 +80,13 @@ class InvalidInputError(LibrusError):
|
|
|
78
80
|
pass
|
|
79
81
|
|
|
80
82
|
|
|
83
|
+
class ConsentRequiredError(InvalidInputError):
|
|
84
|
+
"""A call with an effect the user must agree to was made without consent.
|
|
85
|
+
|
|
86
|
+
A subclass of InvalidInputError, which these calls raised before 1.8.1.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
81
90
|
class CredentialsRejectedError(LibrusError):
|
|
82
91
|
pass
|
|
83
92
|
|
|
@@ -89,6 +98,9 @@ class AccountActionRequiredError(LibrusError):
|
|
|
89
98
|
class SessionExpiredError(LibrusError):
|
|
90
99
|
# Library-private provenance only; never attach a URL, body or credentials.
|
|
91
100
|
_messages_origin = False
|
|
101
|
+
# Inferred from a page without the logged-in menu, not from a 401 or a
|
|
102
|
+
# login redirect; a fresh session getting the same page is a layout.
|
|
103
|
+
_logged_out_page = False
|
|
92
104
|
|
|
93
105
|
|
|
94
106
|
class AccessDeniedError(LibrusError):
|
|
@@ -154,6 +166,7 @@ class StorageError(LibrusError):
|
|
|
154
166
|
_ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
|
|
155
167
|
ErrorKind.CHECKPOINT: CheckpointError,
|
|
156
168
|
ErrorKind.INVALID_INPUT: InvalidInputError,
|
|
169
|
+
ErrorKind.CONSENT_REQUIRED: ConsentRequiredError,
|
|
157
170
|
ErrorKind.CREDENTIALS_REJECTED: CredentialsRejectedError,
|
|
158
171
|
ErrorKind.ACCOUNT_ACTION_REQUIRED: AccountActionRequiredError,
|
|
159
172
|
ErrorKind.SESSION_EXPIRED: SessionExpiredError,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Run one parser on a saved Synergia page and report the outcome offline.
|
|
2
2
|
|
|
3
3
|
python -m librus_python_api.inspect_page grades page.html
|
|
4
|
+
python -m librus_python_api.inspect_page profile informacja.html
|
|
4
5
|
|
|
5
6
|
The report holds counts, code locations and page structure built from
|
|
6
7
|
allowlists (see diagnostics.describe_failure), never page text or values.
|
|
@@ -20,6 +21,8 @@ from librus_python_api.diagnostics import describe_failure, failure_frames
|
|
|
20
21
|
from librus_python_api.exceptions import LibrusError
|
|
21
22
|
from librus_python_api.grade_parsers import parse_final_grades
|
|
22
23
|
from librus_python_api.grade_records import parse_grade_records
|
|
24
|
+
from librus_python_api.models import Availability
|
|
25
|
+
from librus_python_api.parsers import parse_profile
|
|
23
26
|
|
|
24
27
|
NOTICE = (
|
|
25
28
|
"This report holds counts, code locations and page structure only, no page "
|
|
@@ -45,10 +48,19 @@ def _final_grades(body: bytes) -> dict[str, int]:
|
|
|
45
48
|
return {"subjects": len(parse_final_grades(body))}
|
|
46
49
|
|
|
47
50
|
|
|
51
|
+
def _profile(body: bytes) -> dict[str, int]:
|
|
52
|
+
lucky = parse_profile(body).lucky_number.availability
|
|
53
|
+
return {"lucky_number": int(lucky == Availability.AVAILABLE)}
|
|
54
|
+
|
|
55
|
+
|
|
48
56
|
PARSERS: dict[str, Callable[[bytes], dict[str, int]]] = {
|
|
49
57
|
"grades": _grades,
|
|
50
58
|
"final-grades": _final_grades,
|
|
59
|
+
"profile": _profile,
|
|
51
60
|
}
|
|
61
|
+
# macOS TextEdit saves a viewed page source as a new document holding the
|
|
62
|
+
# source as text, not the page itself.
|
|
63
|
+
_TEXT_EXPORT = re.compile(rb'<meta name="Generator" content="Cocoa HTML Writer"', re.I)
|
|
52
64
|
|
|
53
65
|
|
|
54
66
|
def inspect(parser: str, body: bytes) -> tuple[bool, list[str]]:
|
|
@@ -60,6 +72,12 @@ def inspect(parser: str, body: bytes) -> tuple[bool, list[str]]:
|
|
|
60
72
|
# Synergia serves UTF-8; another encoding is a saving problem, not a layout.
|
|
61
73
|
lines.append("FAILED input: the file is not UTF-8; save the page as UTF-8")
|
|
62
74
|
return False, lines
|
|
75
|
+
if _TEXT_EXPORT.search(body[:4096]):
|
|
76
|
+
lines.append(
|
|
77
|
+
"FAILED input: the file is page source saved as a text document; "
|
|
78
|
+
"save the page itself (Safari: File > Save As, format Page Source)"
|
|
79
|
+
)
|
|
80
|
+
return False, lines
|
|
63
81
|
if not re.search(rb"<html[\s>]", body[:4096], re.I):
|
|
64
82
|
# A copied table (outerHTML) is a fragment; parsers expect a page.
|
|
65
83
|
body = b"<!doctype html><html><body>" + body + b"</body></html>"
|
librus_python_api/models.py
CHANGED
|
@@ -90,6 +90,8 @@ class DiagnosticEvent:
|
|
|
90
90
|
budget_response_bytes: int
|
|
91
91
|
# Code locations of a failure (see diagnostics.failure_frames), else empty.
|
|
92
92
|
frames: tuple[str, ...] = ()
|
|
93
|
+
# HTTP status of the operation's last checked read response, if any.
|
|
94
|
+
http_status: int | None = None
|
|
93
95
|
|
|
94
96
|
|
|
95
97
|
@dataclass(frozen=True, slots=True)
|
librus_python_api/parsers.py
CHANGED
|
@@ -8,7 +8,14 @@ from typing import Any
|
|
|
8
8
|
from lxml import etree, html
|
|
9
9
|
from pydantic import ValidationError
|
|
10
10
|
|
|
11
|
-
from librus_python_api.config import
|
|
11
|
+
from librus_python_api.config import (
|
|
12
|
+
LOGGED_IN_PAGE_MARKER,
|
|
13
|
+
LOGGED_OUT_HEADING,
|
|
14
|
+
PAGE_NOTICES,
|
|
15
|
+
PROFILE_LABELS,
|
|
16
|
+
PROFILE_LUCKY_NUMBER_CLASS,
|
|
17
|
+
PROFILE_LUCKY_NUMBER_DISABLED,
|
|
18
|
+
)
|
|
12
19
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
13
20
|
from librus_python_api.models import (
|
|
14
21
|
Availability,
|
|
@@ -178,6 +185,23 @@ def parse_page(body: bytes, *, repeatable_id: str | None = None) -> html.HtmlEle
|
|
|
178
185
|
return document
|
|
179
186
|
|
|
180
187
|
|
|
188
|
+
def is_logged_out_page(body: bytes) -> bool:
|
|
189
|
+
"""Synergia's page for a session it no longer knows; never raises."""
|
|
190
|
+
if LOGGED_OUT_HEADING.encode() not in body:
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
parser = html.HTMLParser(no_network=True, recover=True, huge_tree=False)
|
|
194
|
+
document = html.document_fromstring(body.decode("utf-8"), parser=parser)
|
|
195
|
+
except (UnicodeError, etree.LxmlError, ValueError):
|
|
196
|
+
return False
|
|
197
|
+
if document.get_element_by_id(LOGGED_IN_PAGE_MARKER, None) is not None:
|
|
198
|
+
return False
|
|
199
|
+
headings = [
|
|
200
|
+
" ".join(h.text_content().split()) for h in document.iter("h1", "h2", "h3")
|
|
201
|
+
]
|
|
202
|
+
return headings == [LOGGED_OUT_HEADING]
|
|
203
|
+
|
|
204
|
+
|
|
181
205
|
def parse_profile(body: bytes) -> ProfileFields:
|
|
182
206
|
document = parse_page(body)
|
|
183
207
|
required = {"name", "class_name", "register_number", "tutor", "school"}
|
|
@@ -210,15 +234,21 @@ def parse_profile(body: bytes) -> ProfileFields:
|
|
|
210
234
|
raise LibrusError(ErrorKind.PARSE)
|
|
211
235
|
if not re.fullmatch(r"[0-9]{1,4}", candidates[0]["register_number"]):
|
|
212
236
|
raise LibrusError(ErrorKind.PARSE)
|
|
213
|
-
markers = document.xpath(
|
|
237
|
+
markers = document.xpath(
|
|
238
|
+
'//span[contains(concat(" ",normalize-space(@class)," "),$token)]',
|
|
239
|
+
token=f" {PROFILE_LUCKY_NUMBER_CLASS} ",
|
|
240
|
+
)
|
|
214
241
|
if not markers:
|
|
215
242
|
lucky = LuckyNumber(Availability.UNAVAILABLE)
|
|
216
243
|
elif len(markers) == 1:
|
|
217
244
|
value = " ".join(markers[0].text_content().split())
|
|
218
|
-
if
|
|
245
|
+
if value in ("", PROFILE_LUCKY_NUMBER_DISABLED):
|
|
246
|
+
lucky = LuckyNumber(Availability.UNAVAILABLE)
|
|
247
|
+
elif re.fullmatch(r"[0-9]{1,4}", value):
|
|
248
|
+
# No evidenced civil date in this marker. Never fabricate today's date.
|
|
249
|
+
lucky = LuckyNumber(Availability.AVAILABLE, int(value))
|
|
250
|
+
else:
|
|
219
251
|
raise LibrusError(ErrorKind.PARSE)
|
|
220
|
-
# No evidenced civil date in this marker. Never fabricate today's date.
|
|
221
|
-
lucky = LuckyNumber(Availability.AVAILABLE, int(value))
|
|
222
252
|
else:
|
|
223
253
|
raise LibrusError(ErrorKind.PARSE)
|
|
224
254
|
fields = candidates[0]
|
librus_python_api/service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Public login-scoped account service under one shared traffic boundary."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextvars
|
|
4
5
|
import hashlib
|
|
5
6
|
import hmac
|
|
6
7
|
import json
|
|
@@ -52,6 +53,7 @@ from librus_python_api.config import (
|
|
|
52
53
|
ConnectionSettings,
|
|
53
54
|
OperationLimits,
|
|
54
55
|
SchedulerLimits,
|
|
56
|
+
SideEffect,
|
|
55
57
|
TransportLimits,
|
|
56
58
|
agenda_form,
|
|
57
59
|
attendance_view_form,
|
|
@@ -69,7 +71,12 @@ from librus_python_api.config import (
|
|
|
69
71
|
)
|
|
70
72
|
from librus_python_api.detail_fields import normalize_detail_fields
|
|
71
73
|
from librus_python_api.diagnostics import DiagnosticSink, failure_frames
|
|
72
|
-
from librus_python_api.exceptions import
|
|
74
|
+
from librus_python_api.exceptions import (
|
|
75
|
+
ErrorKind,
|
|
76
|
+
LibrusError,
|
|
77
|
+
ParseError,
|
|
78
|
+
SessionExpiredError,
|
|
79
|
+
)
|
|
73
80
|
from librus_python_api.gateway_grades import (
|
|
74
81
|
DescriptiveTable,
|
|
75
82
|
descriptive_grades,
|
|
@@ -198,7 +205,12 @@ from librus_python_api.notifications import (
|
|
|
198
205
|
parse_schedule_events,
|
|
199
206
|
validate_response,
|
|
200
207
|
)
|
|
201
|
-
from librus_python_api.parsers import
|
|
208
|
+
from librus_python_api.parsers import (
|
|
209
|
+
is_logged_out_page,
|
|
210
|
+
parse_identity,
|
|
211
|
+
parse_login,
|
|
212
|
+
parse_profile,
|
|
213
|
+
)
|
|
202
214
|
from librus_python_api.parsing import ParserPool
|
|
203
215
|
from librus_python_api.recipients import (
|
|
204
216
|
parse_recipient_group_choices,
|
|
@@ -381,6 +393,24 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
|
|
|
381
393
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
382
394
|
|
|
383
395
|
|
|
396
|
+
def _cooldown_due(error: LibrusError) -> bool:
|
|
397
|
+
"""A denial, an unrecovered expiry, or a logged-out page that a session
|
|
398
|
+
just signed in also got: signing in again at once cannot help."""
|
|
399
|
+
if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
|
|
400
|
+
return True
|
|
401
|
+
cause = error.__cause__
|
|
402
|
+
return (
|
|
403
|
+
error.kind is ErrorKind.PARSE
|
|
404
|
+
and isinstance(cause, SessionExpiredError)
|
|
405
|
+
and cause._logged_out_page
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# The running operation's last checked read status, for its diagnostic event.
|
|
410
|
+
# A holder per operation, so concurrent operations do not share one.
|
|
411
|
+
_READ_STATUS: contextvars.ContextVar[list[int | None] | None] = contextvars.ContextVar(
|
|
412
|
+
"librus_read_status", default=None
|
|
413
|
+
)
|
|
384
414
|
_METADATA_MAP_FALLBACK = frozenset(
|
|
385
415
|
{ErrorKind.ACCESS_DENIED, ErrorKind.CONNECTION, ErrorKind.LIMIT, ErrorKind.PARSE}
|
|
386
416
|
)
|
|
@@ -894,10 +924,10 @@ class AccountClient:
|
|
|
894
924
|
max_age_seconds: float = 0.0,
|
|
895
925
|
) -> ModernMessageContent:
|
|
896
926
|
validate_modern_message_reference(reference, self._alias)
|
|
897
|
-
if type(allow_mark_read) is not bool
|
|
898
|
-
reference.folder is MessageFolder.RECEIVED and not allow_mark_read
|
|
899
|
-
):
|
|
927
|
+
if type(allow_mark_read) is not bool:
|
|
900
928
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
929
|
+
if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
|
|
930
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
901
931
|
if reference.archived:
|
|
902
932
|
# No archived content route or mark-read effect is established yet.
|
|
903
933
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
@@ -1178,6 +1208,8 @@ class AccountClient:
|
|
|
1178
1208
|
started = time.monotonic()
|
|
1179
1209
|
outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
|
|
1180
1210
|
frames: tuple[str, ...] = ()
|
|
1211
|
+
status: list[int | None] = [None]
|
|
1212
|
+
status_token = _READ_STATUS.set(status)
|
|
1181
1213
|
try:
|
|
1182
1214
|
if deadline is None:
|
|
1183
1215
|
deadline = asyncio.timeout(None)
|
|
@@ -1195,10 +1227,7 @@ class AccountClient:
|
|
|
1195
1227
|
outcome = error.kind
|
|
1196
1228
|
if self._service._diagnostic_sink is not None:
|
|
1197
1229
|
frames = failure_frames(error)
|
|
1198
|
-
if authenticate and error
|
|
1199
|
-
ErrorKind.ACCESS_DENIED,
|
|
1200
|
-
ErrorKind.SESSION_EXPIRED,
|
|
1201
|
-
):
|
|
1230
|
+
if authenticate and _cooldown_due(error):
|
|
1202
1231
|
self._cooldowns[operation] = (
|
|
1203
1232
|
time.monotonic() + self._service._transport_limits.cooldown_seconds,
|
|
1204
1233
|
error.kind,
|
|
@@ -1215,6 +1244,7 @@ class AccountClient:
|
|
|
1215
1244
|
# produce a misleading success diagnostic at this owner boundary.
|
|
1216
1245
|
outcome = ErrorKind.CONNECTION
|
|
1217
1246
|
finally:
|
|
1247
|
+
_READ_STATUS.reset(status_token)
|
|
1218
1248
|
self._emit(
|
|
1219
1249
|
DiagnosticEvent(
|
|
1220
1250
|
operation,
|
|
@@ -1223,6 +1253,7 @@ class AccountClient:
|
|
|
1223
1253
|
budget.requests_dispatched,
|
|
1224
1254
|
budget.response_bytes,
|
|
1225
1255
|
frames,
|
|
1256
|
+
status[0],
|
|
1226
1257
|
)
|
|
1227
1258
|
)
|
|
1228
1259
|
|
|
@@ -1761,10 +1792,10 @@ class AccountClient:
|
|
|
1761
1792
|
) -> MessageContent:
|
|
1762
1793
|
"""Open one message; received opens require explicit read-effect consent."""
|
|
1763
1794
|
validate_reference(reference, self._alias)
|
|
1764
|
-
if type(allow_mark_read) is not bool
|
|
1765
|
-
reference.folder is MessageFolder.RECEIVED and not allow_mark_read
|
|
1766
|
-
):
|
|
1795
|
+
if type(allow_mark_read) is not bool:
|
|
1767
1796
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
1797
|
+
if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
|
|
1798
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
1768
1799
|
operation: Literal["message_content_received", "message_content_sent"] = (
|
|
1769
1800
|
"message_content_received"
|
|
1770
1801
|
if reference.folder is MessageFolder.RECEIVED
|
|
@@ -2044,6 +2075,8 @@ class AccountClient:
|
|
|
2044
2075
|
started = time.monotonic()
|
|
2045
2076
|
outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
|
|
2046
2077
|
frames: tuple[str, ...] = ()
|
|
2078
|
+
status: list[int | None] = [None]
|
|
2079
|
+
status_token = _READ_STATUS.set(status)
|
|
2047
2080
|
try:
|
|
2048
2081
|
async with asyncio.timeout(budget.remaining_seconds()):
|
|
2049
2082
|
async with self._lock:
|
|
@@ -2062,6 +2095,7 @@ class AccountClient:
|
|
|
2062
2095
|
outcome = ErrorKind.TIMEOUT
|
|
2063
2096
|
raise LibrusError(ErrorKind.TIMEOUT) from None
|
|
2064
2097
|
finally:
|
|
2098
|
+
_READ_STATUS.reset(status_token)
|
|
2065
2099
|
self._emit(
|
|
2066
2100
|
DiagnosticEvent(
|
|
2067
2101
|
operation,
|
|
@@ -2070,6 +2104,7 @@ class AccountClient:
|
|
|
2070
2104
|
budget.requests_dispatched,
|
|
2071
2105
|
budget.response_bytes,
|
|
2072
2106
|
frames,
|
|
2107
|
+
status[0],
|
|
2073
2108
|
)
|
|
2074
2109
|
)
|
|
2075
2110
|
|
|
@@ -2101,7 +2136,7 @@ class AccountClient:
|
|
|
2101
2136
|
try:
|
|
2102
2137
|
result = await self._authenticated(fetch, budget, retry_safe)
|
|
2103
2138
|
except LibrusError as error:
|
|
2104
|
-
if error
|
|
2139
|
+
if _cooldown_due(error):
|
|
2105
2140
|
self._cooldowns[operation] = (
|
|
2106
2141
|
time.monotonic() + self._service._transport_limits.cooldown_seconds,
|
|
2107
2142
|
error.kind,
|
|
@@ -2119,7 +2154,10 @@ class AccountClient:
|
|
|
2119
2154
|
|
|
2120
2155
|
Initial login is never retried: a failed credential submission must not
|
|
2121
2156
|
be replayed. A view-changing POST is never replayed either; expiry only
|
|
2122
|
-
clears the session so a later explicit call can log in again.
|
|
2157
|
+
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.
|
|
2123
2161
|
"""
|
|
2124
2162
|
fresh_login = self._identity is None
|
|
2125
2163
|
if fresh_login:
|
|
@@ -2127,18 +2165,22 @@ class AccountClient:
|
|
|
2127
2165
|
try:
|
|
2128
2166
|
return await fetch(budget, fresh_login)
|
|
2129
2167
|
except SessionExpiredError as error:
|
|
2168
|
+
if error._logged_out_page and fresh_login:
|
|
2169
|
+
raise LibrusError(ErrorKind.PARSE) from error
|
|
2130
2170
|
modern_expired = error._messages_origin
|
|
2131
2171
|
if modern_expired:
|
|
2132
2172
|
self._invalidate_modern()
|
|
2133
2173
|
else:
|
|
2134
2174
|
self._invalidate()
|
|
2135
|
-
if not retry_safe:
|
|
2175
|
+
if not retry_safe and not error._logged_out_page:
|
|
2136
2176
|
raise
|
|
2137
2177
|
if not modern_expired:
|
|
2138
2178
|
await self._authenticate(budget)
|
|
2139
2179
|
try:
|
|
2140
2180
|
return await fetch(budget, not modern_expired)
|
|
2141
2181
|
except SessionExpiredError as error:
|
|
2182
|
+
if error._logged_out_page:
|
|
2183
|
+
raise LibrusError(ErrorKind.PARSE) from error
|
|
2142
2184
|
if error._messages_origin:
|
|
2143
2185
|
self._invalidate_modern()
|
|
2144
2186
|
else:
|
|
@@ -2167,7 +2209,26 @@ class AccountClient:
|
|
|
2167
2209
|
endpoint, budget, form=form, reference_id=reference, query=query
|
|
2168
2210
|
)
|
|
2169
2211
|
self._validate_read_response(response, content_type)
|
|
2170
|
-
|
|
2212
|
+
try:
|
|
2213
|
+
return await self._service._parsers.run(parse, response.body, budget)
|
|
2214
|
+
except ParseError as error:
|
|
2215
|
+
if (
|
|
2216
|
+
content_type != HTML
|
|
2217
|
+
or ENDPOINTS[endpoint].origin != "synergia"
|
|
2218
|
+
or not await self._service._parsers.run(
|
|
2219
|
+
is_logged_out_page, response.body, budget
|
|
2220
|
+
)
|
|
2221
|
+
):
|
|
2222
|
+
raise
|
|
2223
|
+
# Synergia's answer to a session it no longer knows. A read
|
|
2224
|
+
# with an account effect (mark-read) is not repeated: it fails
|
|
2225
|
+
# as an unrecovered expiry and the next call signs in.
|
|
2226
|
+
expired = SessionExpiredError(ErrorKind.SESSION_EXPIRED)
|
|
2227
|
+
expired._logged_out_page = ENDPOINTS[endpoint].side_effect in (
|
|
2228
|
+
SideEffect.NONE,
|
|
2229
|
+
SideEffect.SELECT_VIEW,
|
|
2230
|
+
)
|
|
2231
|
+
raise expired from error
|
|
2171
2232
|
except BaseException as error:
|
|
2172
2233
|
if ENDPOINTS[endpoint].origin == "messages":
|
|
2173
2234
|
self._invalidate_modern()
|
|
@@ -2548,6 +2609,9 @@ class AccountClient:
|
|
|
2548
2609
|
def _validate_read_response(
|
|
2549
2610
|
self, response: TransportResponse, content_type: str
|
|
2550
2611
|
) -> None:
|
|
2612
|
+
holder = _READ_STATUS.get()
|
|
2613
|
+
if holder is not None:
|
|
2614
|
+
holder[0] = response.status
|
|
2551
2615
|
if 300 <= response.status < 400:
|
|
2552
2616
|
if self._is_module_unavailable_redirect(response):
|
|
2553
2617
|
raise LibrusError(ErrorKind.MODULE_UNAVAILABLE)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.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
|
|
@@ -42,9 +42,12 @@ 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.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
**Status:** `1.8.1` recovers a session Synergia no longer knows (it answers
|
|
46
|
+
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
47
|
+
from the page header and summarizes a page that fails as a whole in
|
|
48
|
+
diagnostics. `1.8.0` reports where a failure
|
|
49
|
+
happened (code locations and a redacted page structure, see
|
|
50
|
+
[failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
|
|
48
51
|
(source-informed, first populated read live on one school) and keeps the 1.7.1 point
|
|
49
52
|
grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
|
|
50
53
|
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
@@ -60,7 +63,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
60
63
|
Install the exact release from PyPI after its gated publication completes:
|
|
61
64
|
|
|
62
65
|
```sh
|
|
63
|
-
python -m pip install librus-python-api==1.8.
|
|
66
|
+
python -m pip install librus-python-api==1.8.1
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -76,7 +79,7 @@ python -m pip install .
|
|
|
76
79
|
To install a locally built wheel instead:
|
|
77
80
|
|
|
78
81
|
```sh
|
|
79
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
82
|
+
python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
|
|
80
83
|
```
|
|
81
84
|
|
|
82
85
|
No console script or background process is installed: import the library in your
|
|
@@ -280,7 +283,10 @@ except LibrusError as error:
|
|
|
280
283
|
|
|
281
284
|
To check a saved page offline (a page source saved with Ctrl+U, or a copied
|
|
282
285
|
table), run `python -m librus_python_api.inspect_page grades page.html` (or
|
|
283
|
-
`final-grades
|
|
286
|
+
`final-grades`, or `profile` for the information page `/informacja`). Save the
|
|
287
|
+
page source itself: in Safari use File > Save As with format Page Source; in
|
|
288
|
+
Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
|
|
289
|
+
into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
|
|
284
290
|
structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
|
|
285
291
|
to receive one `DiagnosticEvent` per operation, with `frames` on failure.
|
|
286
292
|
None of these print page text, values, logins or exception messages.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=KnN3xexlc15CN1XQPtwLNhUHc9Y82MyiQrMZEs4TYl4,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
|
|
@@ -12,22 +12,22 @@ librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2
|
|
|
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=
|
|
15
|
+
librus_python_api/config.py,sha256=F4w0F5r6uCObgyzO-cXkUtbz9Okh8HQEyfObAdOysig,61132
|
|
16
16
|
librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
|
|
17
|
-
librus_python_api/diagnostics.py,sha256=
|
|
18
|
-
librus_python_api/exceptions.py,sha256=
|
|
17
|
+
librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
|
|
18
|
+
librus_python_api/exceptions.py,sha256=3Bt_1WbW2blSoljGAa00TK2AzU68tgYuRDd2k1onLTc,5392
|
|
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
21
|
librus_python_api/gateway_grades.py,sha256=HHXJqtC47v2BTP3ufak9i9Q68phOxwtzWjahKdxkyX4,16086
|
|
22
22
|
librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
|
|
23
23
|
librus_python_api/grade_records.py,sha256=YltZ_OQLsfxj0nvK8_xTuv8ifMOu2jJBnVn6TTQKzaY,22722
|
|
24
24
|
librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
|
|
25
|
-
librus_python_api/inspect_page.py,sha256=
|
|
25
|
+
librus_python_api/inspect_page.py,sha256=AUm-n2Ewe4cQUnlFnhttcGK5B9shvFde_7t1JF7mEm0,5285
|
|
26
26
|
librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
|
|
27
27
|
librus_python_api/markup.py,sha256=p97ZRPqhGYTEBbzJq_0j4j8AKihgQzJqTv__QUNm8Ws,6084
|
|
28
28
|
librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
|
|
29
29
|
librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
|
|
30
|
-
librus_python_api/models.py,sha256=
|
|
30
|
+
librus_python_api/models.py,sha256=IO-SYpqpQeuTej6kqNjBRXDbAbKITRZ1njRUyoEZ8t0,41951
|
|
31
31
|
librus_python_api/modern_body.py,sha256=SocXJTyGm1Q_2owzh1PE1zcCB2kqZTz1KPJ2R4yMyoc,3895
|
|
32
32
|
librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
|
|
33
33
|
librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
|
|
@@ -35,7 +35,7 @@ librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5
|
|
|
35
35
|
librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
|
|
36
36
|
librus_python_api/notification_workflow.py,sha256=MsBJdwn6J1UUGlz3Ldzi7WEBz5Qsama7eGmZvUQj25g,12924
|
|
37
37
|
librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
|
|
38
|
-
librus_python_api/parsers.py,sha256=
|
|
38
|
+
librus_python_api/parsers.py,sha256=8sdfPsOZYHNYVZ4rwX_de5Y5rPl0umoQPon3uflQFlY,9178
|
|
39
39
|
librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
|
|
40
40
|
librus_python_api/persistence.py,sha256=F35yBRu6D8aYgdwKTpzVdqVTyiUaLQludUmyyskb3Vo,15529
|
|
41
41
|
librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -44,10 +44,10 @@ librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQm
|
|
|
44
44
|
librus_python_api/school_reads.py,sha256=YHta7DmhjS5vSa4Djq8pzp01-2jhVfGOo1Mnc738rV4,15209
|
|
45
45
|
librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
|
|
46
46
|
librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
|
|
47
|
-
librus_python_api/service.py,sha256=
|
|
47
|
+
librus_python_api/service.py,sha256=JY-RStBHmNOgGHISaC-pB2La2pdVGlJXXn52_kwkd3U,102316
|
|
48
48
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
49
49
|
librus_python_api/transport.py,sha256=n3goXE34xcsO-bilsLVYEUW3JkJR-9rbgmqPu7ff9J8,35036
|
|
50
|
-
librus_python_api-1.8.
|
|
51
|
-
librus_python_api-1.8.
|
|
52
|
-
librus_python_api-1.8.
|
|
53
|
-
librus_python_api-1.8.
|
|
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,,
|
|
File without changes
|
|
File without changes
|