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.
- tyrtulia_tui/__init__.py +3 -0
- tyrtulia_tui/__main__.py +3 -0
- tyrtulia_tui/app.py +222 -0
- tyrtulia_tui/client/__init__.py +1 -0
- tyrtulia_tui/client/errors.py +17 -0
- tyrtulia_tui/client/forum.py +324 -0
- tyrtulia_tui/client/hal.py +97 -0
- tyrtulia_tui/client/ssh.py +39 -0
- tyrtulia_tui/client/storage.py +112 -0
- tyrtulia_tui/main.py +60 -0
- tyrtulia_tui/py.typed +0 -0
- tyrtulia_tui/screens/__init__.py +1 -0
- tyrtulia_tui/screens/common.py +134 -0
- tyrtulia_tui/screens/notifications.py +116 -0
- tyrtulia_tui/screens/profile.py +137 -0
- tyrtulia_tui/screens/search.py +83 -0
- tyrtulia_tui/screens/thread.py +342 -0
- tyrtulia_tui/screens/threads.py +140 -0
- tyrtulia_tui-1.0.0.dist-info/METADATA +138 -0
- tyrtulia_tui-1.0.0.dist-info/RECORD +23 -0
- tyrtulia_tui-1.0.0.dist-info/WHEEL +4 -0
- tyrtulia_tui-1.0.0.dist-info/entry_points.txt +3 -0
- tyrtulia_tui-1.0.0.dist-info/licenses/LICENSE +674 -0
tyrtulia_tui/__init__.py
ADDED
tyrtulia_tui/__main__.py
ADDED
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")
|