librus-python-api 1.6.4__tar.gz → 1.7.0__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.
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/API.md +9 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/CHANGELOG.md +26 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/PKG-INFO +7 -6
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/README.md +6 -5
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/pyproject.toml +5 -2
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/__init__.py +1 -1
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/config.py +10 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/grade_parsers.py +127 -19
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/grade_records.py +77 -17
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/models.py +16 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/uv.lock +14 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/.gitignore +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/LICENSE +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/SECURITY.md +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attachments.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attendance.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/class_free_days.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/diagnostics.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/exceptions.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/formative_grades.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/markup.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_workflow.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/parsers.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/school_year_archive.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/service.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/timetable.py +0 -0
- {librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/transport.py +0 -0
|
@@ -240,6 +240,15 @@ value. The same items stay in `numeric`/`descriptive` unchanged; their
|
|
|
240
240
|
identities are unchanged) links them to `detail_id`. A page without the
|
|
241
241
|
section returns `()`. See [formative grades](contracts/grades.md#formative-grades-and-the-observation-card-140).
|
|
242
242
|
|
|
243
|
+
Point grades (1.7.0) come from the "Oceny punktowe" table that point-grade
|
|
244
|
+
schools show beside, or instead of, the ordinary table. Its entries are
|
|
245
|
+
`NumericGrade`s in `numeric` whose computed `point_grade_id` property holds the
|
|
246
|
+
point-grade detail ID; `raw` is then a score on the scale named by `category`,
|
|
247
|
+
such as `(0-100)`, not a grade symbol. The table's "Wyn." period results are
|
|
248
|
+
`GradeRecords.point_results` (`SchoolAverage`s on the score scale, never mixed
|
|
249
|
+
into `averages`), and a point table with titled summary columns adds its
|
|
250
|
+
subjects to `final_grades()`. See [point grades](contracts/grades.md#point-grades-170).
|
|
251
|
+
|
|
243
252
|
## Attendance
|
|
244
253
|
|
|
245
254
|
- `attendance(view=AttendanceView.ALL)` returns `Attendance` with
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 (2026-10-27) - Point grades
|
|
4
|
+
|
|
5
|
+
Reads the "Oceny punktowe" table that point-grade schools show on the grades
|
|
6
|
+
page. Its presence made `grades()`, `grades_window()` and `final_grades()` fail
|
|
7
|
+
with `ParseError` for those accounts. The layout was established offline from
|
|
8
|
+
a relayed screenshot and a public issue report; live qualification on a
|
|
9
|
+
point-grade account is pending.
|
|
10
|
+
|
|
11
|
+
- Grades: point entries are returned in `numeric` with their raw score, scale
|
|
12
|
+
category and tooltip fields. The new computed `NumericGrade.point_grade_id`
|
|
13
|
+
property identifies them. It is not a field, so notification identities are
|
|
14
|
+
unchanged. A current point entry without its point-grade detail link fails
|
|
15
|
+
instead of passing as a grade symbol.
|
|
16
|
+
- Grades: the new `GradeRecords.point_results` holds the "Wyn." period
|
|
17
|
+
results on the score scale. They are never merged into `averages`, which
|
|
18
|
+
stay the ordinary grade-scale means.
|
|
19
|
+
- Grades: a period grade that both tables show is one record.
|
|
20
|
+
- Final grades: a point table with titled summary columns (midterm, predicted
|
|
21
|
+
or annual) adds its subjects; a missing column is unavailable. For a subject
|
|
22
|
+
in both tables, each value is merged on its own: an assigned value fills an
|
|
23
|
+
unassigned one, equal values are one value and two different assigned values
|
|
24
|
+
fail.
|
|
25
|
+
- More than one point table, duplicate point subjects, misaligned rows and an
|
|
26
|
+
ordinary grade table without an annual column fail instead of returning
|
|
27
|
+
partial output.
|
|
28
|
+
|
|
3
29
|
## 1.6.4 (2026-10-26) - Message body rendering security fix
|
|
4
30
|
|
|
5
31
|
Fixes a denial of service in message body rendering. Public types, signatures
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.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
|
|
@@ -42,9 +42,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
42
42
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
43
43
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
44
44
|
|
|
45
|
-
**Status:** `1.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
**Status:** `1.7.0` reads point grades ("Oceny punktowe"), which made both grade
|
|
46
|
+
reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
|
|
47
|
+
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
48
|
+
points, which are validated and not reported as grades.
|
|
48
49
|
Class free days, typed capability outcomes and the live-check freshness watchdog
|
|
49
50
|
remain available. See [verification](VERIFICATION.md).
|
|
50
51
|
The stable 1.x API does not guarantee every school layout. School features depend
|
|
@@ -56,7 +57,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
56
57
|
Install the exact release from PyPI after its gated publication completes:
|
|
57
58
|
|
|
58
59
|
```sh
|
|
59
|
-
python -m pip install librus-python-api==1.
|
|
60
|
+
python -m pip install librus-python-api==1.7.0
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -72,7 +73,7 @@ python -m pip install .
|
|
|
72
73
|
To install a locally built wheel instead:
|
|
73
74
|
|
|
74
75
|
```sh
|
|
75
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
76
|
+
python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
|
|
76
77
|
```
|
|
77
78
|
|
|
78
79
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -16,9 +16,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
16
16
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
17
17
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
18
18
|
|
|
19
|
-
**Status:** `1.
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
**Status:** `1.7.0` reads point grades ("Oceny punktowe"), which made both grade
|
|
20
|
+
reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
|
|
21
|
+
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
22
|
+
points, which are validated and not reported as grades.
|
|
22
23
|
Class free days, typed capability outcomes and the live-check freshness watchdog
|
|
23
24
|
remain available. See [verification](VERIFICATION.md).
|
|
24
25
|
The stable 1.x API does not guarantee every school layout. School features depend
|
|
@@ -30,7 +31,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
30
31
|
Install the exact release from PyPI after its gated publication completes:
|
|
31
32
|
|
|
32
33
|
```sh
|
|
33
|
-
python -m pip install librus-python-api==1.
|
|
34
|
+
python -m pip install librus-python-api==1.7.0
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -46,7 +47,7 @@ python -m pip install .
|
|
|
46
47
|
To install a locally built wheel instead:
|
|
47
48
|
|
|
48
49
|
```sh
|
|
49
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
50
|
+
python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -41,6 +41,7 @@ dev = [
|
|
|
41
41
|
"types-pywin32>=312,<400",
|
|
42
42
|
"pre-commit>=4.6,<5",
|
|
43
43
|
"pip-audit>=2.10,<3",
|
|
44
|
+
"pytest-timeout>=2.3,<3",
|
|
44
45
|
]
|
|
45
46
|
|
|
46
47
|
[tool.hatch.version]
|
|
@@ -56,8 +57,10 @@ include = ["/src", "/pyproject.toml", "/uv.lock", "/README.md", "/SECURITY.md",
|
|
|
56
57
|
testpaths = ["tests"]
|
|
57
58
|
pythonpath = ["."]
|
|
58
59
|
addopts = "--import-mode=importlib -ra -m 'not performance'"
|
|
59
|
-
#
|
|
60
|
-
|
|
60
|
+
# A hung test dumps every thread's stack, then fails with its id instead of
|
|
61
|
+
# holding the job until its timeout (#64, #98). The slowest test takes 9 s.
|
|
62
|
+
faulthandler_timeout = 100
|
|
63
|
+
timeout = 120
|
|
61
64
|
markers = [
|
|
62
65
|
"performance: opt-in hardware-sensitive resource measurements",
|
|
63
66
|
]
|
|
@@ -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.
|
|
141
|
+
__version__ = "1.7.0"
|
|
142
142
|
|
|
143
143
|
__all__ = [
|
|
144
144
|
"ArchiveAbsences",
|
|
@@ -659,6 +659,9 @@ GRADE_MAX_SUBJECTS = 128
|
|
|
659
659
|
GRADE_MAX_VALUE_LENGTH = 1024
|
|
660
660
|
GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
|
|
661
661
|
GRADE_INLINE_DETAIL_LABEL = "Ocena"
|
|
662
|
+
# A point-grade ("Oceny punktowe") table marks each subject row's expander image
|
|
663
|
+
# with this ID; ordinary grade rows use `przedmioty_<id>_node`.
|
|
664
|
+
GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
|
|
662
665
|
GRADE_VIEW_FIELDS = MappingProxyType(
|
|
663
666
|
{
|
|
664
667
|
"all": "zmiany_logowanie_wszystkie",
|
|
@@ -671,6 +674,11 @@ GRADE_EMPTY_MARKERS = frozenset({"", "-", "Brak ocen"})
|
|
|
671
674
|
GRADE_AVERAGE_HEADERS = MappingProxyType(
|
|
672
675
|
{"Średnia ocen": 1, "Średnia ocen z drugiego okresu": 2, "Średnia roczna": 0}
|
|
673
676
|
)
|
|
677
|
+
# Point tables label their period results by visible text, not by title. Only
|
|
678
|
+
# "Wyn.I" was observed (owner-relayed screenshot, 2026-10-27); the others
|
|
679
|
+
# follow the same period naming as the ordinary I/II/R summary columns.
|
|
680
|
+
GRADE_POINT_RESULT_HEADER = re.compile(r"Wyn\.\s?(I|II|R)")
|
|
681
|
+
GRADE_POINT_RESULT_PERIODS = MappingProxyType({"I": 1, "II": 2, "R": 0})
|
|
674
682
|
GRADE_MAX_RECORDS = 2048
|
|
675
683
|
GRADE_MAX_METADATA_LENGTH = 8192
|
|
676
684
|
GRADE_MAX_METADATA_FIELDS = 32
|
|
@@ -733,6 +741,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
|
733
741
|
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
734
742
|
# Recognition only: behaviour-point detail links are classified, never requested.
|
|
735
743
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
|
|
744
|
+
# Recognition only: point-grade detail links are classified, never requested.
|
|
745
|
+
POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
|
|
736
746
|
FORMATIVE_HEADERS = (
|
|
737
747
|
"przedmiot",
|
|
738
748
|
"ocena kształtująca",
|
|
@@ -8,11 +8,14 @@ from lxml import html
|
|
|
8
8
|
from librus_python_api import markup
|
|
9
9
|
from librus_python_api.config import (
|
|
10
10
|
GRADE_BODY_PREFIX_COLUMNS,
|
|
11
|
+
GRADE_CURRENT_HEADER,
|
|
12
|
+
GRADE_EMPTY_MARKERS,
|
|
11
13
|
GRADE_INLINE_DETAIL_LABEL,
|
|
12
14
|
GRADE_MAX_COLUMNS,
|
|
13
15
|
GRADE_MAX_SUBJECTS,
|
|
14
16
|
GRADE_MAX_VALUE_LENGTH,
|
|
15
17
|
GRADE_MERGED_SUBJECTS,
|
|
18
|
+
GRADE_POINT_ROW_NODE,
|
|
16
19
|
GRADE_SUMMARY_HEADERS,
|
|
17
20
|
)
|
|
18
21
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
@@ -48,13 +51,53 @@ def _header(row: html.HtmlElement) -> tuple[dict[str, int], int]:
|
|
|
48
51
|
return fields, position
|
|
49
52
|
|
|
50
53
|
|
|
51
|
-
def
|
|
54
|
+
def is_point_table(table: html.HtmlElement) -> bool:
|
|
55
|
+
"""A grade table whose subject rows carry point-grade expander images."""
|
|
56
|
+
if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
|
|
57
|
+
return False
|
|
58
|
+
return any(
|
|
59
|
+
GRADE_POINT_ROW_NODE.fullmatch(image.get("id", ""))
|
|
60
|
+
for row in markup.rows(table)
|
|
61
|
+
for cell in markup.cells(row)[:1]
|
|
62
|
+
for image in cell.iter("img")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _current_header(table: html.HtmlElement) -> bool:
|
|
67
|
+
"""A grade table: decorated, with a current-grade header row."""
|
|
68
|
+
return {"decorated", "stretch"} <= set(table.get("class", "").split()) and any(
|
|
69
|
+
next(row.iterancestors("thead"), None) is not None
|
|
70
|
+
and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
|
|
71
|
+
for row in markup.rows(table)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def point_layout(table: html.HtmlElement) -> tuple[dict[str, int], int]:
|
|
76
|
+
"""Summary fields and width from the point table's current-grade header row.
|
|
77
|
+
|
|
78
|
+
Point tables are not required to have an annual column, so the row with
|
|
79
|
+
the two current-grade headers defines the body alignment instead.
|
|
80
|
+
"""
|
|
81
|
+
headers = [
|
|
82
|
+
row
|
|
83
|
+
for row in markup.rows(table)
|
|
84
|
+
if next(row.iterancestors("thead"), None) is not None
|
|
85
|
+
and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
|
|
86
|
+
]
|
|
87
|
+
if len(headers) != 1:
|
|
88
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
89
|
+
return _header(headers[0])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _candidates(
|
|
52
93
|
document: html.HtmlElement,
|
|
53
|
-
) -> tuple[html.HtmlElement, dict[str, int], int]:
|
|
94
|
+
) -> list[tuple[html.HtmlElement, dict[str, int], int]]:
|
|
54
95
|
candidates: list[tuple[html.HtmlElement, dict[str, int], int]] = []
|
|
55
96
|
for table in document.iter("table"):
|
|
56
97
|
if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
|
|
57
98
|
continue
|
|
99
|
+
if is_point_table(table):
|
|
100
|
+
continue
|
|
58
101
|
for row in markup.rows(table):
|
|
59
102
|
if next(row.iterancestors("thead"), None) is None:
|
|
60
103
|
continue
|
|
@@ -64,6 +107,13 @@ def _locate(
|
|
|
64
107
|
continue
|
|
65
108
|
fields, width = _header(row)
|
|
66
109
|
candidates.append((table, fields, width))
|
|
110
|
+
return candidates
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _locate(
|
|
114
|
+
document: html.HtmlElement,
|
|
115
|
+
) -> tuple[html.HtmlElement, dict[str, int], int]:
|
|
116
|
+
candidates = _candidates(document)
|
|
67
117
|
if len(candidates) != 1:
|
|
68
118
|
raise LibrusError(ErrorKind.PARSE)
|
|
69
119
|
return candidates[0]
|
|
@@ -85,11 +135,9 @@ def _outside_nested_tables(row: html.HtmlElement) -> str:
|
|
|
85
135
|
return " ".join(parts).strip()
|
|
86
136
|
|
|
87
137
|
|
|
88
|
-
def
|
|
89
|
-
cells: list[html.HtmlElement],
|
|
90
|
-
|
|
91
|
-
width: int,
|
|
92
|
-
) -> SubjectGradeSummary:
|
|
138
|
+
def _subject_columns(
|
|
139
|
+
cells: list[html.HtmlElement], width: int
|
|
140
|
+
) -> tuple[str, list[html.HtmlElement]]:
|
|
93
141
|
if len(cells) < GRADE_BODY_PREFIX_COLUMNS:
|
|
94
142
|
raise LibrusError(ErrorKind.PARSE)
|
|
95
143
|
subject = markup.text(cells[1])
|
|
@@ -105,6 +153,15 @@ def _summary(
|
|
|
105
153
|
columns.extend([cell] * span)
|
|
106
154
|
if len(columns) != width:
|
|
107
155
|
raise LibrusError(ErrorKind.PARSE)
|
|
156
|
+
return subject, columns
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _summary(
|
|
160
|
+
cells: list[html.HtmlElement],
|
|
161
|
+
fields: dict[str, int],
|
|
162
|
+
width: int,
|
|
163
|
+
) -> SubjectGradeSummary:
|
|
164
|
+
subject, columns = _subject_columns(cells, width)
|
|
108
165
|
selected = [columns[index] for index in fields.values()]
|
|
109
166
|
if len({id(cell) for cell in selected}) != len(selected):
|
|
110
167
|
# A single merged value cannot mean two different summary fields.
|
|
@@ -118,7 +175,8 @@ def _summary(
|
|
|
118
175
|
subject,
|
|
119
176
|
values.get("midterm", absent),
|
|
120
177
|
values.get("predicted_annual", absent),
|
|
121
|
-
|
|
178
|
+
# Only a point table may lack the annual column.
|
|
179
|
+
values.get("annual", absent),
|
|
122
180
|
)
|
|
123
181
|
|
|
124
182
|
|
|
@@ -150,20 +208,70 @@ def _subject_rows(
|
|
|
150
208
|
yield cells
|
|
151
209
|
|
|
152
210
|
|
|
211
|
+
def merge_value(
|
|
212
|
+
first: GradeSummaryValue, other: GradeSummaryValue
|
|
213
|
+
) -> GradeSummaryValue:
|
|
214
|
+
"""One subject's value shown by both grade tables.
|
|
215
|
+
|
|
216
|
+
An assigned value fills an unassigned one and equal values are one value;
|
|
217
|
+
two different assigned values are ambiguous.
|
|
218
|
+
"""
|
|
219
|
+
first_set, other_set = (
|
|
220
|
+
value.raw is not None and value.raw not in GRADE_EMPTY_MARKERS
|
|
221
|
+
for value in (first, other)
|
|
222
|
+
)
|
|
223
|
+
if first_set and other_set and first.raw != other.raw:
|
|
224
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
225
|
+
if other_set and not first_set:
|
|
226
|
+
return other
|
|
227
|
+
# An unassigned shown column still says more than an absent one.
|
|
228
|
+
return other if first.availability is Availability.UNAVAILABLE else first
|
|
229
|
+
|
|
230
|
+
|
|
153
231
|
def parse_final_grades(body: bytes) -> tuple[SubjectGradeSummary, ...]:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
232
|
+
document = parse_page(body)
|
|
233
|
+
point_tables = [table for table in document.iter("table") if is_point_table(table)]
|
|
234
|
+
if len(point_tables) > 1:
|
|
235
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
236
|
+
# A point-grade school may show the ordinary table or omit it. An ordinary
|
|
237
|
+
# grade table without an annual column is still an unknown layout.
|
|
238
|
+
sources = _candidates(document)
|
|
239
|
+
if len(sources) > 1 or not (sources or point_tables):
|
|
240
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
241
|
+
if point_tables:
|
|
242
|
+
located = {id(table) for table, _, _ in sources}
|
|
243
|
+
if any(
|
|
244
|
+
_current_header(table) and id(table) not in located
|
|
245
|
+
for table in document.iter("table")
|
|
246
|
+
if table not in point_tables
|
|
247
|
+
):
|
|
160
248
|
raise LibrusError(ErrorKind.PARSE)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
249
|
+
fields, width = point_layout(point_tables[0])
|
|
250
|
+
if fields:
|
|
251
|
+
sources.append((point_tables[0], fields, width))
|
|
252
|
+
items: dict[str, SubjectGradeSummary] = {}
|
|
253
|
+
for table, fields, width in sources:
|
|
254
|
+
family_subjects: set[str] = set()
|
|
255
|
+
for cells in _subject_rows(table, width):
|
|
256
|
+
item = _summary(cells, fields, width)
|
|
257
|
+
if item.subject in family_subjects:
|
|
258
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
259
|
+
family_subjects.add(item.subject)
|
|
260
|
+
previous = items.get(item.subject)
|
|
261
|
+
items[item.subject] = (
|
|
262
|
+
item
|
|
263
|
+
if previous is None
|
|
264
|
+
else SubjectGradeSummary(
|
|
265
|
+
item.subject,
|
|
266
|
+
merge_value(previous.midterm, item.midterm),
|
|
267
|
+
merge_value(previous.predicted_annual, item.predicted_annual),
|
|
268
|
+
merge_value(previous.annual, item.annual),
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
if len(items) > GRADE_MAX_SUBJECTS:
|
|
272
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
165
273
|
# No evidenced no-subject marker exists. Missing rows are not empty success;
|
|
166
274
|
# a valid all-unassigned page still lists its subjects with raw '-' values.
|
|
167
275
|
if not items:
|
|
168
276
|
raise LibrusError(ErrorKind.PARSE)
|
|
169
|
-
return tuple(items)
|
|
277
|
+
return tuple(items.values())
|
|
@@ -19,6 +19,8 @@ from librus_python_api.config import (
|
|
|
19
19
|
GRADE_MAX_SUBJECTS,
|
|
20
20
|
GRADE_MERGED_SUBJECTS,
|
|
21
21
|
GRADE_PERIOD_HEADERS,
|
|
22
|
+
GRADE_POINT_RESULT_HEADER,
|
|
23
|
+
GRADE_POINT_RESULT_PERIODS,
|
|
22
24
|
GRADE_PREDICTED_ANNUAL_HEADER,
|
|
23
25
|
GRADE_PREDICTED_PERIOD_HEADERS,
|
|
24
26
|
GRADE_PUBLICATION_DATE_LABEL,
|
|
@@ -27,7 +29,14 @@ from librus_python_api.config import (
|
|
|
27
29
|
)
|
|
28
30
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
29
31
|
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
30
|
-
from librus_python_api.grade_parsers import
|
|
32
|
+
from librus_python_api.grade_parsers import (
|
|
33
|
+
_locate,
|
|
34
|
+
_subject_columns,
|
|
35
|
+
_subject_rows,
|
|
36
|
+
_summary,
|
|
37
|
+
is_point_table,
|
|
38
|
+
point_layout,
|
|
39
|
+
)
|
|
31
40
|
from librus_python_api.models import (
|
|
32
41
|
Availability,
|
|
33
42
|
DescriptiveGrade,
|
|
@@ -46,7 +55,9 @@ def _label(title: str) -> str:
|
|
|
46
55
|
return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
|
|
47
56
|
|
|
48
57
|
|
|
49
|
-
def _layout(
|
|
58
|
+
def _layout(
|
|
59
|
+
table: html.HtmlElement, *, points: bool = False
|
|
60
|
+
) -> tuple[list[int], dict[int, int]]:
|
|
50
61
|
current: list[int] = []
|
|
51
62
|
averages: dict[int, int] = {}
|
|
52
63
|
for row in markup.rows(table):
|
|
@@ -64,6 +75,9 @@ def _layout(table: html.HtmlElement) -> tuple[list[int], dict[int, int]]:
|
|
|
64
75
|
raise LibrusError(ErrorKind.PARSE)
|
|
65
76
|
current.append(position)
|
|
66
77
|
period = GRADE_AVERAGE_HEADERS.get(_label(cell.get("title", "")))
|
|
78
|
+
result = GRADE_POINT_RESULT_HEADER.fullmatch(markup.text(cell))
|
|
79
|
+
if points and period is None and result is not None:
|
|
80
|
+
period = GRADE_POINT_RESULT_PERIODS[result.group(1)]
|
|
67
81
|
if period is not None:
|
|
68
82
|
if period in averages or markup.colspan(cell) != 1:
|
|
69
83
|
raise LibrusError(ErrorKind.PARSE)
|
|
@@ -140,7 +154,9 @@ class _Collection:
|
|
|
140
154
|
default_factory=list, repr=False
|
|
141
155
|
)
|
|
142
156
|
averages: list[SchoolAverage] = field(default_factory=list, repr=False)
|
|
157
|
+
point_results: list[SchoolAverage] = field(default_factory=list, repr=False)
|
|
143
158
|
subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
|
|
159
|
+
point_subjects: set[str] = field(default_factory=set, repr=False)
|
|
144
160
|
consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
145
161
|
publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
146
162
|
formative: int = 0
|
|
@@ -164,7 +180,15 @@ class _Collection:
|
|
|
164
180
|
if not value or key in self.subjects:
|
|
165
181
|
raise LibrusError(ErrorKind.PARSE)
|
|
166
182
|
self.subjects.add(key)
|
|
167
|
-
if len(self.subjects) > GRADE_MAX_SUBJECTS:
|
|
183
|
+
if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
|
|
184
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
185
|
+
|
|
186
|
+
def point_subject(self, value: str) -> None:
|
|
187
|
+
# A subject can appear in both the ordinary and the point table.
|
|
188
|
+
if not value or value in self.point_subjects:
|
|
189
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
190
|
+
self.point_subjects.add(value)
|
|
191
|
+
if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
|
|
168
192
|
raise LibrusError(ErrorKind.LIMIT)
|
|
169
193
|
|
|
170
194
|
|
|
@@ -337,19 +361,32 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
|
|
|
337
361
|
return result
|
|
338
362
|
|
|
339
363
|
|
|
340
|
-
def _read_numeric_table(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
364
|
+
def _read_numeric_table(
|
|
365
|
+
table: html.HtmlElement, collection: _Collection, *, points: bool = False
|
|
366
|
+
) -> None:
|
|
367
|
+
# Preserve the established full-row and summary alignment validation. A
|
|
368
|
+
# point table aligns on its current-grade header row and may lack summaries.
|
|
369
|
+
if points:
|
|
370
|
+
fields, width = point_layout(table)
|
|
371
|
+
else:
|
|
372
|
+
_, fields, width = _locate(table)
|
|
373
|
+
current, means = _layout(table, points=points)
|
|
344
374
|
periods = _period_columns(table)
|
|
375
|
+
averages = collection.point_results if points else collection.averages
|
|
376
|
+
# Period grades shown by both tables are one record, kept from the first.
|
|
377
|
+
prior = set(collection.numeric) if points else set()
|
|
345
378
|
for cells in _subject_rows(table, width):
|
|
346
379
|
if cells[0].getparent() in collection.publication_rows:
|
|
347
380
|
continue
|
|
348
|
-
if
|
|
381
|
+
if points:
|
|
382
|
+
subject = _subject_columns(cells, width)[0]
|
|
383
|
+
collection.point_subject(subject)
|
|
384
|
+
elif _descriptive_row(cells):
|
|
349
385
|
_read_descriptive_row(cells, collection)
|
|
350
386
|
continue
|
|
351
|
-
|
|
352
|
-
|
|
387
|
+
else:
|
|
388
|
+
subject = _summary(cells, fields, width).subject
|
|
389
|
+
collection.subject(subject)
|
|
353
390
|
columns = [cell for cell in cells for _ in range(markup.colspan(cell))]
|
|
354
391
|
for period in (1, 2, 0):
|
|
355
392
|
index = means.get(period)
|
|
@@ -360,7 +397,7 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
360
397
|
Availability.AVAILABLE, markup.text(columns[index])
|
|
361
398
|
)
|
|
362
399
|
)
|
|
363
|
-
|
|
400
|
+
averages.append(SchoolAverage(subject, period, value))
|
|
364
401
|
if columns[current[0]] is columns[current[1]] and _boxes(columns[current[0]]):
|
|
365
402
|
raise LibrusError(ErrorKind.PARSE)
|
|
366
403
|
seen: set[html.HtmlElement] = set()
|
|
@@ -370,14 +407,23 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
370
407
|
subject in GRADE_MERGED_SUBJECTS
|
|
371
408
|
and _behaviour_points(columns[index], collection)
|
|
372
409
|
):
|
|
410
|
+
start = len(collection.numeric)
|
|
373
411
|
_entries(
|
|
374
412
|
columns[index], subject, cast(Literal[1, 2], period), collection
|
|
375
413
|
)
|
|
414
|
+
# A point entry without its detail link would pass as a
|
|
415
|
+
# grade symbol; an unseen link layout fails instead.
|
|
416
|
+
if points and any(
|
|
417
|
+
record.point_grade_id is None
|
|
418
|
+
for record in collection.numeric[start:]
|
|
419
|
+
):
|
|
420
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
376
421
|
seen.add(columns[index])
|
|
377
422
|
for (period, kind), index in periods.items():
|
|
378
423
|
cell = columns[index]
|
|
379
424
|
if cell in seen and _boxes(cell):
|
|
380
425
|
raise LibrusError(ErrorKind.PARSE)
|
|
426
|
+
start = len(collection.numeric)
|
|
381
427
|
_entries(
|
|
382
428
|
cell,
|
|
383
429
|
subject,
|
|
@@ -386,6 +432,9 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
386
432
|
kind=kind,
|
|
387
433
|
require_empty_marker=False,
|
|
388
434
|
)
|
|
435
|
+
collection.numeric[start:] = [
|
|
436
|
+
record for record in collection.numeric[start:] if record not in prior
|
|
437
|
+
]
|
|
389
438
|
|
|
390
439
|
|
|
391
440
|
def _read_publications(table: html.HtmlElement, collection: _Collection) -> None:
|
|
@@ -450,8 +499,13 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
450
499
|
collection = _Collection()
|
|
451
500
|
numeric_tables = []
|
|
452
501
|
tables = list(document.iter("table"))
|
|
502
|
+
point_tables = [table for table in tables if is_point_table(table)]
|
|
503
|
+
if len(point_tables) > 1:
|
|
504
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
453
505
|
for table in tables:
|
|
454
|
-
if not {"decorated", "stretch"} <= set(
|
|
506
|
+
if not {"decorated", "stretch"} <= set(
|
|
507
|
+
table.get("class", "").split()
|
|
508
|
+
) or is_point_table(table):
|
|
455
509
|
continue
|
|
456
510
|
if any(
|
|
457
511
|
next(row.iterancestors("thead"), None) is not None
|
|
@@ -472,6 +526,8 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
472
526
|
_read_publications(table, collection)
|
|
473
527
|
if table in numeric_tables:
|
|
474
528
|
_read_numeric_table(table, collection)
|
|
529
|
+
elif table in point_tables:
|
|
530
|
+
_read_numeric_table(table, collection, points=True)
|
|
475
531
|
else:
|
|
476
532
|
for row in markup.rows(table):
|
|
477
533
|
cells = markup.cells(row)
|
|
@@ -484,6 +540,7 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
484
540
|
<= set(cells[0].get("class", "").split())
|
|
485
541
|
):
|
|
486
542
|
raise LibrusError(ErrorKind.PARSE)
|
|
543
|
+
grade_tables = numeric_tables + point_tables
|
|
487
544
|
for element in document.iter():
|
|
488
545
|
if not isinstance(element.tag, str) or element in collection.consumed:
|
|
489
546
|
continue
|
|
@@ -491,14 +548,12 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
491
548
|
"Data:" in (node.get("title") or "") for node in element.iter()
|
|
492
549
|
)
|
|
493
550
|
if dated_box:
|
|
494
|
-
if any(
|
|
495
|
-
parent in numeric_tables for parent in element.iterancestors("table")
|
|
496
|
-
):
|
|
551
|
+
if any(parent in grade_tables for parent in element.iterancestors("table")):
|
|
497
552
|
# Expanded full-row detail mirrors were intentionally excluded.
|
|
498
553
|
cell = next(element.iterancestors("td", "th"), None)
|
|
499
554
|
if (
|
|
500
555
|
cell is not None
|
|
501
|
-
and next(cell.iterancestors("table"), None) not in
|
|
556
|
+
and next(cell.iterancestors("table"), None) not in grade_tables
|
|
502
557
|
):
|
|
503
558
|
continue
|
|
504
559
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
@@ -511,7 +566,11 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
511
566
|
for parent in anchor.iterancestors("table")
|
|
512
567
|
):
|
|
513
568
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
514
|
-
if
|
|
569
|
+
if (
|
|
570
|
+
not collection.subjects
|
|
571
|
+
and not collection.point_subjects
|
|
572
|
+
and not collection.descriptive
|
|
573
|
+
):
|
|
515
574
|
raise LibrusError(ErrorKind.PARSE)
|
|
516
575
|
for descriptive, subject in sorted(collection.subjects):
|
|
517
576
|
if descriptive and (False, subject) not in collection.subjects:
|
|
@@ -529,4 +588,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
529
588
|
tuple(collection.averages),
|
|
530
589
|
tuple(collection.descriptive_summaries),
|
|
531
590
|
formative,
|
|
591
|
+
tuple(collection.point_results),
|
|
532
592
|
)
|
|
@@ -1053,6 +1053,19 @@ class NumericGrade:
|
|
|
1053
1053
|
|
|
1054
1054
|
return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
|
|
1055
1055
|
|
|
1056
|
+
@property
|
|
1057
|
+
def point_grade_id(self) -> str | None:
|
|
1058
|
+
"""The point-grade ID when `href` is a point-grade detail link.
|
|
1059
|
+
|
|
1060
|
+
`raw` is then a score on the scale its category names, such as
|
|
1061
|
+
`(0-100)`, not a grade symbol. Computed for the same identity reason
|
|
1062
|
+
as `formative_id`.
|
|
1063
|
+
"""
|
|
1064
|
+
from librus_python_api.config import POINT_GRADE_DETAIL_PATH_PREFIX
|
|
1065
|
+
from librus_python_api.markup import detail_id
|
|
1066
|
+
|
|
1067
|
+
return detail_id(self.href, POINT_GRADE_DETAIL_PATH_PREFIX)
|
|
1068
|
+
|
|
1056
1069
|
|
|
1057
1070
|
@dataclass(frozen=True, slots=True)
|
|
1058
1071
|
class DescriptiveGrade:
|
|
@@ -1123,6 +1136,9 @@ class GradeRecords:
|
|
|
1123
1136
|
default=(), repr=False
|
|
1124
1137
|
)
|
|
1125
1138
|
formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
|
|
1139
|
+
# A point table's period results, on its score scale; never merged into
|
|
1140
|
+
# `averages`, whose values are the ordinary grade-scale means.
|
|
1141
|
+
point_results: tuple[SchoolAverage, ...] = field(default=(), repr=False)
|
|
1126
1142
|
|
|
1127
1143
|
|
|
1128
1144
|
@dataclass(frozen=True, slots=True)
|
|
@@ -921,6 +921,7 @@ dev = [
|
|
|
921
921
|
{ name = "pip-audit" },
|
|
922
922
|
{ name = "pre-commit" },
|
|
923
923
|
{ name = "pytest" },
|
|
924
|
+
{ name = "pytest-timeout" },
|
|
924
925
|
{ name = "pyyaml" },
|
|
925
926
|
{ name = "ruff" },
|
|
926
927
|
{ name = "twine" },
|
|
@@ -947,6 +948,7 @@ dev = [
|
|
|
947
948
|
{ name = "pip-audit", specifier = ">=2.10,<3" },
|
|
948
949
|
{ name = "pre-commit", specifier = ">=4.6,<5" },
|
|
949
950
|
{ name = "pytest", specifier = ">=9,<10" },
|
|
951
|
+
{ name = "pytest-timeout", specifier = ">=2.3,<3" },
|
|
950
952
|
{ name = "pyyaml", specifier = ">=6,<7" },
|
|
951
953
|
{ name = "ruff", specifier = ">=0.15,<0.18" },
|
|
952
954
|
{ name = "twine", specifier = ">=7,<8" },
|
|
@@ -1797,6 +1799,18 @@ wheels = [
|
|
|
1797
1799
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
1798
1800
|
]
|
|
1799
1801
|
|
|
1802
|
+
[[package]]
|
|
1803
|
+
name = "pytest-timeout"
|
|
1804
|
+
version = "2.4.0"
|
|
1805
|
+
source = { registry = "https://pypi.org/simple" }
|
|
1806
|
+
dependencies = [
|
|
1807
|
+
{ name = "pytest" },
|
|
1808
|
+
]
|
|
1809
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
|
1810
|
+
wheels = [
|
|
1811
|
+
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
|
1812
|
+
]
|
|
1813
|
+
|
|
1800
1814
|
[[package]]
|
|
1801
1815
|
name = "python-discovery"
|
|
1802
1816
|
version = "1.6.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_notification_bootstrap.py
RENAMED
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_notification_codec.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/_windows_filesystem.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attachment_routes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/attendance_frequency.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/class_free_days.py
RENAMED
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/completed_lessons.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/formative_grades.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/message_content.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/modern_messages.py
RENAMED
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_models.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/notification_workflow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.6.4 → librus_python_api-1.7.0}/src/librus_python_api/school_year_archive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|