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 +1 -0
- lanis_mcp/client.py +239 -0
- lanis_mcp/server.py +1722 -0
- pylanis-0.2.0.dist-info/METADATA +343 -0
- pylanis-0.2.0.dist-info/RECORD +8 -0
- pylanis-0.2.0.dist-info/WHEEL +4 -0
- pylanis-0.2.0.dist-info/entry_points.txt +2 -0
- pylanis-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|