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.
@@ -0,0 +1,205 @@
1
+ """``labtab auth`` — login, logout, whoami.
2
+
3
+ ``login`` drives the device-pairing flow in :mod:`labtab.auth` and persists
4
+ the returned API key into the config file. ``logout`` clears that key.
5
+ ``whoami`` prints the currently signed-in identity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from urllib.parse import urlsplit, urlunsplit
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from ..auth import PairingResult, persist_pairing, run_pairing, summarise_identity
16
+ from ..client import LabtabClient
17
+ from ..config import Config
18
+ from ..errors import LabtabError, PairingError
19
+ from ..models import CLIPairStart
20
+ from ..output import (
21
+ dump_json,
22
+ print_error,
23
+ print_info,
24
+ print_success,
25
+ render_item,
26
+ )
27
+
28
+ app = typer.Typer(help="Authenticate this terminal with LabTab.")
29
+
30
+
31
+ def _validate_base_url(value: str) -> str:
32
+ parsed = urlsplit(value)
33
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
34
+ raise typer.BadParameter(
35
+ "Base URL must include a scheme and host, e.g. https://labtab.app/api/v1.",
36
+ )
37
+ return value.rstrip("/")
38
+
39
+
40
+ def _replace_host(base_url: str, host: str) -> str:
41
+ parsed = urlsplit(base_url)
42
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
43
+ raise typer.BadParameter(
44
+ f"Current base URL is invalid and cannot be rewritten: {base_url}",
45
+ )
46
+ if "://" in host or "/" in host:
47
+ raise typer.BadParameter("`--host` must be a bare hostname, e.g. labtab.io.")
48
+ return urlunsplit(parsed._replace(netloc=host)).rstrip("/")
49
+
50
+
51
+ @app.command("login")
52
+ def login(
53
+ no_browser: bool = typer.Option(
54
+ False,
55
+ "--no-browser",
56
+ help="Don't try to open the confirmation URL in a browser.",
57
+ ),
58
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
59
+ ) -> None:
60
+ """Pair this terminal with your LabTab account."""
61
+
62
+ config = Config.load()
63
+ client = LabtabClient.from_config(config)
64
+ console = Console()
65
+
66
+ def _announce(start: CLIPairStart) -> None:
67
+ if json_mode:
68
+ return
69
+ minutes = max(1, int((start.expires_in or 600) // 60))
70
+ console.print(
71
+ f"Visit [cyan]{start.confirm_url}[/cyan] to confirm this terminal "
72
+ f"(code: [bold]{start.pairing_code}[/bold], expires in {minutes} min).",
73
+ )
74
+
75
+ try:
76
+ if json_mode:
77
+ result: PairingResult = run_pairing(
78
+ client, open_browser=not no_browser, on_start=_announce,
79
+ )
80
+ else:
81
+ with console.status("Waiting for browser confirmation…", spinner="dots"):
82
+ result = run_pairing(
83
+ client, open_browser=not no_browser, on_start=_announce,
84
+ )
85
+ except PairingError as exc:
86
+ if json_mode:
87
+ dump_json({"ok": False, "error": exc.message})
88
+ raise typer.Exit(code=exc.exit_code) from exc
89
+ print_error(exc.message)
90
+ raise typer.Exit(code=exc.exit_code) from exc
91
+ except LabtabError as exc:
92
+ if json_mode:
93
+ dump_json({"ok": False, "error": exc.message})
94
+ raise typer.Exit(code=exc.exit_code) from exc
95
+ print_error(exc.message)
96
+ raise typer.Exit(code=exc.exit_code) from exc
97
+ finally:
98
+ client.close()
99
+
100
+ persist_pairing(config, result)
101
+
102
+ if json_mode:
103
+ dump_json(
104
+ {
105
+ "ok": True,
106
+ "user_email": result.user_email,
107
+ "default_lab_slug": result.default_lab_slug,
108
+ },
109
+ )
110
+ return
111
+
112
+ email = result.user_email or "your account"
113
+ print_success(f"Signed in as {email}")
114
+ if result.default_lab_slug:
115
+ print_info(f"Default lab: {result.default_lab_slug}")
116
+
117
+
118
+ @app.command("logout")
119
+ def logout(
120
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
121
+ ) -> None:
122
+ """Remove the stored API key from this machine."""
123
+
124
+ config = Config.load()
125
+ was_signed_in = bool(config.api_key)
126
+ config.clear_auth()
127
+ config.save()
128
+
129
+ if json_mode:
130
+ dump_json({"ok": True, "was_signed_in": was_signed_in})
131
+ return
132
+ if was_signed_in:
133
+ print_success("Signed out.")
134
+ else:
135
+ print_info("Already signed out.")
136
+
137
+
138
+ @app.command("whoami")
139
+ def whoami(
140
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
141
+ ) -> None:
142
+ """Show who this terminal is signed in as."""
143
+
144
+ config = Config.load()
145
+ summary = summarise_identity(config)
146
+
147
+ if json_mode:
148
+ dump_json(summary)
149
+ return
150
+
151
+ if not summary["signed_in"]:
152
+ print_info("Not signed in. Run `labtab auth login`.")
153
+ raise typer.Exit(code=2)
154
+
155
+ render_item(
156
+ summary,
157
+ fields=[
158
+ ("User", "user_email"),
159
+ ("Default lab", "default_lab_slug"),
160
+ ("Default lab id", "default_lab_id"),
161
+ ("API base URL", "base_url"),
162
+ ("Config file", "config_path"),
163
+ ],
164
+ title="Signed in",
165
+ )
166
+
167
+
168
+ @app.command("set-base-url")
169
+ def set_base_url(
170
+ url: str | None = typer.Argument(
171
+ None,
172
+ help="Full API base URL, e.g. https://labtab.io/api/v1.",
173
+ ),
174
+ host: str | None = typer.Option(
175
+ None,
176
+ "--host",
177
+ help="Replace only the hostname in the current API base URL.",
178
+ ),
179
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
180
+ ) -> None:
181
+ """Update the API base URL stored in config."""
182
+
183
+ if (url is None) == (host is None):
184
+ raise typer.BadParameter("Provide either a full URL or `--host`, but not both.")
185
+
186
+ config = Config.load()
187
+ old_base_url = config.base_url
188
+ new_base_url = _validate_base_url(url) if url is not None else _replace_host(old_base_url, host)
189
+
190
+ config.base_url = new_base_url
191
+ config.save()
192
+
193
+ payload = {
194
+ "ok": True,
195
+ "old_base_url": old_base_url,
196
+ "base_url": new_base_url,
197
+ }
198
+ if json_mode:
199
+ dump_json(payload)
200
+ return
201
+
202
+ if new_base_url == old_base_url:
203
+ print_info(f"API base URL already set to {new_base_url}")
204
+ return
205
+ print_success(f"API base URL updated to {new_base_url}")
@@ -0,0 +1,238 @@
1
+ """``labtab export`` — kick off a lab export, poll for status, download.
2
+
3
+ The flow is:
4
+
5
+ 1. ``POST /labs/<id>/export/`` with ``{format: zip|json}`` returns an
6
+ ``ExportStatus`` (usually ``pending`` or ``processing``).
7
+ 2. Poll ``GET /labs/<id>/export/<export_id>/`` every ``--poll`` seconds
8
+ until status is ``ready`` (download URL available) or ``failed``.
9
+ 3. Stream the signed URL to either ``./labtab-export-<timestamp>.<ext>``
10
+ or stdout when the user passes ``-`` for the output path.
11
+
12
+ The progress indicator is a :class:`rich.progress.Progress` bar during
13
+ polling and a spinner during the streaming download.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ import time
20
+ from collections.abc import Callable
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+
24
+ import typer
25
+ from rich.console import Console
26
+ from rich.progress import Progress, SpinnerColumn, TextColumn
27
+
28
+ from ..client import LabtabClient
29
+ from ..config import Config
30
+ from ..errors import APIError, LabtabError
31
+ from ..models import ExportStatus
32
+ from ..output import dump_json, print_error, print_success
33
+ from ._common import bail, resolve_lab
34
+
35
+ app = typer.Typer(help="Export a lab's data.", invoke_without_command=True)
36
+
37
+ DEFAULT_POLL_INTERVAL = 3.0
38
+ DEFAULT_TIMEOUT = 600.0
39
+
40
+
41
+ def _resolve_lab_id(config: Config, override: str | None) -> str | int:
42
+ target = resolve_lab(config, override)
43
+ if target is None:
44
+ raise APIError(
45
+ "No lab selected. Pass --lab <slug> or set default_lab_slug in config.",
46
+ )
47
+ return target
48
+
49
+
50
+ def _format_extension(fmt: str) -> str:
51
+ return "zip" if fmt == "zip" else "json"
52
+
53
+
54
+ def _default_output(fmt: str) -> Path:
55
+ stamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
56
+ return Path.cwd() / f"labtab-export-{stamp}.{_format_extension(fmt)}"
57
+
58
+
59
+ def _is_terminal(status: str) -> bool:
60
+ return status in {"ready", "failed"}
61
+
62
+
63
+ def poll_until_ready(
64
+ client: LabtabClient,
65
+ *,
66
+ lab_id: str | int,
67
+ export_id: str | int,
68
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
69
+ timeout: float = DEFAULT_TIMEOUT,
70
+ on_update: Callable[[ExportStatus], None] | None = None,
71
+ sleep: Callable[[float], None] = time.sleep,
72
+ now: Callable[[], float] = time.monotonic,
73
+ ) -> ExportStatus:
74
+ """Poll the export endpoint until it reaches a terminal status.
75
+
76
+ Returns the final :class:`ExportStatus`. Raises :class:`APIError` on
77
+ timeout or if the backend reports ``failed``.
78
+ """
79
+
80
+ deadline = now() + timeout
81
+ while True:
82
+ status = client.get_export_status(lab_id=lab_id, export_id=export_id)
83
+ if on_update is not None:
84
+ on_update(status)
85
+ if (status.status or "").lower() == "ready":
86
+ return status
87
+ if (status.status or "").lower() == "failed":
88
+ raise APIError(status.error or "Export failed on the server.")
89
+ if now() >= deadline:
90
+ raise APIError("Export timed out waiting for the backend.")
91
+ sleep(poll_interval)
92
+
93
+
94
+ @app.callback(invoke_without_command=True)
95
+ def export(
96
+ ctx: typer.Context,
97
+ fmt: str = typer.Option(
98
+ "zip",
99
+ "--format",
100
+ help="Export format — zip or json.",
101
+ case_sensitive=False,
102
+ ),
103
+ lab: str | None = typer.Option(None, "--lab", help="Override the default lab."),
104
+ output: str | None = typer.Option(
105
+ None,
106
+ "--output",
107
+ "-o",
108
+ help="Destination path. Pass '-' to write to stdout.",
109
+ ),
110
+ poll_interval: float = typer.Option(
111
+ DEFAULT_POLL_INTERVAL,
112
+ "--poll",
113
+ help="Seconds between status polls.",
114
+ ),
115
+ timeout: float = typer.Option(
116
+ DEFAULT_TIMEOUT,
117
+ "--timeout",
118
+ help="Hard timeout (seconds) for the export.",
119
+ ),
120
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
121
+ ) -> None:
122
+ """Kick off and download a full lab export."""
123
+
124
+ # When used with sub-commands we would want to skip; we don't have any,
125
+ # so run unconditionally. (``invoke_without_command=True`` still triggers
126
+ # the callback when no sub-command is given.)
127
+ if ctx.invoked_subcommand is not None: # pragma: no cover - defensive
128
+ return
129
+
130
+ fmt = fmt.lower()
131
+ if fmt not in {"zip", "json"}:
132
+ print_error(f"Unsupported format '{fmt}'. Use zip or json.")
133
+ raise typer.Exit(code=2)
134
+
135
+ config = Config.load()
136
+ if not config.api_key:
137
+ from ..errors import NotAuthenticatedError
138
+
139
+ bail(NotAuthenticatedError(), json_mode=json_mode)
140
+ return
141
+
142
+ client = LabtabClient.from_config(config)
143
+ console = Console()
144
+
145
+ try:
146
+ try:
147
+ lab_id = _resolve_lab_id(config, lab)
148
+ pending = client.start_export(lab_id=lab_id, fmt=fmt)
149
+ except LabtabError as exc:
150
+ bail(exc, json_mode=json_mode)
151
+ return
152
+
153
+ if json_mode:
154
+ final = poll_until_ready(
155
+ client,
156
+ lab_id=lab_id,
157
+ export_id=pending.id,
158
+ poll_interval=poll_interval,
159
+ timeout=timeout,
160
+ )
161
+ else:
162
+ with Progress(
163
+ SpinnerColumn(),
164
+ TextColumn("[bold]{task.description}"),
165
+ transient=True,
166
+ console=console,
167
+ ) as progress:
168
+ task = progress.add_task("Preparing export…", total=None)
169
+
170
+ def _update(status: ExportStatus) -> None:
171
+ progress.update(task, description=f"Export status: {status.status}")
172
+
173
+ try:
174
+ final = poll_until_ready(
175
+ client,
176
+ lab_id=lab_id,
177
+ export_id=pending.id,
178
+ poll_interval=poll_interval,
179
+ timeout=timeout,
180
+ on_update=_update,
181
+ )
182
+ except LabtabError as exc:
183
+ bail(exc, json_mode=json_mode)
184
+ return
185
+
186
+ if not final.download_url:
187
+ bail(APIError("Export is ready but no download URL was returned."), json_mode=json_mode)
188
+ return
189
+
190
+ # stream to destination
191
+ try:
192
+ bytes_written = _stream_export(client, final.download_url, output, fmt, console, json_mode)
193
+ except LabtabError as exc:
194
+ bail(exc, json_mode=json_mode)
195
+ return
196
+ finally:
197
+ client.close()
198
+
199
+ if json_mode:
200
+ dump_json(
201
+ {
202
+ "ok": True,
203
+ "lab_id": lab_id,
204
+ "export_id": final.id,
205
+ "bytes": bytes_written,
206
+ "output": output if output else str(_default_output(fmt)),
207
+ },
208
+ )
209
+ return
210
+
211
+ target = output if output == "-" else (output or str(_default_output(fmt)))
212
+ print_success(f"Wrote {bytes_written} bytes to {target}")
213
+
214
+
215
+ def _stream_export(
216
+ client: LabtabClient,
217
+ url: str,
218
+ output: str | None,
219
+ fmt: str,
220
+ console: Console,
221
+ json_mode: bool,
222
+ ) -> int:
223
+ """Stream ``url`` to ``output``. Returns bytes written."""
224
+
225
+ if output == "-":
226
+ # Binary stream on stdout — byte-accurate.
227
+ return client.stream_to_file(url, sys.stdout.buffer)
228
+
229
+ destination = Path(output) if output else _default_output(fmt)
230
+ destination.parent.mkdir(parents=True, exist_ok=True)
231
+
232
+ if json_mode:
233
+ with destination.open("wb") as fh:
234
+ return client.stream_to_file(url, fh)
235
+
236
+ with console.status(f"Downloading to {destination}…", spinner="dots"):
237
+ with destination.open("wb") as fh:
238
+ return client.stream_to_file(url, fh)
@@ -0,0 +1,77 @@
1
+ """``labtab grants`` — list and show grants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ..errors import LabtabError
8
+ from ..output import render_item, render_items
9
+ from ._common import bail, client_session, resolve_lab
10
+
11
+ app = typer.Typer(help="Browse grants.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_grants(
16
+ status: str | None = typer.Option(None, "--status", help="Filter by grant status."),
17
+ lab: str | None = typer.Option(None, "--lab", help="Override the default lab."),
18
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
19
+ ) -> None:
20
+ """List grants in a lab."""
21
+
22
+ with client_session() as (config, client):
23
+ target_lab = resolve_lab(config, lab)
24
+ try:
25
+ grants = client.list_grants(lab=target_lab, status=status)
26
+ except LabtabError as exc:
27
+ bail(exc, json_mode=json_mode)
28
+ return
29
+
30
+ render_items(
31
+ grants,
32
+ columns=[
33
+ ("ID", "id"),
34
+ ("Title", "title"),
35
+ ("Status", "status"),
36
+ ("Funder", "funder"),
37
+ ("Amount", "amount"),
38
+ ("Starts", "start_date"),
39
+ ("Ends", "end_date"),
40
+ ],
41
+ title="Grants",
42
+ json_mode=json_mode,
43
+ empty_message="No grants to show.",
44
+ )
45
+
46
+
47
+ @app.command("show")
48
+ def show_grant(
49
+ grant_id: str = typer.Argument(..., help="Grant ID or slug."),
50
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
51
+ ) -> None:
52
+ """Show a single grant's details."""
53
+
54
+ with client_session() as (_config, client):
55
+ try:
56
+ grant = client.get_grant(grant_id)
57
+ except LabtabError as exc:
58
+ bail(exc, json_mode=json_mode)
59
+ return
60
+
61
+ render_item(
62
+ grant,
63
+ fields=[
64
+ ("ID", "id"),
65
+ ("Title", "title"),
66
+ ("Status", "status"),
67
+ ("Funder", "funder"),
68
+ ("Amount", "amount"),
69
+ ("Currency", "currency"),
70
+ ("PI", "pi_name"),
71
+ ("Starts", "start_date"),
72
+ ("Ends", "end_date"),
73
+ ("Description", "description"),
74
+ ],
75
+ title=f"Grant {grant_id}",
76
+ json_mode=json_mode,
77
+ )
@@ -0,0 +1,39 @@
1
+ """``labtab labs`` — list labs the signed-in user belongs to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ..errors import LabtabError
8
+ from ..output import render_items
9
+ from ._common import bail, client_session
10
+
11
+ app = typer.Typer(help="Work with labs you belong to.")
12
+
13
+
14
+ @app.command("list")
15
+ def list_labs(
16
+ json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
17
+ ) -> None:
18
+ """List labs you have membership in."""
19
+
20
+ with client_session() as (_config, client):
21
+ try:
22
+ labs = client.list_labs()
23
+ except LabtabError as exc:
24
+ bail(exc, json_mode=json_mode)
25
+ return
26
+
27
+ render_items(
28
+ labs,
29
+ columns=[
30
+ ("ID", "id"),
31
+ ("Slug", "slug"),
32
+ ("Name", "name"),
33
+ ("PI", "pi.full_name"),
34
+ ("Members", "member_count"),
35
+ ],
36
+ title="Labs",
37
+ json_mode=json_mode,
38
+ empty_message="You are not a member of any labs yet.",
39
+ )