pylanis 0.2.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.
lanis_mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MCP server for Schulportal Hessen (Lanis)."""
lanis_mcp/client.py ADDED
@@ -0,0 +1,239 @@
1
+ """Lanis client wrapper with lazy initialization and session management."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from difflib import SequenceMatcher
6
+ from typing import Optional
7
+
8
+ import httpx
9
+ from lanisapi import LanisClient, LanisAccount, LanisCookie, School
10
+ from lanisapi.helpers import authentication as _auth
11
+ from lanisapi.helpers.request import Request as _Request
12
+ from lanisapi.constants import LOGGER as _LOGGER
13
+
14
+
15
+ def _fixed_get_authentication_sid(
16
+ url: str,
17
+ cookies: httpx.Cookies,
18
+ schoolid: str,
19
+ ) -> httpx.Cookies:
20
+ """Fixed version of lanisapi's get_authentication_sid.
21
+
22
+ The original crashes with an IndexError because it parses the set-cookie
23
+ header with hardcoded string splits that break when httpx returns the
24
+ cookies in a different format. This version uses httpx's built-in cookie
25
+ handling and falls back to robust header parsing.
26
+ """
27
+ _Request.set_cookies(cookies)
28
+ response = _Request.head(url) # type: ignore[arg-type]
29
+
30
+ result = httpx.Cookies()
31
+ result.set("i", schoolid)
32
+
33
+ sid_value: Optional[str] = response.cookies.get("sid")
34
+ if not sid_value:
35
+ for raw in response.headers.get_list("set-cookie"):
36
+ for field in raw.split(";"):
37
+ field = field.strip()
38
+ if field.lower().startswith("sid="):
39
+ sid_value = field.split("=", 1)[1]
40
+ break
41
+
42
+ if not sid_value:
43
+ raise ValueError("Authentication failed: 'sid' cookie not found in response.")
44
+
45
+ result.set("sid", sid_value)
46
+ _LOGGER.info("Authentication - Get sid: Success.")
47
+ return result
48
+
49
+
50
+ # Patch into lanisapi.client's namespace (where _create_new_session resolves it)
51
+ import lanisapi.client as _lanisapi_client # noqa: E402
52
+ import lanisapi.functions.apps as _lanisapi_apps # noqa: E402
53
+
54
+ _auth.get_authentication_sid = _fixed_get_authentication_sid
55
+ _lanisapi_client.get_authentication_sid = _fixed_get_authentication_sid
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Monkey-patch: _get_available_apps — add URL-based app detection
60
+ # ---------------------------------------------------------------------------
61
+
62
+ # Maps each supported app name to the PHP filename in its link URL.
63
+ # Schools may rename apps (e.g. "Meine Kurse" instead of "Mein Unterricht"),
64
+ # so we fall back to URL matching when name similarity is too low.
65
+ _APP_URL_MAP: dict[str, str] = {
66
+ "Kalender": "kalender.php",
67
+ "Mein Unterricht": "meinunterricht.php",
68
+ "Nachrichten - Beta-Version": "nachrichten.php",
69
+ "Vertretungsplan": "vertretungsplan.php",
70
+ }
71
+
72
+
73
+ def _fixed_get_available_apps() -> list[str]:
74
+ """Fixed version of lanisapi's _get_available_apps.
75
+
76
+ The original uses SequenceMatcher with a 0.8 threshold which fails when
77
+ schools rename apps (e.g. 'Meine Kurse' instead of 'Mein Unterricht').
78
+ This version adds URL-based detection as a fallback so that any app whose
79
+ link points to the expected PHP page is recognised regardless of its name.
80
+ """
81
+ gotten_apps = _lanisapi_apps._get_apps()
82
+ available_apps: list[str] = []
83
+
84
+ for app in gotten_apps:
85
+ for implemented, url_path in _APP_URL_MAP.items():
86
+ if implemented in available_apps:
87
+ continue
88
+ # Check by name similarity (original logic)
89
+ name_match = (
90
+ SequenceMatcher(None, app.name.lower(), implemented.lower()).ratio()
91
+ > 0.8
92
+ )
93
+ # Check by URL path (fallback for renamed apps)
94
+ url_match = url_path in (app.link or "")
95
+ if name_match or url_match:
96
+ available_apps.append(implemented)
97
+
98
+ _LOGGER.info("Get apps availability (patched): Success.")
99
+ return available_apps
100
+
101
+
102
+ _lanisapi_apps._get_available_apps = _fixed_get_available_apps
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Monkey-patch: _get_substitutions — handle a plan without data
107
+ # ---------------------------------------------------------------------------
108
+
109
+ import lanisapi.functions.substitution as _lanisapi_substitution # noqa: E402
110
+ from lanisapi.exceptions import AppNotAvailableError # noqa: E402
111
+
112
+
113
+ def _fixed_get_substitutions() -> _lanisapi_substitution.SubstitutionPlan:
114
+ """Fixed version of lanisapi's _get_substitutions.
115
+
116
+ Schools can show the Vertretungsplan app on the dashboard without using it.
117
+ Lanis then answers with a number instead of a list of substitutions, which
118
+ the original iterates over and fails with a TypeError. This version raises
119
+ AppNotAvailableError instead.
120
+ """
121
+ info = _lanisapi_substitution._get_substitution_info()
122
+ response = _Request.post(
123
+ _lanisapi_substitution.URL.substitution_plan,
124
+ data={"ganzerPlan": "true", "tag": info["date"]},
125
+ )
126
+ raw = response.json()
127
+ if not isinstance(raw, list):
128
+ raise AppNotAvailableError(
129
+ f"The Vertretungsplan returned no substitution data (response: {raw!r}). "
130
+ "Your school probably does not use the Vertretungsplan app."
131
+ )
132
+
133
+ plan = _lanisapi_substitution.SubstitutionPlan(
134
+ datetime.strptime(info["date"], "%d.%m.%Y").date(), info["notice"], []
135
+ )
136
+ for entry in raw:
137
+ plan.substitutions.append(
138
+ _lanisapi_substitution.SubstitutionPlan.Substitution(
139
+ substitute=entry["Vertreter"],
140
+ teacher=entry["Lehrer"],
141
+ hours=entry["Stunde"],
142
+ class_name=entry["Klasse"],
143
+ subject=entry["Fach"],
144
+ room=entry["Raum"],
145
+ notice=entry["Hinweis"] if entry["Hinweis"] else None,
146
+ )
147
+ )
148
+
149
+ _LOGGER.info("Get substitution plan (patched): Success.")
150
+ return plan
151
+
152
+
153
+ # LanisClient.get_substitution_plan resolves the function in lanisapi.client.
154
+ _lanisapi_substitution._get_substitutions = _fixed_get_substitutions
155
+ _lanisapi_client._get_substitutions = _fixed_get_substitutions
156
+
157
+
158
+ _client: Optional[LanisClient] = None
159
+
160
+
161
+ def _get_credentials() -> tuple[
162
+ Optional[str],
163
+ Optional[str],
164
+ Optional[str],
165
+ Optional[str],
166
+ Optional[str],
167
+ Optional[str],
168
+ ]:
169
+ """Read credentials from environment variables.
170
+
171
+ Supports two authentication modes:
172
+ 1. Username/password: LANIS_SCHOOL_ID (or LANIS_SCHOOL_NAME + LANIS_SCHOOL_CITY),
173
+ LANIS_USERNAME, LANIS_PASSWORD
174
+ 2. Session cookie: LANIS_SCHOOL_ID + LANIS_SESSION_ID
175
+ """
176
+ school_id = os.environ.get("LANIS_SCHOOL_ID")
177
+ school_name = os.environ.get("LANIS_SCHOOL_NAME")
178
+ school_city = os.environ.get("LANIS_SCHOOL_CITY")
179
+ username = os.environ.get("LANIS_USERNAME")
180
+ password = os.environ.get("LANIS_PASSWORD")
181
+ session_id = os.environ.get("LANIS_SESSION_ID")
182
+ return school_id, school_name, school_city, username, password, session_id
183
+
184
+
185
+ def get_client() -> LanisClient:
186
+ """Return an authenticated LanisClient, creating and authenticating one if needed."""
187
+ global _client
188
+
189
+ if _client is not None:
190
+ return _client
191
+
192
+ school_id, school_name, school_city, username, password, session_id = (
193
+ _get_credentials()
194
+ )
195
+
196
+ if school_id and session_id:
197
+ # Cookie-based auth (fastest, no password needed)
198
+ auth = LanisCookie(school_id, session_id)
199
+ elif username and password:
200
+ # Username/password auth
201
+ if school_id:
202
+ school = school_id
203
+ elif school_name and school_city:
204
+ school = School(school_name, school_city)
205
+ else:
206
+ raise ValueError(
207
+ "Authentication requires either LANIS_SCHOOL_ID or both "
208
+ "LANIS_SCHOOL_NAME and LANIS_SCHOOL_CITY"
209
+ )
210
+ auth = LanisAccount(school, username, password)
211
+ else:
212
+ raise ValueError(
213
+ "Authentication requires either:\n"
214
+ " - LANIS_SCHOOL_ID + LANIS_SESSION_ID (cookie auth)\n"
215
+ " - (LANIS_SCHOOL_ID or LANIS_SCHOOL_NAME + LANIS_SCHOOL_CITY) "
216
+ "+ LANIS_USERNAME + LANIS_PASSWORD"
217
+ )
218
+
219
+ # lanisapi writes html_logs.txt and session.json into the CWD.
220
+ # Switch to a writable directory before instantiating the client so this
221
+ # works in Docker containers with a read-only root filesystem.
222
+ data_dir = os.environ.get("LANIS_DATA_DIR", "/tmp")
223
+ os.makedirs(data_dir, exist_ok=True)
224
+ os.chdir(data_dir)
225
+
226
+ _client = LanisClient(auth)
227
+ _client.authenticate()
228
+ return _client
229
+
230
+
231
+ def reset_client() -> None:
232
+ """Reset the cached client (forces re-authentication on next call)."""
233
+ global _client
234
+ if _client is not None:
235
+ try:
236
+ _client.close()
237
+ except Exception:
238
+ pass
239
+ _client = None