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 +5 -0
- labtab/auth.py +178 -0
- labtab/cli.py +77 -0
- labtab/client.py +316 -0
- labtab/commands/__init__.py +5 -0
- labtab/commands/_common.py +55 -0
- labtab/commands/auth.py +205 -0
- labtab/commands/export.py +238 -0
- labtab/commands/grants.py +77 -0
- labtab/commands/labs.py +39 -0
- labtab/commands/meetings.py +198 -0
- labtab/commands/papers.py +338 -0
- labtab/commands/people.py +70 -0
- labtab/commands/projects.py +70 -0
- labtab/commands/tasks.py +234 -0
- labtab/config.py +141 -0
- labtab/errors.py +60 -0
- labtab/models/__init__.py +48 -0
- labtab/models/api.py +153 -0
- labtab/models/generated.py +1390 -0
- labtab/output.py +149 -0
- labtab/py.typed +0 -0
- labtab-0.1.0.dist-info/METADATA +152 -0
- labtab-0.1.0.dist-info/RECORD +28 -0
- labtab-0.1.0.dist-info/WHEEL +5 -0
- labtab-0.1.0.dist-info/entry_points.txt +2 -0
- labtab-0.1.0.dist-info/licenses/LICENSE +21 -0
- labtab-0.1.0.dist-info/top_level.txt +1 -0
labtab/commands/auth.py
ADDED
|
@@ -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
|
+
)
|
labtab/commands/labs.py
ADDED
|
@@ -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
|
+
)
|