pyclann 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,208 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyclann
3
+ Version: 0.1.0
4
+ Summary: Python client library for the Clann family-tree API
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/ullav-dev/pyclann
7
+ Project-URL: Documentation, https://ullav-dev.github.io/pyclann/
8
+ Project-URL: Bug Tracker, https://github.com/ullav-dev/pyclann/issues
9
+ Keywords: clann,genealogy,family-tree,ullav
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: requests>=2.28
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+ Requires-Dist: pytest-cov>=4; extra == "dev"
24
+ Requires-Dist: responses>=0.23; extra == "dev"
25
+ Requires-Dist: ruff; extra == "dev"
26
+ Requires-Dist: mypy; extra == "dev"
27
+ Requires-Dist: types-requests; extra == "dev"
28
+ Provides-Extra: docs
29
+ Requires-Dist: mkdocs-material>=9; extra == "docs"
30
+ Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
31
+
32
+ # pyclann
33
+
34
+ Python client library for the [Clann](https://github.com/colinmanning/clann-server) family-tree API.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install pyclann
40
+ ```
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from pyclann import ClannClient
46
+
47
+ client = ClannClient(
48
+ api_url="http://localhost:8090",
49
+ auth_url="http://localhost:8081", # ullav-user-management service
50
+ )
51
+ client.login(email="user@example.com", password="secret")
52
+
53
+ # Family trees
54
+ trees = client.trees.list(owner="alice")
55
+ tree = client.trees.create("walsh-family", "Walsh Family", owner="alice")
56
+
57
+ # Persons
58
+ father = client.persons.create(
59
+ "Walsh", "Patrick", "Male",
60
+ trees=["walsh-family"],
61
+ date_of_birth="1820-06-01",
62
+ created_by="alice",
63
+ )
64
+ son = client.persons.create(
65
+ "Walsh", "Michael", "Male",
66
+ trees=["walsh-family"],
67
+ created_by="alice",
68
+ )
69
+
70
+ # Relationships
71
+ client.relationships.add(son.id, "Father", father.id)
72
+ rels = client.relationships.get(son.id) # RelationshipsResponse
73
+
74
+ # Life events
75
+ client.life_events.create(
76
+ father.id, "Born in Galway", "Birth",
77
+ date="1820-06-01",
78
+ created_by="alice",
79
+ )
80
+
81
+ # Research notes
82
+ note = client.notes.create(
83
+ "Walsh Family Research",
84
+ trees=["walsh-family"],
85
+ body="Found records at Galway archives.",
86
+ is_shared=True,
87
+ created_by="alice",
88
+ )
89
+ client.notes.create_reply(note.id, "Also check Dublin records.", created_by="alice")
90
+
91
+ # Profile picture
92
+ with open("patrick.jpg", "rb") as f:
93
+ client.persons.upload_image(father.id, f.read(), "image/jpeg")
94
+
95
+ image_bytes = client.persons.get_image(father.id) # public endpoint, no auth needed
96
+ ```
97
+
98
+ ## Authentication
99
+
100
+ `ClannClient` authenticates against the `ullav-user-management` service, which issues
101
+ the JWT accepted by the Clann server.
102
+
103
+ - `api_url` — Clann server base URL (e.g. `http://clann-server:8090`)
104
+ - `auth_url` — auth service base URL (e.g. `http://ullav-user-management:8081`);
105
+ omit if both services are behind the same proxy
106
+
107
+ Call `client.login(email, password)` before any other method. Tokens expire according
108
+ to the server's configuration; call `login()` again to refresh.
109
+
110
+ ## Resource clients
111
+
112
+ | Attribute | Resource |
113
+ |---|---|
114
+ | `client.trees` | Family tree CRUD, primary flag, team assignment, avatar image |
115
+ | `client.persons` | Person CRUD, tree membership, profile/life-story media |
116
+ | `client.relationships` | Add/remove father/mother/sibling/spouse edges, family-tree view |
117
+ | `client.life_events` | Life event CRUD per person |
118
+ | `client.notes` | Research note CRUD, folder assignment, replies |
119
+ | `client.folders` | Research folder CRUD |
120
+ | `client.chat` | AI chat session and message management |
121
+ | `client.ai_settings` | Per-user AI provider settings (encrypted BYOK) |
122
+
123
+ ## Error handling
124
+
125
+ ```python
126
+ from pyclann import ClannAuthError, ClannNotFoundError, ClannValidationError
127
+
128
+ try:
129
+ tree = client.trees.get("non-existent-tree")
130
+ except ClannNotFoundError:
131
+ print("tree not found")
132
+ except ClannAuthError:
133
+ client.login(email, password) # token expired — re-authenticate
134
+ except ClannValidationError as e:
135
+ print("bad request:", e)
136
+ ```
137
+
138
+ | Exception | HTTP status |
139
+ |---|---|
140
+ | `ClannAuthError` | 401 / 403, or `login()` not called |
141
+ | `ClannNotFoundError` | 404 |
142
+ | `ClannValidationError` | 400 |
143
+ | `ClannServerError` | 5xx |
144
+ | `ClannError` | base class |
145
+
146
+ ## Record IDs
147
+
148
+ The Clann API uses SurrealDB record IDs of the form `"table:ulid"`, e.g.
149
+ `"person:01jd4a8xyz"`. All client methods that take an `*_id` parameter accept
150
+ either the full form or the bare ULID — the library strips the table prefix when
151
+ building URL paths.
152
+
153
+ ```python
154
+ person = client.persons.get("person:01jd4a8xyz") # full ID
155
+ person = client.persons.get("01jd4a8xyz") # bare ULID — both work
156
+ ```
157
+
158
+ Note: when specifying a `related_id` in relationship calls, always pass the **full**
159
+ record ID (`"person:01jd4a8xyz"`), since the server uses it verbatim in URL paths.
160
+
161
+ ## Relationship types
162
+
163
+ ```python
164
+ from pyclann import RelationshipType, SiblingType
165
+
166
+ # Add a father
167
+ client.relationships.add(child.id, RelationshipType.FATHER, father.id)
168
+
169
+ # Add a sibling — sibling_type is required
170
+ client.relationships.add(
171
+ person.id,
172
+ RelationshipType.SIBLING,
173
+ sibling.id,
174
+ sibling_type=SiblingType.BROTHER,
175
+ )
176
+
177
+ # Add a spouse with dates
178
+ client.relationships.add(
179
+ person.id,
180
+ RelationshipType.SPOUSE,
181
+ spouse.id,
182
+ spouse_from="1845-09-14",
183
+ )
184
+
185
+ # Remove a relationship
186
+ client.relationships.remove(child.id, RelationshipType.FATHER, father.id)
187
+ ```
188
+
189
+ ## Image uploads
190
+
191
+ Profile pictures accept JPEG or PNG only (max 2 MB). Life-story media accepts
192
+ the same image formats on Individual/Family plans; Professional/Enterprise plans
193
+ also allow video (MP4, MOV, WebM), audio (MP3, WAV, OGG), and PDF.
194
+
195
+ ```python
196
+ # Upload
197
+ with open("profile.jpg", "rb") as f:
198
+ client.persons.upload_image(person.id, f.read(), "image/jpeg")
199
+
200
+ # Download — no login() required
201
+ data = client.persons.get_image(person.id)
202
+ with open("downloaded.jpg", "wb") as f:
203
+ f.write(data)
204
+ ```
205
+
206
+ ## Licence
207
+
208
+ MIT
@@ -0,0 +1,177 @@
1
+ # pyclann
2
+
3
+ Python client library for the [Clann](https://github.com/colinmanning/clann-server) family-tree API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pyclann
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from pyclann import ClannClient
15
+
16
+ client = ClannClient(
17
+ api_url="http://localhost:8090",
18
+ auth_url="http://localhost:8081", # ullav-user-management service
19
+ )
20
+ client.login(email="user@example.com", password="secret")
21
+
22
+ # Family trees
23
+ trees = client.trees.list(owner="alice")
24
+ tree = client.trees.create("walsh-family", "Walsh Family", owner="alice")
25
+
26
+ # Persons
27
+ father = client.persons.create(
28
+ "Walsh", "Patrick", "Male",
29
+ trees=["walsh-family"],
30
+ date_of_birth="1820-06-01",
31
+ created_by="alice",
32
+ )
33
+ son = client.persons.create(
34
+ "Walsh", "Michael", "Male",
35
+ trees=["walsh-family"],
36
+ created_by="alice",
37
+ )
38
+
39
+ # Relationships
40
+ client.relationships.add(son.id, "Father", father.id)
41
+ rels = client.relationships.get(son.id) # RelationshipsResponse
42
+
43
+ # Life events
44
+ client.life_events.create(
45
+ father.id, "Born in Galway", "Birth",
46
+ date="1820-06-01",
47
+ created_by="alice",
48
+ )
49
+
50
+ # Research notes
51
+ note = client.notes.create(
52
+ "Walsh Family Research",
53
+ trees=["walsh-family"],
54
+ body="Found records at Galway archives.",
55
+ is_shared=True,
56
+ created_by="alice",
57
+ )
58
+ client.notes.create_reply(note.id, "Also check Dublin records.", created_by="alice")
59
+
60
+ # Profile picture
61
+ with open("patrick.jpg", "rb") as f:
62
+ client.persons.upload_image(father.id, f.read(), "image/jpeg")
63
+
64
+ image_bytes = client.persons.get_image(father.id) # public endpoint, no auth needed
65
+ ```
66
+
67
+ ## Authentication
68
+
69
+ `ClannClient` authenticates against the `ullav-user-management` service, which issues
70
+ the JWT accepted by the Clann server.
71
+
72
+ - `api_url` — Clann server base URL (e.g. `http://clann-server:8090`)
73
+ - `auth_url` — auth service base URL (e.g. `http://ullav-user-management:8081`);
74
+ omit if both services are behind the same proxy
75
+
76
+ Call `client.login(email, password)` before any other method. Tokens expire according
77
+ to the server's configuration; call `login()` again to refresh.
78
+
79
+ ## Resource clients
80
+
81
+ | Attribute | Resource |
82
+ |---|---|
83
+ | `client.trees` | Family tree CRUD, primary flag, team assignment, avatar image |
84
+ | `client.persons` | Person CRUD, tree membership, profile/life-story media |
85
+ | `client.relationships` | Add/remove father/mother/sibling/spouse edges, family-tree view |
86
+ | `client.life_events` | Life event CRUD per person |
87
+ | `client.notes` | Research note CRUD, folder assignment, replies |
88
+ | `client.folders` | Research folder CRUD |
89
+ | `client.chat` | AI chat session and message management |
90
+ | `client.ai_settings` | Per-user AI provider settings (encrypted BYOK) |
91
+
92
+ ## Error handling
93
+
94
+ ```python
95
+ from pyclann import ClannAuthError, ClannNotFoundError, ClannValidationError
96
+
97
+ try:
98
+ tree = client.trees.get("non-existent-tree")
99
+ except ClannNotFoundError:
100
+ print("tree not found")
101
+ except ClannAuthError:
102
+ client.login(email, password) # token expired — re-authenticate
103
+ except ClannValidationError as e:
104
+ print("bad request:", e)
105
+ ```
106
+
107
+ | Exception | HTTP status |
108
+ |---|---|
109
+ | `ClannAuthError` | 401 / 403, or `login()` not called |
110
+ | `ClannNotFoundError` | 404 |
111
+ | `ClannValidationError` | 400 |
112
+ | `ClannServerError` | 5xx |
113
+ | `ClannError` | base class |
114
+
115
+ ## Record IDs
116
+
117
+ The Clann API uses SurrealDB record IDs of the form `"table:ulid"`, e.g.
118
+ `"person:01jd4a8xyz"`. All client methods that take an `*_id` parameter accept
119
+ either the full form or the bare ULID — the library strips the table prefix when
120
+ building URL paths.
121
+
122
+ ```python
123
+ person = client.persons.get("person:01jd4a8xyz") # full ID
124
+ person = client.persons.get("01jd4a8xyz") # bare ULID — both work
125
+ ```
126
+
127
+ Note: when specifying a `related_id` in relationship calls, always pass the **full**
128
+ record ID (`"person:01jd4a8xyz"`), since the server uses it verbatim in URL paths.
129
+
130
+ ## Relationship types
131
+
132
+ ```python
133
+ from pyclann import RelationshipType, SiblingType
134
+
135
+ # Add a father
136
+ client.relationships.add(child.id, RelationshipType.FATHER, father.id)
137
+
138
+ # Add a sibling — sibling_type is required
139
+ client.relationships.add(
140
+ person.id,
141
+ RelationshipType.SIBLING,
142
+ sibling.id,
143
+ sibling_type=SiblingType.BROTHER,
144
+ )
145
+
146
+ # Add a spouse with dates
147
+ client.relationships.add(
148
+ person.id,
149
+ RelationshipType.SPOUSE,
150
+ spouse.id,
151
+ spouse_from="1845-09-14",
152
+ )
153
+
154
+ # Remove a relationship
155
+ client.relationships.remove(child.id, RelationshipType.FATHER, father.id)
156
+ ```
157
+
158
+ ## Image uploads
159
+
160
+ Profile pictures accept JPEG or PNG only (max 2 MB). Life-story media accepts
161
+ the same image formats on Individual/Family plans; Professional/Enterprise plans
162
+ also allow video (MP4, MOV, WebM), audio (MP3, WAV, OGG), and PDF.
163
+
164
+ ```python
165
+ # Upload
166
+ with open("profile.jpg", "rb") as f:
167
+ client.persons.upload_image(person.id, f.read(), "image/jpeg")
168
+
169
+ # Download — no login() required
170
+ data = client.persons.get_image(person.id)
171
+ with open("downloaded.jpg", "wb") as f:
172
+ f.write(data)
173
+ ```
174
+
175
+ ## Licence
176
+
177
+ MIT
@@ -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
+ ]
@@ -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)