tyrtulia-tui 1.0.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.
@@ -0,0 +1,3 @@
1
+ """Terminal client for Tyrtulia."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from tyrtulia_tui.main import main
2
+
3
+ main()
tyrtulia_tui/app.py ADDED
@@ -0,0 +1,222 @@
1
+ """The Textual application."""
2
+
3
+ import asyncio
4
+ import json
5
+ from functools import partial
6
+ from pathlib import Path
7
+
8
+ from textual import work
9
+ from textual.app import App
10
+ from textual.binding import Binding
11
+ from textual.markup import escape
12
+
13
+ from tyrtulia_tui.client import ssh
14
+ from tyrtulia_tui.client.errors import ApiError, NotConnected
15
+ from tyrtulia_tui.client.forum import Forum
16
+ from tyrtulia_tui.client.hal import Resource
17
+ from tyrtulia_tui.client.storage import ReadPositions, Tokens
18
+ from tyrtulia_tui.screens.common import Prompt, describe, then
19
+ from tyrtulia_tui.screens.notifications import NotificationsScreen
20
+ from tyrtulia_tui.screens.profile import ProfileScreen
21
+ from tyrtulia_tui.screens.search import SearchScreen
22
+ from tyrtulia_tui.screens.thread import ThreadScreen
23
+ from tyrtulia_tui.screens.threads import ThreadsScreen
24
+
25
+ RECONNECT_SECONDS = 5.0
26
+
27
+
28
+ class TyrtuliaApp(App[None]):
29
+ TITLE = "Tyrtulia"
30
+ BINDINGS = [
31
+ Binding("q", "quit", "Quit"),
32
+ Binding("i", "notifications", "Notifications"),
33
+ Binding("slash", "search", "Search"),
34
+ Binding("p", "profile", "Profile"),
35
+ Binding("l", "login", "Log in"),
36
+ Binding("o", "logout", "Log out"),
37
+ ]
38
+
39
+ def __init__(
40
+ self,
41
+ forum: Forum,
42
+ *,
43
+ instance: str,
44
+ positions: ReadPositions,
45
+ tokens: Tokens | None = None,
46
+ username: str | None = None,
47
+ key: Path | None = None,
48
+ live: bool = True,
49
+ ) -> None:
50
+ super().__init__()
51
+ self.forum = forum
52
+ self.instance = instance
53
+ self.positions = positions
54
+ self.tokens = tokens
55
+ self.username = username
56
+ self.key = key
57
+ self.live = live
58
+ self.root: Resource | None = None
59
+ self.unread: int | None = None
60
+
61
+ # Start
62
+
63
+ async def on_mount(self) -> None:
64
+ try:
65
+ await self.connect()
66
+ except (ApiError, NotConnected) as error:
67
+ self.exit(return_code=1, message=describe(error))
68
+ return
69
+ await self.push_screen(ThreadsScreen())
70
+
71
+ async def connect(self) -> None:
72
+ forum = self.forum
73
+ try:
74
+ self.root = await forum.root(refresh=True)
75
+ except ApiError as error:
76
+ if error.status != 401 or forum.via_socket:
77
+ raise
78
+ # The saved token expired or was revoked: continue without it.
79
+ self.forget_token()
80
+ self.root = await forum.root(refresh=True)
81
+ if not forum.via_socket and not forum.authenticated and self.username and self.key:
82
+ try:
83
+ await self.login(self.username, self.key)
84
+ except Exception as error:
85
+ self.notify(f"Login failed: {describe(error)}", severity="error", markup=False)
86
+ await self.after_identity_change()
87
+
88
+ async def after_identity_change(self) -> None:
89
+ self.root = await self.forum.root(refresh=True)
90
+ self.title = escape(str(self.root["name"]))
91
+ await self.update_unread()
92
+ self.refresh_bindings()
93
+ if self.live and self.forum.authenticated:
94
+ self.listen()
95
+
96
+ async def update_unread(self) -> None:
97
+ try:
98
+ me = await self.forum.me()
99
+ except ApiError, NotConnected:
100
+ return
101
+ if me is None:
102
+ self.sub_title = "reading as a guest · l to log in"
103
+ return
104
+ self.unread = me.get("unreadNotificationsCount")
105
+ unread = f" · {self.unread} unread" if self.unread else ""
106
+ self.sub_title = escape(f"{me['username']} ({me['role']}){unread}")
107
+
108
+ # Identity
109
+
110
+ async def login(self, username: str, key: Path) -> None:
111
+ token = await self.forum.login(username, lambda challenge: ssh.sign(challenge, key))
112
+ if self.tokens is not None:
113
+ self.tokens.set(self.instance, token)
114
+
115
+ def forget_token(self) -> None:
116
+ self.forum.token = None
117
+ if self.tokens is not None:
118
+ self.tokens.set(self.instance, None)
119
+
120
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
121
+ authenticated = self.forum.authenticated
122
+ if action in ("notifications", "profile"):
123
+ return authenticated
124
+ if action == "login":
125
+ return not self.forum.via_socket and not authenticated
126
+ if action == "logout":
127
+ return not self.forum.via_socket and authenticated
128
+ return True
129
+
130
+ def action_login(self) -> None:
131
+ self.push_screen(Prompt("Username", self.username or ""), then(self.ask_key))
132
+
133
+ def ask_key(self, username: str | None) -> None:
134
+ if not username:
135
+ return
136
+ default = self.key or ssh.default_key() or Path.home() / ".ssh" / "id_ed25519"
137
+ self.push_screen(
138
+ Prompt("Private key (or its .pub, to sign with the agent)", str(default)),
139
+ then(partial(self.run_login, username)),
140
+ )
141
+
142
+ @work(exclusive=True, group="login")
143
+ async def run_login(self, username: str, key: str | None) -> None:
144
+ if not key:
145
+ return
146
+ try:
147
+ await self.login(username, Path(key).expanduser())
148
+ except Exception as error:
149
+ self.notify(f"Login failed: {describe(error)}", severity="error", markup=False)
150
+ return
151
+ self.username = username
152
+ self.notify(f"Logged in as {username}", markup=False)
153
+ await self.after_identity_change()
154
+ self.reload_screen()
155
+
156
+ @work(exclusive=True, group="login")
157
+ async def action_logout(self) -> None:
158
+ try:
159
+ await self.forum.logout()
160
+ except Exception as error:
161
+ self.notify(describe(error), severity="error", markup=False)
162
+ self.forget_token()
163
+ self.workers.cancel_group(self, "events")
164
+ await self.after_identity_change()
165
+ self.reload_screen()
166
+
167
+ # Navigation
168
+
169
+ def open_thread(self, href: str, post_number: int | None = None) -> None:
170
+ screen = self.screen
171
+ if isinstance(screen, ThreadScreen) and screen.href == href:
172
+ screen.focus_post = post_number
173
+ screen.load()
174
+ return
175
+ self.push_screen(ThreadScreen(href, post_number))
176
+
177
+ def action_notifications(self) -> None:
178
+ self.push_screen(NotificationsScreen())
179
+
180
+ def action_search(self) -> None:
181
+ self.push_screen(SearchScreen())
182
+
183
+ def action_profile(self) -> None:
184
+ self.push_screen(ProfileScreen())
185
+
186
+ def reload_screen(self) -> None:
187
+ screen = self.screen
188
+ load = getattr(screen, "load", None)
189
+ if callable(load):
190
+ load()
191
+ screen.refresh_bindings()
192
+
193
+ # Real time
194
+
195
+ @work(exclusive=True, group="events")
196
+ async def listen(self) -> None:
197
+ last_event_id: str | None = None
198
+ while self.forum.authenticated:
199
+ try:
200
+ async for event in self.forum.events(last_event_id):
201
+ last_event_id = event.id or last_event_id
202
+ await self.on_server_event(event.event, event.data)
203
+ except ApiError, NotConnected:
204
+ pass
205
+ await asyncio.sleep(RECONNECT_SECONDS)
206
+
207
+ async def on_server_event(self, name: str, data: str) -> None:
208
+ if name == "reset":
209
+ await self.update_unread()
210
+ self.reload_screen()
211
+ return
212
+ if name != "notification.created":
213
+ return
214
+ try:
215
+ kind = json.loads(data).get("kind", "notification")
216
+ except ValueError:
217
+ kind = "notification"
218
+ self.bell()
219
+ self.notify(f"New {kind} notification · i to open", title="Tyrtulia", markup=False)
220
+ await self.update_unread()
221
+ if isinstance(self.screen, (ThreadsScreen, ThreadScreen, NotificationsScreen)):
222
+ self.reload_screen()
@@ -0,0 +1 @@
1
+ """A Tyrtulia API client that only knows ``GET /`` and follows links."""
@@ -0,0 +1,17 @@
1
+ """Errors returned by the server as RFC 9457 problem documents."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ApiError(Exception):
7
+ def __init__(self, status: int, problem: dict[str, Any]) -> None:
8
+ self.status = status
9
+ self.problem = problem
10
+ self.kind = str(problem.get("type", "about:blank")).rsplit("/", 1)[-1]
11
+ self.title = str(problem.get("title", "Error"))
12
+ self.detail = str(problem.get("detail") or self.title)
13
+ super().__init__(f"{status} {self.kind}: {self.detail}")
14
+
15
+
16
+ class NotConnected(Exception):
17
+ """The configured socket or URL cannot be reached."""
@@ -0,0 +1,324 @@
1
+ """The forum as seen by a client: every operation follows a link."""
2
+
3
+ import json as jsonlib
4
+ from collections.abc import AsyncIterator, Awaitable, Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from tyrtulia_tui.client.errors import ApiError, NotConnected
11
+ from tyrtulia_tui.client.hal import Link, Page, Resource
12
+
13
+ API_TYPE = "application/vnd.tyrtulia.v1+json"
14
+ MERGE_PATCH = "application/merge-patch+json"
15
+ EVENT_STREAM = "text/event-stream"
16
+
17
+ type Signer = Callable[[str], Awaitable[str]]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ServerEvent:
22
+ id: str | None
23
+ event: str
24
+ data: str
25
+
26
+
27
+ class Forum:
28
+ """A connection to one Tyrtulia instance, through the socket or over HTTP."""
29
+
30
+ def __init__(
31
+ self, http: httpx.AsyncClient, *, via_socket: bool, token: str | None = None
32
+ ) -> None:
33
+ self.http = http
34
+ self.via_socket = via_socket
35
+ self.token = token
36
+ self._root: Resource | None = None
37
+
38
+ # Plumbing
39
+
40
+ def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
41
+ headers = {"Accept": API_TYPE}
42
+ if self.token and not self.via_socket:
43
+ headers["Authorization"] = f"Bearer {self.token}"
44
+ headers.update(extra or {})
45
+ return headers
46
+
47
+ async def request(
48
+ self,
49
+ target: Link | str,
50
+ *,
51
+ method: str | None = None,
52
+ json: dict[str, Any] | None = None,
53
+ headers: dict[str, str] | None = None,
54
+ ) -> httpx.Response:
55
+ href = target.href if isinstance(target, Link) else target
56
+ verb = method or (target.method if isinstance(target, Link) else "GET")
57
+ extra = dict(headers or {})
58
+ content = None
59
+ if json is not None:
60
+ content = jsonlib.dumps(json).encode()
61
+ extra.setdefault("Content-Type", MERGE_PATCH if verb == "PATCH" else "application/json")
62
+ try:
63
+ response = await self.http.request(
64
+ verb, href, content=content, headers=self._headers(extra)
65
+ )
66
+ except httpx.TransportError as error:
67
+ raise NotConnected(str(error) or type(error).__name__) from error
68
+ if response.status_code >= 400:
69
+ raise _problem(response)
70
+ return response
71
+
72
+ async def get(self, target: Link | str) -> Resource:
73
+ response = await self.request(target, method="GET")
74
+ return Resource.parse(response.json())
75
+
76
+ async def send(
77
+ self,
78
+ link: Link,
79
+ json: dict[str, Any] | None = None,
80
+ headers: dict[str, str] | None = None,
81
+ ) -> Resource | None:
82
+ response = await self.request(link, json=json, headers=headers)
83
+ if response.status_code in (202, 204) or not response.content:
84
+ return None
85
+ return Resource.parse(response.json())
86
+
87
+ async def page(self, target: Link | str) -> Page:
88
+ resource = await self.get(target)
89
+ embedded = resource.get("_embedded", {})
90
+ name = next(iter(embedded), None)
91
+ items = resource.embedded_list(name) if name else []
92
+ return Page(items, resource)
93
+
94
+ async def root(self, *, refresh: bool = False) -> Resource:
95
+ if self._root is None or refresh:
96
+ self._root = await self.get("/")
97
+ return self._root
98
+
99
+ async def aclose(self) -> None:
100
+ await self.http.aclose()
101
+
102
+ # Identity
103
+
104
+ @property
105
+ def authenticated(self) -> bool:
106
+ return self._root is not None and self._root.has("me")
107
+
108
+ async def me(self) -> Resource | None:
109
+ root = await self.root()
110
+ return await self.get(root.link("me")) if root.has("me") else None
111
+
112
+ async def login(self, username: str, sign: Signer) -> str:
113
+ """Request a challenge, sign it with the SSH key and keep the session token."""
114
+ root = await self.root()
115
+ challenge = await self.send(root.link("sshChallenge"), {"username": username})
116
+ assert challenge is not None
117
+ signature = await sign(str(challenge["challenge"]))
118
+ session = await self.send(
119
+ root.link("login"),
120
+ {"username": username, "challenge": challenge["challenge"], "signature": signature},
121
+ )
122
+ assert session is not None
123
+ self.token = str(session["token"])
124
+ await self.root(refresh=True)
125
+ return self.token
126
+
127
+ async def logout(self) -> None:
128
+ me = await self.me()
129
+ if me is not None and me.has("logout"):
130
+ await self.send(me.link("logout"))
131
+ self.token = None
132
+ await self.root(refresh=True)
133
+
134
+ # Threads and posts
135
+
136
+ async def threads(self, *, watching: bool = False) -> Page:
137
+ root = await self.root()
138
+ rel = "watching" if watching and root.has("watching") else "latest"
139
+ return await self.page(root.link(rel))
140
+
141
+ async def create_thread(self, title: str, body: str) -> Resource:
142
+ root = await self.root()
143
+ thread = await self.send(root.link("createThread"), {"title": title, "body": body})
144
+ assert thread is not None
145
+ return thread
146
+
147
+ async def posts(self, thread: Resource, *, after: int | None = None) -> Page:
148
+ href = thread.link("posts").href
149
+ if after is not None:
150
+ href += f"?after={after}"
151
+ return await self.page(href)
152
+
153
+ async def reply(self, thread: Resource, body: str, reply_to: int | None = None) -> Resource:
154
+ payload: dict[str, Any] = {"body": body}
155
+ if reply_to is not None:
156
+ payload["replyToPostNumber"] = reply_to
157
+ post = await self.send(thread.link("reply"), payload)
158
+ assert post is not None
159
+ return post
160
+
161
+ async def edit_post(self, post: Resource, body: str, reason: str | None = None) -> Resource:
162
+ payload: dict[str, Any] = {"body": body}
163
+ if reason:
164
+ payload["reason"] = reason
165
+ edited = await self.send(
166
+ post.link("edit"), payload, headers={"If-Match": f'"v{post["version"]}"'}
167
+ )
168
+ assert edited is not None
169
+ return edited
170
+
171
+ async def edit_thread(self, thread: Resource, rel: str, **fields: Any) -> Resource:
172
+ """``rel`` is ``edit``, ``pin`` or ``close``; titles need the first post version."""
173
+ headers = {}
174
+ if "title" in fields:
175
+ current = await self.request(thread.link("self"))
176
+ headers["If-Match"] = current.headers["ETag"]
177
+ changed = await self.send(thread.link(rel), fields, headers=headers)
178
+ assert changed is not None
179
+ return changed
180
+
181
+ async def hide(self, post: Resource, hidden: bool, reason: str | None = None) -> Resource:
182
+ rel = "hide" if hidden else "unhide"
183
+ payload: dict[str, Any] = {"hidden": hidden}
184
+ if hidden and reason:
185
+ payload["hiddenReason"] = reason
186
+ changed = await self.send(post.link(rel), payload)
187
+ assert changed is not None
188
+ return changed
189
+
190
+ async def delete(self, resource: Resource) -> None:
191
+ await self.send(resource.link("delete"))
192
+
193
+ async def subscribe(self, thread: Resource, level: str) -> str:
194
+ result = await self.send(thread.link("subscribe"), {"level": level})
195
+ assert result is not None
196
+ return str(result["level"])
197
+
198
+ async def report(self, resource: Resource, message: str) -> None:
199
+ target = resource.link("self").href
200
+ await self.send(resource.link("report"), {"target": target, "message": message})
201
+
202
+ async def thread_of(self, post: Resource) -> Resource:
203
+ return await self.get(post.link("up"))
204
+
205
+ # Search
206
+
207
+ async def search(self, query: str, *, author: str | None = None) -> Page:
208
+ root = await self.root()
209
+ return await self.page(root.link("search").expand(q=query, author=author))
210
+
211
+ # Notifications
212
+
213
+ async def notifications(self, *, unread: bool = False) -> Page:
214
+ root = await self.root()
215
+ href = root.link("notifications").href + ("?filter=unread" if unread else "")
216
+ return await self.page(href)
217
+
218
+ async def mark_read(self, notification: Resource) -> None:
219
+ if notification.has("markRead"):
220
+ await self.send(notification.link("markRead"), {"read": True})
221
+
222
+ async def mark_all_read(self, page: Page) -> None:
223
+ link = page.resource.links.get("markAllRead")
224
+ if link is not None and page.items:
225
+ await self.send(link, {"before": page.items[0]["id"]})
226
+
227
+ async def events(self, last_event_id: str | None = None) -> AsyncIterator[ServerEvent]:
228
+ """The user's event stream; heartbeats are skipped."""
229
+ root = await self.root()
230
+ link = root.link("events")
231
+ headers = self._headers({"Accept": EVENT_STREAM})
232
+ if last_event_id:
233
+ headers["Last-Event-ID"] = last_event_id
234
+ timeout = httpx.Timeout(10.0, read=None)
235
+ try:
236
+ async with self.http.stream(
237
+ "GET", link.href, headers=headers, timeout=timeout
238
+ ) as response:
239
+ if response.status_code >= 400:
240
+ await response.aread()
241
+ raise _problem(response)
242
+ async for event in parse_events(response.aiter_lines()):
243
+ yield event
244
+ except httpx.TransportError as error:
245
+ raise NotConnected(str(error) or type(error).__name__) from error
246
+
247
+ # Profile and keys
248
+
249
+ async def edit_profile(self, **fields: Any) -> Resource:
250
+ me = await self.me()
251
+ assert me is not None
252
+ changed = await self.send(me.link("edit"), fields)
253
+ assert changed is not None
254
+ return changed
255
+
256
+ async def ssh_keys(self) -> Page:
257
+ me = await self.me()
258
+ assert me is not None
259
+ return await self.page(me.link("sshKeys"))
260
+
261
+ async def add_key(self, public_key: str, title: str | None = None) -> Resource:
262
+ keys = await self.ssh_keys()
263
+ payload: dict[str, Any] = {"publicKey": public_key.strip()}
264
+ if title:
265
+ payload["title"] = title
266
+ key = await self.send(keys.resource.link("create"), payload)
267
+ assert key is not None
268
+ return key
269
+
270
+
271
+ def _problem(response: httpx.Response) -> ApiError:
272
+ try:
273
+ problem = response.json()
274
+ except ValueError:
275
+ problem = {"title": response.reason_phrase or "Error"}
276
+ if not isinstance(problem, dict):
277
+ problem = {"title": str(problem)}
278
+ return ApiError(response.status_code, problem)
279
+
280
+
281
+ async def parse_events(lines: AsyncIterator[str]) -> AsyncIterator[ServerEvent]:
282
+ """Server-Sent Events framing: fields until a blank line; comments are ignored."""
283
+ event_id: str | None = None
284
+ name = "message"
285
+ data: list[str] = []
286
+ async for line in lines:
287
+ if line == "":
288
+ if data:
289
+ yield ServerEvent(event_id, name, "\n".join(data))
290
+ name, data = "message", []
291
+ continue
292
+ if line.startswith(":"):
293
+ continue
294
+ field, _, value = line.partition(":")
295
+ value = value.removeprefix(" ")
296
+ if field == "id":
297
+ event_id = value
298
+ elif field == "event":
299
+ name = value
300
+ elif field == "data":
301
+ data.append(value)
302
+
303
+
304
+ def connect(
305
+ *,
306
+ socket: str | None = None,
307
+ url: str | None = None,
308
+ token: str | None = None,
309
+ transport: httpx.AsyncBaseTransport | None = None,
310
+ ) -> Forum:
311
+ """A forum through a Unix socket (no token needed) or over HTTP(S)."""
312
+ timeout = httpx.Timeout(15.0)
313
+ if transport is not None:
314
+ base = url or "http://localhost"
315
+ http = httpx.AsyncClient(base_url=base, transport=transport, timeout=timeout)
316
+ return Forum(http, via_socket=socket is not None, token=token)
317
+ if socket:
318
+ uds = httpx.AsyncHTTPTransport(uds=socket)
319
+ http = httpx.AsyncClient(base_url="http://localhost", transport=uds, timeout=timeout)
320
+ return Forum(http, via_socket=True)
321
+ if not url:
322
+ raise NotConnected("No socket and no URL configured")
323
+ http = httpx.AsyncClient(base_url=url.rstrip("/"), timeout=timeout)
324
+ return Forum(http, via_socket=False, token=token)
@@ -0,0 +1,97 @@
1
+ """HAL documents: links, embedded resources and RFC 6570 templates."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+ from urllib.parse import quote, urlencode
7
+
8
+ _EXPRESSION = re.compile(r"\{([?&]?)([^}]+)\}")
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Link:
13
+ href: str
14
+ method: str = "GET"
15
+ templated: bool = False
16
+ type: str | None = None
17
+ schema: str | None = None
18
+
19
+ @classmethod
20
+ def parse(cls, data: dict[str, Any]) -> Link:
21
+ return cls(
22
+ href=str(data["href"]),
23
+ method=str(data.get("method", "GET")),
24
+ templated=bool(data.get("templated", False)),
25
+ type=data.get("type"),
26
+ schema=data.get("schema"),
27
+ )
28
+
29
+ def expand(self, **values: str | int | None) -> str:
30
+ """Expand simple (``{name}``) and form-style query (``{?a,b}``) expressions."""
31
+ if not self.templated:
32
+ return self.href
33
+
34
+ def replace(match: re.Match[str]) -> str:
35
+ operator, names = match.group(1), match.group(2).split(",")
36
+ if not operator:
37
+ return "".join(quote(str(values.get(n) or ""), safe="") for n in names)
38
+ pairs = [(n, str(values[n])) for n in names if values.get(n) not in (None, "")]
39
+ if not pairs:
40
+ return ""
41
+ return ("?" if operator == "?" else "&") + urlencode(pairs)
42
+
43
+ return _EXPRESSION.sub(replace, self.href)
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Resource:
48
+ """A HAL resource: its own fields, its links and its embedded resources."""
49
+
50
+ data: dict[str, Any]
51
+ links: dict[str, Link] = field(default_factory=dict)
52
+
53
+ @classmethod
54
+ def parse(cls, data: dict[str, Any]) -> Resource:
55
+ links = {rel: Link.parse(value) for rel, value in data.get("_links", {}).items()}
56
+ return cls(data=data, links=links)
57
+
58
+ def __getitem__(self, key: str) -> Any:
59
+ return self.data[key]
60
+
61
+ def get(self, key: str, default: Any = None) -> Any:
62
+ return self.data.get(key, default)
63
+
64
+ def has(self, rel: str) -> bool:
65
+ return rel in self.links
66
+
67
+ def link(self, rel: str) -> Link:
68
+ return self.links[rel]
69
+
70
+ def embedded(self, name: str) -> Resource | None:
71
+ value = self.data.get("_embedded", {}).get(name)
72
+ return Resource.parse(value) if isinstance(value, dict) else None
73
+
74
+ def embedded_list(self, name: str) -> list[Resource]:
75
+ values = self.data.get("_embedded", {}).get(name) or []
76
+ return [Resource.parse(value) for value in values]
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class Page:
81
+ """A page of a collection, with its navigation links."""
82
+
83
+ items: list[Resource]
84
+ resource: Resource
85
+
86
+ @property
87
+ def meta(self) -> dict[str, Any]:
88
+ meta: dict[str, Any] = self.resource.get("meta", {})
89
+ return meta
90
+
91
+ @property
92
+ def next(self) -> Link | None:
93
+ return self.resource.links.get("next")
94
+
95
+ @property
96
+ def prev(self) -> Link | None:
97
+ return self.resource.links.get("prev")