pyedookit 0.3.0__tar.gz → 0.3.2__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.
- {pyedookit-0.3.0 → pyedookit-0.3.2}/LICENSE +1 -1
- pyedookit-0.3.2/PKG-INFO +115 -0
- pyedookit-0.3.2/README.md +93 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/pyproject.toml +22 -9
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/__init__.py +1 -2
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/auth.py +41 -47
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/client.py +42 -27
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/models.py +0 -6
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/dates.py +7 -1
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/events.py +3 -3
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/timetable.py +2 -2
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/session.py +24 -4
- pyedookit-0.3.2/tests/__init__.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fakes.py +2 -10
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_client.py +14 -6
- pyedookit-0.3.2/tests/test_session.py +54 -0
- pyedookit-0.3.0/.github/workflows/ci.yml +0 -16
- pyedookit-0.3.0/.github/workflows/publish.yml +0 -34
- pyedookit-0.3.0/PKG-INFO +0 -111
- pyedookit-0.3.0/README.md +0 -97
- pyedookit-0.3.0/scripts/capture_fixtures.py +0 -75
- pyedookit-0.3.0/uv.lock +0 -1113
- {pyedookit-0.3.0 → pyedookit-0.3.2}/.gitignore +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/exceptions.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/__init__.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/fingerprint.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/payments.py +0 -0
- /pyedookit-0.3.0/tests/__init__.py → /pyedookit-0.3.2/src/pyedookit/py.typed +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fixtures/payments_structure.html +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fixtures/timetable_real_structure.html +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fixtures/upcoming_events_structure.html +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_auth.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_dates.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_events_parser.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_fingerprint.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_payments_parser.py +0 -0
- {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_timetable_parser.py +0 -0
pyedookit-0.3.2/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyedookit
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Async client for the Edookit parent portal (edookit.net)
|
|
5
|
+
Project-URL: Homepage, https://github.com/TFoxik/pyedookit
|
|
6
|
+
Author: TFoxik
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: aiohttp>=3.14.3
|
|
18
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
19
|
+
Requires-Dist: lxml>=6.1.0
|
|
20
|
+
Requires-Dist: yarl>=1.24
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pyedookit
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/pyedookit/)
|
|
26
|
+
[](https://github.com/TFoxik/pyedookit/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
Unofficial async Python client for the [Edookit](https://edookit.com) parent
|
|
29
|
+
portal (`https://<school>.edookit.net`) used by Czech schools. It powers the
|
|
30
|
+
[ha-edookit](https://github.com/TFoxik/ha-edookit) Home Assistant integration
|
|
31
|
+
and works standalone.
|
|
32
|
+
|
|
33
|
+
Edookit's REST API only works if a school administrator enables it, so this
|
|
34
|
+
library signs in like a parent does (Plus4U OIDC, no browser) and reads the
|
|
35
|
+
portal's pages. Edookit doesn't support it, and a change to the portal's
|
|
36
|
+
markup can break a parser until it's updated.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install pyedookit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python 3.12+.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
from pyedookit import EdookitClient, login
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
session = await login("zs-example", "parent@example.com", "hunter2")
|
|
54
|
+
client = EdookitClient(session)
|
|
55
|
+
for lesson in await client.get_timetable():
|
|
56
|
+
print(lesson.start, lesson.subject, lesson.room)
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Call | Returns |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `get_timetable()` | this week's `Lesson`s, times in `PORTAL_TZ` (Europe/Prague) |
|
|
64
|
+
| `get_events()` | upcoming `SchoolEvent`s; all-day events have `date` start/end, end inclusive |
|
|
65
|
+
| `get_payments()` | `Payments` totals and requests, amounts as `Decimal` |
|
|
66
|
+
| `fetch_page(name)` | raw HTML of a page without a parser yet (`CAPTURE_PAGES`) |
|
|
67
|
+
|
|
68
|
+
`content_fingerprint(html)` tells you when such a page's content changed.
|
|
69
|
+
|
|
70
|
+
### Sessions
|
|
71
|
+
|
|
72
|
+
The password is only used by `login()`. Store `session.to_json()` instead,
|
|
73
|
+
and treat it like a password: it holds the Plus4U cookie, which signs in
|
|
74
|
+
for about 19 days. Load it with `Session.from_json()`.
|
|
75
|
+
|
|
76
|
+
The edookit session itself lasts 30-60 minutes. When it runs out, call
|
|
77
|
+
`refresh(session)` to get a new one from the Plus4U cookie. When `refresh()`
|
|
78
|
+
raises `AuthError`, the Plus4U cookie has expired too and you need `login()`
|
|
79
|
+
again.
|
|
80
|
+
|
|
81
|
+
| Exception | Meaning | What to do |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `SessionExpired` | the edookit session ran out | `refresh()` and retry |
|
|
84
|
+
| `AuthError` | bad credentials, or the Plus4U cookie expired | `login()` |
|
|
85
|
+
| `EdookitConnectionError` | network error or 30 s timeout | retry later |
|
|
86
|
+
| `ParseError` | the portal's markup changed | the parser needs updating |
|
|
87
|
+
|
|
88
|
+
All of them derive from `EdookitError`. `login()`, `refresh()` and
|
|
89
|
+
`EdookitClient` accept an `http: aiohttp.ClientSession` to reuse. Create it
|
|
90
|
+
with `cookie_jar=aiohttp.DummyCookieJar()`: cookies are handled per host by
|
|
91
|
+
hand, and a real jar would resend them.
|
|
92
|
+
|
|
93
|
+
## Limitations
|
|
94
|
+
|
|
95
|
+
- **Current week only.** The portal ignores `week_offset=1`, and the real
|
|
96
|
+
week-navigation parameter hasn't been found yet. A browser HAR trace of
|
|
97
|
+
clicking the next-week arrow would solve it; `scripts/capture_fixtures.py`
|
|
98
|
+
captures portal HTML.
|
|
99
|
+
- **Cancelled and changed lessons are unverified.** No real capture has had
|
|
100
|
+
one yet, so `Lesson.is_changed` and `is_cancelled` are always `False`.
|
|
101
|
+
- **Unpaid payment requests are unverified.** Only paid ones have been seen.
|
|
102
|
+
A request without the "accepted" marker is reported as unpaid.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv sync --group dev
|
|
108
|
+
uv run pytest
|
|
109
|
+
uv run ruff check .
|
|
110
|
+
uv run mypy src
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
To release, bump `version` in `pyproject.toml` and push a `vX.Y.Z` tag. The
|
|
114
|
+
publish workflow checks that the tag matches the version, then tests, builds
|
|
115
|
+
and uploads to PyPI with trusted publishing.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# pyedookit
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pyedookit/)
|
|
4
|
+
[](https://github.com/TFoxik/pyedookit/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
Unofficial async Python client for the [Edookit](https://edookit.com) parent
|
|
7
|
+
portal (`https://<school>.edookit.net`) used by Czech schools. It powers the
|
|
8
|
+
[ha-edookit](https://github.com/TFoxik/ha-edookit) Home Assistant integration
|
|
9
|
+
and works standalone.
|
|
10
|
+
|
|
11
|
+
Edookit's REST API only works if a school administrator enables it, so this
|
|
12
|
+
library signs in like a parent does (Plus4U OIDC, no browser) and reads the
|
|
13
|
+
portal's pages. Edookit doesn't support it, and a change to the portal's
|
|
14
|
+
markup can break a parser until it's updated.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pyedookit
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.12+.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
from pyedookit import EdookitClient, login
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
session = await login("zs-example", "parent@example.com", "hunter2")
|
|
32
|
+
client = EdookitClient(session)
|
|
33
|
+
for lesson in await client.get_timetable():
|
|
34
|
+
print(lesson.start, lesson.subject, lesson.room)
|
|
35
|
+
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Call | Returns |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `get_timetable()` | this week's `Lesson`s, times in `PORTAL_TZ` (Europe/Prague) |
|
|
42
|
+
| `get_events()` | upcoming `SchoolEvent`s; all-day events have `date` start/end, end inclusive |
|
|
43
|
+
| `get_payments()` | `Payments` totals and requests, amounts as `Decimal` |
|
|
44
|
+
| `fetch_page(name)` | raw HTML of a page without a parser yet (`CAPTURE_PAGES`) |
|
|
45
|
+
|
|
46
|
+
`content_fingerprint(html)` tells you when such a page's content changed.
|
|
47
|
+
|
|
48
|
+
### Sessions
|
|
49
|
+
|
|
50
|
+
The password is only used by `login()`. Store `session.to_json()` instead,
|
|
51
|
+
and treat it like a password: it holds the Plus4U cookie, which signs in
|
|
52
|
+
for about 19 days. Load it with `Session.from_json()`.
|
|
53
|
+
|
|
54
|
+
The edookit session itself lasts 30-60 minutes. When it runs out, call
|
|
55
|
+
`refresh(session)` to get a new one from the Plus4U cookie. When `refresh()`
|
|
56
|
+
raises `AuthError`, the Plus4U cookie has expired too and you need `login()`
|
|
57
|
+
again.
|
|
58
|
+
|
|
59
|
+
| Exception | Meaning | What to do |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `SessionExpired` | the edookit session ran out | `refresh()` and retry |
|
|
62
|
+
| `AuthError` | bad credentials, or the Plus4U cookie expired | `login()` |
|
|
63
|
+
| `EdookitConnectionError` | network error or 30 s timeout | retry later |
|
|
64
|
+
| `ParseError` | the portal's markup changed | the parser needs updating |
|
|
65
|
+
|
|
66
|
+
All of them derive from `EdookitError`. `login()`, `refresh()` and
|
|
67
|
+
`EdookitClient` accept an `http: aiohttp.ClientSession` to reuse. Create it
|
|
68
|
+
with `cookie_jar=aiohttp.DummyCookieJar()`: cookies are handled per host by
|
|
69
|
+
hand, and a real jar would resend them.
|
|
70
|
+
|
|
71
|
+
## Limitations
|
|
72
|
+
|
|
73
|
+
- **Current week only.** The portal ignores `week_offset=1`, and the real
|
|
74
|
+
week-navigation parameter hasn't been found yet. A browser HAR trace of
|
|
75
|
+
clicking the next-week arrow would solve it; `scripts/capture_fixtures.py`
|
|
76
|
+
captures portal HTML.
|
|
77
|
+
- **Cancelled and changed lessons are unverified.** No real capture has had
|
|
78
|
+
one yet, so `Lesson.is_changed` and `is_cancelled` are always `False`.
|
|
79
|
+
- **Unpaid payment requests are unverified.** Only paid ones have been seen.
|
|
80
|
+
A request without the "accepted" marker is reported as unpaid.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv sync --group dev
|
|
86
|
+
uv run pytest
|
|
87
|
+
uv run ruff check .
|
|
88
|
+
uv run mypy src
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To release, bump `version` in `pyproject.toml` and push a `vX.Y.Z` tag. The
|
|
92
|
+
publish workflow checks that the tag matches the version, then tests, builds
|
|
93
|
+
and uploads to PyPI with trusted publishing.
|
|
@@ -4,16 +4,26 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyedookit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Async client for the Edookit parent portal (edookit.net)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "TFoxik" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
13
22
|
dependencies = [
|
|
14
|
-
"aiohttp>=3.
|
|
23
|
+
"aiohttp>=3.14.3",
|
|
15
24
|
"beautifulsoup4>=4.12",
|
|
16
|
-
"lxml>=
|
|
25
|
+
"lxml>=6.1.0",
|
|
26
|
+
"yarl>=1.24",
|
|
17
27
|
]
|
|
18
28
|
|
|
19
29
|
[project.urls]
|
|
@@ -22,6 +32,9 @@ Homepage = "https://github.com/TFoxik/pyedookit"
|
|
|
22
32
|
[tool.hatch.build.targets.wheel]
|
|
23
33
|
packages = ["src/pyedookit"]
|
|
24
34
|
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
37
|
+
|
|
25
38
|
[tool.pytest.ini_options]
|
|
26
39
|
asyncio_mode = "auto"
|
|
27
40
|
testpaths = ["tests"]
|
|
@@ -39,9 +52,9 @@ python_version = "3.12"
|
|
|
39
52
|
|
|
40
53
|
[dependency-groups]
|
|
41
54
|
dev = [
|
|
42
|
-
"pytest>=
|
|
43
|
-
"pytest-asyncio>=0
|
|
44
|
-
"multidict>=6.
|
|
45
|
-
"ruff>=0.
|
|
46
|
-
"mypy>=
|
|
55
|
+
"pytest>=9.0",
|
|
56
|
+
"pytest-asyncio>=1.0",
|
|
57
|
+
"multidict>=6.9.1",
|
|
58
|
+
"ruff>=0.17",
|
|
59
|
+
"mypy>=2.4",
|
|
47
60
|
]
|
|
@@ -7,7 +7,7 @@ from .exceptions import (
|
|
|
7
7
|
ParseError,
|
|
8
8
|
SessionExpired,
|
|
9
9
|
)
|
|
10
|
-
from .models import PORTAL_TZ, Lesson, PaymentRequest, Payments, SchoolEvent,
|
|
10
|
+
from .models import PORTAL_TZ, Lesson, PaymentRequest, Payments, SchoolEvent, Term
|
|
11
11
|
from .parsers.fingerprint import content_fingerprint
|
|
12
12
|
from .session import Session
|
|
13
13
|
|
|
@@ -25,7 +25,6 @@ __all__ = [
|
|
|
25
25
|
"SchoolEvent",
|
|
26
26
|
"Session",
|
|
27
27
|
"SessionExpired",
|
|
28
|
-
"Student",
|
|
29
28
|
"Term",
|
|
30
29
|
"content_fingerprint",
|
|
31
30
|
"login",
|
|
@@ -11,7 +11,7 @@ import aiohttp
|
|
|
11
11
|
from yarl import URL
|
|
12
12
|
|
|
13
13
|
from .exceptions import AuthError, EdookitConnectionError
|
|
14
|
-
from .session import Session
|
|
14
|
+
from .session import Session, school_base, validate_school
|
|
15
15
|
|
|
16
16
|
_PLUS4U_OIDC_BASE = (
|
|
17
17
|
"https://uuidentity.plus4u.net/uu-oidc-maing02/bb977a99f4cc4c37a2afce3fd599d0a7"
|
|
@@ -32,11 +32,9 @@ _CLIENT_ID_RE = re.compile(
|
|
|
32
32
|
|
|
33
33
|
_PLUS4U_HOST = "uuidentity.plus4u.net"
|
|
34
34
|
_MAX_REDIRECTS = 5
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
# otherwise a value like "evil.com/" would redirect the OIDC flow off-portal.
|
|
39
|
-
_SCHOOL_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
35
|
+
# Every hop of the login flow stays on these over https; anything else means
|
|
36
|
+
# a response was tampered with, and must not receive cookies or the code.
|
|
37
|
+
_ALLOWED_HOST_SUFFIXES = (".plus4u.net", ".edookit.net")
|
|
40
38
|
|
|
41
39
|
REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
|
42
40
|
|
|
@@ -66,24 +64,24 @@ class _FetchResult:
|
|
|
66
64
|
url: str
|
|
67
65
|
text: str
|
|
68
66
|
location: str | None
|
|
69
|
-
is_redirect: bool
|
|
70
|
-
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
@property
|
|
69
|
+
def is_redirect(self) -> bool:
|
|
70
|
+
return self.status in REDIRECT_STATUSES
|
|
75
71
|
|
|
76
72
|
|
|
77
73
|
def _school_host(school: str) -> str:
|
|
78
74
|
return f"{school}.edookit.net"
|
|
79
75
|
|
|
80
76
|
|
|
81
|
-
def
|
|
82
|
-
return f"
|
|
77
|
+
def _callback_url(school: str) -> str:
|
|
78
|
+
return f"{school_base(school)}/user/oidc-login-callback"
|
|
83
79
|
|
|
84
80
|
|
|
85
|
-
def
|
|
86
|
-
|
|
81
|
+
def _check_hop(url: URL) -> None:
|
|
82
|
+
host = url.host or ""
|
|
83
|
+
if url.scheme != "https" or not host.endswith(_ALLOWED_HOST_SUFFIXES):
|
|
84
|
+
raise AuthError(f"login flow tried to leave Plus4U/Edookit (to {url.scheme}://{host})")
|
|
87
85
|
|
|
88
86
|
|
|
89
87
|
def _apply_set_cookies(headers: Iterable[str], jar: dict[str, str]) -> None:
|
|
@@ -140,7 +138,7 @@ async def _discover_client_id(
|
|
|
140
138
|
- PHPSESSID etc. - joins the same cookie tracking the rest of the login
|
|
141
139
|
flow uses, rather than being discarded.
|
|
142
140
|
"""
|
|
143
|
-
result = await _fetch(http, "GET", f"{
|
|
141
|
+
result = await _fetch(http, "GET", f"{school_base(school)}/user/login", cookies_by_host)
|
|
144
142
|
match = _CLIENT_ID_RE.search(result.text)
|
|
145
143
|
if not match:
|
|
146
144
|
raise AuthError(f"could not find a Plus4U client_id on {school}'s login page")
|
|
@@ -170,7 +168,9 @@ async def _fetch(
|
|
|
170
168
|
current_params = params
|
|
171
169
|
|
|
172
170
|
for _ in range(_MAX_REDIRECTS + 1):
|
|
173
|
-
|
|
171
|
+
parsed = URL(current_url)
|
|
172
|
+
_check_hop(parsed)
|
|
173
|
+
host = parsed.host or ""
|
|
174
174
|
headers: dict[str, str] = {}
|
|
175
175
|
host_cookies = cookies_by_host.get(host)
|
|
176
176
|
if host_cookies:
|
|
@@ -189,20 +189,17 @@ async def _fetch(
|
|
|
189
189
|
if set_cookies := resp.headers.getall("Set-Cookie", []):
|
|
190
190
|
_apply_set_cookies(set_cookies, cookies_by_host.setdefault(host, {}))
|
|
191
191
|
|
|
192
|
-
is_redirect = resp.status in REDIRECT_STATUSES
|
|
193
192
|
location = resp.headers.get("Location")
|
|
194
|
-
text = "" if is_redirect else await resp.text()
|
|
195
193
|
result = _FetchResult(
|
|
196
194
|
status=resp.status,
|
|
197
195
|
url=str(resp.url),
|
|
198
|
-
text=text,
|
|
196
|
+
text="" if resp.status in REDIRECT_STATUSES else await resp.text(),
|
|
199
197
|
location=location,
|
|
200
|
-
is_redirect=is_redirect,
|
|
201
198
|
)
|
|
202
199
|
except (aiohttp.ClientError, TimeoutError) as err:
|
|
203
200
|
raise EdookitConnectionError(f"request to {host} failed: {err}") from err
|
|
204
201
|
|
|
205
|
-
if not (follow_redirects and is_redirect and location):
|
|
202
|
+
if not (follow_redirects and result.is_redirect and location):
|
|
206
203
|
return result
|
|
207
204
|
|
|
208
205
|
current_url = str(URL(current_url).join(URL(location)))
|
|
@@ -221,7 +218,7 @@ def _new_http_session() -> aiohttp.ClientSession:
|
|
|
221
218
|
|
|
222
219
|
|
|
223
220
|
@asynccontextmanager
|
|
224
|
-
async def
|
|
221
|
+
async def http_session(http: aiohttp.ClientSession | None) -> AsyncIterator[aiohttp.ClientSession]:
|
|
225
222
|
if http is not None:
|
|
226
223
|
yield http
|
|
227
224
|
return
|
|
@@ -264,7 +261,7 @@ async def _finish_callback(
|
|
|
264
261
|
result = await _fetch(http, "GET", callback_url, cookies_by_host, follow_redirects=True)
|
|
265
262
|
if is_login_url(result.url):
|
|
266
263
|
raise AuthError("edookit rejected the Plus4U authorization code")
|
|
267
|
-
settle = await _fetch(http, "GET",
|
|
264
|
+
settle = await _fetch(http, "GET", school_base(school), cookies_by_host, follow_redirects=True)
|
|
268
265
|
if is_login_url(settle.url):
|
|
269
266
|
raise AuthError("session did not settle after the OIDC callback")
|
|
270
267
|
return _session_from_cookies(school, cookies_by_host)
|
|
@@ -276,12 +273,26 @@ async def probe_client_id(school: str) -> str:
|
|
|
276
273
|
Used by scripts/capture_fixtures.py to show what login()/refresh() will
|
|
277
274
|
use for this school before spending the user's password.
|
|
278
275
|
"""
|
|
279
|
-
|
|
276
|
+
validate_school(school)
|
|
280
277
|
cookies_by_host: CookiesByHost = {}
|
|
281
278
|
async with _new_http_session() as http:
|
|
282
279
|
return await _discover_client_id(http, school, cookies_by_host)
|
|
283
280
|
|
|
284
281
|
|
|
282
|
+
async def _authorize(
|
|
283
|
+
http: aiohttp.ClientSession,
|
|
284
|
+
school: str,
|
|
285
|
+
cookies_by_host: CookiesByHost,
|
|
286
|
+
*,
|
|
287
|
+
prompt_none: bool = False,
|
|
288
|
+
) -> tuple[str, str, _FetchResult]:
|
|
289
|
+
"""Start the OIDC authorization: shared opening of login() and refresh()."""
|
|
290
|
+
client_id = await _discover_client_id(http, school, cookies_by_host)
|
|
291
|
+
state = secrets.token_urlsafe(16)
|
|
292
|
+
params = _auth_params(school, client_id, state, prompt_none=prompt_none)
|
|
293
|
+
return client_id, state, await _fetch(http, "GET", _AUTH_URL, cookies_by_host, params=params)
|
|
294
|
+
|
|
295
|
+
|
|
285
296
|
async def login(
|
|
286
297
|
school: str,
|
|
287
298
|
username: str,
|
|
@@ -299,18 +310,10 @@ async def login(
|
|
|
299
310
|
`aiohttp.DummyCookieJar()`): cookies are tracked per host by hand here,
|
|
300
311
|
and a real jar would also collect and resend them.
|
|
301
312
|
"""
|
|
302
|
-
|
|
313
|
+
validate_school(school)
|
|
303
314
|
cookies_by_host: CookiesByHost = {}
|
|
304
|
-
async with
|
|
305
|
-
client_id = await
|
|
306
|
-
state = secrets.token_urlsafe(16)
|
|
307
|
-
step1 = await _fetch(
|
|
308
|
-
http,
|
|
309
|
-
"GET",
|
|
310
|
-
_AUTH_URL,
|
|
311
|
-
cookies_by_host,
|
|
312
|
-
params=_auth_params(school, client_id, state),
|
|
313
|
-
)
|
|
315
|
+
async with http_session(http) as http:
|
|
316
|
+
client_id, state, step1 = await _authorize(http, school, cookies_by_host)
|
|
314
317
|
if not step1.is_redirect or not step1.location:
|
|
315
318
|
raise AuthError(f"expected a redirect from Plus4U auth endpoint, got {step1.status}")
|
|
316
319
|
login_url = URL(step1.location)
|
|
@@ -355,21 +358,12 @@ async def refresh(session: Session, *, http: aiohttp.ClientSession | None = None
|
|
|
355
358
|
every 19 days), which means a full `login()` with the password is needed.
|
|
356
359
|
`http` has the same cookie-jar requirement as in `login()`.
|
|
357
360
|
"""
|
|
358
|
-
_validate_school(session.school)
|
|
359
361
|
# Only the Plus4U cookies: the portal answers /user/login with a redirect
|
|
360
362
|
# to /oidc/logout when it sees the expired edookit session, so the new
|
|
361
363
|
# edookit session is built from scratch, exactly like in login().
|
|
362
364
|
cookies_by_host: CookiesByHost = {_PLUS4U_HOST: dict(session.plus4u_cookies)}
|
|
363
|
-
async with
|
|
364
|
-
|
|
365
|
-
state = secrets.token_urlsafe(16)
|
|
366
|
-
step1 = await _fetch(
|
|
367
|
-
http,
|
|
368
|
-
"GET",
|
|
369
|
-
_AUTH_URL,
|
|
370
|
-
cookies_by_host,
|
|
371
|
-
params=_auth_params(session.school, client_id, state, prompt_none=True),
|
|
372
|
-
)
|
|
365
|
+
async with http_session(http) as http:
|
|
366
|
+
_, _, step1 = await _authorize(http, session.school, cookies_by_host, prompt_none=True)
|
|
373
367
|
if not step1.is_redirect or not step1.location or "code=" not in step1.location:
|
|
374
368
|
raise AuthError("Plus4U session expired; full login required")
|
|
375
369
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
3
6
|
import aiohttp
|
|
4
7
|
|
|
5
8
|
from . import auth
|
|
@@ -35,6 +38,9 @@ class EdookitClient:
|
|
|
35
38
|
and closed per request. Cookies are sent by hand, so give it a
|
|
36
39
|
`aiohttp.DummyCookieJar()` rather than a shared session whose jar would
|
|
37
40
|
also collect the portal's cookies.
|
|
41
|
+
|
|
42
|
+
Pages are parsed in a worker thread so a large timetable doesn't stall
|
|
43
|
+
the caller's event loop.
|
|
38
44
|
"""
|
|
39
45
|
|
|
40
46
|
def __init__(self, session: Session, http: aiohttp.ClientSession | None = None) -> None:
|
|
@@ -46,12 +52,11 @@ class EdookitClient:
|
|
|
46
52
|
return self._session
|
|
47
53
|
|
|
48
54
|
async def get_terms(self) -> list[Term]:
|
|
49
|
-
|
|
50
|
-
return parse_terms(html)
|
|
55
|
+
return await asyncio.to_thread(parse_terms, await self.fetch_timetable_html())
|
|
51
56
|
|
|
52
57
|
async def get_timetable(self, week_offset: int = 0) -> list[Lesson]:
|
|
53
58
|
html = await self.fetch_timetable_html(week_offset)
|
|
54
|
-
return parse_timetable
|
|
59
|
+
return await asyncio.to_thread(parse_timetable, html)
|
|
55
60
|
|
|
56
61
|
async def fetch_timetable_html(self, week_offset: int = 0) -> str:
|
|
57
62
|
"""Raw `/timetable/` HTML for one week - also used by
|
|
@@ -59,10 +64,10 @@ class EdookitClient:
|
|
|
59
64
|
return await self._get(_TIMETABLE_PATH, params={"familyTimetable-value": week_offset})
|
|
60
65
|
|
|
61
66
|
async def get_events(self) -> list[SchoolEvent]:
|
|
62
|
-
return parse_events
|
|
67
|
+
return await asyncio.to_thread(parse_events, await self._get(_EVENTS_PATH))
|
|
63
68
|
|
|
64
69
|
async def get_payments(self) -> Payments:
|
|
65
|
-
return parse_payments
|
|
70
|
+
return await asyncio.to_thread(parse_payments, await self._get(_PAYMENTS_PATH))
|
|
66
71
|
|
|
67
72
|
async def fetch_page(self, name: str) -> str:
|
|
68
73
|
"""Raw HTML of one of `CAPTURE_PAGES`, for pages without a parser yet."""
|
|
@@ -74,34 +79,44 @@ class EdookitClient:
|
|
|
74
79
|
|
|
75
80
|
async def _get(self, path: str, params: dict[str, int] | None = None) -> str:
|
|
76
81
|
url = f"{self._session.base_url}{path}"
|
|
77
|
-
headers = auth.build_headers(self._session)
|
|
78
|
-
if self._http is not None:
|
|
79
|
-
return await self._request(self._http, url, params, headers)
|
|
80
|
-
async with aiohttp.ClientSession() as http:
|
|
81
|
-
return await self._request(http, url, params, headers)
|
|
82
|
-
|
|
83
|
-
async def _request(
|
|
84
|
-
self,
|
|
85
|
-
http: aiohttp.ClientSession,
|
|
86
|
-
url: str,
|
|
87
|
-
params: dict[str, int] | None,
|
|
88
|
-
headers: dict[str, str],
|
|
89
|
-
) -> str:
|
|
90
82
|
# allow_redirects=False: our Cookie/CSRF headers are set manually, and
|
|
91
83
|
# older aiohttp forwards a manually-set Cookie header to a redirect's
|
|
92
84
|
# target even when that target is a different host - so an expired
|
|
93
85
|
# session's redirect to Plus4U must never be followed with them
|
|
94
86
|
# attached. Any redirect at all means the session isn't good anymore.
|
|
95
87
|
try:
|
|
96
|
-
async with
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
88
|
+
async with (
|
|
89
|
+
auth.http_session(self._http) as http,
|
|
90
|
+
http.get(
|
|
91
|
+
url,
|
|
92
|
+
params=params,
|
|
93
|
+
headers=auth.build_headers(self._session),
|
|
94
|
+
allow_redirects=False,
|
|
95
|
+
timeout=auth.REQUEST_TIMEOUT,
|
|
96
|
+
) as resp,
|
|
97
|
+
):
|
|
98
|
+
if resp.status in auth.REDIRECT_STATUSES:
|
|
104
99
|
raise SessionExpired("edookit session has expired")
|
|
105
|
-
|
|
100
|
+
body = await resp.text()
|
|
106
101
|
except (aiohttp.ClientError, TimeoutError) as err:
|
|
107
102
|
raise EdookitConnectionError(f"request to {url} failed: {err}") from err
|
|
103
|
+
if _redirects_to_login(resp.headers.get("Content-Type", ""), body):
|
|
104
|
+
raise SessionExpired("edookit session has expired")
|
|
105
|
+
return body
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _redirects_to_login(content_type: str, body: str) -> bool:
|
|
109
|
+
"""Whether the portal answered with its AJAX-style login redirect.
|
|
110
|
+
|
|
111
|
+
Requests carry X-Requested-With, so an expired session gets a 200 with
|
|
112
|
+
{"redirect": "https://<school>.edookit.net/user/login?message=1"} rather
|
|
113
|
+
than an HTTP redirect (confirmed against a real portal).
|
|
114
|
+
"""
|
|
115
|
+
if not content_type.startswith("application/json"):
|
|
116
|
+
return False
|
|
117
|
+
try:
|
|
118
|
+
data = json.loads(body)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return False
|
|
121
|
+
redirect = data.get("redirect") if isinstance(data, dict) else None
|
|
122
|
+
return isinstance(redirect, str) and auth.is_login_url(redirect)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from datetime import date, timedelta
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from ..models import PORTAL_TZ
|
|
5
7
|
|
|
6
8
|
# The portal writes the year only when it isn't the current one, and uses a
|
|
7
9
|
# word instead of a date for recent days ("Dnes , 8:00 – 8:45"; Včera/Dnes
|
|
@@ -15,6 +17,10 @@ _DAY_RE = re.compile(
|
|
|
15
17
|
TIME_RE = re.compile(r"(\d{1,2}):(\d{2})")
|
|
16
18
|
|
|
17
19
|
|
|
20
|
+
def portal_today() -> date:
|
|
21
|
+
return datetime.now(PORTAL_TZ).date()
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
def find_days(text: str, today: date) -> list[date]:
|
|
19
25
|
days: list[date] = []
|
|
20
26
|
for match in _DAY_RE.finditer(text):
|
|
@@ -8,7 +8,7 @@ from bs4 import BeautifulSoup, Tag
|
|
|
8
8
|
|
|
9
9
|
from ..exceptions import ParseError
|
|
10
10
|
from ..models import PORTAL_TZ, SchoolEvent
|
|
11
|
-
from .dates import TIME_RE, find_days
|
|
11
|
+
from .dates import TIME_RE, find_days, portal_today
|
|
12
12
|
|
|
13
13
|
_EVENT_ID_RE = re.compile(r"event=(\d+)")
|
|
14
14
|
|
|
@@ -27,7 +27,7 @@ def parse_events(html: str, today: date | None = None) -> list[SchoolEvent]:
|
|
|
27
27
|
if soup.select_one("section.content") is None:
|
|
28
28
|
raise ParseError("upcoming events page has no content section")
|
|
29
29
|
if today is None:
|
|
30
|
-
today =
|
|
30
|
+
today = portal_today()
|
|
31
31
|
|
|
32
32
|
events: list[SchoolEvent] = []
|
|
33
33
|
for row in soup.select("div.events_table ul.table_row"):
|
|
@@ -71,7 +71,7 @@ def _parse_row(row: Tag, today: date) -> SchoolEvent | None:
|
|
|
71
71
|
start, end = days[0], days[-1]
|
|
72
72
|
|
|
73
73
|
description = " ".join(
|
|
74
|
-
|
|
74
|
+
text for p in row.select("li.c2 > p") if (text := p.get_text(" ", strip=True))
|
|
75
75
|
)
|
|
76
76
|
creator = row.select_one("li.c3 div.smaller p b")
|
|
77
77
|
return SchoolEvent(
|
|
@@ -8,7 +8,7 @@ from bs4 import BeautifulSoup, Tag
|
|
|
8
8
|
|
|
9
9
|
from ..exceptions import ParseError
|
|
10
10
|
from ..models import PORTAL_TZ, Lesson, Term
|
|
11
|
-
from .dates import find_days
|
|
11
|
+
from .dates import find_days, portal_today
|
|
12
12
|
|
|
13
13
|
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})")
|
|
14
14
|
_TERM_ID_RE = re.compile(r"termSelector-new=(\d+)")
|
|
@@ -44,7 +44,7 @@ def parse_timetable(html: str, today: date | None = None) -> list[Lesson]:
|
|
|
44
44
|
raise ParseError("no lesson-info blocks found on timetable page")
|
|
45
45
|
|
|
46
46
|
if today is None:
|
|
47
|
-
today =
|
|
47
|
+
today = portal_today()
|
|
48
48
|
lessons = []
|
|
49
49
|
for block in blocks:
|
|
50
50
|
lesson = _parse_lesson_info(block, today)
|