feat: Gruppe 1 – Projektgerüst, Auth, CLI (v0.1.0)

- pyproject.toml: hatchling build, mcp-familywall entry-point, deps
- src/mcp_familywall/__init__.py: version 0.1.0
- src/mcp_familywall/config.py: YAML config loader (schema_version: 1)
- src/mcp_familywall/auth.py: keyring credential resolution (FW_EMAIL/FW_PASSWORD → keyring)
- src/mcp_familywall/fw_client.py: httpx client, login/logout/call, FW_DEBUG logging
- src/mcp_familywall/cli.py: click CLI with setup / check / serve commands
- .gitignore, README.md, CLAUDE.md, SPEC.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 12:18:37 +02:00
parent a58c776298
commit 38da31b0cb
10 changed files with 948 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
# Virtual environments
.venv/
venv/
env/
# Build artifacts
dist/
build/
*.egg-info/
# Environment files (may contain secrets)
.env
.env.*
# Test / coverage
.pytest_cache/
.coverage
coverage.xml
htmlcov/
# Type checker
.mypy_cache/
# Ruff
.ruff_cache/
# Editors
.vscode/
.idea/
*.swp
*.swo
# macOS
.DS_Store
# uv
uv.lock
+148
View File
@@ -0,0 +1,148 @@
# mcp-familywall
## Kontext
Dieses Projekt entwickelt `mcp-familywall` einen MCP-Server für den Lesezugriff
auf Family Wall (familywall.com). Der MCP-Server läuft lokal und wird in Claude Desktop
eingebunden.
## Infrastruktur
| | |
|---|---|
| **Family Wall API** | `https://api.familywall.com/api` |
| **Gitea** | `https://gitea.gecheckt.de/marcus/mcp-familywall` |
| **Lokaler Code** | `D:\Dev\Projects\mcp-familywall` |
| **Sprache** | Python 3.12+, `uv`, MCP SDK, `httpx`, `keyring`, `click`, `rich` |
## Deploy-Workflow (nach jeder Code-Änderung)
1. Claude Code committet und pusht (bei Berechtigungsfehler: bis zu 2 Retries, je 1s warten)
## Aktueller Stand
### Implementierte Tools (v1.0)
| Kategorie | Tools |
|---|---|
| Kreise | `get_circles` |
| Listen | `get_lists` |
| Tasks | `get_tasks` |
## Roadmap
- v1.0: Lesezugriff (Kreise + Listen + Tasks) ← aktuell
- v2.0: Schreibzugriff (Tasks anlegen, abhaken, löschen)
## Referenzprojekt
Im Ordner `reference/` liegen Dateien aus einem anderen MCP-Server-Projekt
als Orientierung für Struktur und Patterns:
| Datei | Zweck |
|---|---|
| `reference/pyproject.toml` | Projektstruktur, Dependencies, Entry-Points, Build-System |
| `reference/config.py` | Config-Pattern: YAML laden, validieren, speichern |
| `reference/auth.py` | Keyring-Integration, Credential-Resolution-Reihenfolge |
| `reference/cli.py` | Setup-Wizard, check, serve CLI-Struktur |
**Wichtig:** Diese Dateien sind Referenz, kein Copy-Paste. Alle
Synology/DSM-spezifischen Teile werden nicht übernommen.
Family Wall nutzt ein anderes Auth-Schema siehe SPEC.md.
## Architektur-Entscheidungen
### Session-Strategie
Kein Session-Caching. Jeder Tool-Call führt Login → API-Call → Logout durch.
Credentials liegen im OS Keyring (nur `email` + `password`), kein `session_id`.
### Kreise (Scopes)
Family Wall kennt mehrere Kreise (z.B. Familie, erweiterter Familienkreis).
`get_lists` unterstützt optionalen `scope`-Parameter zur Filterung.
Ohne `scope` werden alle Kreise zurückgegeben.
### Listen-Namen
Systembezeichnungen (z.B. `SYS-CAT-SHOPPINGLIST`) werden in deutsche
Klarnamen übersetzt. Mapping-Tabelle in `modules/lists.py`.
## Claude Code Implementierungsregeln
- Keine destruktiven Operationen in v1.0 → kein Confirmation-Pattern erforderlich
- Fehlerbehandlung: API-Fehler als verständliche Meldung zurückgeben, keine Stacktraces
- Keine Secrets in stderr-Ausgaben (Passwort bei Debug-Logging maskieren)
- Type Hints und Docstrings konsequent verwenden
- Formatter: `ruff format`, Linter: `ruff check`, Tests: `pytest`
- Alle Texte (Docstrings, Kommentare, README): Englisch
- Debug-Logging via `FW_DEBUG=1` Umgebungsvariable
- Nach jeder Aufgabe: git commit + push. Bei Berechtigungsfehler: 1s warten, bis zu 2 Retries
- .gitignore eigenständig pflegen (Credentials, __pycache__, .venv, .env, *.pyc etc.)
- README.md im Projekt-Root pflegen und bei jedem Aufruf aktualisieren
## Implementierungsreihenfolge
### Gruppe 1 Projektgerüst + Auth + CLI ✦ Prio: hoch
- `pyproject.toml`: Package `mcp-familywall`, Entry-Point `mcp-familywall`
- `src/mcp_familywall/config.py`: `~/.config/mcp-familywall/config.yaml`,
schema_version 1
- `src/mcp_familywall/auth.py`: Keyring-Service `mcp-familywall`,
Keys: `email`, `password`. Credential-Resolution:
1. Umgebungsvariablen `FW_EMAIL`, `FW_PASSWORD`
2. OS Keyring
- `src/mcp_familywall/fw_client.py`: HTTP-Client mit `httpx`.
Methoden: `login()`, `logout()`, `call(endpoint, params)`.
Debug-Logging wenn `FW_DEBUG=1` (Passwort maskieren).
- `src/mcp_familywall/cli.py`:
- `setup`: fragt E-Mail + Passwort, führt Login/Logout durch,
speichert Credentials im Keyring, gibt Claude-Desktop-Snippet aus
- `check`: testet Auth und API-Erreichbarkeit
- `serve`: startet MCP-Server
Config-Datei enthält nur:
```yaml
schema_version: 1
```
### Gruppe 2 MCP Tools ✦ Prio: hoch
`src/mcp_familywall/server.py` + `src/mcp_familywall/modules/lists.py`
**`get_circles`**
- Ruft `famlistfamily` auf
- Gibt alle Kreise zurück: `id`, `name`
- Confirmation: nein
**`get_lists`**
- Signatur: `get_lists(scope: str = None) -> str`
- Ruft `accgetallfamily` auf (Parameter: `a01call=taskcategorysync`, `a02call=tasksync`)
- Gibt zurück: `id` (metaId), `name` (übersetzt), `type`, offene Einträge,
Gesamteinträge, Kreis-Name
- Ohne `scope`: alle Kreise; mit `scope`: nur dieser Kreis
- Confirmation: nein
**`get_tasks`**
- Signatur: `get_tasks(list_id: str, only_open: bool = True) -> str`
- `list_id`: metaId aus `get_lists`
- Gibt zurück: `id`, `text`, `description`, `completed`
- Confirmation: nein
## Test-Credentials (nur für Entwicklung)
| | |
|---|---|
| E-Mail | `marcus@gecheckt.de` |
| Passwort | `Lasdas1234` |
## Hintergrund
Marcus ist Senior Software Engineer (Java, Jakarta EE).
Präferenz: State-of-the-Art, Best Practices, saubere Architektur.
Automatisierung spart Zeit für die Familie. 🌱
+75
View File
@@ -0,0 +1,75 @@
# mcp-familywall
MCP server for [Family Wall](https://www.familywall.com) -- read your family's circles, lists, and tasks directly from Claude.
## Features (v1.0 -- read-only)
- `get_circles` -- list all family circles
- `get_lists` -- list all task lists (optionally filtered by circle)
- `get_tasks` -- list tasks in a specific list
## Requirements
- Python 3.12+
- [uv](https://docs.astral.sh/uv/)
- A Family Wall account
## Installation
```bash
uv tool install mcp-familywall
```
## Setup
Run the interactive setup wizard once to store your credentials securely in the OS keyring:
```bash
mcp-familywall setup
```
This will:
1. Prompt for your Family Wall email and password
2. Verify the credentials against the API
3. Store them in the OS keyring
4. Print a Claude Desktop configuration snippet
## Claude Desktop configuration
Add the printed snippet to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"familywall": {
"command": "uvx",
"args": ["mcp-familywall", "serve"]
}
}
}
```
## Verify
```bash
mcp-familywall check
```
## Debug logging
Set `FW_DEBUG=1` to log full request/response bodies to stderr (passwords are masked):
```bash
FW_DEBUG=1 mcp-familywall check
```
## Credentials
Credentials are resolved in this order:
1. Environment variables `FW_EMAIL` and `FW_PASSWORD`
2. OS keyring (set by `mcp-familywall setup`)
## License
MIT
+137
View File
@@ -0,0 +1,137 @@
# Family Wall API Spezifikation
Erarbeitet durch Browser-Traffic-Analyse (April 2026).
Es gibt keine offizielle API-Dokumentation.
## Base URL
https://api.familywall.com/api
## Authentifizierung
### Login
POST https://api.familywall.com/api/log2in
Content-Type: application/x-www-form-urlencoded
**Request-Parameter:**
| Parameter | Wert |
|---|---|
| `identifier` | E-Mail-Adresse |
| `password` | Passwort |
| `type` | zu verifizieren beim ersten echten Login-Call (vermutlich `"email"`) |
| `clientId` | weglassen |
| `clientSecret` | weglassen |
| `generateAutologinToken` | weglassen |
| `countryCode` | weglassen |
**Response (Erfolg):**
```json
{ "r": { "r": <SessionObject> } }
```
**Response (Fehler):**
```json
{ "ex": { "ex": <ErrorObject> } }
{ "un": { "un": <ErrorObject> } }
```
Der Server setzt nach erfolgreichem Login ein Session-Cookie:
Set-Cookie: JSESSIONID=<session-id>
### Folgecalls (nach Login)
Alle API-Calls nach dem Login benötigen:
| | |
|---|---|
| **Cookie** | `JSESSIONID=<session-id>` |
| **Header** | `Tokencsrf: <session-id>` (identisch zur JSESSIONID) |
| **Content-Type** | `application/x-www-form-urlencoded` |
### Logout
POST https://api.familywall.com/api/log2out
Content-Type: application/x-www-form-urlencoded
Keine Parameter. Session wird serverseitig invalidiert.
### Session-Strategie
Kein Session-Caching. Jeder MCP-Tool-Call führt folgende Sequenz aus:
POST /api/log2in → Session-ID
POST /api/<endpoint> → Nutzdaten
POST /api/log2out → Session invalidieren
Credentials (E-Mail + Passwort) werden einmalig via `mcp-familywall setup`
im OS Keyring gespeichert (Keys: `email`, `password`). Kein Keyring-Eintrag
für `session_id`.
## Bekannte Endpoints
### `famlistfamily` Kreise abrufen
POST https://api.familywall.com/api/famlistfamily
Content-Type: application/x-www-form-urlencoded
**Body-Parameter:** keine (zu verifizieren beim ersten echten Call)
**Response-Struktur:** zu verifizieren beim ersten echten Call
### `accgetallfamily` Listen + Tasks abrufen
POST https://api.familywall.com/api/accgetallfamily
Content-Type: application/x-www-form-urlencoded
**Body-Parameter:**
| Parameter | Wert |
|---|---|
| `a01call` | `"taskcategorysync"` |
| `a02call` | `"tasksync"` |
Hinweis: `partnerScope`, `a03call`, `a03id`, `withStateBean` werden
weggelassen. Falls der Server sie erfordert, beim ersten echten Call
nachbessern.
**Response-Struktur (relevant für v1.0):**
a00.r.r[] → Listen (taskcategorysync)
.metaId → eindeutige Listen-ID
.name → Name (ggf. Systembezeichnung, s.u.)
.taskListType → Typ der Liste
.remainingTaskNumber → offene Einträge (String)
.totalTaskNumber → Gesamteinträge (String)
.<Kreis-Feld> → zu verifizieren beim ersten echten Call
a02.r.r.updatedCreated[] → Tasks (tasksync)
.metaId → eindeutige Task-ID
.text → Aufgabentext
.description → optionale Beschreibung
.taskListId → Zugehörigkeit zur Liste (= metaId der Liste)
.complete → "true" / "false" (String, nicht Boolean!)
## Systembezeichnungen für Listen-Namen
Bekannte Systembezeichnungen werden deutsch übersetzt:
| Systembezeichnung | Deutsch |
|---|---|
| `SYS-CAT-SHOPPINGLIST` | `Einkaufsliste` |
Unbekannte Bezeichnungen werden unverändert zurückgegeben.
Mapping-Tabelle bei Bedarf erweitern.
## Debug-Logging
Wenn die Umgebungsvariable `FW_DEBUG=1` gesetzt ist, loggt `fw_client.py`
vollständige Request-Bodies und Responses nach stderr. Dient zur Verifikation
offener Punkte (z.B. `type`-Parameter beim Login, Kreis-Felder in Response).
**Wichtig:** Keine Secrets in Debug-Ausgaben (Passwort maskieren).
## Noch zu verifizieren
- Exakter Wert für `type`-Parameter beim Login
- Response-Struktur von `famlistfamily` (Kreise)
- Kreis-Zuordnung in `accgetallfamily`-Response
- Ob `partnerScope` / `withStateBean` benötigt werden
- Session-Lebensdauer (irrelevant da kein Caching)
+45
View File
@@ -0,0 +1,45 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mcp-familywall"
version = "0.1.0"
description = "MCP server for Family Wall — read your family's lists and tasks via Claude"
readme = "README.md"
requires-python = ">=3.12"
authors = [
{ name = "Marcus van Elst" },
]
keywords = ["mcp", "familywall", "family", "tasks", "lists"]
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"keyring>=25.0",
"click>=8.0",
"rich>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"ruff>=0.5",
]
[project.scripts]
mcp-familywall = "mcp_familywall.cli:app"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_familywall"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "TCH"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+91
View File
@@ -0,0 +1,91 @@
"""Credential resolution for Family Wall.
Credential resolution order:
1. Environment variables FW_EMAIL, FW_PASSWORD
2. OS keyring (service: 'mcp-familywall', keys: 'email', 'password')
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
KEYRING_SERVICE = "mcp-familywall"
def get_credentials() -> tuple[str, str]:
"""Resolve Family Wall credentials.
Resolution order:
1. Environment variables FW_EMAIL / FW_PASSWORD
2. OS keyring
Returns:
Tuple of (email, password).
Raises:
RuntimeError: If credentials cannot be resolved from any source.
"""
email: str | None = None
password: str | None = None
# 1. Environment variables (highest priority)
email = os.environ.get("FW_EMAIL")
if email:
logger.debug("Email from env var FW_EMAIL: %s", email)
password = os.environ.get("FW_PASSWORD")
if password:
logger.debug("Password from env var FW_PASSWORD")
# 2. OS keyring
if not email or not password:
try:
import keyring
kr_email = keyring.get_password(KEYRING_SERVICE, "email")
kr_password = keyring.get_password(KEYRING_SERVICE, "password")
if kr_email and not email:
email = kr_email
logger.debug("Email from keyring: %s", email)
if kr_password and not password:
password = kr_password
logger.debug("Password from keyring")
except Exception:
logger.debug("Keyring not available or could not be read.")
if not email or not password:
msg = (
"No credentials found. Run 'mcp-familywall setup' to store credentials "
"in the OS keyring, or set FW_EMAIL and FW_PASSWORD environment variables."
)
raise RuntimeError(msg)
logger.debug("Credentials resolved: email=%s, has_password=yes", email)
return email, password
def store_credentials(email: str, password: str) -> bool:
"""Store credentials in the OS keyring.
Args:
email: Family Wall email address.
password: Family Wall password.
Returns:
True if stored successfully, False if keyring is unavailable.
"""
try:
import keyring
from keyring.errors import KeyringError
keyring.set_password(KEYRING_SERVICE, "email", email)
keyring.set_password(KEYRING_SERVICE, "password", password)
logger.debug("Credentials stored in keyring (service=%s)", KEYRING_SERVICE)
return True
except (ImportError, KeyringError, OSError) as exc:
logger.debug("Failed to store credentials in keyring: %s", exc)
return False
+151
View File
@@ -0,0 +1,151 @@
"""CLI entry point for mcp-familywall.
Commands:
- setup : Interactive credential setup; stores email+password in OS keyring.
- check : Validate stored credentials against the Family Wall API.
- serve : Start the MCP server (launched by Claude Desktop).
"""
from __future__ import annotations
import json
import shutil
import sys
import click
from mcp_familywall import __version__
@click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True)
@click.version_option(__version__, "-v", "--version", prog_name="mcp-familywall")
@click.pass_context
def app(ctx: click.Context) -> None:
"""mcp-familywall — MCP server for Family Wall (read-only)."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ---------------------------------------------------------------------------
# setup
# ---------------------------------------------------------------------------
@app.command()
def setup() -> None:
"""Interactive credential setup.
Prompts for email and password, verifies them against the Family Wall API,
stores them in the OS keyring, and prints a Claude Desktop config snippet.
"""
from mcp_familywall.auth import store_credentials
from mcp_familywall.config import save_config
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
click.echo("mcp-familywall setup\n")
email = click.prompt("Family Wall email")
password = click.prompt("Family Wall password", hide_input=True)
# Verify credentials
click.echo("\nVerifying credentials...")
try:
with FamilyWallClient() as client:
client.login(email, password)
client.logout()
except FamilyWallError as exc:
click.echo(click.style(f"Login failed: {exc}", fg="red"), err=True)
sys.exit(1)
except Exception as exc:
click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True)
sys.exit(1)
click.echo(click.style("Login successful!", fg="green"))
# Store credentials
ok = store_credentials(email, password)
if ok:
click.echo("Credentials stored in OS keyring.")
else:
click.echo(
click.style("Keyring not available.", fg="yellow")
+ " Set environment variables instead:\n"
f" FW_EMAIL={email}\n"
" FW_PASSWORD=<your-password>"
)
# Ensure config file exists
save_config()
# Print Claude Desktop snippet
_emit_claude_desktop_snippet()
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
@app.command()
def check() -> None:
"""Validate stored credentials against the Family Wall API."""
from mcp_familywall.auth import get_credentials
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
click.echo("Checking Family Wall credentials...")
try:
email, password = get_credentials()
except RuntimeError as exc:
click.echo(click.style(f"Error: {exc}", fg="red"), err=True)
sys.exit(1)
click.echo(f"Email: {email}")
try:
with FamilyWallClient() as client:
client.login(email, password)
client.logout()
except FamilyWallError as exc:
click.echo(click.style(f"Authentication failed: {exc}", fg="red"), err=True)
sys.exit(1)
except Exception as exc:
click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True)
sys.exit(1)
click.echo(click.style("Authentication successful!", fg="green"))
# ---------------------------------------------------------------------------
# serve
# ---------------------------------------------------------------------------
@app.command()
def serve() -> None:
"""Start the MCP server (launched by Claude Desktop)."""
from mcp_familywall.server import create_server
server = create_server()
server.run(transport="stdio")
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _emit_claude_desktop_snippet() -> None:
"""Print a Claude Desktop JSON config snippet."""
uvx_path = shutil.which("uvx") or "<path-to-uvx>"
snippet = {
"mcpServers": {
"familywall": {
"command": uvx_path,
"args": ["mcp-familywall", "serve"],
}
}
}
click.echo("\nAdd this to your Claude Desktop config (claude_desktop_config.json):\n")
click.echo(json.dumps(snippet, indent=2))
+73
View File
@@ -0,0 +1,73 @@
"""YAML config loading and validation.
Config path: ~/.config/mcp-familywall/config.yaml
Schema: {schema_version: 1}
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
CURRENT_SCHEMA_VERSION = 1
CONFIG_DIR = Path.home() / ".config" / "mcp-familywall"
CONFIG_PATH = CONFIG_DIR / "config.yaml"
_DEFAULT_CONFIG: dict[str, Any] = {"schema_version": CURRENT_SCHEMA_VERSION}
def load_config() -> dict[str, Any]:
"""Load and validate the config file.
Returns the config dict. Creates a default config if none exists.
Raises:
ValueError: If schema_version does not match the expected version.
"""
if not CONFIG_PATH.exists():
logger.debug("No config found at %s; using defaults", CONFIG_PATH)
return dict(_DEFAULT_CONFIG)
logger.debug("Loading config from %s", CONFIG_PATH)
raw_text = CONFIG_PATH.read_text(encoding="utf-8")
raw: dict[str, Any] = yaml.safe_load(raw_text) or {}
_validate(raw)
logger.debug("Config loaded: schema_version=%s", raw.get("schema_version"))
return raw
def save_config(config: dict[str, Any] | None = None) -> None:
"""Save a config dict to disk (or write defaults if None).
Args:
config: Config dict to save. Defaults to minimal default config.
"""
if config is None:
config = dict(_DEFAULT_CONFIG)
_validate(config)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
header = "# Generated by mcp-familywall setup\n"
CONFIG_PATH.write_text(header + yaml.dump(config, default_flow_style=False), encoding="utf-8")
logger.debug("Config written to %s", CONFIG_PATH)
def _validate(config: dict[str, Any]) -> None:
"""Validate config dict.
Raises:
ValueError: If schema_version is missing or wrong.
"""
version = config.get("schema_version")
if version != CURRENT_SCHEMA_VERSION:
msg = (
f"Config schema_version is {version!r}, "
f"but this server expects {CURRENT_SCHEMA_VERSION}."
)
raise ValueError(msg)
+184
View File
@@ -0,0 +1,184 @@
"""Family Wall HTTP client.
Session strategy: Login → API call → Logout per MCP tool call. No session caching.
Debug logging is enabled when the environment variable FW_DEBUG=1 is set.
Passwords are always masked in debug output.
"""
from __future__ import annotations
import logging
import os
import sys
from typing import Any
import httpx
logger = logging.getLogger(__name__)
BASE_URL = "https://api.familywall.com/api"
_debug = os.environ.get("FW_DEBUG") == "1"
def _debug_log(label: str, data: Any) -> None:
"""Write debug info to stderr when FW_DEBUG=1."""
if _debug:
print(f"[FW_DEBUG] {label}: {data}", file=sys.stderr)
def _mask_password(data: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of data with the password field masked."""
masked = dict(data)
if "password" in masked:
masked["password"] = "***"
return masked
class FamilyWallError(Exception):
"""Raised when the Family Wall API returns an error response."""
def __init__(self, message: str, response_data: Any = None) -> None:
super().__init__(message)
self.response_data = response_data
class FamilyWallClient:
"""Synchronous HTTP client for the Family Wall API.
Usage::
client = FamilyWallClient()
client.login(email, password)
data = client.call("accgetallfamily", {"a01call": "taskcategorysync"})
client.logout()
Or use as a context manager::
with FamilyWallClient() as client:
client.login(email, password)
data = client.call("famlistfamily")
"""
def __init__(self) -> None:
self._http: httpx.Client = httpx.Client(timeout=30)
self._session_id: str | None = None
def __enter__(self) -> FamilyWallClient:
return self
def __exit__(self, *args: object) -> None:
self._http.close()
def login(self, email: str, password: str) -> None:
"""Authenticate with Family Wall and store the session ID.
Args:
email: Family Wall account email.
password: Family Wall account password.
Raises:
FamilyWallError: If authentication fails.
httpx.HTTPError: On network-level errors.
"""
endpoint = f"{BASE_URL}/log2in"
payload = {
"identifier": email,
"password": password,
"type": "email",
}
_debug_log("LOGIN request", _mask_password(payload))
response = self._http.post(
endpoint,
data=payload,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
body: dict[str, Any] = response.json()
_debug_log("LOGIN response", body)
if "ex" in body or "un" in body:
error_data = body.get("ex", body.get("un"))
msg = f"Login failed: {error_data}"
raise FamilyWallError(msg, response_data=body)
if "r" not in body:
raise FamilyWallError("Login failed: unexpected response format", response_data=body)
# Extract JSESSIONID from the Set-Cookie header
session_id = response.cookies.get("JSESSIONID")
if not session_id:
raise FamilyWallError("Login succeeded but no JSESSIONID cookie returned.")
self._session_id = session_id
logger.debug("Logged in; session acquired")
# Configure the HTTP client to send the session cookie and CSRF token on all future calls
self._http.cookies.set("JSESSIONID", session_id)
self._http.headers.update({"Tokencsrf": session_id})
def logout(self) -> None:
"""Invalidate the current session server-side.
Silently ignores errors — the session will eventually expire anyway.
"""
if not self._session_id:
return
endpoint = f"{BASE_URL}/log2out"
_debug_log("LOGOUT request", {})
try:
response = self._http.post(
endpoint,
data={},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
_debug_log("LOGOUT response", response.text)
except httpx.HTTPError as exc:
logger.debug("Logout request failed (session may already be expired): %s", exc)
finally:
self._session_id = None
self._http.cookies.clear()
if "Tokencsrf" in self._http.headers:
del self._http.headers["Tokencsrf"]
def call(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Call a Family Wall API endpoint.
Must be called after a successful :meth:`login`.
Args:
endpoint: API endpoint name (e.g. ``"famlistfamily"``).
params: Optional form parameters to send.
Returns:
Parsed JSON response body.
Raises:
FamilyWallError: If not logged in, or if the API returns an error.
httpx.HTTPError: On network-level errors.
"""
if not self._session_id:
raise FamilyWallError("Not logged in. Call login() first.")
url = f"{BASE_URL}/{endpoint}"
payload = params or {}
_debug_log(f"CALL {endpoint} request", payload)
response = self._http.post(
url,
data=payload,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
body: dict[str, Any] = response.json()
_debug_log(f"CALL {endpoint} response", body)
if "ex" in body or "un" in body:
error_data = body.get("ex", body.get("un"))
msg = f"API error from {endpoint!r}: {error_data}"
raise FamilyWallError(msg, response_data=body)
return body