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/__init__.py
ADDED
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,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)
|