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.
Files changed (37) hide show
  1. {pyedookit-0.3.0 → pyedookit-0.3.2}/LICENSE +1 -1
  2. pyedookit-0.3.2/PKG-INFO +115 -0
  3. pyedookit-0.3.2/README.md +93 -0
  4. {pyedookit-0.3.0 → pyedookit-0.3.2}/pyproject.toml +22 -9
  5. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/__init__.py +1 -2
  6. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/auth.py +41 -47
  7. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/client.py +42 -27
  8. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/models.py +0 -6
  9. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/dates.py +7 -1
  10. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/events.py +3 -3
  11. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/timetable.py +2 -2
  12. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/session.py +24 -4
  13. pyedookit-0.3.2/tests/__init__.py +0 -0
  14. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fakes.py +2 -10
  15. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_client.py +14 -6
  16. pyedookit-0.3.2/tests/test_session.py +54 -0
  17. pyedookit-0.3.0/.github/workflows/ci.yml +0 -16
  18. pyedookit-0.3.0/.github/workflows/publish.yml +0 -34
  19. pyedookit-0.3.0/PKG-INFO +0 -111
  20. pyedookit-0.3.0/README.md +0 -97
  21. pyedookit-0.3.0/scripts/capture_fixtures.py +0 -75
  22. pyedookit-0.3.0/uv.lock +0 -1113
  23. {pyedookit-0.3.0 → pyedookit-0.3.2}/.gitignore +0 -0
  24. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/exceptions.py +0 -0
  25. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/__init__.py +0 -0
  26. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/fingerprint.py +0 -0
  27. {pyedookit-0.3.0 → pyedookit-0.3.2}/src/pyedookit/parsers/payments.py +0 -0
  28. /pyedookit-0.3.0/tests/__init__.py → /pyedookit-0.3.2/src/pyedookit/py.typed +0 -0
  29. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fixtures/payments_structure.html +0 -0
  30. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fixtures/timetable_real_structure.html +0 -0
  31. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/fixtures/upcoming_events_structure.html +0 -0
  32. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_auth.py +0 -0
  33. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_dates.py +0 -0
  34. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_events_parser.py +0 -0
  35. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_fingerprint.py +0 -0
  36. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_payments_parser.py +0 -0
  37. {pyedookit-0.3.0 → pyedookit-0.3.2}/tests/test_timetable_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Tomas Liska
3
+ Copyright (c) 2026 TFoxik
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pyedookit)](https://pypi.org/project/pyedookit/)
26
+ [![CI](https://github.com/TFoxik/pyedookit/actions/workflows/ci.yml/badge.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/pyedookit)](https://pypi.org/project/pyedookit/)
4
+ [![CI](https://github.com/TFoxik/pyedookit/actions/workflows/ci.yml/badge.svg)](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.0"
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 = "tomas.liska" }]
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.9",
23
+ "aiohttp>=3.14.3",
15
24
  "beautifulsoup4>=4.12",
16
- "lxml>=5.0",
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>=8.0",
43
- "pytest-asyncio>=0.24",
44
- "multidict>=6.0",
45
- "ruff>=0.6",
46
- "mypy>=1.11",
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, Student, Term
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
- # A school subdomain becomes part of a URL (redirect_uri, portal base) with
37
- # no other encoding, so reject anything that isn't a bare hostname label -
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
- def _validate_school(school: str) -> None:
73
- if not _SCHOOL_RE.match(school):
74
- raise AuthError(f"{school!r} is not a valid Edookit school subdomain")
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 _school_base(school: str) -> str:
82
- return f"https://{_school_host(school)}"
77
+ def _callback_url(school: str) -> str:
78
+ return f"{school_base(school)}/user/oidc-login-callback"
83
79
 
84
80
 
85
- def _callback_url(school: str) -> str:
86
- return f"{_school_base(school)}/user/oidc-login-callback"
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"{_school_base(school)}/user/login", cookies_by_host)
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
- host = URL(current_url).host or ""
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 _http_session(http: aiohttp.ClientSession | None) -> AsyncIterator[aiohttp.ClientSession]:
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", _school_base(school), cookies_by_host, follow_redirects=True)
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
- _validate_school(school)
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
- _validate_school(school)
313
+ validate_school(school)
303
314
  cookies_by_host: CookiesByHost = {}
304
- async with _http_session(http) as http:
305
- client_id = await _discover_client_id(http, school, cookies_by_host)
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 _http_session(http) as http:
364
- client_id = await _discover_client_id(http, session.school, cookies_by_host)
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
- html = await self.fetch_timetable_html()
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(html)
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(await self._get(_EVENTS_PATH))
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(await self._get(_PAYMENTS_PATH))
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 http.get(
97
- url,
98
- params=params,
99
- headers=headers,
100
- allow_redirects=False,
101
- timeout=auth.REQUEST_TIMEOUT,
102
- ) as resp:
103
- if resp.status in auth.REDIRECT_STATUSES or auth.is_login_url(str(resp.url)):
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
- return await resp.text()
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)
@@ -29,12 +29,6 @@ class Term:
29
29
  name: str
30
30
 
31
31
 
32
- @dataclass(frozen=True, slots=True)
33
- class Student:
34
- id: str
35
- name: str
36
-
37
-
38
32
  @dataclass(frozen=True, slots=True)
39
33
  class SchoolEvent:
40
34
  id: str
@@ -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 = datetime.now(PORTAL_TZ).date()
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
- p.get_text(" ", strip=True) for p in row.select("li.c2 > p") if p.get_text(strip=True)
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 = datetime.now(PORTAL_TZ).date()
47
+ today = portal_today()
48
48
  lessons = []
49
49
  for block in blocks:
50
50
  lesson = _parse_lesson_info(block, today)