pyedookit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyedookit/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ from .auth import login, refresh
2
+ from .client import CAPTURE_PAGES, EdookitClient
3
+ from .exceptions import (
4
+ AuthError,
5
+ EdookitConnectionError,
6
+ EdookitError,
7
+ ParseError,
8
+ SessionExpired,
9
+ )
10
+ from .models import PORTAL_TZ, Lesson, PaymentRequest, Payments, SchoolEvent, Student, Term
11
+ from .parsers.fingerprint import content_fingerprint
12
+ from .session import Session
13
+
14
+ __all__ = [
15
+ "CAPTURE_PAGES",
16
+ "PORTAL_TZ",
17
+ "AuthError",
18
+ "EdookitClient",
19
+ "EdookitConnectionError",
20
+ "EdookitError",
21
+ "Lesson",
22
+ "ParseError",
23
+ "PaymentRequest",
24
+ "Payments",
25
+ "SchoolEvent",
26
+ "Session",
27
+ "SessionExpired",
28
+ "Student",
29
+ "Term",
30
+ "content_fingerprint",
31
+ "login",
32
+ "refresh",
33
+ ]
pyedookit/auth.py ADDED
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ import re
5
+ import secrets
6
+ from collections.abc import AsyncIterator, Iterable, Mapping
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass
9
+
10
+ import aiohttp
11
+ from yarl import URL
12
+
13
+ from .exceptions import AuthError, EdookitConnectionError
14
+ from .session import Session
15
+
16
+ _PLUS4U_OIDC_BASE = (
17
+ "https://uuidentity.plus4u.net/uu-oidc-maing02/bb977a99f4cc4c37a2afce3fd599d0a7"
18
+ )
19
+ _AUTH_URL = f"{_PLUS4U_OIDC_BASE}/oidc/auth"
20
+ _AUTH_PASSWORD_URL = f"{_PLUS4U_OIDC_BASE}/authPassword/authenticate"
21
+ _REALM_CODE = "uuIdentityPasswordAuthNRealm"
22
+
23
+ # Confirmed against a real portal: the OIDC client_id is per-school, not a
24
+ # single value shared across every Edookit install (third-party docs this
25
+ # was originally inferred from implied otherwise). Each school's /user/login
26
+ # page embeds it in a small inline JS config block, e.g.
27
+ # uu_app_oidc_providers_oidcg02_client_id: "983960dcb7b84b34b9aecc5194381d56"
28
+ # so it has to be scraped fresh per school rather than hardcoded.
29
+ _CLIENT_ID_RE = re.compile(
30
+ r"uu_app_oidc_providers_oidcg02_client_id\s*:\s*['\"]([^'\"]+)['\"]"
31
+ )
32
+
33
+ _PLUS4U_HOST = "uuidentity.plus4u.net"
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}$")
40
+
41
+ REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
42
+
43
+ REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30)
44
+
45
+ # Edookit sends the browser back to one of these paths when a session has
46
+ # expired or was never valid; shared with client.py so "did we get logged
47
+ # out" is checked the same way everywhere.
48
+ LOGIN_MARKERS = ("/user/login", "/user/oidc")
49
+
50
+
51
+ def is_login_url(url: str) -> bool:
52
+ return any(marker in url for marker in LOGIN_MARKERS)
53
+
54
+
55
+ # Cookies are tracked per host explicitly rather than via aiohttp's
56
+ # CookieJar: the login flow hops between the plus4u.net and edookit.net
57
+ # hosts, and a plain per-host dict is simpler to reason about (and to test)
58
+ # than relying on the jar's own domain-matching for a flow that only ever
59
+ # touches two hosts.
60
+ CookiesByHost = dict[str, dict[str, str]]
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class _FetchResult:
65
+ status: int
66
+ url: str
67
+ text: str
68
+ location: str | None
69
+ is_redirect: bool
70
+
71
+
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")
75
+
76
+
77
+ def _school_host(school: str) -> str:
78
+ return f"{school}.edookit.net"
79
+
80
+
81
+ def _school_base(school: str) -> str:
82
+ return f"https://{_school_host(school)}"
83
+
84
+
85
+ def _callback_url(school: str) -> str:
86
+ return f"{_school_base(school)}/user/oidc-login-callback"
87
+
88
+
89
+ def _apply_set_cookies(headers: Iterable[str], jar: dict[str, str]) -> None:
90
+ """Record Set-Cookie headers into `jar` the way a browser would.
91
+
92
+ Parsed by hand instead of via aiohttp's `resp.cookies`: that uses
93
+ SimpleCookie, which silently drops a whole header when it meets an
94
+ attribute it doesn't know (e.g. `Partitioned`) or an unquoted space or
95
+ quote in the value - and that is how Plus4U's long-lived `uoid.ps`
96
+ cookie was lost, breaking the silent refresh. Values are kept verbatim
97
+ (quotes included) because that is what gets sent back.
98
+ """
99
+ for header in headers:
100
+ pair, _, attributes = header.partition(";")
101
+ name, sep, value = pair.partition("=")
102
+ name = name.strip()
103
+ if not sep or not name:
104
+ continue
105
+ expired = any(
106
+ attr.strip().lower().replace(" ", "") in ("max-age=0", "max-age=-1")
107
+ for attr in attributes.split(";")
108
+ )
109
+ if expired:
110
+ jar.pop(name, None)
111
+ else:
112
+ jar[name] = value.strip()
113
+
114
+
115
+ def _cookie_header(cookies: Mapping[str, str]) -> str:
116
+ return "; ".join(f"{k}={v}" for k, v in cookies.items())
117
+
118
+
119
+ def _auth_params(
120
+ school: str, client_id: str, state: str, *, prompt_none: bool = False
121
+ ) -> dict[str, str]:
122
+ params = {
123
+ "response_type": "code",
124
+ "client_id": client_id,
125
+ "redirect_uri": _callback_url(school),
126
+ "scope": "openid",
127
+ "state": state,
128
+ }
129
+ if prompt_none:
130
+ params["prompt"] = "none"
131
+ return params
132
+
133
+
134
+ async def _discover_client_id(
135
+ http: aiohttp.ClientSession, school: str, cookies_by_host: CookiesByHost
136
+ ) -> str:
137
+ """Scrape the school's own /user/login page for its Plus4U client_id.
138
+
139
+ Routed through `_fetch` (not a bare request) so its Set-Cookie response
140
+ - PHPSESSID etc. - joins the same cookie tracking the rest of the login
141
+ flow uses, rather than being discarded.
142
+ """
143
+ result = await _fetch(http, "GET", f"{_school_base(school)}/user/login", cookies_by_host)
144
+ match = _CLIENT_ID_RE.search(result.text)
145
+ if not match:
146
+ raise AuthError(f"could not find a Plus4U client_id on {school}'s login page")
147
+ return match.group(1)
148
+
149
+
150
+ async def _fetch(
151
+ http: aiohttp.ClientSession,
152
+ method: str,
153
+ url: str,
154
+ cookies_by_host: CookiesByHost,
155
+ *,
156
+ json: dict[str, object] | None = None,
157
+ params: Mapping[str, str] | None = None,
158
+ follow_redirects: bool = False,
159
+ ) -> _FetchResult:
160
+ """Make one request (optionally chasing redirects), updating cookies_by_host in place.
161
+
162
+ Each hop sends only the cookies stored for that hop's own host, and any
163
+ Set-Cookie in the response is recorded under that same host - this is
164
+ what lets the flow move from plus4u.net to <school>.edookit.net without
165
+ leaking cookies to the wrong domain.
166
+ """
167
+ current_url = url
168
+ current_method = method
169
+ current_json = json
170
+ current_params = params
171
+
172
+ for _ in range(_MAX_REDIRECTS + 1):
173
+ host = URL(current_url).host or ""
174
+ headers: dict[str, str] = {}
175
+ host_cookies = cookies_by_host.get(host)
176
+ if host_cookies:
177
+ headers["Cookie"] = _cookie_header(host_cookies)
178
+
179
+ try:
180
+ async with http.request(
181
+ current_method,
182
+ current_url,
183
+ params=current_params,
184
+ json=current_json,
185
+ headers=headers,
186
+ allow_redirects=False,
187
+ timeout=REQUEST_TIMEOUT,
188
+ ) as resp:
189
+ if set_cookies := resp.headers.getall("Set-Cookie", []):
190
+ _apply_set_cookies(set_cookies, cookies_by_host.setdefault(host, {}))
191
+
192
+ is_redirect = resp.status in REDIRECT_STATUSES
193
+ location = resp.headers.get("Location")
194
+ text = "" if is_redirect else await resp.text()
195
+ result = _FetchResult(
196
+ status=resp.status,
197
+ url=str(resp.url),
198
+ text=text,
199
+ location=location,
200
+ is_redirect=is_redirect,
201
+ )
202
+ except (aiohttp.ClientError, TimeoutError) as err:
203
+ raise EdookitConnectionError(f"request to {host} failed: {err}") from err
204
+
205
+ if not (follow_redirects and is_redirect and location):
206
+ return result
207
+
208
+ current_url = str(URL(current_url).join(URL(location)))
209
+ current_method = "GET"
210
+ current_json = None
211
+ current_params = None
212
+
213
+ raise AuthError("too many redirects while following the Plus4U/edookit login flow")
214
+
215
+
216
+ def _new_http_session() -> aiohttp.ClientSession:
217
+ # Cookies are tracked manually in `cookies_by_host`, not via aiohttp's own
218
+ # jar, so redirect-following never needs a real one; tests replace this
219
+ # factory with a fake session instead of mocking ClientSession internals.
220
+ return aiohttp.ClientSession(cookie_jar=aiohttp.DummyCookieJar())
221
+
222
+
223
+ @asynccontextmanager
224
+ async def _http_session(http: aiohttp.ClientSession | None) -> AsyncIterator[aiohttp.ClientSession]:
225
+ if http is not None:
226
+ yield http
227
+ return
228
+ async with _new_http_session() as own:
229
+ yield own
230
+
231
+
232
+ def _session_from_cookies(school: str, cookies_by_host: CookiesByHost) -> Session:
233
+ edookit_cookies = cookies_by_host.get(_school_host(school), {})
234
+ plus4u_cookies = cookies_by_host.get(_PLUS4U_HOST, {})
235
+ if not edookit_cookies.get("PHPSESSID"):
236
+ raise AuthError("login flow completed but no edookit session cookie was set")
237
+ return Session(
238
+ school=school,
239
+ edookit_cookies=edookit_cookies,
240
+ plus4u_cookies=plus4u_cookies,
241
+ csrf_token=edookit_cookies.get("uu.app.csrf", ""),
242
+ )
243
+
244
+
245
+ async def _finish_callback(
246
+ http: aiohttp.ClientSession,
247
+ callback_url: str,
248
+ cookies_by_host: CookiesByHost,
249
+ school: str,
250
+ ) -> Session:
251
+ """Follow the final OIDC callback redirect chain and build a Session from it.
252
+
253
+ Shared tail of login() and refresh(): both end with "GET the code=
254
+ callback, make sure we didn't land back on the login page, then read the
255
+ cookies that piled up along the way."
256
+
257
+ Confirmed against a real browser trace (HAR): after the callback GET
258
+ resolves (still 200 on /oidc/callback), the browser makes one more GET
259
+ to the portal root, which is what actually settles the session server
260
+ side - skipping it leaves the account looking like it has almost no
261
+ modules enabled (only the dashboard and badges), even though login
262
+ itself succeeded and the session cookies are valid.
263
+ """
264
+ result = await _fetch(http, "GET", callback_url, cookies_by_host, follow_redirects=True)
265
+ if is_login_url(result.url):
266
+ raise AuthError("edookit rejected the Plus4U authorization code")
267
+ settle = await _fetch(http, "GET", _school_base(school), cookies_by_host, follow_redirects=True)
268
+ if is_login_url(settle.url):
269
+ raise AuthError("session did not settle after the OIDC callback")
270
+ return _session_from_cookies(school, cookies_by_host)
271
+
272
+
273
+ async def probe_client_id(school: str) -> str:
274
+ """Discover a school's Plus4U client_id without logging in.
275
+
276
+ Used by scripts/capture_fixtures.py to show what login()/refresh() will
277
+ use for this school before spending the user's password.
278
+ """
279
+ _validate_school(school)
280
+ cookies_by_host: CookiesByHost = {}
281
+ async with _new_http_session() as http:
282
+ return await _discover_client_id(http, school, cookies_by_host)
283
+
284
+
285
+ async def login(
286
+ school: str,
287
+ username: str,
288
+ password: str,
289
+ *,
290
+ http: aiohttp.ClientSession | None = None,
291
+ ) -> Session:
292
+ """Run the Plus4U OIDC password login and return a usable Session.
293
+
294
+ Raises AuthError if the credentials are rejected or the flow's shape has
295
+ changed (e.g. Edookit or Plus4U altered a redirect), and
296
+ EdookitConnectionError if either host can't be reached.
297
+
298
+ A passed-in `http` session must not keep its own cookies (use
299
+ `aiohttp.DummyCookieJar()`): cookies are tracked per host by hand here,
300
+ and a real jar would also collect and resend them.
301
+ """
302
+ _validate_school(school)
303
+ 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
+ )
314
+ if not step1.is_redirect or not step1.location:
315
+ raise AuthError(f"expected a redirect from Plus4U auth endpoint, got {step1.status}")
316
+ login_url = URL(step1.location)
317
+ server_state = login_url.query.get("state", state)
318
+ server_client_id = login_url.query.get("clientId", client_id)
319
+
320
+ payload = {
321
+ "clientId": server_client_id,
322
+ "state": server_state,
323
+ "rememberMe": True,
324
+ "realmCode": _REALM_CODE,
325
+ "username": username,
326
+ "password": password,
327
+ }
328
+ step2 = await _fetch(http, "POST", _AUTH_PASSWORD_URL, cookies_by_host, json=payload)
329
+ if step2.status in (400, 401):
330
+ raise AuthError("Plus4U rejected the username or password")
331
+ continue_url = step2.location
332
+ if not continue_url and step2.text:
333
+ try:
334
+ body = _json.loads(step2.text)
335
+ except ValueError:
336
+ body = None
337
+ if isinstance(body, dict):
338
+ continue_url = body.get("redirectUrl")
339
+ if not continue_url:
340
+ raise AuthError("Plus4U password auth did not return a continuation URL")
341
+
342
+ step3 = await _fetch(http, "GET", continue_url, cookies_by_host)
343
+ if not step3.location or "code=" not in step3.location:
344
+ raise AuthError("Plus4U did not return an authorization code")
345
+
346
+ return await _finish_callback(http, step3.location, cookies_by_host, school)
347
+
348
+
349
+ async def refresh(session: Session, *, http: aiohttp.ClientSession | None = None) -> Session:
350
+ """Obtain a fresh edookit session using the stored Plus4U cookies.
351
+
352
+ No password is needed; this is what keeps a long-running Home Assistant
353
+ integration logged in across the ~30-60 minute edookit session lifetime.
354
+ Raises AuthError if the Plus4U cookies have themselves expired (roughly
355
+ every 19 days), which means a full `login()` with the password is needed.
356
+ `http` has the same cookie-jar requirement as in `login()`.
357
+ """
358
+ _validate_school(session.school)
359
+ # Only the Plus4U cookies: the portal answers /user/login with a redirect
360
+ # to /oidc/logout when it sees the expired edookit session, so the new
361
+ # edookit session is built from scratch, exactly like in login().
362
+ 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
+ )
373
+ if not step1.is_redirect or not step1.location or "code=" not in step1.location:
374
+ raise AuthError("Plus4U session expired; full login required")
375
+
376
+ return await _finish_callback(http, step1.location, cookies_by_host, session.school)
377
+
378
+
379
+ def build_headers(session: Session) -> dict[str, str]:
380
+ """Cookie/CSRF headers for a plain page request against the school's portal."""
381
+ return {
382
+ "Cookie": _cookie_header(session.edookit_cookies),
383
+ "X-CSRF-Token": session.csrf_token,
384
+ "X-Requested-With": "XMLHttpRequest",
385
+ }
pyedookit/client.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import aiohttp
4
+
5
+ from . import auth
6
+ from .exceptions import EdookitConnectionError, SessionExpired
7
+ from .models import Lesson, Payments, SchoolEvent, Term
8
+ from .parsers.events import parse_events
9
+ from .parsers.payments import parse_payments
10
+ from .parsers.timetable import parse_terms, parse_timetable
11
+ from .session import Session
12
+
13
+ _TIMETABLE_PATH = "/timetable/"
14
+ _EVENTS_PATH = "/timetable/upcoming"
15
+ _PAYMENTS_PATH = "/payments/"
16
+
17
+ CAPTURE_PAGES: dict[str, str] = {
18
+ "assignments": "/assignments/",
19
+ "exams": "/exams/",
20
+ "inbox": "/overview/updates",
21
+ "consents": "/consents/",
22
+ }
23
+
24
+
25
+ class EdookitClient:
26
+ """Fetches parsed data from one school's Edookit parent portal.
27
+
28
+ Holds a `Session` that the caller is responsible for persisting and
29
+ refreshing (see `pyedookit.auth.refresh`); this class only detects when
30
+ the current session has gone stale and raises `SessionExpired` so the
31
+ caller can refresh and retry.
32
+
33
+ Pass `http` to reuse an existing `aiohttp.ClientSession` across requests
34
+ instead of opening a new connection per call; otherwise one is created
35
+ and closed per request. Cookies are sent by hand, so give it a
36
+ `aiohttp.DummyCookieJar()` rather than a shared session whose jar would
37
+ also collect the portal's cookies.
38
+ """
39
+
40
+ def __init__(self, session: Session, http: aiohttp.ClientSession | None = None) -> None:
41
+ self._session = session
42
+ self._http = http
43
+
44
+ @property
45
+ def session(self) -> Session:
46
+ return self._session
47
+
48
+ async def get_terms(self) -> list[Term]:
49
+ html = await self.fetch_timetable_html()
50
+ return parse_terms(html)
51
+
52
+ async def get_timetable(self, week_offset: int = 0) -> list[Lesson]:
53
+ html = await self.fetch_timetable_html(week_offset)
54
+ return parse_timetable(html)
55
+
56
+ async def fetch_timetable_html(self, week_offset: int = 0) -> str:
57
+ """Raw `/timetable/` HTML for one week - also used by
58
+ scripts/capture_fixtures.py, so the request shape lives in one place."""
59
+ return await self._get(_TIMETABLE_PATH, params={"familyTimetable-value": week_offset})
60
+
61
+ async def get_events(self) -> list[SchoolEvent]:
62
+ return parse_events(await self._get(_EVENTS_PATH))
63
+
64
+ async def get_payments(self) -> Payments:
65
+ return parse_payments(await self._get(_PAYMENTS_PATH))
66
+
67
+ async def fetch_page(self, name: str) -> str:
68
+ """Raw HTML of one of `CAPTURE_PAGES`, for pages without a parser yet."""
69
+ try:
70
+ path = CAPTURE_PAGES[name]
71
+ except KeyError:
72
+ raise ValueError(f"{name!r} is not one of {sorted(CAPTURE_PAGES)}") from None
73
+ return await self._get(path)
74
+
75
+ async def _get(self, path: str, params: dict[str, int] | None = None) -> str:
76
+ 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
+ # allow_redirects=False: our Cookie/CSRF headers are set manually, and
91
+ # older aiohttp forwards a manually-set Cookie header to a redirect's
92
+ # target even when that target is a different host - so an expired
93
+ # session's redirect to Plus4U must never be followed with them
94
+ # attached. Any redirect at all means the session isn't good anymore.
95
+ 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)):
104
+ raise SessionExpired("edookit session has expired")
105
+ return await resp.text()
106
+ except (aiohttp.ClientError, TimeoutError) as err:
107
+ raise EdookitConnectionError(f"request to {url} failed: {err}") from err
@@ -0,0 +1,22 @@
1
+ class EdookitError(Exception):
2
+ """Base error for all pyedookit failures."""
3
+
4
+
5
+ class AuthError(EdookitError):
6
+ """Credentials were rejected, or the long-lived Plus4U session expired."""
7
+
8
+
9
+ class SessionExpired(EdookitError):
10
+ """The short-lived edookit session cookie expired; caller should refresh and retry."""
11
+
12
+
13
+ class ParseError(EdookitError):
14
+ """A portal page did not have the expected structure.
15
+
16
+ Edookit's markup isn't versioned, so this is the signal a parser needs updating
17
+ rather than a bug in the caller's usage.
18
+ """
19
+
20
+
21
+ class EdookitConnectionError(EdookitError):
22
+ """The portal or Plus4U could not be reached (network error or timeout)."""
pyedookit/models.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date, datetime
5
+ from decimal import Decimal
6
+ from zoneinfo import ZoneInfo
7
+
8
+ # The portal renders wall-clock times with no zone; every Edookit school is
9
+ # Czech, so parsed times are pinned to Prague rather than left naive.
10
+ PORTAL_TZ = ZoneInfo("Europe/Prague")
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Lesson:
15
+ start: datetime
16
+ end: datetime
17
+ subject: str
18
+ teacher: str | None = None
19
+ room: str | None = None
20
+ group: str | None = None
21
+ is_changed: bool = False
22
+ is_cancelled: bool = False
23
+ note: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Term:
28
+ id: str
29
+ name: str
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Student:
34
+ id: str
35
+ name: str
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class SchoolEvent:
40
+ id: str
41
+ title: str
42
+ start: datetime | date
43
+ end: datetime | date
44
+ description: str | None = None
45
+ created_by: str | None = None
46
+
47
+ @property
48
+ def all_day(self) -> bool:
49
+ return not isinstance(self.start, datetime)
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class PaymentRequest:
54
+ title: str
55
+ amount: Decimal
56
+ currency: str
57
+ is_paid: bool
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Payments:
62
+ paid: Decimal
63
+ remaining: Decimal
64
+ overpaid: Decimal
65
+ currency: str
66
+ requests: list[PaymentRequest]
File without changes
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date, timedelta
5
+
6
+ # The portal writes the year only when it isn't the current one, and uses a
7
+ # word instead of a date for recent days ("Dnes , 8:00 – 8:45"; Včera/Dnes
8
+ # confirmed, Zítra assumed).
9
+ _RELATIVE_DAYS = {"včera": -1, "dnes": 0, "zítra": 1}
10
+ _DAY_RE = re.compile(
11
+ r"(?P<relative>včera|dnes|zítra)\b"
12
+ r"|(?P<day>\d{1,2})\.\s*(?P<month>\d{1,2})\.(?:\s*(?P<year>\d{4}))?",
13
+ re.IGNORECASE,
14
+ )
15
+ TIME_RE = re.compile(r"(\d{1,2}):(\d{2})")
16
+
17
+
18
+ def find_days(text: str, today: date) -> list[date]:
19
+ days: list[date] = []
20
+ for match in _DAY_RE.finditer(text):
21
+ if relative := match.group("relative"):
22
+ days.append(today + timedelta(days=_RELATIVE_DAYS[relative.lower()]))
23
+ continue
24
+ year = int(match.group("year")) if match.group("year") else today.year
25
+ try:
26
+ days.append(date(year, int(match.group("month")), int(match.group("day"))))
27
+ except ValueError:
28
+ continue
29
+ return days
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from datetime import date, datetime, time
6
+
7
+ from bs4 import BeautifulSoup, Tag
8
+
9
+ from ..exceptions import ParseError
10
+ from ..models import PORTAL_TZ, SchoolEvent
11
+ from .dates import TIME_RE, find_days
12
+
13
+ _EVENT_ID_RE = re.compile(r"event=(\d+)")
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ def parse_events(html: str, today: date | None = None) -> list[SchoolEvent]:
19
+ """Parse `/timetable/upcoming` ("Mé budoucí události").
20
+
21
+ Confirmed against a real capture: one `ul.table_row` per event inside
22
+ `div.events_table`, after a `gridLabel` header row that shares the class.
23
+ Only a timed event on a single day, or an all-day event over one or more
24
+ days, has been seen; anything else is skipped with a warning.
25
+ """
26
+ soup = BeautifulSoup(html, "lxml")
27
+ if soup.select_one("section.content") is None:
28
+ raise ParseError("upcoming events page has no content section")
29
+ if today is None:
30
+ today = datetime.now(PORTAL_TZ).date()
31
+
32
+ events: list[SchoolEvent] = []
33
+ for row in soup.select("div.events_table ul.table_row"):
34
+ if "gridLabel" in (row.get("class") or []):
35
+ continue
36
+ event = _parse_row(row, today)
37
+ if event is None:
38
+ _LOGGER.warning("skipping unparseable event row %s", _event_id(row) or "?")
39
+ else:
40
+ events.append(event)
41
+ return events
42
+
43
+
44
+ def _event_id(row: Tag) -> str | None:
45
+ link = row.select_one("li.c2 a[href]")
46
+ match = _EVENT_ID_RE.search(str(link.get("href"))) if link else None
47
+ return match.group(1) if match else None
48
+
49
+
50
+ def _parse_row(row: Tag, today: date) -> SchoolEvent | None:
51
+ event_id = _event_id(row)
52
+ when = row.select_one("li.c1 b")
53
+ title = row.select_one("li.c2 div.bold")
54
+ if event_id is None or when is None or title is None:
55
+ return None
56
+
57
+ text = " ".join(when.get_text(" ", strip=True).split())
58
+ days = find_days(text, today)
59
+ times = [time(int(h), int(m)) for h, m in TIME_RE.findall(text)]
60
+ if not days:
61
+ return None
62
+
63
+ start: datetime | date
64
+ end: datetime | date
65
+ if times:
66
+ if len(days) != 1 or len(times) != 2:
67
+ return None
68
+ start = datetime.combine(days[0], times[0], tzinfo=PORTAL_TZ)
69
+ end = datetime.combine(days[0], times[1], tzinfo=PORTAL_TZ)
70
+ else:
71
+ start, end = days[0], days[-1]
72
+
73
+ description = " ".join(
74
+ p.get_text(" ", strip=True) for p in row.select("li.c2 > p") if p.get_text(strip=True)
75
+ )
76
+ creator = row.select_one("li.c3 div.smaller p b")
77
+ return SchoolEvent(
78
+ id=event_id,
79
+ title=title.get_text(" ", strip=True),
80
+ start=start,
81
+ end=end,
82
+ description=description or None,
83
+ created_by=creator.get_text(strip=True) if creator else None,
84
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+
6
+ from bs4 import BeautifulSoup
7
+
8
+ _NUMBER_RE = re.compile(r"\d+")
9
+
10
+
11
+ def content_fingerprint(html: str) -> str | None:
12
+ """Hash of a page's visible content, stable across counters and timestamps.
13
+
14
+ Used to notice when a page that has only ever been empty gets real items.
15
+ It can't rely on empty-state text disappearing, because some pages keep
16
+ their hidden "no data" block in the HTML even when items exist. Returns
17
+ None for error and maintenance pages, which lack the portal's content
18
+ section and must not count as a change.
19
+ """
20
+ root = BeautifulSoup(html, "lxml").select_one("section.content")
21
+ if root is None:
22
+ return None
23
+ for tag in root.select("script, style"):
24
+ tag.decompose()
25
+ text = " ".join(root.get_text(" ", strip=True).split())
26
+ return hashlib.sha256(_NUMBER_RE.sub("#", text).encode()).hexdigest()
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from decimal import Decimal
6
+
7
+ from bs4 import BeautifulSoup, Tag
8
+
9
+ from ..exceptions import ParseError
10
+ from ..models import PaymentRequest, Payments
11
+
12
+ _AMOUNT_RE = re.compile(r"(-?\d[\d\s ]*(?:,\d+)?)\s*(\S+)?")
13
+ _CURRENCIES = {"Kč": "CZK", "CZK": "CZK", "€": "EUR", "EUR": "EUR"}
14
+ _DEFAULT_CURRENCY = "CZK"
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ def parse_amount(text: str) -> tuple[Decimal, str | None]:
20
+ match = _AMOUNT_RE.search(text)
21
+ if match is None:
22
+ raise ParseError(f"no amount in {text!r}")
23
+ number = re.sub(r"[\s ]", "", match.group(1)).replace(",", ".")
24
+ return Decimal(number), _CURRENCIES.get(match.group(2) or "")
25
+
26
+
27
+ def parse_payments(html: str) -> Payments:
28
+ """Parse `/payments/` ("Platby") for the whole family.
29
+
30
+ Totals come from the `Everyone` spans because the portal bills parents
31
+ as well as the child. Only paid requests (`div.accepted`) have been seen
32
+ on a real portal, so every other request counts as unpaid and the
33
+ summary's "remaining" total stays the authoritative number.
34
+ """
35
+ soup = BeautifulSoup(html, "lxml")
36
+ summary = soup.select_one("table.payments-summary.desktop") or soup.select_one(
37
+ "table.payments-summary"
38
+ )
39
+ if summary is None:
40
+ raise ParseError("no payments summary table found")
41
+
42
+ paid, currency = _total(summary, "td.paid span.sumsEveryone")
43
+ remaining, _ = _total(summary, "td.remaining span.sumsEveryone")
44
+ overpaid, _ = _total(summary, "td.overpaymentEveryone span")
45
+
46
+ requests: list[PaymentRequest] = []
47
+ for item in soup.select("div.item.prescriptionEveryone"):
48
+ request = _parse_request(item, currency)
49
+ if request is None:
50
+ _LOGGER.warning("skipping unparseable payment request")
51
+ else:
52
+ requests.append(request)
53
+ return Payments(
54
+ paid=paid, remaining=remaining, overpaid=overpaid, currency=currency, requests=requests
55
+ )
56
+
57
+
58
+ def _total(summary: Tag, selector: str) -> tuple[Decimal, str]:
59
+ cell = summary.select_one(selector)
60
+ if cell is None:
61
+ raise ParseError(f"payments summary has no {selector}")
62
+ amount, currency = parse_amount(cell.get_text(" ", strip=True))
63
+ return amount, currency or _DEFAULT_CURRENCY
64
+
65
+
66
+ def _parse_request(item: Tag, default_currency: str) -> PaymentRequest | None:
67
+ title = item.select_one("div.heading div.text")
68
+ amount_cell = item.select_one("div.column.c4.desktop div.amount") or item.select_one(
69
+ "div.amount"
70
+ )
71
+ if title is None or amount_cell is None:
72
+ return None
73
+ try:
74
+ amount, currency = parse_amount(amount_cell.get_text(" ", strip=True))
75
+ except ParseError:
76
+ return None
77
+ return PaymentRequest(
78
+ title=title.get_text(" ", strip=True),
79
+ amount=amount,
80
+ currency=currency or default_currency,
81
+ is_paid=item.select_one("div.status-icon div.accepted") is not None,
82
+ )
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from datetime import date, datetime, time
6
+
7
+ from bs4 import BeautifulSoup, Tag
8
+
9
+ from ..exceptions import ParseError
10
+ from ..models import PORTAL_TZ, Lesson, Term
11
+ from .dates import find_days
12
+
13
+ _TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})")
14
+ _TERM_ID_RE = re.compile(r"termSelector-new=(\d+)")
15
+ _NOTE_PREFIX = "Učivo:"
16
+
17
+ _LOGGER = logging.getLogger(__name__)
18
+
19
+
20
+ def parse_timetable(html: str, today: date | None = None) -> list[Lesson]:
21
+ """Parse the hidden `.lesson-info` detail blocks on a rendered `/timetable/` page.
22
+
23
+ Confirmed against a real portal capture: the *visible* week grid is an
24
+ absolutely-positioned canvas of `<a class="... lesson ...">` tiles with
25
+ no semantic day/time markup at all (position is encoded as inline CSS
26
+ percentages). Each lesson also gets a hidden
27
+ `<div class="lesson-info" id="lesson-info-{id}">` block, though, with a
28
+ fixed field order and a full date+time already inside it - parsing those
29
+ is far more reliable than reading positions off the canvas, and doesn't
30
+ need a day-column or week_start argument at all since each block already
31
+ carries its own date.
32
+
33
+ Yesterday's and today's lessons carry "Včera"/"Dnes" instead of a date;
34
+ those resolve against `today`, which defaults to the current date in
35
+ `PORTAL_TZ`.
36
+
37
+ Cancelled/substituted lessons could not be confirmed: the captured week
38
+ had none, so `is_changed`/`is_cancelled` are always False for now. Fix
39
+ once a real capture has one - see the parser test file for the exact gap.
40
+ """
41
+ soup = BeautifulSoup(html, "lxml")
42
+ blocks = soup.select("div.lesson-info")
43
+ if not blocks:
44
+ raise ParseError("no lesson-info blocks found on timetable page")
45
+
46
+ if today is None:
47
+ today = datetime.now(PORTAL_TZ).date()
48
+ lessons = []
49
+ for block in blocks:
50
+ lesson = _parse_lesson_info(block, today)
51
+ if lesson is None:
52
+ _LOGGER.warning("skipping unparseable lesson block %s", block.get("id"))
53
+ else:
54
+ lessons.append(lesson)
55
+ return lessons
56
+
57
+
58
+ def parse_terms(html: str) -> list[Term]:
59
+ soup = BeautifulSoup(html, "lxml")
60
+ terms: list[Term] = []
61
+ seen: set[str] = set()
62
+ for link in soup.select('a[href*="termSelector-new"]'):
63
+ href = str(link.get("href") or "")
64
+ match = _TERM_ID_RE.search(href)
65
+ if not match:
66
+ continue
67
+ term_id = match.group(1)
68
+ if term_id in seen:
69
+ continue
70
+ seen.add(term_id)
71
+ terms.append(Term(id=term_id, name=link.get_text(strip=True)))
72
+ return terms
73
+
74
+
75
+ def _parse_lesson_info(block: Tag, today: date) -> Lesson | None:
76
+ fields = block.find_all("div", recursive=False)
77
+ datetime_div = next((f for f in fields if f.select_one(".date_time")), None)
78
+ if datetime_div is None:
79
+ return None
80
+
81
+ text = datetime_div.get_text(" ", strip=True)
82
+ days = find_days(text, today)
83
+ lesson_date = days[0] if days else None
84
+ time_match = _TIME_RANGE_RE.search(text)
85
+ if lesson_date is None or not time_match:
86
+ return None
87
+ sh, sm, eh, em = (int(g) for g in time_match.groups())
88
+
89
+ # Field order after the date/time div is fixed on every lesson observed:
90
+ # "{code} - {group}", full subject name, teacher(s), room, curriculum
91
+ # notes (last one is sometimes absent).
92
+ rest = [f for f in fields if f is not datetime_div]
93
+ code_group = rest[0].get_text(strip=True) if len(rest) > 0 else ""
94
+ subject = rest[1].get_text(strip=True) if len(rest) > 1 else code_group
95
+ teacher = rest[2].get_text(strip=True) if len(rest) > 2 else None
96
+ room = rest[3].get_text(strip=True) if len(rest) > 3 else None
97
+
98
+ note = None
99
+ if len(rest) > 4:
100
+ note_text = rest[4].get_text(" ", strip=True)
101
+ if note_text.startswith(_NOTE_PREFIX):
102
+ note = note_text[len(_NOTE_PREFIX) :].strip() or None
103
+
104
+ _, _, group = code_group.partition(" - ")
105
+
106
+ return Lesson(
107
+ start=datetime.combine(lesson_date, time(sh, sm), tzinfo=PORTAL_TZ),
108
+ end=datetime.combine(lesson_date, time(eh, em), tzinfo=PORTAL_TZ),
109
+ subject=subject or code_group,
110
+ teacher=teacher or None,
111
+ room=room or None,
112
+ group=group or None,
113
+ note=note,
114
+ )
pyedookit/session.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class Session:
10
+ """Everything needed to talk to a school's portal without the password.
11
+
12
+ `edookit_cookies` is short-lived (expires in roughly 30-60 minutes).
13
+ `plus4u_cookies` is long-lived (roughly 19 days) and is what `refresh()`
14
+ uses to obtain a fresh edookit session silently. The password itself is
15
+ never stored here.
16
+ """
17
+
18
+ school: str
19
+ edookit_cookies: dict[str, str]
20
+ plus4u_cookies: dict[str, str]
21
+ csrf_token: str
22
+ edookit_expires_at: datetime = field(
23
+ default_factory=lambda: datetime.now(UTC) + timedelta(minutes=30)
24
+ )
25
+
26
+ @property
27
+ def base_url(self) -> str:
28
+ return f"https://{self.school}.edookit.net"
29
+
30
+ @property
31
+ def is_edookit_expired(self) -> bool:
32
+ """True once the session is expected to have aged out.
33
+
34
+ A conservative estimate (login/refresh don't know the portal's real
35
+ TTL), meant for a caller to refresh proactively before it bothers
36
+ making a request that's likely to just be redirected to the login
37
+ page - not a substitute for handling `SessionExpired` reactively too.
38
+ """
39
+ return datetime.now(UTC) >= self.edookit_expires_at
40
+
41
+ def to_json(self) -> str:
42
+ data = asdict(self)
43
+ data["edookit_expires_at"] = self.edookit_expires_at.isoformat()
44
+ return json.dumps(data)
45
+
46
+ @classmethod
47
+ def from_json(cls, raw: str) -> Session:
48
+ data = json.loads(raw)
49
+ data["edookit_expires_at"] = datetime.fromisoformat(data["edookit_expires_at"])
50
+ return cls(**data)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyedookit
3
+ Version: 0.3.0
4
+ Summary: Async client for the Edookit parent portal (edookit.net)
5
+ Project-URL: Homepage, https://github.com/TFoxik/pyedookit
6
+ Author: tomas.liska
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: aiohttp>=3.9
11
+ Requires-Dist: beautifulsoup4>=4.12
12
+ Requires-Dist: lxml>=5.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # pyedookit
16
+
17
+ Unofficial async Python client for the Edookit parent portal
18
+ (`https://<school>.edookit.net`), used by Czech schools. Built for the
19
+ `ha-edookit` Home Assistant integration but usable standalone.
20
+
21
+ Edookit's official REST API exists but is only enabled per-school by an
22
+ administrator, so this library works the way a parent actually does: it logs
23
+ in through Plus4U (OIDC) and scrapes the portal's server-rendered pages.
24
+ There is no vendor support for this - selectors may break when Edookit
25
+ changes its markup.
26
+
27
+ ## Status
28
+
29
+ v0.3.0 - timetable, upcoming school events and payments. Verified end-to-end against a real portal
30
+ (login, session-settling after the OIDC callback, and the timetable parser
31
+ all confirmed working on real data).
32
+
33
+ **Only `week_offset=0` (the current week) is confirmed working.** The
34
+ portal accepts `week_offset=1` ("next week") without error, but returns the
35
+ same week's data back rather than actually advancing - the real
36
+ week-navigation parameter hasn't been identified yet. Since a school's
37
+ weekly timetable is typically the same all year, current-week-only is
38
+ usually enough in practice. See `scripts/capture_fixtures.py` for how to
39
+ capture and inspect real portal HTML if you want to chase this further (a
40
+ browser HAR trace of clicking the forward-week arrow is what's needed).
41
+
42
+ Cancelled/substituted lessons are also unverified: no real capture has had
43
+ one yet, so `Lesson.is_changed`/`is_cancelled` are always `False` for now.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install pyedookit
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ```python
54
+ import asyncio
55
+ from pyedookit import login, EdookitClient
56
+
57
+ async def main():
58
+ session = await login("zs-example", "parent@example.com", "hunter2")
59
+ client = EdookitClient(session)
60
+ for lesson in await client.get_timetable(week_offset=0):
61
+ print(lesson.start, lesson.subject, lesson.room)
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ `client.get_events()` returns upcoming school events (`SchoolEvent`, all-day
67
+ events have `date` start/end with an inclusive end), and `client.get_payments()`
68
+ returns the family's payment totals and requests (`Payments`, amounts as
69
+ `Decimal`). Only paid requests have been seen on a real portal so far; a request
70
+ without the "accepted" marker is reported as unpaid.
71
+
72
+ `client.fetch_page(name)` returns the raw HTML of a page that has no parser yet
73
+ (`CAPTURE_PAGES`: assignments, exams, inbox, consents), and
74
+ `content_fingerprint(html)` tells you when such a page's content changed.
75
+
76
+ Persist `session.to_json()` / `Session.from_json()` between runs instead of
77
+ storing the password; use `pyedookit.refresh(session)` to renew the
78
+ short-lived edookit session from the long-lived Plus4U cookie, and fall back
79
+ to `login()` only when `refresh()` raises `AuthError`.
80
+
81
+ Lesson times are timezone-aware (`pyedookit.PORTAL_TZ`, Europe/Prague).
82
+
83
+ Errors all derive from `EdookitError`:
84
+
85
+ | Exception | Meaning | What to do |
86
+ |---|---|---|
87
+ | `SessionExpired` | short-lived edookit session aged out | `refresh()` and retry |
88
+ | `AuthError` | bad credentials, or the Plus4U cookie expired | full `login()` |
89
+ | `EdookitConnectionError` | network error or 30 s timeout | retry later |
90
+ | `ParseError` | portal markup changed | parser needs updating |
91
+
92
+ `login()`, `refresh()` and `EdookitClient` all accept an optional
93
+ `http: aiohttp.ClientSession` to reuse. Cookies are handled by hand, so
94
+ create that session with `cookie_jar=aiohttp.DummyCookieJar()`; a session
95
+ with a real cookie jar would also store and resend the portal's cookies.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ uv sync --group dev
101
+ uv run pytest
102
+ uv run ruff check .
103
+ uv run mypy src
104
+ ```
105
+
106
+ ## Releasing
107
+
108
+ Bump `version` in `pyproject.toml`, then push a `vX.Y.Z` tag. The
109
+ `Publish to PyPI` workflow tests, builds and uploads using PyPI trusted
110
+ publishing, which has to be configured once on pypi.org for this repository
111
+ and the `pypi` environment.
@@ -0,0 +1,16 @@
1
+ pyedookit/__init__.py,sha256=OJz_k3hKcHqzUgD-XiqcPoVyaHWGn_oKivy8xyyxF0s,730
2
+ pyedookit/auth.py,sha256=3guqbDtrIVmF02sCVDx7rNcOBYOtpynZpNoVgElRBFw,14712
3
+ pyedookit/client.py,sha256=v6Y_5Pt1JRf1zNrGzctTop6wmUwjko91Y5wtDLTVo9Q,4278
4
+ pyedookit/exceptions.py,sha256=PgNJQ4-4OWDM-fUpzJPmvOmWsRUbUtZsWqDWV0iGO9Y,677
5
+ pyedookit/models.py,sha256=kr2xDLhAAhP8wvTPpdZoa8G3KOu0bmL6oQOeUzakEJI,1391
6
+ pyedookit/session.py,sha256=HzspoMGQnRNXge1XmGYoqgb3qPTSF2gwENVpBBS_EFg,1713
7
+ pyedookit/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pyedookit/parsers/dates.py,sha256=A0v4FJrUnLTqkkzZX8Xy0OAZmjKB3SBYD6biCx-QGyw,1043
9
+ pyedookit/parsers/events.py,sha256=Qx-L6vLm8D08whKB3xNRuPHjOL1hsTp4FsEqUiKZoQg,2816
10
+ pyedookit/parsers/fingerprint.py,sha256=YvMs7Eeeb6JiNuPeFagFovxehtA_rzG4esh6TejvJe8,927
11
+ pyedookit/parsers/payments.py,sha256=qW4DS-I4dXmmHPRz8wnbCeulWoRAtrO9BB_mtYAbTE0,2961
12
+ pyedookit/parsers/timetable.py,sha256=OxIS18OzHBLgIITL3R11FYqQD50KZ30Rzh5xEzFa5Qk,4324
13
+ pyedookit-0.3.0.dist-info/METADATA,sha256=fwlZpGqoXifd0d_hp4XnbP5Yh2dZ3Ahxjs-xA01i854,4221
14
+ pyedookit-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ pyedookit-0.3.0.dist-info/licenses/LICENSE,sha256=o2p-_Uzf9isg79dSi8fdeTa14Gl1OUe7j59D24Q-L_U,1068
16
+ pyedookit-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tomas Liska
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.