pyclann 0.1.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.
pyclann/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """pyclann — Python client library for the Clann family-tree API."""
2
+
3
+ from .client import ClannClient
4
+ from .exceptions import (
5
+ ClannAuthError,
6
+ ClannError,
7
+ ClannNotFoundError,
8
+ ClannServerError,
9
+ ClannValidationError,
10
+ )
11
+ from .models import (
12
+ ChatMessage,
13
+ ChatSession,
14
+ EventType,
15
+ FamilyTree,
16
+ FamilyTreeNode,
17
+ LifeEvent,
18
+ LoginInfo,
19
+ Person,
20
+ RelationshipsResponse,
21
+ RelationshipType,
22
+ ResearchFolder,
23
+ ResearchNote,
24
+ Sex,
25
+ SiblingType,
26
+ SpouseInfo,
27
+ UserAiSettings,
28
+ )
29
+
30
+ __all__ = [
31
+ # Client
32
+ "ClannClient",
33
+ # Exceptions
34
+ "ClannError",
35
+ "ClannAuthError",
36
+ "ClannNotFoundError",
37
+ "ClannValidationError",
38
+ "ClannServerError",
39
+ # Enumerations
40
+ "Sex",
41
+ "RelationshipType",
42
+ "SiblingType",
43
+ "EventType",
44
+ # Models
45
+ "LoginInfo",
46
+ "FamilyTree",
47
+ "Person",
48
+ "LifeEvent",
49
+ "SpouseInfo",
50
+ "RelationshipsResponse",
51
+ "FamilyTreeNode",
52
+ "ResearchNote",
53
+ "ResearchFolder",
54
+ "ChatSession",
55
+ "ChatMessage",
56
+ "UserAiSettings",
57
+ ]
pyclann/_http.py ADDED
@@ -0,0 +1,145 @@
1
+ """Internal HTTP session: request dispatch and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from .exceptions import (
10
+ ClannAuthError,
11
+ ClannError,
12
+ ClannNotFoundError,
13
+ ClannServerError,
14
+ ClannValidationError,
15
+ )
16
+
17
+
18
+ def _compact(d: dict[str, Any]) -> dict[str, Any]:
19
+ """Return a copy of *d* with all ``None``-valued keys removed."""
20
+ return {k: v for k, v in d.items() if v is not None}
21
+
22
+
23
+ def _bare_id(record_id: str) -> str:
24
+ """Strip the SurrealDB table prefix from a record ID for use in URL paths.
25
+
26
+ ``"person:01jd4a8xyz"`` → ``"01jd4a8xyz"``. Bare IDs pass through unchanged.
27
+ """
28
+ return record_id.split(":", 1)[-1] if ":" in record_id else record_id
29
+
30
+
31
+ class _HttpSession:
32
+ """Thin wrapper around :class:`requests.Session` that handles auth headers and
33
+ maps non-2xx responses to typed exceptions."""
34
+
35
+ def __init__(self, api_url: str, auth_url: str) -> None:
36
+ self._api_url = api_url.rstrip("/")
37
+ self._auth_url = auth_url.rstrip("/")
38
+ self._session = requests.Session()
39
+ self._session.headers.update({"Content-Type": "application/json"})
40
+ self._token: str | None = None
41
+
42
+ def set_token(self, token: str) -> None:
43
+ """Store the JWT and attach it to all subsequent requests."""
44
+ self._token = token
45
+ self._session.headers["Authorization"] = f"Bearer {token}"
46
+
47
+ def _require_auth(self) -> None:
48
+ if self._token is None:
49
+ raise ClannAuthError("Not authenticated — call ClannClient.login() first.")
50
+
51
+ def _raise_for_status(self, response: requests.Response) -> None:
52
+ if response.ok:
53
+ return
54
+ try:
55
+ body = response.json()
56
+ message = body.get("error") or body.get("message") or response.text
57
+ except Exception:
58
+ message = response.text
59
+ if response.status_code == 401:
60
+ raise ClannAuthError(message)
61
+ if response.status_code == 403:
62
+ raise ClannAuthError(f"Forbidden: {message}")
63
+ if response.status_code == 404:
64
+ raise ClannNotFoundError(message)
65
+ if response.status_code == 400:
66
+ raise ClannValidationError(message)
67
+ if response.status_code >= 500:
68
+ raise ClannServerError(f"Server error {response.status_code}: {message}")
69
+ raise ClannError(f"HTTP {response.status_code}: {message}")
70
+
71
+ def _json_or_none(self, response: requests.Response) -> Any:
72
+ if not response.content:
73
+ return None
74
+ return response.json()
75
+
76
+ # ── auth service ──────────────────────────────────────────────────────────
77
+
78
+ def post_auth(self, path: str, json: Any) -> Any:
79
+ """POST to the authentication service (no JWT header required)."""
80
+ response = self._session.post(f"{self._auth_url}{path}", json=json)
81
+ self._raise_for_status(response)
82
+ return response.json()
83
+
84
+ # ── Clann API ─────────────────────────────────────────────────────────────
85
+
86
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
87
+ self._require_auth()
88
+ response = self._session.get(f"{self._api_url}{path}", params=params)
89
+ self._raise_for_status(response)
90
+ return response.json()
91
+
92
+ def get_bytes(self, path: str) -> bytes:
93
+ """Fetch a binary resource (e.g. an image). No auth required."""
94
+ response = self._session.get(f"{self._api_url}{path}")
95
+ self._raise_for_status(response)
96
+ return response.content
97
+
98
+ def post(self, path: str, json: Any = None) -> Any:
99
+ self._require_auth()
100
+ response = self._session.post(f"{self._api_url}{path}", json=json)
101
+ self._raise_for_status(response)
102
+ return self._json_or_none(response)
103
+
104
+ def post_multipart(
105
+ self,
106
+ path: str,
107
+ field: str,
108
+ data: bytes,
109
+ filename: str,
110
+ content_type: str,
111
+ ) -> Any:
112
+ """Upload a file using ``multipart/form-data``.
113
+
114
+ The Content-Type header is removed from the session for this request so
115
+ that *requests* can set the correct ``multipart/form-data; boundary=…``
116
+ header automatically.
117
+ """
118
+ self._require_auth()
119
+ files: dict[str, tuple[str, bytes, str]] = {field: (filename, data, content_type)}
120
+ saved = self._session.headers.pop("Content-Type", None)
121
+ try:
122
+ response = self._session.post(f"{self._api_url}{path}", files=files)
123
+ self._raise_for_status(response)
124
+ return self._json_or_none(response)
125
+ finally:
126
+ if saved is not None:
127
+ self._session.headers["Content-Type"] = saved
128
+
129
+ def put(self, path: str, json: Any = None) -> Any:
130
+ self._require_auth()
131
+ response = self._session.put(f"{self._api_url}{path}", json=json)
132
+ self._raise_for_status(response)
133
+ return self._json_or_none(response)
134
+
135
+ def patch(self, path: str, json: Any = None) -> Any:
136
+ self._require_auth()
137
+ response = self._session.patch(f"{self._api_url}{path}", json=json)
138
+ self._raise_for_status(response)
139
+ return self._json_or_none(response)
140
+
141
+ def delete(self, path: str, json: Any = None) -> Any:
142
+ self._require_auth()
143
+ response = self._session.delete(f"{self._api_url}{path}", json=json)
144
+ self._raise_for_status(response)
145
+ return self._json_or_none(response)