cgcardona / muse public
hub_client.py python
388 lines 11.8 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Hub HTTP client with JWT bearer authentication.
2
3 Reads the auth token from ``.muse/config.toml`` and injects it into every
4 outbound request as ``Authorization: Bearer <token>``. The token value is
5 never written to logs — log lines use ``"Bearer ***"`` as a placeholder.
6
7 Usage::
8
9 async with MuseHubClient(base_url="https://hub.example.com", repo_root=root) as hub:
10 response = await hub.post("/push", json=payload)
11
12 If ``[auth] token`` is missing or empty in ``.muse/config.toml``, the client
13 raises :class:`typer.Exit` with exit-code ``1`` and prints an actionable
14 error message via :func:`typer.echo` before raising.
15
16 Security note: ``.muse/config.toml`` should be added to ``.gitignore`` to
17 prevent the token from being committed to version control.
18 """
19 from __future__ import annotations
20
21 import logging
22 import pathlib
23 import types
24 from typing import TypedDict
25
26 import httpx
27 import typer
28
29 from maestro.muse_cli.config import get_auth_token
30 from maestro.muse_cli.errors import ExitCode
31
32 logger = logging.getLogger(__name__)
33
34 _MISSING_TOKEN_MSG = (
35 "No auth token configured. "
36 'Add `token = "..."` under `[auth]` in `.muse/config.toml`.'
37 )
38
39
40 # ---------------------------------------------------------------------------
41 # Push / Pull typed payload contracts
42 # ---------------------------------------------------------------------------
43
44
45 class PushCommitPayload(TypedDict):
46 """A single commit record sent to the Hub during a push.
47
48 All datetime fields are ISO-8601 strings (UTC). ``metadata`` carries
49 music-domain annotations (tempo_bpm, key, meter, etc.).
50 """
51
52 commit_id: str
53 parent_commit_id: str | None
54 snapshot_id: str
55 branch: str
56 message: str
57 author: str
58 committed_at: str
59 metadata: dict[str, object] | None
60
61
62 class PushObjectPayload(TypedDict):
63 """A content-addressed object descriptor sent during a push."""
64
65 object_id: str
66 size_bytes: int
67
68
69 class PushTagPayload(TypedDict):
70 """A VCS-style tag ref sent during a push with ``--tags``.
71
72 Represents a lightweight ref stored in ``.muse/refs/tags/<tag_name>``
73 that points to a commit ID.
74 """
75
76 tag_name: str
77 commit_id: str
78
79
80 class _PushRequestRequired(TypedDict):
81 """Required fields for every push request."""
82
83 branch: str
84 head_commit_id: str
85 commits: list[PushCommitPayload]
86 objects: list[PushObjectPayload]
87
88
89 class PushRequest(_PushRequestRequired, total=False):
90 """Payload sent to ``POST /musehub/repos/{repo_id}/push``.
91
92 Optional flags control override behaviour and extra data:
93
94 - ``force``: overwrite remote branch even on non-fast-forward.
95 - ``force_with_lease``: overwrite only if remote HEAD matches
96 ``expected_remote_head``; the Hub must reject if the remote has
97 advanced since we last fetched.
98 - ``expected_remote_head``: the commit ID we believe the remote HEAD to
99 be (required when ``force_with_lease`` is ``True``).
100 - ``tags``: VCS-style tag refs from ``.muse/refs/tags/`` to push alongside
101 the branch commits.
102 """
103
104 force: bool
105 force_with_lease: bool
106 expected_remote_head: str | None
107 tags: list[PushTagPayload]
108
109
110 class PushResponse(TypedDict):
111 """Response from the Hub push endpoint."""
112
113 accepted: bool
114 message: str
115
116
117 class _PullRequestRequired(TypedDict):
118 """Required fields for every pull request."""
119
120 branch: str
121 have_commits: list[str]
122 have_objects: list[str]
123
124
125 class PullRequest(_PullRequestRequired, total=False):
126 """Payload sent to ``POST /musehub/repos/{repo_id}/pull``.
127
128 Optional flags are informational hints for the Hub (and drive local
129 post-fetch behaviour):
130
131 - ``rebase``: caller intends to rebase local commits onto the fetched
132 remote HEAD rather than merge.
133 - ``ff_only``: caller will refuse to integrate if the result would not be
134 a fast-forward; the Hub may use this to gate the response.
135 """
136
137 rebase: bool
138 ff_only: bool
139
140
141 class PullCommitPayload(TypedDict):
142 """A single commit record received from the Hub during a pull."""
143
144 commit_id: str
145 parent_commit_id: str | None
146 snapshot_id: str
147 branch: str
148 message: str
149 author: str
150 committed_at: str
151 metadata: dict[str, object] | None
152
153
154 class PullObjectPayload(TypedDict):
155 """A content-addressed object descriptor received during a pull."""
156
157 object_id: str
158 size_bytes: int
159
160
161 class PullResponse(TypedDict):
162 """Response from the Hub pull endpoint.
163
164 ``diverged`` is ``True`` when the remote HEAD is not an ancestor of the
165 local branch HEAD — the caller should display a divergence warning.
166 """
167
168 commits: list[PullCommitPayload]
169 objects: list[PullObjectPayload]
170 remote_head: str | None
171 diverged: bool
172
173
174 # ---------------------------------------------------------------------------
175 # Fetch typed payload contracts
176 # ---------------------------------------------------------------------------
177
178
179 class FetchRequest(TypedDict):
180 """Payload sent to ``POST /musehub/repos/{repo_id}/fetch``.
181
182 ``branches`` lists the specific branch names to fetch. An empty list
183 means "fetch all branches" — the Hub returns all it knows about.
184 """
185
186 branches: list[str]
187
188
189 class FetchBranchInfo(TypedDict):
190 """A single branch's current state on the remote, returned by fetch.
191
192 ``is_new`` is ``True`` when the branch does not yet exist in the local
193 remote-tracking refs (so the CLI can print "(new branch)" in the report).
194 ``head_commit_id`` is the short-form commit ID suitable for display.
195 """
196
197 branch: str
198 head_commit_id: str
199 is_new: bool
200
201
202 class FetchResponse(TypedDict):
203 """Response from the Hub fetch endpoint.
204
205 ``branches`` lists every branch the Hub knows about (filtered by the
206 request's ``branches`` list when non-empty). The caller uses this to
207 update local remote-tracking refs and, when ``--prune`` is active, to
208 identify stale local refs that should be removed.
209 """
210
211 branches: list[FetchBranchInfo]
212
213
214 # ---------------------------------------------------------------------------
215 # MuseHubClient
216 # ---------------------------------------------------------------------------
217
218
219 class MuseHubClient:
220 """Async HTTP client for the Muse Hub API.
221
222 Wraps :class:`httpx.AsyncClient` and injects the Bearer token read from
223 ``.muse/config.toml`` into every request. All auth logic is handled at
224 construction time — if the token is absent the caller never reaches the
225 first network call.
226
227 Args:
228 base_url: Muse Hub base URL (e.g. ``"https://hub.example.com"``).
229 repo_root: Repository root to search for ``.muse/config.toml``.
230 Defaults to ``Path.cwd()``.
231 timeout: Request timeout in seconds (default 30).
232 """
233
234 def __init__(
235 self,
236 base_url: str,
237 repo_root: pathlib.Path | None = None,
238 timeout: float = 30.0,
239 ) -> None:
240 self._base_url = base_url
241 self._repo_root = repo_root
242 self._timeout = timeout
243 self._client: httpx.AsyncClient | None = None
244
245 # ------------------------------------------------------------------
246 # Internal helpers
247 # ------------------------------------------------------------------
248
249 def _build_auth_headers(self) -> dict[str, str]:
250 """Return ``{"Authorization": "Bearer <token>"}`` or exit 1.
251
252 Reads the token from ``.muse/config.toml`` via
253 :func:`~maestro.muse_cli.config.get_auth_token`. If the token is
254 absent or empty, prints an actionable message and raises
255 :class:`typer.Exit` with code 1.
256
257 The raw token value is never logged.
258 """
259 token = get_auth_token(self._repo_root)
260 if not token:
261 typer.echo(_MISSING_TOKEN_MSG)
262 raise typer.Exit(code=int(ExitCode.USER_ERROR))
263 logger.debug("✅ MuseHubClient auth header set (Bearer ***)")
264 return {"Authorization": f"Bearer {token}"}
265
266 # ------------------------------------------------------------------
267 # Async context manager
268 # ------------------------------------------------------------------
269
270 async def __aenter__(self) -> MuseHubClient:
271 headers = self._build_auth_headers()
272 self._client = httpx.AsyncClient(
273 base_url=self._base_url,
274 headers=headers,
275 timeout=self._timeout,
276 )
277 return self
278
279 async def __aexit__(
280 self,
281 exc_type: type[BaseException] | None,
282 exc_val: BaseException | None,
283 exc_tb: types.TracebackType | None,
284 ) -> None:
285 if self._client is not None:
286 await self._client.aclose()
287 self._client = None
288
289 # ------------------------------------------------------------------
290 # HTTP verb helpers (thin wrappers around httpx.AsyncClient)
291 # ------------------------------------------------------------------
292
293 def _require_client(self) -> httpx.AsyncClient:
294 """Return the underlying client or raise if not inside context manager."""
295 if self._client is None:
296 raise RuntimeError(
297 "MuseHubClient must be used as an async context manager."
298 )
299 return self._client
300
301 async def get(self, path: str, **kwargs: object) -> httpx.Response:
302 """Issue a GET request to *path*."""
303 return await self._require_client().get(path, **kwargs) # type: ignore[arg-type] # httpx stubs use Any for kwargs
304
305 async def post(self, path: str, **kwargs: object) -> httpx.Response:
306 """Issue a POST request to *path*."""
307 return await self._require_client().post(path, **kwargs) # type: ignore[arg-type] # httpx stubs use Any for kwargs
308
309 async def put(self, path: str, **kwargs: object) -> httpx.Response:
310 """Issue a PUT request to *path*."""
311 return await self._require_client().put(path, **kwargs) # type: ignore[arg-type] # httpx stubs use Any for kwargs
312
313 async def delete(self, path: str, **kwargs: object) -> httpx.Response:
314 """Issue a DELETE request to *path*."""
315 return await self._require_client().delete(path, **kwargs) # type: ignore[arg-type] # httpx stubs use Any for kwargs
316
317
318 class CloneRequest(TypedDict):
319 """Payload sent to ``POST /musehub/repos/{repo_id}/clone``.
320
321 ``branch`` selects which branch HEAD to seed the clone from. When
322 ``depth`` is set the Hub returns only the last *N* commits (shallow
323 clone). ``single_track`` restricts returned file paths to those
324 whose first path component matches the given instrument track name
325 (e.g. ``"drums"``).
326 """
327
328 branch: str | None
329 depth: int | None
330 single_track: str | None
331
332
333 class CloneCommitPayload(TypedDict):
334 """A single commit record received from the Hub during a clone."""
335
336 commit_id: str
337 parent_commit_id: str | None
338 snapshot_id: str
339 branch: str
340 message: str
341 author: str
342 committed_at: str
343 metadata: dict[str, object] | None
344
345
346 class CloneObjectPayload(TypedDict):
347 """A content-addressed object descriptor received during a clone."""
348
349 object_id: str
350 size_bytes: int
351
352
353 class CloneResponse(TypedDict):
354 """Response from the Hub clone endpoint.
355
356 ``repo_id`` is the canonical Hub identifier for the cloned repository
357 stored in ``<target>/.muse/repo.json`` so subsequent push/pull calls can
358 address the correct Hub repo. ``default_branch`` is the branch that
359 ``remote_head`` belongs to. ``commits`` and ``objects`` carry the
360 payload to seed the local database.
361 """
362
363 repo_id: str
364 default_branch: str
365 remote_head: str | None
366 commits: list[CloneCommitPayload]
367 objects: list[CloneObjectPayload]
368
369
370 __all__ = [
371 "MuseHubClient",
372 "PushCommitPayload",
373 "PushObjectPayload",
374 "PushTagPayload",
375 "PushRequest",
376 "PushResponse",
377 "PullRequest",
378 "PullCommitPayload",
379 "PullObjectPayload",
380 "PullResponse",
381 "CloneRequest",
382 "CloneCommitPayload",
383 "CloneObjectPayload",
384 "CloneResponse",
385 "FetchRequest",
386 "FetchBranchInfo",
387 "FetchResponse",
388 ]