v2.0.0
[2.0.0] – 2026-07-31
Ein Major-Release, obwohl fast alles darin eine Reparatur ist. Der Grund für
die 2: srgssr_audio_get_shows verlangt neu einen Pflichtparameter, und
srgssr_weather_current liefert Werte aus einer anderen API mit anderer
Bedeutung. Wer auf ^1.1 pinnt, bekommt keine kompatible Fortsetzung.
Was 1.1.0 tatsächlich war: 13 der 15 Tools riefen Routen auf, die es am
Gateway nicht gibt — die Basispfade /video/v3, /audio/v3,
/forecasts/v2.0/weather und /polis/v1 sind dort nicht registriert, und
video/audio verwendeten unter dem korrigierten v2-Basispfad weiterhin die
v3-Unterpfade. Vollständig funktioniert hat einzig srgssr_epg_get_programs;
das zusammenfassende srgssr_daily_briefing lieferte dank Graceful
Degradation immerhin seine EPG-Hälfte und für das Wetter einen Fehler.
Alle 15 Tools sind jetzt gegen die Live-API verifiziert (Stand 2026-07-31).
Grundlage sind die OpenAPI-Specs aus dem Developer-Portal statt Rateversuche.
Changed (BREAKING)
-
srgssr_audio_get_shows:channel_idist neu Pflicht. Die v2-API listet
Radiosendungen ausschliesslich pro Kanal; eine Liste pro Unternehmenseinheit
gibt es nicht. Gültige IDs liefertsrgssr_audio_get_livestreams, und der
Fehler-Hint sagt das auch. Das Tool hat dafür ein eigenes Eingabemodell
AudioShowsInputstatt wie bisherVideoShowsInputmitzubenutzen. -
srgssr_weather_currentmeldet keine Messung mehr, sondern eine Prognose.
SRF Meteo v2 bietet keinen Echtzeitwert an. «Aktuell» ist jetzt das erste
Stundenintervall der Prognose — der ehrlichste verfügbare Wert. Ein
gleichnamiges Tool mit anderer Semantik ist ein Bruch, auch wenn die Signatur
gleich bleibt. -
srgssr_video_get_shows: neues optionalescharacter_filter. Die
v2-API gruppiert Sendungen nach Anfangsbuchstabe und kennt keinen
«alles»-Aufruf. Mitcharacter_filter(a–zoder#) ist es eine
Abfrage; ohne fächert der Server über alle 27 Buckets auf und führt
zusammen. Der Fan-out ist der Default, weil die Alternative — still nur
einen Buchstaben liefern — eine Teilmenge als Gesamtkatalog ausgäbe. -
has_morefolgt jetzt demnext-Cursor der API. v2 paginiert über ein
opakes Token statt über Offsets und meldet keine Gesamtzahl.totalist
deshalb, was der Aufruf geliefert hat, undhas_morespiegelt, ob die API
eine Fortsetzung anbietet — statt auspage * page_sizegeschätzt zu werden.
Added
-
3xx-Guard in
_api_get. Ein Basispfad, den das Gateway nicht kennt,
wird mit302aufdeveloper.srgssr.chbeantwortet statt mit404.
raise_for_status()wirft darauf zwar, aber der Fehler fiel in den
generischen Zweig von_handle_errorund erzeugteAPI-Fehler 302:plus
den leeren Redirect-Body: ein Statuscode und sonst nichts — kein Endpunkt,
kein Hinweis auf die Ursache.Hinter dieser Meldung haben vier tote Basispfade (
/video/v3,/audio/v3,
/forecasts/v2.0/weather,/polis/v1) ein Release überlebt. Der Guard
nennt jetzt Pfad und Redirect-Ziel und stuft es als
«Konfigurationsfehler» ein — was ein nicht mehr existierender Basispfad
auch ist. Gilt für Daten- und Token-Requests gleichermassen.Gegenprobe: ohne den Guard liefert derselbe Test
API-Fehler 302:mit
leerem Body, mit ihm eine Meldung, dietv_shows/alphabeticalund das
Redirect-Ziel nennt. -
Sender-Register für das EPG, direkt von der API erhoben. Die gültigen
Sender-IDs standen bisher nirgends;srf1in allen Beispielen war schlicht
falsch, und für RTS und RSI hatte niemand belastbare Werte. Der Gateway
nennt sie selbst: eine unbekannte Station beantwortet er mit
400.01.004/005/006und zählt iminfo-Feld die zulässigen Werte auf.
Abgefragt am 2026-07-31 für alle sechs Kombinationen:Unternehmenseinheit TV Radio SRF srf-1,srf-2,srf-infosrf-1,srf-2,srf-2-kultur,srf-3,srf-4,srf-musikwelle,srf-virusRTS rts-1,rts-2,rts-infoLA1ERE,ESPACE2,COULEUR3,OPTION_MUSIQUERSI la-1,la-2rete-uno,rete-due,rete-treRTS-Radio fällt aus dem Schema — Grossbuchstaben mit Unterstrich. Ein
geratenesrts-1wäre dort ebenso falsch gewesen wie das alterts1.EPG_STATIONSintools/epg.pyhält die Werte und speist zwei Stellen: die
Tool-Description, damit das Modell gleich eine gültige ID wählt, und den
Fehler-Hint, damit eine falsche ID in einem Schritt korrigierbar ist. Der
bisherige Hint verwies aufsrgssr_video_get_livestreams— ein Umweg, denn
jene IDs stammen aus einer anderen API und müssen mit den EPG-IDs nicht
übereinstimmen.Bewusst keine Eingabe-Validierung. Eine harte lokale Prüfung würde einen
Request ablehnen, den die API beantworten würde, sobald SRG SSR einen Sender
ergänzt. Echte Daten zu verweigern ist der schlechtere Fehler. Das Register
informiert, es blockiert nicht.
Changed
-
Polis: Jahres- und Kantonsfilter sind jetzt echte Filter.
PolisListInput
botyear_from,year_toundcantonan — keiner dieser Parameter
existiert in der v2-API. Sie wurden mitgeschickt und ignoriert: Eine Frage
nach «Abstimmungen im Kanton Bern zwischen 2010 und 2020» lieferte alles und
sah dabei aus wie eine gefilterte Antwort. Das ist heimtückischer als ein
Fehler, und es ist ausgerechnet die Anker-Demo-Abfrage der README.Die API filtert über
locationidundcaseid. Beides wird jetzt aufgelöst:
Das Kantonskürzel geht über/locations?locationtypeid=2, der Jahresbereich
über/casesin die Abstimmungstage des Zeitraums, von denen dann nur so
viele abgefragt werden, wie die Seitengrösse verlangt. Ein unbekanntes
Kantonskürzel ist ein Fehler statt einer stillschweigend ungefilterten
Liste./cases?listAllCases=trueist laut Spec langsam und «nicht öfter als einmal
täglich» aufzurufen; die Kantons- und Fall-Listen werden deshalb sechs
Stunden prozessweit gecacht. -
Polis: Response-Parsing an die gemessenen Formen angepasst. Die Spec
lässt die200-Antworten leer (content: {}), die JSON-Schlüssel stammen
also aus echten Antworten (2026-07-31). Sie sind XML-abgeleitet und
uneinheitlich: Abstimmungen liegen unterItems, Fälle unterCase, Wahlen
eine Ebene tiefer unterElections.Election— ein Dict um das Array,
weshalb eine «nimm die erste Liste»-Heuristik danebengegriffen hätte. Datum
istEventDate, TitelTitle.Wahlen tragen weder Titel noch Datum: beides steht am
Case-Objekt daneben,
das jetzt mitgeführt wird. -
Ein Totalausfall im Fan-out ist ein Fehler, kein leerer Katalog.
Schlagen alle 27 Buckets fehl, kommt eineToolErrorResponsezurück. Sonst
hätten 27 verschluckte Fehler exakt wie 27 leere Buchstaben ausgesehen und
das Modell hätte «SRF hat keine Sendungen» berichtet. Teilausfälle liefern
weiter, was funktioniert hat, und protokollierenpartial_result. -
Live-Tests entrümpelt.
tests/test_live.pyprüfte Strings
(assert "TV-Sendungen" in result), obwohl die Tools seit SDK-002
Pydantic-Modelle zurückgeben — die Assertions konnten gegen einBaseModel
gar nicht fehlschlagen, und_is_errorriefstr.startswithauf einem
Modell auf. Dazu setzten drei Testsresponse_format, ein Feld, das es seit
SDK-002 nicht mehr gibt und dasextra="forbid"abgelehnt hätte. Das
Nightly, das die Pfadfehler hätte melden sollen, war damit blind. Jetzt
typisierte Assertions auf echte Felder.
Fixed
-
Video- und Audio-Tools auf die tatsächlichen v2-Routen umgestellt. Der
Basispfad war seit 1.1.0 richtig, die Pfade darunter nicht — sie stammten
noch aus v3 und lieferten404. Grundlage sind jetzt die OpenAPI-Specs aus
dem Developer-Portal (SRGSSR Video 2.0.4,SRGSSR Audio 2.0.5), nicht mehr
Rateversuche:Tool Alt Neu srgssr_video_get_shows{bu}/showList/tv_shows/alphabetical?bu=&characterFilter=srgssr_video_get_episodes{bu}/showEpisodesList/{id}/latest_episodes/shows/{showId}?bu=srgssr_video_get_livestreams{bu}/channels/tv_channels?bu=srgssr_audio_get_shows{bu}/showList/radioshows/byChannel?bu=&channelId=&characterFilter=srgssr_audio_get_episodes{bu}/showEpisodesList/{id}/episodeComposition/shows/{showId}?bu=srgssr_audio_get_livestreams{bu}/channels/radio/channels?bu=buist durchgehend Query-Parameter statt Pfadsegment. Episoden liegen unter
episodeCompositionstattepisodeList; die alten Feldnamen bleiben als
Fallback. -
Wetter-Tools auf die SRF-Meteo-v2-API umgestellt.
WEATHER_BASEzeigte
auf/forecasts/v2.0/weather— einen Basispfad, den das Gateway nicht kennt
(302aufs Developer-Portal). Laut SpecSRF Weather 2.0.1ist es
/srf-meteo/v2.Der Umbau geht tiefer als ein Pfadwechsel, weil v2 anders geschnitten ist:
Es gibt keine getrenntencurrent-,24hour- und7day-Endpunkte. Ein
einziger Aufruf/forecastpoint/{geolocationId}liefertdays,
three_hoursundhourszusammen; die drei Tools schneiden daraus
verschiedene Arrays.Auch die Feldnamen sind andere: flach und in Grossbuchstaben (
TTT_C,
RRR_MM,FF_KMH,DD_DEG,RELHUM_PERCENT,TN_C/TX_C,
symbol_code,date_time) statt der verschachtelten
values.ttt.value-Form._extract_valueentfällt damit.Die Standortsuche liegt neu auf
/geolocationNamesund kennt keinen
kombinierten Suchbegriff: Postleitzahlen gehen anzip, alles andere an
name. Die Antwort kommt mal als Array, mal als einzelnes Objekt — beides
wird normalisiert. Zurückgegeben wird die geolocation-ID, nicht die ID
des Namenseintrags, denn nur erstere funktioniert am Forecast-Endpunkt. -
Wetter: die Koordinaten taugen nicht als
geolocationId. Die Spec
beschreibt den Pfadparameter als'[lat],[lon]'auf vier Nachkommastellen,
was sich liest, als könnte man sie direkt einsetzen. Gemessen am 2026-07-31:
/forecastpoint/47.3769,8.5417antwortet mit404. Die Koordinaten werden
jetzt zuerst über/geolocationsin eine Stations-ID aufgelöst; findet sich
keine, ist das ein Fehler statt einer leeren Prognose. Eine explizit
übergebenegeolocation_idspart den Zusatz-Request. -
Polis-Tools auf
polis-api/v2umgestellt.POLIS_BASEzeigte auf
/polis/v1— ein Basispfad, den das Gateway nicht kennt. Sechs Varianten
hatte ich erfolglos durchprobiert; die SpecSRGSSR Polis 2.0.2nennt
/polis-api/v2, credential-frei bestätigt (401statt302). -
Polis-Jahresfilter las die falschen vier Ziffern. Der zweite Live-Lauf
holte 570 Abstimmungstage und wählte davon keinen einzigen für 2020–2024 aus
— ohne Fehler, weil die Daten sich ja lesen liessen. Nur eben falsch.Die API ist durchgehend .NET-XML-abgeleitet (PascalCase,
EventDateSpecified-Flags), und dazu gehört die Datumsform
/Date(1601164800000)/. Die ersten vier Ziffern daraus ergeben 1601 —
eine plausibel aussehende Jahreszahl, die jeden Filter passiert und dabei
jeden Treffer ausschliesst. Aus einem vollständigen Datensatz wurde so ein
leerer Zeitraum, ohne dass irgendwo etwas rot wurde._year_ofversteht jetzt beide Formen — ISO-Strings und
Epoch-Millisekunden — und prüft das Ergebnis gegen 1800–2100. Ein Wert
ausserhalb gilt als nicht lesbar und löst denselbencase_dates_unparseable
-Fehler aus wie ein fehlendes Datum, statt sich als Filter zu tarnen. -
Polis: unlesbare Falldaten sind ein Fehler, kein leerer Zeitraum. Lässt
sich aus vorhandenen Fällen kein einziges Datum lesen, wird das gemeldet
und die Feldnamen landen im Log untercase_dates_unparseable— statt als
«keine Abstimmungen in diesem Zeitraum» durchzugehen. Daten, die sich lesen
lassen und nur ausserhalb des Bereichs liegen, bleiben ein legitimes leeres
Ergebnis. -
Fehler-Hints erscheinen jetzt auch bei
400, nicht nur bei404. Eine
unbekannte Sender-ID beantwortet das EPG mit400, nicht mit404— der
Hint, der genau für diesen Fall geschrieben ist, hätte den Aufrufer also nie
erreicht.400heisst wie404«deine Eingabe war falsch», und erst der
Hint macht das reparierbar. Andere Statuscodes bleiben unberührt, inklusive
Test dafür. -
Live-Tests scheiterten an
RuntimeError: Event loop is closed. Der
geteiltehttpx.AsyncClientwird einmal erzeugt und für die Prozesslaufzeit
gehalten — richtig für einen Server, falsch unter pytest-asyncio, das jedem
Test einen frischen Event-Loop gibt. Ab dem zweiten Test erbte der Lauf einen
Client, dessen gepoolte Verbindungen zu einem bereits geschlossenen Loop
gehörten.Das sah aus wie eine kaputte API und war keine: sechs der acht
Fehlschläge im ersten echten Live-Lauf gingen darauf zurück, nicht auf die
Endpunkte. Eine Autouse-Fixture schliesst den Client jetzt nach jedem Test,
analog zum bestehenden DNS-Pin-Reset. -
Zwei Live-Tests prüften noch Strings (
assert "Wahlen" in result,
assert "Volksabstimmungen" in result) — übersehen bei der Umstellung auf
typisierte Returns. Gegen einBaseModeliteriertinüber die Feldnamen
und kann nie zutreffen.
Security
-
Die
live_credentials-Fixture gab Key und Secret zurück. pytest druckt
Fixture-Werte in jeden Fehlerbericht, also standen beide im Klartext zuoberst
in der Ausgabe jedes fehlschlagenden Live-Tests — und solche Ausgaben landen
in Issues, Chats und Bug-Reports. Kein Test hat den Rückgabewert je benutzt;
die Tools lesen die Zugangsdaten selbst aus der Umgebung. Die Fixture gibt
jetzt nichts mehr zurück. -
Egress-Allowlist wieder bei genau einem Host — und die Doku stimmt dazu.
Mit dem Endpunkt-Fix in 1.1.0 warsrgssr-prod.apigee.netinALLOWED_HOSTS
gelandet; dorthin gingen seither die Basic-Auth-Client-Credentials. Die
Sicherheitsdokumentation nannte an fünf Stellen weiterhin{"api.srgssr.ch"}
als vollständige Allowlist — sie beschrieb also eine engere Vertrauensgrenze,
als der Code zog.Mit echten Credentials nachgemessen: beide Token-Endpunkte liefern 200, und
beide Tokens verhalten sich auf allen 14 geprüften Endpunkten identisch
(Video, Audio, EPG, Wetter, Polis, jeweils Status und Response-Grösse
gleich). Der zweite Host war nie nötig. Die Begründung in PR #46 stützte sich
auf die Fault-Meldung"Invalid access token"— die der Gateway aber auf
jeden unauthentifizierten Request an einen v2-Basepath liefert, also kein
Beleg für einen Issuer-Mismatch ist. Die tatsächliche Ursache lag bei den
Pfaden.Der Host ist damit wieder draussen, statt nur dokumentiert zu sein: keine
Client-Credentials mehr an multi-tenant-Infrastruktur unter Google-Betrieb.
test_allowed_hosts_is_pinnedpinnt die Menge jetzt explizit auf
{"api.srgssr.ch"}— die bestehenden Wächter-Tests prüften nur
Mitgliedschaft und werden durch jede Erweiterung per Konstruktion grün, sie
konnten den neuen Egress-Zielhost gar nicht bemerken.Für Betreiber relevant:
docs/network-egress.mdliefert
Kubernetes-NetworkPolicy-, Cilium-FQDN-, AWS-Security-Group- und
Cloudflare-Zero-Trust-Beispiele. Wer sie unter 1.1.0 übernommen hätte, hätte
den Token-Endpunkt ausgesperrt und damit jeden Request.