labtab 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.
labtab/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """labtab — command-line interface for LabTab (Wetware Ltd)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
labtab/auth.py ADDED
@@ -0,0 +1,178 @@
1
+ """Device-pairing primitives for the labtab CLI.
2
+
3
+ The flow implemented by this module is:
4
+
5
+ 1. ``POST /cli/pair/start/`` with ``{client_info: {user_agent, hostname}}``.
6
+ 2. The server returns ``{pairing_code, confirm_url, expires_in}``.
7
+ 3. The user opens ``confirm_url`` in their browser (we try to open it for them
8
+ via :mod:`webbrowser`) and approves the pairing.
9
+ 4. Meanwhile the CLI polls ``GET /cli/pair/poll/?code=<pairing_code>`` every
10
+ ``poll_interval`` seconds. On ``status == "confirmed"`` the server returns
11
+ an API key plus a user profile which we persist to the config file.
12
+ 5. If the server reports ``expired`` or ``denied``, or we exceed ``timeout``
13
+ seconds of polling, we raise :class:`PairingError`.
14
+
15
+ Agent L's backend endpoints are what we consume here. Until those endpoints
16
+ land, tests mock the HTTP responses via :mod:`pytest_httpx`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import platform
22
+ import socket
23
+ import time
24
+ import webbrowser
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ from . import __version__
30
+ from .client import LabtabClient
31
+ from .config import Config
32
+ from .errors import APIError, PairingError
33
+ from .models import CLIPairConfirm, CLIPairStart
34
+
35
+ DEFAULT_POLL_INTERVAL = 2.0
36
+ DEFAULT_TIMEOUT_SECONDS = 600 # 10 minutes
37
+
38
+
39
+ def client_info() -> dict[str, str]:
40
+ """Metadata sent to ``/cli/pair/start/`` so users can identify a terminal."""
41
+
42
+ return {
43
+ "user_agent": f"labtab/{__version__} ({platform.system().lower()})",
44
+ "hostname": socket.gethostname(),
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class PairingResult:
50
+ """Result of a successful pairing — used to populate the config."""
51
+
52
+ api_key: str
53
+ user_email: str | None
54
+ default_lab_id: int | None
55
+ default_lab_slug: str | None
56
+
57
+
58
+ def start_pairing(client: LabtabClient) -> CLIPairStart:
59
+ """Initiate the pairing flow. Does not require authentication."""
60
+
61
+ payload = client.request(
62
+ "POST",
63
+ "/cli/pair/start/",
64
+ json={"client_info": client_info()},
65
+ require_auth=False,
66
+ )
67
+ try:
68
+ return CLIPairStart.model_validate(payload)
69
+ except Exception as exc: # pragma: no cover - validation details
70
+ raise PairingError(f"Unexpected response from /cli/pair/start/: {exc}") from exc
71
+
72
+
73
+ def poll_pairing(client: LabtabClient, code: str) -> CLIPairConfirm:
74
+ """Poll once for the pairing status."""
75
+
76
+ payload = client.request(
77
+ "GET",
78
+ "/cli/pair/poll/",
79
+ params={"code": code},
80
+ require_auth=False,
81
+ )
82
+ try:
83
+ return CLIPairConfirm.model_validate(payload)
84
+ except Exception as exc: # pragma: no cover - validation details
85
+ raise PairingError(f"Unexpected response from /cli/pair/poll/: {exc}") from exc
86
+
87
+
88
+ def maybe_open_browser(url: str, *, enabled: bool) -> None:
89
+ """Open ``url`` in the default browser unless ``enabled`` is False."""
90
+
91
+ if not enabled:
92
+ return
93
+ try:
94
+ webbrowser.open(url)
95
+ except webbrowser.Error: # pragma: no cover - platform specific
96
+ pass
97
+
98
+
99
+ def run_pairing(
100
+ client: LabtabClient,
101
+ *,
102
+ open_browser: bool = True,
103
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
104
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
105
+ on_start: Callable[[CLIPairStart], None] | None = None,
106
+ sleep: Callable[[float], None] = time.sleep,
107
+ now: Callable[[], float] = time.monotonic,
108
+ ) -> PairingResult:
109
+ """Drive the full pairing flow.
110
+
111
+ ``on_start`` is called once with the start payload so callers can print the
112
+ confirmation URL and code. ``sleep`` and ``now`` are injectable for tests.
113
+ """
114
+
115
+ try:
116
+ start = start_pairing(client)
117
+ except APIError as exc:
118
+ raise PairingError(f"Could not start pairing: {exc.message}") from exc
119
+
120
+ if on_start is not None:
121
+ on_start(start)
122
+ maybe_open_browser(start.confirm_url, enabled=open_browser)
123
+
124
+ deadline = now() + min(timeout, start.expires_in or timeout)
125
+
126
+ while True:
127
+ try:
128
+ poll = poll_pairing(client, start.pairing_code)
129
+ except APIError as exc:
130
+ raise PairingError(f"Pairing poll failed: {exc.message}") from exc
131
+
132
+ status = (poll.status or "").lower()
133
+ if status == "confirmed":
134
+ if not poll.api_key:
135
+ raise PairingError(
136
+ "Pairing confirmed but server did not return an API key.",
137
+ )
138
+ return PairingResult(
139
+ api_key=poll.api_key,
140
+ user_email=poll.user_email,
141
+ default_lab_id=poll.default_lab_id,
142
+ default_lab_slug=poll.default_lab_slug,
143
+ )
144
+ if status == "expired":
145
+ raise PairingError("Pairing code expired. Run `labtab auth login` again.")
146
+ if status == "denied":
147
+ raise PairingError("Pairing was denied in the browser.")
148
+
149
+ if now() >= deadline:
150
+ raise PairingError(
151
+ "Timed out waiting for browser confirmation. Run `labtab auth login` again.",
152
+ )
153
+
154
+ sleep(poll_interval)
155
+
156
+
157
+ def persist_pairing(config: Config, result: PairingResult) -> Config:
158
+ """Write ``result`` into ``config`` and save to disk."""
159
+
160
+ config.api_key = result.api_key
161
+ config.user_email = result.user_email
162
+ config.default_lab_id = result.default_lab_id
163
+ config.default_lab_slug = result.default_lab_slug
164
+ config.save()
165
+ return config
166
+
167
+
168
+ def summarise_identity(config: Config) -> dict[str, Any]:
169
+ """Return a plain dict describing the current signed-in identity."""
170
+
171
+ return {
172
+ "signed_in": bool(config.api_key),
173
+ "user_email": config.user_email,
174
+ "default_lab_id": config.default_lab_id,
175
+ "default_lab_slug": config.default_lab_slug,
176
+ "base_url": config.base_url,
177
+ "config_path": str(config.path),
178
+ }
labtab/cli.py ADDED
@@ -0,0 +1,77 @@
1
+ """Top-level typer app for the labtab CLI.
2
+
3
+ This module wires every command sub-app together. The entry point
4
+ ``labtab = "labtab.cli:app"`` is declared in ``pyproject.toml``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from . import __version__
12
+ from .commands import (
13
+ auth,
14
+ export,
15
+ grants,
16
+ labs,
17
+ meetings,
18
+ papers,
19
+ people,
20
+ projects,
21
+ tasks,
22
+ )
23
+ from .errors import LabtabError
24
+ from .output import print_error
25
+
26
+ app = typer.Typer(
27
+ name="labtab",
28
+ help="LabTab CLI (Wetware Ltd) — manage your lab from the terminal.",
29
+ no_args_is_help=True,
30
+ add_completion=False,
31
+ )
32
+
33
+ app.add_typer(auth.app, name="auth")
34
+ app.add_typer(labs.app, name="labs")
35
+ app.add_typer(grants.app, name="grants")
36
+ app.add_typer(papers.app, name="papers")
37
+ app.add_typer(meetings.app, name="meetings")
38
+ app.add_typer(tasks.app, name="tasks")
39
+ app.add_typer(projects.app, name="projects")
40
+ app.add_typer(people.app, name="people")
41
+
42
+ # ``export`` is a bare command rather than a nested group — we register its
43
+ # sub-app with ``invoke_without_command`` so ``labtab export`` works directly.
44
+ app.add_typer(export.app, name="export")
45
+
46
+
47
+ def _version_callback(value: bool) -> None:
48
+ if value:
49
+ typer.echo(f"labtab {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback()
54
+ def _root(
55
+ version: bool = typer.Option(
56
+ False,
57
+ "--version",
58
+ callback=_version_callback,
59
+ is_eager=True,
60
+ help="Show the labtab version and exit.",
61
+ ),
62
+ ) -> None:
63
+ """Root callback — only exists to host the ``--version`` flag."""
64
+
65
+
66
+ def main() -> None:
67
+ """Entrypoint wrapper that converts :class:`LabtabError` to a clean exit."""
68
+
69
+ try:
70
+ app()
71
+ except LabtabError as exc:
72
+ print_error(exc.message)
73
+ raise typer.Exit(code=exc.exit_code) from exc
74
+
75
+
76
+ if __name__ == "__main__": # pragma: no cover
77
+ main()
labtab/client.py ADDED
@@ -0,0 +1,316 @@
1
+ """HTTP client for the labtab CLI.
2
+
3
+ :class:`LabtabClient` wraps :class:`httpx.Client`, attaches the API key from
4
+ the config as a bearer token, and exposes typed helpers that return validated
5
+ pydantic models. Error responses are translated into the typed exception
6
+ hierarchy defined in :mod:`labtab.errors`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, TypeAdapter, ValidationError
16
+
17
+ from . import __version__
18
+ from .config import Config
19
+ from .errors import (
20
+ APIError,
21
+ NotAuthenticatedError,
22
+ NotFoundError,
23
+ PermissionDeniedError,
24
+ )
25
+ from .models import (
26
+ ExportStatus,
27
+ Grant,
28
+ Lab,
29
+ Meeting,
30
+ Paper,
31
+ Person,
32
+ Project,
33
+ Task,
34
+ User,
35
+ )
36
+
37
+ USER_AGENT = f"labtab/{__version__}"
38
+
39
+
40
+ def _unwrap_list(payload: Any) -> list[Any]:
41
+ """Return a list of items from either a bare list or a DRF paginated dict."""
42
+
43
+ if isinstance(payload, list):
44
+ return payload
45
+ if isinstance(payload, Mapping):
46
+ results = payload.get("results")
47
+ if isinstance(results, list):
48
+ return results
49
+ return []
50
+
51
+
52
+ def _parse_list(model: type[BaseModel], payload: Any) -> list[Any]:
53
+ adapter = TypeAdapter(list[model]) # type: ignore[valid-type]
54
+ try:
55
+ return adapter.validate_python(_unwrap_list(payload))
56
+ except ValidationError as exc:
57
+ raise APIError(f"Could not parse response: {exc}") from exc
58
+
59
+
60
+ def _parse(model: type[BaseModel], payload: Any) -> Any:
61
+ try:
62
+ return model.model_validate(payload)
63
+ except ValidationError as exc:
64
+ raise APIError(f"Could not parse response: {exc}") from exc
65
+
66
+
67
+ class LabtabClient:
68
+ """Minimal typed wrapper over :class:`httpx.Client`.
69
+
70
+ Callers should use the :meth:`from_config` constructor so that the base URL
71
+ and API key are loaded consistently. The client may be used as a context
72
+ manager, but that is optional — ``close()`` is safe to call on an
73
+ already-closed instance.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ base_url: str,
79
+ api_key: str | None,
80
+ *,
81
+ client: httpx.Client | None = None,
82
+ timeout: float = 30.0,
83
+ ) -> None:
84
+ self.base_url = base_url.rstrip("/")
85
+ self.api_key = api_key
86
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
87
+ if api_key:
88
+ headers["Authorization"] = f"Bearer {api_key}"
89
+ self._client = client or httpx.Client(
90
+ base_url=self.base_url,
91
+ headers=headers,
92
+ timeout=timeout,
93
+ )
94
+
95
+ # -- lifecycle ----------------------------------------------------------
96
+
97
+ @classmethod
98
+ def from_config(
99
+ cls,
100
+ config: Config,
101
+ *,
102
+ client: httpx.Client | None = None,
103
+ ) -> LabtabClient:
104
+ return cls(config.base_url, config.api_key, client=client)
105
+
106
+ def __enter__(self) -> LabtabClient:
107
+ return self
108
+
109
+ def __exit__(self, *exc: Any) -> None:
110
+ self.close()
111
+
112
+ def close(self) -> None:
113
+ self._client.close()
114
+
115
+ # -- low-level ---------------------------------------------------------
116
+
117
+ def request(
118
+ self,
119
+ method: str,
120
+ path: str,
121
+ *,
122
+ params: Mapping[str, Any] | None = None,
123
+ json: Any = None,
124
+ require_auth: bool = True,
125
+ ) -> Any:
126
+ """Send a request and return decoded JSON, raising on error."""
127
+
128
+ if require_auth and not self.api_key:
129
+ raise NotAuthenticatedError()
130
+ if params is not None:
131
+ params = {k: v for k, v in params.items() if v is not None}
132
+
133
+ try:
134
+ resp = self._client.request(method, path, params=params, json=json)
135
+ except httpx.TimeoutException as exc:
136
+ raise APIError(f"Request timed out talking to {self.base_url}") from exc
137
+ except httpx.TransportError as exc:
138
+ raise APIError(f"Network error talking to {self.base_url}: {exc}") from exc
139
+
140
+ return self._handle(resp, path=path)
141
+
142
+ def get(self, path: str, **kwargs: Any) -> Any:
143
+ return self.request("GET", path, **kwargs)
144
+
145
+ def post(self, path: str, **kwargs: Any) -> Any:
146
+ return self.request("POST", path, **kwargs)
147
+
148
+ def stream_to_file(self, url: str, destination: Any, *, chunk_size: int = 1 << 15) -> int:
149
+ """Stream ``url`` into ``destination`` (a writable binary stream).
150
+
151
+ ``url`` is used as-is — this is intended for signed S3-style URLs, not
152
+ authenticated API calls. Returns the total byte count written.
153
+ """
154
+
155
+ total = 0
156
+ try:
157
+ with httpx.stream("GET", url, timeout=60.0) as resp:
158
+ if resp.status_code >= 400:
159
+ raise APIError(
160
+ f"Download failed with status {resp.status_code}"
161
+ )
162
+ for chunk in resp.iter_bytes(chunk_size):
163
+ destination.write(chunk)
164
+ total += len(chunk)
165
+ except httpx.TransportError as exc:
166
+ raise APIError(f"Download transport error: {exc}") from exc
167
+ return total
168
+
169
+ # -- error handling ----------------------------------------------------
170
+
171
+ @staticmethod
172
+ def _handle(resp: httpx.Response, *, path: str) -> Any:
173
+ status = resp.status_code
174
+ if status == 204:
175
+ return None
176
+ if 200 <= status < 300:
177
+ if not resp.content:
178
+ return None
179
+ try:
180
+ return resp.json()
181
+ except ValueError as exc:
182
+ raise APIError(f"Invalid JSON from {path}") from exc
183
+ if status == 401:
184
+ raise NotAuthenticatedError()
185
+ if status == 403:
186
+ raise PermissionDeniedError(
187
+ "Permission denied. Your account may not have access to this resource.",
188
+ )
189
+ if status == 404:
190
+ raise NotFoundError(f"Not found: {path}")
191
+ if 400 <= status < 500:
192
+ detail = _extract_detail(resp) or f"Request failed with status {status}"
193
+ raise APIError(detail)
194
+ raise APIError(f"API error ({status}) from {path} — try again later.")
195
+
196
+ # -- typed helpers -----------------------------------------------------
197
+
198
+ def whoami(self) -> User:
199
+ data = self.get("/users/me/")
200
+ return _parse(User, data)
201
+
202
+ def list_labs(self) -> list[Lab]:
203
+ data = self.get("/labs/")
204
+ return _parse_list(Lab, data)
205
+
206
+ def list_grants(
207
+ self,
208
+ *,
209
+ lab: str | int | None = None,
210
+ status: str | None = None,
211
+ ) -> list[Grant]:
212
+ params = {"lab": lab, "status": status}
213
+ return _parse_list(Grant, self.get("/grants/", params=params))
214
+
215
+ def get_grant(self, grant_id: str | int) -> Grant:
216
+ return _parse(Grant, self.get(f"/grants/{grant_id}/"))
217
+
218
+ def list_papers(
219
+ self,
220
+ *,
221
+ lab: str | int | None = None,
222
+ year: int | None = None,
223
+ ) -> list[Paper]:
224
+ params = {"lab": lab, "year": year}
225
+ return _parse_list(Paper, self.get("/papers/", params=params))
226
+
227
+ def get_paper(self, paper_id: str | int) -> Paper:
228
+ return _parse(Paper, self.get(f"/papers/{paper_id}/"))
229
+
230
+ def list_meetings(
231
+ self,
232
+ *,
233
+ lab: str | int | None = None,
234
+ upcoming_days: int | None = None,
235
+ ) -> list[Meeting]:
236
+ params = {"lab": lab, "upcoming_days": upcoming_days}
237
+ return _parse_list(Meeting, self.get("/meetings/", params=params))
238
+
239
+ def get_meeting(self, meeting_id: str | int) -> Meeting:
240
+ return _parse(Meeting, self.get(f"/meetings/{meeting_id}/"))
241
+
242
+ def list_tasks(
243
+ self,
244
+ *,
245
+ lab: str | int | None = None,
246
+ status: str | None = None,
247
+ assigned_to_me: bool | None = None,
248
+ ) -> list[Task]:
249
+ params: dict[str, Any] = {"lab": lab, "status": status}
250
+ if assigned_to_me:
251
+ params["assigned_to_me"] = "true"
252
+ return _parse_list(Task, self.get("/tasks/", params=params))
253
+
254
+ def get_task(self, task_id: str | int) -> Task:
255
+ return _parse(Task, self.get(f"/tasks/{task_id}/"))
256
+
257
+ def list_projects(self, *, lab: str | int | None = None) -> list[Project]:
258
+ return _parse_list(Project, self.get("/projects/", params={"lab": lab}))
259
+
260
+ def get_project(self, project_id: str | int) -> Project:
261
+ return _parse(Project, self.get(f"/projects/{project_id}/"))
262
+
263
+ def list_people(self, *, lab: str | int | None = None) -> list[Person]:
264
+ return _parse_list(Person, self.get("/people/", params={"lab": lab}))
265
+
266
+ def get_person(self, person_id: str | int) -> Person:
267
+ return _parse(Person, self.get(f"/people/{person_id}/"))
268
+
269
+ def start_export(
270
+ self,
271
+ *,
272
+ lab_id: str | int,
273
+ fmt: str = "zip",
274
+ ) -> ExportStatus:
275
+ payload = self.post(f"/labs/{lab_id}/export/", json={"format": fmt})
276
+ return _parse(ExportStatus, payload)
277
+
278
+ def get_export_status(
279
+ self,
280
+ *,
281
+ lab_id: str | int,
282
+ export_id: str | int,
283
+ ) -> ExportStatus:
284
+ data = self.get(f"/labs/{lab_id}/export/{export_id}/")
285
+ return _parse(ExportStatus, data)
286
+
287
+
288
+ def _extract_detail(resp: httpx.Response) -> str | None:
289
+ try:
290
+ body = resp.json()
291
+ except ValueError:
292
+ return None
293
+ if isinstance(body, Mapping):
294
+ for key in ("detail", "message", "error"):
295
+ value = body.get(key)
296
+ if isinstance(value, str):
297
+ return value
298
+ return None
299
+
300
+
301
+ def build_client(config: Config, *, timeout: float = 30.0) -> httpx.Client:
302
+ """Return a raw :class:`httpx.Client` configured with the user's auth.
303
+
304
+ Complements :class:`LabtabClient` for call sites that need lower-level
305
+ access (multipart uploads, streaming downloads) without the parsed-model
306
+ layer.
307
+ """
308
+
309
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
310
+ if config.api_key:
311
+ headers["Authorization"] = f"Bearer {config.api_key}"
312
+ return httpx.Client(
313
+ base_url=config.base_url.rstrip("/"),
314
+ headers=headers,
315
+ timeout=timeout,
316
+ )
@@ -0,0 +1,5 @@
1
+ """Typer sub-apps for the labtab CLI.
2
+
3
+ Each module exposes a module-level :class:`typer.Typer` named ``app`` that is
4
+ registered by :mod:`labtab.cli`.
5
+ """
@@ -0,0 +1,55 @@
1
+ """Shared helpers used by every command module.
2
+
3
+ Each command follows the same shape:
4
+
5
+ * Load :class:`Config` (with :func:`resolve_lab` layered on top).
6
+ * Open a :class:`LabtabClient` against it.
7
+ * Call a typed method, render via :mod:`labtab.output`, handle errors.
8
+
9
+ The helpers in this module keep those commands terse without reaching for
10
+ inheritance or decorators.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from contextlib import contextmanager
16
+ from typing import Any
17
+
18
+ import typer
19
+
20
+ from ..client import LabtabClient
21
+ from ..config import Config
22
+ from ..errors import LabtabError
23
+ from ..output import dump_json, print_error
24
+
25
+
26
+ @contextmanager
27
+ def client_session() -> Any:
28
+ """Yield a (config, client) pair, closing the client on exit."""
29
+
30
+ config = Config.load()
31
+ client = LabtabClient.from_config(config)
32
+ try:
33
+ yield config, client
34
+ finally:
35
+ client.close()
36
+
37
+
38
+ def resolve_lab(config: Config, override: str | int | None) -> str | int | None:
39
+ """Resolve the ``--lab`` flag against the config's default."""
40
+
41
+ if override is not None:
42
+ return override
43
+ if config.default_lab_slug:
44
+ return config.default_lab_slug
45
+ return config.default_lab_id
46
+
47
+
48
+ def bail(exc: LabtabError, *, json_mode: bool) -> None:
49
+ """Render a :class:`LabtabError` and exit with its exit code."""
50
+
51
+ if json_mode:
52
+ dump_json({"ok": False, "error": exc.message})
53
+ else:
54
+ print_error(exc.message)
55
+ raise typer.Exit(code=exc.exit_code)