librus-python-api 1.2.1__py3-none-any.whl → 1.3.0__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 +17 -1
- librus_python_api/config.py +37 -0
- librus_python_api/models.py +74 -0
- librus_python_api/school_year_archive.py +432 -0
- librus_python_api/service.py +26 -0
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.3.0.dist-info}/METADATA +15 -7
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.3.0.dist-info}/RECORD +9 -8
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.3.0.dist-info}/WHEEL +0 -0
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.3.0.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -16,6 +16,13 @@ from librus_python_api.models import (
|
|
|
16
16
|
AgendaEvent,
|
|
17
17
|
Announcement,
|
|
18
18
|
Announcements,
|
|
19
|
+
ArchiveAbsences,
|
|
20
|
+
ArchiveAchievement,
|
|
21
|
+
ArchiveCounts,
|
|
22
|
+
ArchiveDescriptive,
|
|
23
|
+
ArchiveMarks,
|
|
24
|
+
ArchiveSubject,
|
|
25
|
+
ArchiveYear,
|
|
19
26
|
AttachmentHeaders,
|
|
20
27
|
AttachmentMetadata,
|
|
21
28
|
Attendance,
|
|
@@ -110,6 +117,7 @@ from librus_python_api.models import (
|
|
|
110
117
|
SchoolAverage,
|
|
111
118
|
SchoolDetail,
|
|
112
119
|
SchoolReference,
|
|
120
|
+
SchoolYearArchive,
|
|
113
121
|
SendResult,
|
|
114
122
|
SendStatus,
|
|
115
123
|
SendSubmission,
|
|
@@ -127,9 +135,17 @@ from librus_python_api.models import (
|
|
|
127
135
|
from librus_python_api.sending import SendAttempt
|
|
128
136
|
from librus_python_api.service import AccountClient, LibrusService
|
|
129
137
|
|
|
130
|
-
__version__ = "1.
|
|
138
|
+
__version__ = "1.3.0"
|
|
131
139
|
|
|
132
140
|
__all__ = [
|
|
141
|
+
"ArchiveAbsences",
|
|
142
|
+
"ArchiveAchievement",
|
|
143
|
+
"ArchiveCounts",
|
|
144
|
+
"ArchiveDescriptive",
|
|
145
|
+
"ArchiveMarks",
|
|
146
|
+
"ArchiveSubject",
|
|
147
|
+
"ArchiveYear",
|
|
148
|
+
"SchoolYearArchive",
|
|
133
149
|
"AccountContext",
|
|
134
150
|
"MessagingBackend",
|
|
135
151
|
"ModernAccountData",
|
librus_python_api/config.py
CHANGED
|
@@ -505,6 +505,14 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
|
505
505
|
True,
|
|
506
506
|
Evidence.INDEPENDENTLY_OBSERVED,
|
|
507
507
|
),
|
|
508
|
+
Endpoint(
|
|
509
|
+
"school_year_archive",
|
|
510
|
+
"GET",
|
|
511
|
+
"/archiwum",
|
|
512
|
+
SideEffect.NONE,
|
|
513
|
+
True,
|
|
514
|
+
Evidence.INDEPENDENTLY_OBSERVED,
|
|
515
|
+
),
|
|
508
516
|
Endpoint(
|
|
509
517
|
"agenda",
|
|
510
518
|
"POST",
|
|
@@ -722,6 +730,35 @@ ANNOUNCEMENT_MAX_ITEMS = 256
|
|
|
722
730
|
ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
|
|
723
731
|
ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
|
|
724
732
|
ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH = 262144
|
|
733
|
+
|
|
734
|
+
# School-year archive (/archiwum): labels as rendered upstream, including the
|
|
735
|
+
# absence spellings. Matching is case-insensitive and exact otherwise.
|
|
736
|
+
ARCHIVE_TABLE_CLASSES = frozenset({"decorated"})
|
|
737
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES = frozenset({"decorated", "big", "center"})
|
|
738
|
+
ARCHIVE_YEAR_PATTERN = (
|
|
739
|
+
r"Klasa: (?P<class_name>\S(?:.*\S)?) Rok: (?P<first>[0-9]{4})/(?P<second>[0-9]{4})"
|
|
740
|
+
)
|
|
741
|
+
ARCHIVE_PERIOD_LABELS = ("okres 1", "okres 2", "koniec roku")
|
|
742
|
+
ARCHIVE_BEHAVIOUR_HEADING = "zachowanie"
|
|
743
|
+
ARCHIVE_ABSENCES_HEADING = "nieobecności"
|
|
744
|
+
ARCHIVE_ABSENCE_LABELS = MappingProxyType(
|
|
745
|
+
{
|
|
746
|
+
"nieusprawiedlione": "unexcused",
|
|
747
|
+
"usprawiedlione": "excused",
|
|
748
|
+
"spóźnienia": "late",
|
|
749
|
+
}
|
|
750
|
+
)
|
|
751
|
+
ARCHIVE_ACHIEVEMENT_HEADERS = ("data", "klasa", "kategoria", "osiągnięcie")
|
|
752
|
+
ARCHIVE_YEAR_HEADER_TAGS = frozenset({"span", "b"})
|
|
753
|
+
ARCHIVE_MAX_YEARS = 16
|
|
754
|
+
ARCHIVE_MAX_SUBJECTS = 128
|
|
755
|
+
ARCHIVE_MAX_DESCRIPTIVE = 32
|
|
756
|
+
ARCHIVE_MAX_ACHIEVEMENTS = 256
|
|
757
|
+
ARCHIVE_MAX_TEXT_LENGTH = 65536
|
|
758
|
+
ARCHIVE_MAX_TOTAL_TEXT_LENGTH = 262144
|
|
759
|
+
ARCHIVE_MAX_YEAR_HEADER_LENGTH = 1024 + len("Klasa: Rok: 0000/0000")
|
|
760
|
+
ARCHIVE_EMPTY_TITLE = "brak danych"
|
|
761
|
+
ARCHIVE_EMPTY_BOX_CLASSES = frozenset({"warning-box", "information", "medium"})
|
|
725
762
|
ANNOUNCEMENT_TABLE_CLASSES = frozenset(
|
|
726
763
|
{"decorated", "big", "center", "printable", "margin-top"}
|
|
727
764
|
)
|
librus_python_api/models.py
CHANGED
|
@@ -38,6 +38,7 @@ type OperationName = Literal[
|
|
|
38
38
|
"attachment_download",
|
|
39
39
|
"recipient_groups",
|
|
40
40
|
"recipients",
|
|
41
|
+
"school_year_archive",
|
|
41
42
|
"notification_counts",
|
|
42
43
|
"consume_schedule_events",
|
|
43
44
|
"decode_schedule_events",
|
|
@@ -778,6 +779,79 @@ class Announcements:
|
|
|
778
779
|
observation: Observation
|
|
779
780
|
|
|
780
781
|
|
|
782
|
+
@dataclass(frozen=True, slots=True)
|
|
783
|
+
class ArchiveCounts:
|
|
784
|
+
"""Counts per period as rendered; the year-end value is not checked as a sum."""
|
|
785
|
+
|
|
786
|
+
first_semester: int
|
|
787
|
+
second_semester: int
|
|
788
|
+
year_end: int
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@dataclass(frozen=True, slots=True)
|
|
792
|
+
class ArchiveAbsences:
|
|
793
|
+
unexcused: ArchiveCounts
|
|
794
|
+
excused: ArchiveCounts
|
|
795
|
+
late: ArchiveCounts
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@dataclass(frozen=True, slots=True)
|
|
799
|
+
class ArchiveMarks:
|
|
800
|
+
"""Rendered marks; "-" and empty text are preserved, never invented.
|
|
801
|
+
|
|
802
|
+
`year_end` is the archive's year-end column, not asserted to equal the
|
|
803
|
+
published annual grade of `SubjectGradeSummary`.
|
|
804
|
+
"""
|
|
805
|
+
|
|
806
|
+
first_semester: str = field(repr=False)
|
|
807
|
+
second_semester: str = field(repr=False)
|
|
808
|
+
year_end: str = field(repr=False)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@dataclass(frozen=True, slots=True)
|
|
812
|
+
class ArchiveSubject:
|
|
813
|
+
subject: str = field(repr=False)
|
|
814
|
+
marks: ArchiveMarks = field(repr=False)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@dataclass(frozen=True, slots=True)
|
|
818
|
+
class ArchiveDescriptive:
|
|
819
|
+
label: str = field(repr=False)
|
|
820
|
+
text: str = field(repr=False)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@dataclass(frozen=True, slots=True)
|
|
824
|
+
class ArchiveYear:
|
|
825
|
+
"""One earlier school year. Behaviour cells are raw positional text: the
|
|
826
|
+
first-semester column and the cell spanning second semester and year end;
|
|
827
|
+
their populated meaning is not established."""
|
|
828
|
+
|
|
829
|
+
class_name: str = field(repr=False)
|
|
830
|
+
school_year: str = field(repr=False)
|
|
831
|
+
first_year: int = field(repr=False)
|
|
832
|
+
subjects: tuple[ArchiveSubject, ...] = field(repr=False)
|
|
833
|
+
descriptive: tuple[ArchiveDescriptive, ...] = field(repr=False)
|
|
834
|
+
behaviour_first_semester: str = field(repr=False)
|
|
835
|
+
behaviour_second_semester_and_year_end: str = field(repr=False)
|
|
836
|
+
absences: ArchiveAbsences = field(repr=False)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@dataclass(frozen=True, slots=True)
|
|
840
|
+
class ArchiveAchievement:
|
|
841
|
+
day: date = field(repr=False)
|
|
842
|
+
class_name: str = field(repr=False)
|
|
843
|
+
category: str = field(repr=False)
|
|
844
|
+
text: str = field(repr=False)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@dataclass(frozen=True, slots=True)
|
|
848
|
+
class SchoolYearArchive:
|
|
849
|
+
identity: Identity
|
|
850
|
+
years: tuple[ArchiveYear, ...] = field(repr=False)
|
|
851
|
+
achievements: tuple[ArchiveAchievement, ...] = field(repr=False)
|
|
852
|
+
observation: Observation
|
|
853
|
+
|
|
854
|
+
|
|
781
855
|
@dataclass(frozen=True, slots=True)
|
|
782
856
|
class AgendaEvent:
|
|
783
857
|
day: date = field(repr=False)
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""The school-year archive page (`/archiwum`), authored from an owner-authorized
|
|
2
|
+
structural observation; every fixture is synthetic.
|
|
3
|
+
|
|
4
|
+
One table holds every earlier school year: a year header row, a period row,
|
|
5
|
+
grade rows (subject marks or full-year descriptive text), then exactly one
|
|
6
|
+
behaviour section, one absences section and an empty footer, in that order.
|
|
7
|
+
A separate table lists achievements. Unknown layouts are unsupported;
|
|
8
|
+
contradictions inside the known layout are parse errors. The student's name in
|
|
9
|
+
the page header and the chart scripts are never read.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import NoReturn
|
|
14
|
+
|
|
15
|
+
from lxml import html
|
|
16
|
+
|
|
17
|
+
from librus_python_api import markup
|
|
18
|
+
from librus_python_api.config import (
|
|
19
|
+
ARCHIVE_ABSENCE_LABELS,
|
|
20
|
+
ARCHIVE_ABSENCES_HEADING,
|
|
21
|
+
ARCHIVE_ACHIEVEMENT_HEADERS,
|
|
22
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES,
|
|
23
|
+
ARCHIVE_BEHAVIOUR_HEADING,
|
|
24
|
+
ARCHIVE_EMPTY_BOX_CLASSES,
|
|
25
|
+
ARCHIVE_EMPTY_TITLE,
|
|
26
|
+
ARCHIVE_MAX_ACHIEVEMENTS,
|
|
27
|
+
ARCHIVE_MAX_DESCRIPTIVE,
|
|
28
|
+
ARCHIVE_MAX_SUBJECTS,
|
|
29
|
+
ARCHIVE_MAX_TEXT_LENGTH,
|
|
30
|
+
ARCHIVE_MAX_TOTAL_TEXT_LENGTH,
|
|
31
|
+
ARCHIVE_MAX_YEAR_HEADER_LENGTH,
|
|
32
|
+
ARCHIVE_MAX_YEARS,
|
|
33
|
+
ARCHIVE_PERIOD_LABELS,
|
|
34
|
+
ARCHIVE_TABLE_CLASSES,
|
|
35
|
+
ARCHIVE_YEAR_HEADER_TAGS,
|
|
36
|
+
ARCHIVE_YEAR_PATTERN,
|
|
37
|
+
)
|
|
38
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
39
|
+
from librus_python_api.models import (
|
|
40
|
+
ArchiveAbsences,
|
|
41
|
+
ArchiveAchievement,
|
|
42
|
+
ArchiveCounts,
|
|
43
|
+
ArchiveDescriptive,
|
|
44
|
+
ArchiveMarks,
|
|
45
|
+
ArchiveSubject,
|
|
46
|
+
ArchiveYear,
|
|
47
|
+
)
|
|
48
|
+
from librus_python_api.parsers import parse_page
|
|
49
|
+
|
|
50
|
+
_YEAR = re.compile(ARCHIVE_YEAR_PATTERN)
|
|
51
|
+
_COUNT = re.compile(r"[0-9]{1,6}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fail(kind: ErrorKind) -> NoReturn:
|
|
55
|
+
raise LibrusError(kind)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Text:
|
|
59
|
+
"""Rendered cell text under one total budget for the whole page."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.used = 0
|
|
63
|
+
|
|
64
|
+
def __call__(
|
|
65
|
+
self,
|
|
66
|
+
cell: html.HtmlElement,
|
|
67
|
+
*,
|
|
68
|
+
multiline: bool = False,
|
|
69
|
+
limit: int = markup.FIELD_LENGTH,
|
|
70
|
+
) -> str:
|
|
71
|
+
value = markup.text(cell, limit, multiline=multiline)
|
|
72
|
+
self.used += len(value)
|
|
73
|
+
if self.used > ARCHIVE_MAX_TOTAL_TEXT_LENGTH:
|
|
74
|
+
_fail(ErrorKind.LIMIT)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plain(cell: html.HtmlElement, allowed: frozenset[str] = frozenset()) -> None:
|
|
79
|
+
"""Only the observed child vocabulary; other markup is a new layout."""
|
|
80
|
+
for node in cell.iterdescendants():
|
|
81
|
+
if isinstance(node.tag, str) and node.tag not in allowed:
|
|
82
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
83
|
+
for node in cell.iter():
|
|
84
|
+
if any(name == "title" or name.startswith("on") for name in node.attrib):
|
|
85
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _td(cells: list[html.HtmlElement]) -> None:
|
|
89
|
+
if any(cell.tag != "td" for cell in cells):
|
|
90
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _classes(table: html.HtmlElement) -> set[str]:
|
|
94
|
+
return set(table.get("class", "").split())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _first_cells(table: html.HtmlElement) -> list[html.HtmlElement]:
|
|
98
|
+
first = next(markup.rows(table), None)
|
|
99
|
+
return [] if first is None else markup.cells(first)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_archive(table: html.HtmlElement) -> bool:
|
|
103
|
+
cells = _first_cells(table)
|
|
104
|
+
return (
|
|
105
|
+
len(cells) >= 2
|
|
106
|
+
and markup.text(cells[0]) == ""
|
|
107
|
+
and all(
|
|
108
|
+
_YEAR.fullmatch(markup.text(cell, ARCHIVE_MAX_YEAR_HEADER_LENGTH))
|
|
109
|
+
for cell in cells[1:]
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_achievement_list(table: html.HtmlElement) -> bool:
|
|
115
|
+
cells = _first_cells(table)
|
|
116
|
+
return [
|
|
117
|
+
markup.text(cell, ARCHIVE_MAX_YEAR_HEADER_LENGTH).casefold() for cell in cells
|
|
118
|
+
] == list(ARCHIVE_ACHIEVEMENT_HEADERS)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _in_footer(row: html.HtmlElement) -> bool:
|
|
122
|
+
return next(row.iterancestors("tfoot"), None) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _years(header: html.HtmlElement, text: _Text) -> list[tuple[str, str, int]]:
|
|
126
|
+
cells = markup.cells(header)
|
|
127
|
+
_td(cells)
|
|
128
|
+
_plain(cells[0])
|
|
129
|
+
if len(cells) - 1 > ARCHIVE_MAX_YEARS:
|
|
130
|
+
_fail(ErrorKind.LIMIT)
|
|
131
|
+
if markup.colspan(cells[0]) != 1:
|
|
132
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
133
|
+
years: list[tuple[str, str, int]] = []
|
|
134
|
+
for cell in cells[1:]:
|
|
135
|
+
if markup.colspan(cell) != 3:
|
|
136
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
137
|
+
_plain(cell, ARCHIVE_YEAR_HEADER_TAGS)
|
|
138
|
+
match = _YEAR.fullmatch(text(cell, limit=ARCHIVE_MAX_YEAR_HEADER_LENGTH))
|
|
139
|
+
if match is None:
|
|
140
|
+
_fail(ErrorKind.PARSE)
|
|
141
|
+
first, second = int(match["first"]), int(match["second"])
|
|
142
|
+
if len(match["class_name"]) > markup.FIELD_LENGTH:
|
|
143
|
+
_fail(ErrorKind.LIMIT)
|
|
144
|
+
if second != first + 1 or any(first == seen for _, _, seen in years):
|
|
145
|
+
_fail(ErrorKind.PARSE)
|
|
146
|
+
years.append((match["class_name"], f"{first}/{second}", first))
|
|
147
|
+
return years
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_periods(row: html.HtmlElement, count: int, text: _Text) -> None:
|
|
151
|
+
cells = markup.cells(row)
|
|
152
|
+
_td(cells)
|
|
153
|
+
for cell in cells:
|
|
154
|
+
_plain(cell)
|
|
155
|
+
if (
|
|
156
|
+
len(cells) != 1 + 3 * count
|
|
157
|
+
or any(markup.colspan(cell) != 1 for cell in cells)
|
|
158
|
+
or text(cells[0])
|
|
159
|
+
or [text(cell).casefold() for cell in cells[1:]]
|
|
160
|
+
!= list(ARCHIVE_PERIOD_LABELS) * count
|
|
161
|
+
):
|
|
162
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _Body:
|
|
166
|
+
"""The section order: grades, behaviour, absences, footer."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, count: int, text: _Text) -> None:
|
|
169
|
+
self.count = count
|
|
170
|
+
self.text = text
|
|
171
|
+
self.state = "grades"
|
|
172
|
+
self.labels: set[str] = set()
|
|
173
|
+
self.subjects: list[tuple[str, list[ArchiveMarks]]] = []
|
|
174
|
+
self.descriptive: list[tuple[str, list[str]]] = []
|
|
175
|
+
self.behaviour: list[tuple[str, str]] = []
|
|
176
|
+
self.absences: dict[str, list[ArchiveCounts]] = {}
|
|
177
|
+
|
|
178
|
+
def read(self, row: html.HtmlElement) -> None:
|
|
179
|
+
cells = markup.cells(row)
|
|
180
|
+
spans = [markup.colspan(cell) for cell in cells]
|
|
181
|
+
full = [1 + 3 * self.count]
|
|
182
|
+
heading = spans == full and "bolded" in row.get("class", "").split()
|
|
183
|
+
if self.state == "grades":
|
|
184
|
+
if heading:
|
|
185
|
+
self._heading(cells, ARCHIVE_BEHAVIOUR_HEADING, "behaviour")
|
|
186
|
+
else:
|
|
187
|
+
self._grade(cells, spans)
|
|
188
|
+
elif self.state == "behaviour":
|
|
189
|
+
self._behaviour(cells, spans, heading)
|
|
190
|
+
elif self.state == "absences_heading":
|
|
191
|
+
if not heading:
|
|
192
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
193
|
+
self._heading(cells, ARCHIVE_ABSENCES_HEADING, "absences")
|
|
194
|
+
elif self.state == "absences":
|
|
195
|
+
if spans == full and not heading:
|
|
196
|
+
self._footer(cells)
|
|
197
|
+
else:
|
|
198
|
+
self._absence(cells, spans)
|
|
199
|
+
else:
|
|
200
|
+
_fail(ErrorKind.PARSE)
|
|
201
|
+
|
|
202
|
+
def _heading(self, cells: list[html.HtmlElement], label: str, state: str) -> None:
|
|
203
|
+
_td(cells)
|
|
204
|
+
_plain(cells[0])
|
|
205
|
+
if self.text(cells[0]).casefold() != label:
|
|
206
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
207
|
+
self.state = state
|
|
208
|
+
|
|
209
|
+
def _label(self, cells: list[html.HtmlElement]) -> str:
|
|
210
|
+
if not cells or cells[0].tag != "th":
|
|
211
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
212
|
+
_plain(cells[0])
|
|
213
|
+
return self.text(cells[0])
|
|
214
|
+
|
|
215
|
+
def _values(
|
|
216
|
+
self,
|
|
217
|
+
cells: list[html.HtmlElement],
|
|
218
|
+
*,
|
|
219
|
+
multiline: bool = False,
|
|
220
|
+
limit: int = markup.FIELD_LENGTH,
|
|
221
|
+
) -> list[str]:
|
|
222
|
+
_td(cells)
|
|
223
|
+
values = []
|
|
224
|
+
for cell in cells:
|
|
225
|
+
_plain(cell, frozenset({"br"}) if multiline else frozenset())
|
|
226
|
+
values.append(self.text(cell, multiline=multiline, limit=limit))
|
|
227
|
+
return values
|
|
228
|
+
|
|
229
|
+
def _grade(self, cells: list[html.HtmlElement], spans: list[int]) -> None:
|
|
230
|
+
label = self._label(cells)
|
|
231
|
+
if not label:
|
|
232
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
233
|
+
if spans == [1] * (1 + 3 * self.count):
|
|
234
|
+
values = self._values(cells[1:])
|
|
235
|
+
marks = [
|
|
236
|
+
ArchiveMarks(*values[3 * i : 3 * i + 3]) for i in range(self.count)
|
|
237
|
+
]
|
|
238
|
+
self.subjects.append((label, marks))
|
|
239
|
+
limit, size = ARCHIVE_MAX_SUBJECTS, len(self.subjects)
|
|
240
|
+
elif spans == [1] + [3] * self.count:
|
|
241
|
+
texts = self._values(
|
|
242
|
+
cells[1:], multiline=True, limit=ARCHIVE_MAX_TEXT_LENGTH
|
|
243
|
+
)
|
|
244
|
+
self.descriptive.append((label, texts))
|
|
245
|
+
limit, size = ARCHIVE_MAX_DESCRIPTIVE, len(self.descriptive)
|
|
246
|
+
else:
|
|
247
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
248
|
+
if label in self.labels:
|
|
249
|
+
_fail(ErrorKind.PARSE)
|
|
250
|
+
self.labels.add(label)
|
|
251
|
+
if size > limit:
|
|
252
|
+
_fail(ErrorKind.LIMIT)
|
|
253
|
+
|
|
254
|
+
def _behaviour(
|
|
255
|
+
self, cells: list[html.HtmlElement], spans: list[int], heading: bool
|
|
256
|
+
) -> None:
|
|
257
|
+
if heading or spans != [1] + [1, 2] * self.count or self._label(cells):
|
|
258
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
259
|
+
values = self._values(cells[1:])
|
|
260
|
+
self.behaviour = [(values[2 * i], values[2 * i + 1]) for i in range(self.count)]
|
|
261
|
+
self.state = "absences_heading"
|
|
262
|
+
|
|
263
|
+
def _absence(self, cells: list[html.HtmlElement], spans: list[int]) -> None:
|
|
264
|
+
field = ARCHIVE_ABSENCE_LABELS.get(self._label(cells).casefold())
|
|
265
|
+
if field is None or spans != [1] * (1 + 3 * self.count):
|
|
266
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
267
|
+
if field in self.absences:
|
|
268
|
+
_fail(ErrorKind.PARSE)
|
|
269
|
+
values = self._values(cells[1:])
|
|
270
|
+
if not all(_COUNT.fullmatch(value) for value in values):
|
|
271
|
+
_fail(ErrorKind.PARSE)
|
|
272
|
+
numbers = [int(value) for value in values]
|
|
273
|
+
self.absences[field] = [
|
|
274
|
+
ArchiveCounts(*numbers[3 * i : 3 * i + 3]) for i in range(self.count)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
def _footer(self, cells: list[html.HtmlElement]) -> None:
|
|
278
|
+
_td(cells)
|
|
279
|
+
_plain(cells[0])
|
|
280
|
+
if self.text(cells[0]) or len(self.absences) != len(ARCHIVE_ABSENCE_LABELS):
|
|
281
|
+
_fail(ErrorKind.PARSE)
|
|
282
|
+
self.state = "end"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _archive(table: html.HtmlElement, text: _Text) -> tuple[ArchiveYear, ...]:
|
|
286
|
+
rows = list(markup.rows(table))
|
|
287
|
+
if len(rows) < 2:
|
|
288
|
+
_fail(ErrorKind.PARSE)
|
|
289
|
+
years = _years(rows[0], text)
|
|
290
|
+
_check_periods(rows[1], len(years), text)
|
|
291
|
+
body = _Body(len(years), text)
|
|
292
|
+
for row in rows[2:]:
|
|
293
|
+
body.read(row)
|
|
294
|
+
if body.state != "end":
|
|
295
|
+
_fail(ErrorKind.PARSE)
|
|
296
|
+
return tuple(
|
|
297
|
+
ArchiveYear(
|
|
298
|
+
class_name=class_name,
|
|
299
|
+
school_year=school_year,
|
|
300
|
+
first_year=first_year,
|
|
301
|
+
subjects=tuple(
|
|
302
|
+
ArchiveSubject(label, marks[i]) for label, marks in body.subjects
|
|
303
|
+
),
|
|
304
|
+
descriptive=tuple(
|
|
305
|
+
ArchiveDescriptive(label, texts[i]) for label, texts in body.descriptive
|
|
306
|
+
),
|
|
307
|
+
behaviour_first_semester=body.behaviour[i][0],
|
|
308
|
+
behaviour_second_semester_and_year_end=body.behaviour[i][1],
|
|
309
|
+
absences=ArchiveAbsences(
|
|
310
|
+
unexcused=body.absences["unexcused"][i],
|
|
311
|
+
excused=body.absences["excused"][i],
|
|
312
|
+
late=body.absences["late"][i],
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
for i, (class_name, school_year, first_year) in enumerate(years)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _achievements(
|
|
320
|
+
table: html.HtmlElement, text: _Text
|
|
321
|
+
) -> tuple[ArchiveAchievement, ...]:
|
|
322
|
+
items: list[ArchiveAchievement] = []
|
|
323
|
+
rows = list(markup.rows(table))
|
|
324
|
+
headers = markup.cells(rows[0])
|
|
325
|
+
_td(headers)
|
|
326
|
+
for cell in headers:
|
|
327
|
+
_plain(cell)
|
|
328
|
+
if markup.colspan(cell) != 1:
|
|
329
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
330
|
+
text(cell)
|
|
331
|
+
finished = False
|
|
332
|
+
for row in rows[1:]:
|
|
333
|
+
if finished:
|
|
334
|
+
_fail(ErrorKind.PARSE)
|
|
335
|
+
cells = markup.cells(row)
|
|
336
|
+
_td(cells)
|
|
337
|
+
if _in_footer(row):
|
|
338
|
+
if len(cells) != 1 or text(cells[0]):
|
|
339
|
+
_fail(ErrorKind.PARSE)
|
|
340
|
+
_plain(cells[0])
|
|
341
|
+
if markup.colspan(cells[0]) != 4:
|
|
342
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
343
|
+
finished = True
|
|
344
|
+
continue
|
|
345
|
+
if len(cells) != 4 or any(markup.colspan(cell) != 1 for cell in cells):
|
|
346
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
347
|
+
for i, cell in enumerate(cells):
|
|
348
|
+
_plain(cell, frozenset({"br"}) if i == 3 else frozenset())
|
|
349
|
+
items.append(
|
|
350
|
+
ArchiveAchievement(
|
|
351
|
+
day=markup.civil_date(text(cells[0])),
|
|
352
|
+
class_name=text(cells[1]),
|
|
353
|
+
category=text(cells[2]),
|
|
354
|
+
text=text(cells[3], multiline=True, limit=ARCHIVE_MAX_TEXT_LENGTH),
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
if len(items) > ARCHIVE_MAX_ACHIEVEMENTS:
|
|
358
|
+
_fail(ErrorKind.LIMIT)
|
|
359
|
+
return tuple(items)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _explicit_empty(document: html.HtmlElement) -> bool:
|
|
363
|
+
notices = []
|
|
364
|
+
for box in document.iter("div"):
|
|
365
|
+
if not ARCHIVE_EMPTY_BOX_CLASSES <= _classes(box):
|
|
366
|
+
continue
|
|
367
|
+
parent = box.getparent()
|
|
368
|
+
if parent is None or "container-background" not in _classes(parent):
|
|
369
|
+
continue
|
|
370
|
+
for head in box:
|
|
371
|
+
if head.tag != "div" or "warning-head" not in _classes(head):
|
|
372
|
+
continue
|
|
373
|
+
for title in head:
|
|
374
|
+
if title.tag == "span" and "warning-title" in _classes(title):
|
|
375
|
+
_plain(title)
|
|
376
|
+
if markup.text(title).casefold() != ARCHIVE_EMPTY_TITLE:
|
|
377
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
378
|
+
notices.append(title)
|
|
379
|
+
if len(notices) > 1:
|
|
380
|
+
_fail(ErrorKind.PARSE)
|
|
381
|
+
if notices and any("decorated" in _classes(t) for t in document.iter("table")):
|
|
382
|
+
_fail(ErrorKind.PARSE)
|
|
383
|
+
return bool(notices)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _check_nesting(table: html.HtmlElement, *, chart_trailer: bool = False) -> None:
|
|
387
|
+
if next(table.iterancestors("table"), None) is not None:
|
|
388
|
+
_fail(ErrorKind.PARSE)
|
|
389
|
+
nested = [node for node in table.iter("table") if node is not table]
|
|
390
|
+
if not nested:
|
|
391
|
+
return
|
|
392
|
+
# Live HTML nests the post-footer charts in a stray opening table. Permit
|
|
393
|
+
# that single rowless trailing container, never nested tabular data.
|
|
394
|
+
if not chart_trailer or len(nested) != 1:
|
|
395
|
+
_fail(ErrorKind.PARSE)
|
|
396
|
+
trailer = nested[0]
|
|
397
|
+
previous = trailer.getprevious()
|
|
398
|
+
if (
|
|
399
|
+
trailer.getparent() is not table
|
|
400
|
+
or trailer.getnext() is not None
|
|
401
|
+
or previous is None
|
|
402
|
+
or previous.tag != "tfoot"
|
|
403
|
+
or trailer.attrib
|
|
404
|
+
or any(
|
|
405
|
+
node.tag in {"tr", "td", "th", "thead", "tbody", "tfoot"}
|
|
406
|
+
for node in trailer.iterdescendants()
|
|
407
|
+
)
|
|
408
|
+
):
|
|
409
|
+
_fail(ErrorKind.PARSE)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def parse_school_year_archive(
|
|
413
|
+
body: bytes,
|
|
414
|
+
) -> tuple[tuple[ArchiveYear, ...], tuple[ArchiveAchievement, ...]]:
|
|
415
|
+
document = parse_page(body)
|
|
416
|
+
archives = [t for t in document.iter("table") if _is_archive(t)]
|
|
417
|
+
lists = [t for t in document.iter("table") if _is_achievement_list(t)]
|
|
418
|
+
if _explicit_empty(document):
|
|
419
|
+
if archives or lists:
|
|
420
|
+
_fail(ErrorKind.PARSE)
|
|
421
|
+
return (), ()
|
|
422
|
+
if len(archives) != 1 or len(lists) != 1:
|
|
423
|
+
_fail(ErrorKind.PARSE)
|
|
424
|
+
archive, achievements = archives[0], lists[0]
|
|
425
|
+
_check_nesting(archive)
|
|
426
|
+
_check_nesting(achievements, chart_trailer=True)
|
|
427
|
+
if not ARCHIVE_TABLE_CLASSES <= _classes(archive) or not (
|
|
428
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES <= _classes(achievements)
|
|
429
|
+
):
|
|
430
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
431
|
+
text = _Text()
|
|
432
|
+
return _archive(archive, text), _achievements(achievements, text)
|
librus_python_api/service.py
CHANGED
|
@@ -138,6 +138,7 @@ from librus_python_api.models import (
|
|
|
138
138
|
SchedulerSnapshot,
|
|
139
139
|
SchoolDetail,
|
|
140
140
|
SchoolReference,
|
|
141
|
+
SchoolYearArchive,
|
|
141
142
|
SendResult,
|
|
142
143
|
SendSubmission,
|
|
143
144
|
StudentInformation,
|
|
@@ -196,6 +197,7 @@ from librus_python_api.school_reads import (
|
|
|
196
197
|
parse_homework,
|
|
197
198
|
parse_school_detail,
|
|
198
199
|
)
|
|
200
|
+
from librus_python_api.school_year_archive import parse_school_year_archive
|
|
199
201
|
from librus_python_api.sending import SendAttempt, parse_send_acknowledgement
|
|
200
202
|
from librus_python_api.timetable import parse_timetable
|
|
201
203
|
from librus_python_api.transport import (
|
|
@@ -1486,6 +1488,30 @@ class AccountClient:
|
|
|
1486
1488
|
|
|
1487
1489
|
return await self._read(("announcements",), fetch, budget, max_age_seconds)
|
|
1488
1490
|
|
|
1491
|
+
async def school_year_archive(
|
|
1492
|
+
self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
|
|
1493
|
+
) -> SchoolYearArchive:
|
|
1494
|
+
"""Read every earlier school year's summary and the achievements list.
|
|
1495
|
+
|
|
1496
|
+
One GET with no side effect; the page's student name and charts are
|
|
1497
|
+
never read. A layout other than the observed one is unsupported.
|
|
1498
|
+
"""
|
|
1499
|
+
|
|
1500
|
+
async def fetch(budget: RequestBudget, _: bool) -> SchoolYearArchive:
|
|
1501
|
+
years, achievements = await self._page(
|
|
1502
|
+
"school_year_archive", budget, parse_school_year_archive
|
|
1503
|
+
)
|
|
1504
|
+
return SchoolYearArchive(
|
|
1505
|
+
self._session_identity(),
|
|
1506
|
+
years,
|
|
1507
|
+
achievements,
|
|
1508
|
+
self._observation("school_year_archive"),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
return await self._read(
|
|
1512
|
+
("school_year_archive",), fetch, budget, max_age_seconds
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1489
1515
|
async def agenda(
|
|
1490
1516
|
self,
|
|
1491
1517
|
year: int,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
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
|
|
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
# librus-python-api
|
|
28
28
|
|
|
29
|
+
[](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml)
|
|
30
|
+
|
|
29
31
|
Read grades, attendance, homework, timetables and messages from **Librus Synergia**
|
|
30
32
|
in Python. This independent, asynchronous client handles login, session recovery,
|
|
31
33
|
pagination and request limits, returning typed Python objects rather than HTML.
|
|
@@ -40,9 +42,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
40
42
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
41
43
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
42
44
|
|
|
43
|
-
**Status:** `1.0
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
**Status:** `1.3.0` adds a read-only school-year archive.
|
|
46
|
+
Installed live checks passed for populated and empty
|
|
47
|
+
archives on two parent logins; see [verification](VERIFICATION.md).
|
|
48
|
+
The stable 1.x API does not guarantee every school layout. School features depend
|
|
46
49
|
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
47
50
|
See [limitations](#supported-features-and-limitations) below.
|
|
48
51
|
|
|
@@ -51,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
51
54
|
Install the exact release from PyPI after its gated publication completes:
|
|
52
55
|
|
|
53
56
|
```sh
|
|
54
|
-
python -m pip install librus-python-api==1.
|
|
57
|
+
python -m pip install librus-python-api==1.3.0
|
|
55
58
|
```
|
|
56
59
|
|
|
57
60
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -67,7 +70,7 @@ python -m pip install .
|
|
|
67
70
|
To install a locally built wheel instead:
|
|
68
71
|
|
|
69
72
|
```sh
|
|
70
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
73
|
+
python -m pip install ./dist/librus_python_api-1.3.0-py3-none-any.whl
|
|
71
74
|
```
|
|
72
75
|
|
|
73
76
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -148,6 +151,11 @@ attendance = await client.attendance_window(today.replace(day=1), today)
|
|
|
148
151
|
|
|
149
152
|
# Permit reuse of this account's cached result for up to 60 seconds.
|
|
150
153
|
announcements = await client.announcements(max_age_seconds=60)
|
|
154
|
+
|
|
155
|
+
# Earlier school years, separate from the message archive (1.3.0).
|
|
156
|
+
archive = await client.school_year_archive()
|
|
157
|
+
for year in archive.years:
|
|
158
|
+
print(year.school_year, year.subjects)
|
|
151
159
|
```
|
|
152
160
|
|
|
153
161
|
Collections expose named record tuples, for example `homework.items`, rather than
|
|
@@ -233,7 +241,7 @@ removed in 0.7.
|
|
|
233
241
|
|
|
234
242
|
| Area | Available |
|
|
235
243
|
| --- | --- |
|
|
236
|
-
| School data | Profile, grades, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
244
|
+
| School data | Profile, grades, school-year archive, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
237
245
|
| Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
|
|
238
246
|
| Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
|
|
239
247
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=XnV4fql56WvZHQuk3zrUHlQ4mtK0272dM07QFKyINU4,6393
|
|
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,7 +11,7 @@ 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/completed_lessons.py,sha256=sR2oLSGb2q1580l-2upCBv9sQfIcHi7CQfWr1K0EFXI,7635
|
|
14
|
-
librus_python_api/config.py,sha256=
|
|
14
|
+
librus_python_api/config.py,sha256=8DckId5C13QH9KPelZwPfsci8upoqb_SJBtq7XviPPY,54628
|
|
15
15
|
librus_python_api/detail_fields.py,sha256=K10Stx_ehDTttkUI7-G7a2Ko9exYEu5NJpR-3sBnQ60,1789
|
|
16
16
|
librus_python_api/diagnostics.py,sha256=2l7wq6RA3it-Gwj-JRLcw5qbI37fl3xII6GZA2mZj0o,856
|
|
17
17
|
librus_python_api/exceptions.py,sha256=yWWoo0xyrQAk1d13HOJZnQBsq4ulh_P0hRSI6ZiNQOE,4622
|
|
@@ -23,7 +23,7 @@ librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaA
|
|
|
23
23
|
librus_python_api/markup.py,sha256=GZk7MmBBGB5SIVY_VngmQxQErI3eGHOpJs3mFVlMR3M,5298
|
|
24
24
|
librus_python_api/message_content.py,sha256=sUPuqpjuavqDyn6QGM5aPH4mpoQV62bDLepSSQ2AsUE,8686
|
|
25
25
|
librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
|
|
26
|
-
librus_python_api/models.py,sha256=
|
|
26
|
+
librus_python_api/models.py,sha256=wD4Wg7Jq0dYU2YA8FVztz0EguueN2cue0V0dbvxxJkw,38548
|
|
27
27
|
librus_python_api/modern_body.py,sha256=3L4P_cIrHdIxZqiinFSQrDMhG6iklT1GoA-VEWMJUiI,2682
|
|
28
28
|
librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
|
|
29
29
|
librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
|
|
@@ -38,11 +38,12 @@ librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
38
38
|
librus_python_api/recipients.py,sha256=6GQCKo-Zjg3VFSg1J8YXcDsy_U3r6-MUS4VCvz0xdaQ,10033
|
|
39
39
|
librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQmA,10729
|
|
40
40
|
librus_python_api/school_reads.py,sha256=PCnZ4lv44j-68FgJpQiMteaPSyfhbX8_3CUyE6zhFcQ,14760
|
|
41
|
+
librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
|
|
41
42
|
librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
|
|
42
|
-
librus_python_api/service.py,sha256=
|
|
43
|
+
librus_python_api/service.py,sha256=0nu0XEVEnPFZ6gaeDN7Ao9WPmkDfuYbz8nW_Eg-y78I,94275
|
|
43
44
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
44
45
|
librus_python_api/transport.py,sha256=QnYXnrv1lW4qToYoTv6HyZw-Lt4rDc-_SMTJpMp6_bc,35297
|
|
45
|
-
librus_python_api-1.
|
|
46
|
-
librus_python_api-1.
|
|
47
|
-
librus_python_api-1.
|
|
48
|
-
librus_python_api-1.
|
|
46
|
+
librus_python_api-1.3.0.dist-info/METADATA,sha256=7LcWlfUprMbkEQZbGWQHxj_p6mhrieVWk30-hAaClQ0,11990
|
|
47
|
+
librus_python_api-1.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
48
|
+
librus_python_api-1.3.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
49
|
+
librus_python_api-1.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|