labtab 0.2.0__tar.gz → 0.2.2__tar.gz
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-0.2.0/src/labtab.egg-info → labtab-0.2.2}/PKG-INFO +7 -4
- {labtab-0.2.0 → labtab-0.2.2}/README.md +6 -3
- {labtab-0.2.0 → labtab-0.2.2}/pyproject.toml +1 -1
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/__init__.py +1 -1
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/client.py +12 -14
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/_common.py +8 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/auth.py +23 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/export.py +25 -25
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/labs.py +10 -10
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/meetings.py +5 -4
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/papers.py +1 -1
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/shares.py +1 -1
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/tasks.py +7 -7
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/models/api.py +2 -2
- {labtab-0.2.0 → labtab-0.2.2/src/labtab.egg-info}/PKG-INFO +7 -4
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_client.py +20 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_auth.py +64 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_export.py +32 -7
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_labs.py +22 -2
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_meetings.py +29 -6
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_notes.py +1 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_papers.py +26 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_shares.py +3 -2
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_tasks_complete.py +4 -4
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_tasks_create.py +2 -2
- {labtab-0.2.0 → labtab-0.2.2}/.gitignore +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/LICENSE +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/RELEASING.md +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/setup.cfg +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/auth.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/cli.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/__init__.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/account.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/ai.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/api_keys.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/audit_logs.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/deadlines.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/grants.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/home.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/letters.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/literature.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/notes.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/notifications.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/people.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/posters.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/projects.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/results.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/search.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/talks.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/commands/wiki.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/config.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/datetimes.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/errors.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/models/__init__.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/models/generated.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/output.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab/py.typed +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab.egg-info/SOURCES.txt +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab.egg-info/dependency_links.txt +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab.egg-info/entry_points.txt +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab.egg-info/requires.txt +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/src/labtab.egg-info/top_level.txt +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/__init__.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/conftest.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_auth.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_account.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_ai.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_api_keys.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_audit_logs.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_deadlines.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_grants.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_home.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_letters.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_literature.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_meetings_create.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_notifications.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_papers_import.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_people.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_posters.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_projects.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_results.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_search.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_talks.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_tasks.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_commands_wiki.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_config.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_datetimes.py +0 -0
- {labtab-0.2.0 → labtab-0.2.2}/tests/test_models_generated.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labtab
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Command-line interface for LabTab (Wetware Ltd). Manage your lab from the terminal.
|
|
5
5
|
Author-email: Wetware Ltd <support@wetware.ai>
|
|
6
6
|
License: MIT
|
|
@@ -59,6 +59,9 @@ labtab --show-completion
|
|
|
59
59
|
# Pair your terminal with your LabTab account (opens a browser window)
|
|
60
60
|
labtab auth login
|
|
61
61
|
|
|
62
|
+
# Later logins reuse the API key in ~/.config/labtab/config.toml; pair again only when needed
|
|
63
|
+
labtab auth login --force
|
|
64
|
+
|
|
62
65
|
# Pair with a read-only key instead - mutating commands will be rejected
|
|
63
66
|
labtab auth login --read-only
|
|
64
67
|
|
|
@@ -132,7 +135,7 @@ labtab grants list
|
|
|
132
135
|
labtab grants show gr_abc123
|
|
133
136
|
|
|
134
137
|
# Show the next 7 days of meetings
|
|
135
|
-
labtab meetings
|
|
138
|
+
labtab meetings list --days 7
|
|
136
139
|
|
|
137
140
|
# Get JSON for scripting
|
|
138
141
|
labtab papers list --json | jq '.[] | .title'
|
|
@@ -199,7 +202,7 @@ permission check.
|
|
|
199
202
|
| `labtab notes create <type> <id>` | Create a note from `--text` or `--file` |
|
|
200
203
|
| `labtab notes update <id>` | Replace note content from `--text` or `--file` |
|
|
201
204
|
| `labtab notes export <type> <id>` | Export entity notes as Markdown or text |
|
|
202
|
-
| `labtab auth login` | Pair
|
|
205
|
+
| `labtab auth login` | Pair once, then reuse the locally stored key; `--force` replaces it and `--read-only` requests a key that cannot modify data |
|
|
203
206
|
| `labtab auth logout` | Forget the local API key |
|
|
204
207
|
| `labtab auth whoami` | Show the signed-in user and default lab |
|
|
205
208
|
| `labtab auth set-base-url` | Update the stored API base URL |
|
|
@@ -246,7 +249,7 @@ permission check.
|
|
|
246
249
|
| `labtab results create` | Create a result, optionally uploading evidence files |
|
|
247
250
|
| `labtab results links <id>` | List research objects linked to a result |
|
|
248
251
|
| `labtab results link <id>` | Link a result to a paper, poster, talk, or grant |
|
|
249
|
-
| `labtab meetings
|
|
252
|
+
| `labtab meetings list` | Meetings in the next `--days` (default 7) |
|
|
250
253
|
| `labtab meetings show <id>` | Meeting detail card |
|
|
251
254
|
| `labtab meetings create` | Create a meeting; `--datetime` takes ISO-8601 or `today 14:30` / `tomorrow 10:00` / `monday 09:00` / `next monday 09:00` |
|
|
252
255
|
| `labtab tasks list` | Tasks, filter by `--status` |
|
|
@@ -33,6 +33,9 @@ labtab --show-completion
|
|
|
33
33
|
# Pair your terminal with your LabTab account (opens a browser window)
|
|
34
34
|
labtab auth login
|
|
35
35
|
|
|
36
|
+
# Later logins reuse the API key in ~/.config/labtab/config.toml; pair again only when needed
|
|
37
|
+
labtab auth login --force
|
|
38
|
+
|
|
36
39
|
# Pair with a read-only key instead - mutating commands will be rejected
|
|
37
40
|
labtab auth login --read-only
|
|
38
41
|
|
|
@@ -106,7 +109,7 @@ labtab grants list
|
|
|
106
109
|
labtab grants show gr_abc123
|
|
107
110
|
|
|
108
111
|
# Show the next 7 days of meetings
|
|
109
|
-
labtab meetings
|
|
112
|
+
labtab meetings list --days 7
|
|
110
113
|
|
|
111
114
|
# Get JSON for scripting
|
|
112
115
|
labtab papers list --json | jq '.[] | .title'
|
|
@@ -173,7 +176,7 @@ permission check.
|
|
|
173
176
|
| `labtab notes create <type> <id>` | Create a note from `--text` or `--file` |
|
|
174
177
|
| `labtab notes update <id>` | Replace note content from `--text` or `--file` |
|
|
175
178
|
| `labtab notes export <type> <id>` | Export entity notes as Markdown or text |
|
|
176
|
-
| `labtab auth login` | Pair
|
|
179
|
+
| `labtab auth login` | Pair once, then reuse the locally stored key; `--force` replaces it and `--read-only` requests a key that cannot modify data |
|
|
177
180
|
| `labtab auth logout` | Forget the local API key |
|
|
178
181
|
| `labtab auth whoami` | Show the signed-in user and default lab |
|
|
179
182
|
| `labtab auth set-base-url` | Update the stored API base URL |
|
|
@@ -220,7 +223,7 @@ permission check.
|
|
|
220
223
|
| `labtab results create` | Create a result, optionally uploading evidence files |
|
|
221
224
|
| `labtab results links <id>` | List research objects linked to a result |
|
|
222
225
|
| `labtab results link <id>` | Link a result to a paper, poster, talk, or grant |
|
|
223
|
-
| `labtab meetings
|
|
226
|
+
| `labtab meetings list` | Meetings in the next `--days` (default 7) |
|
|
224
227
|
| `labtab meetings show <id>` | Meeting detail card |
|
|
225
228
|
| `labtab meetings create` | Create a meeting; `--datetime` takes ISO-8601 or `today 14:30` / `tomorrow 10:00` / `monday 09:00` / `next monday 09:00` |
|
|
226
229
|
| `labtab tasks list` | Tasks, filter by `--status` |
|
|
@@ -12,6 +12,7 @@ from collections.abc import Mapping
|
|
|
12
12
|
from email.message import Message
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any
|
|
15
|
+
from urllib.parse import urljoin, urlparse
|
|
15
16
|
|
|
16
17
|
import httpx
|
|
17
18
|
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
@@ -87,13 +88,7 @@ def _parse(model: type[BaseModel], payload: Any) -> Any:
|
|
|
87
88
|
|
|
88
89
|
|
|
89
90
|
class LabtabClient:
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
Callers should use the :meth:`from_config` constructor so that the base URL
|
|
93
|
-
and API key are loaded consistently. The client may be used as a context
|
|
94
|
-
manager, but that is optional - ``close()`` is safe to call on an
|
|
95
|
-
already-closed instance.
|
|
96
|
-
"""
|
|
91
|
+
"""Typed LabTab API client."""
|
|
97
92
|
|
|
98
93
|
def __init__(
|
|
99
94
|
self,
|
|
@@ -180,13 +175,17 @@ class LabtabClient:
|
|
|
180
175
|
def stream_to_file(self, url: str, destination: Any, *, chunk_size: int = 1 << 15) -> int:
|
|
181
176
|
"""Stream ``url`` into ``destination`` (a writable binary stream).
|
|
182
177
|
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
Absolute signed URLs are used as-is; API-relative download URLs are
|
|
179
|
+
resolved against the configured server. Returns the total byte count
|
|
180
|
+
written.
|
|
185
181
|
"""
|
|
186
182
|
|
|
187
183
|
total = 0
|
|
184
|
+
download_url = urljoin(f"{self.base_url}/", url)
|
|
185
|
+
same_origin = urlparse(self.base_url)[:2] == urlparse(download_url)[:2]
|
|
186
|
+
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key and same_origin else None
|
|
188
187
|
try:
|
|
189
|
-
with httpx.stream("GET",
|
|
188
|
+
with httpx.stream("GET", download_url, headers=headers, timeout=60.0) as resp:
|
|
190
189
|
if resp.status_code >= 400:
|
|
191
190
|
raise APIError(
|
|
192
191
|
f"Download failed with status {resp.status_code}"
|
|
@@ -310,7 +309,7 @@ class LabtabClient:
|
|
|
310
309
|
)
|
|
311
310
|
return data if isinstance(data, dict) else {}
|
|
312
311
|
|
|
313
|
-
def transfer_lab_pi(self, lab_id: str | int, to_user_id: int) -> LabMembership:
|
|
312
|
+
def transfer_lab_pi(self, lab_id: str | int, to_user_id: str | int) -> LabMembership:
|
|
314
313
|
return _parse(
|
|
315
314
|
LabMembership,
|
|
316
315
|
self.post(
|
|
@@ -553,7 +552,7 @@ class LabtabClient:
|
|
|
553
552
|
*,
|
|
554
553
|
lab: str | int | None = None,
|
|
555
554
|
record_type: str,
|
|
556
|
-
object_id: int,
|
|
555
|
+
object_id: str | int,
|
|
557
556
|
recipient_email: str,
|
|
558
557
|
permission_level: str = "view",
|
|
559
558
|
expires_at: str | None = None,
|
|
@@ -1057,7 +1056,6 @@ class LabtabClient:
|
|
|
1057
1056
|
resp = self._client.get(
|
|
1058
1057
|
path,
|
|
1059
1058
|
params=params,
|
|
1060
|
-
headers={"Accept": "text/markdown, text/plain"},
|
|
1061
1059
|
)
|
|
1062
1060
|
except httpx.TimeoutException as exc:
|
|
1063
1061
|
raise APIError(f"Request timed out talking to {self.base_url}") from exc
|
|
@@ -1130,7 +1128,7 @@ class LabtabClient:
|
|
|
1130
1128
|
lab_id: str | int,
|
|
1131
1129
|
export_id: str | int,
|
|
1132
1130
|
) -> ExportStatus:
|
|
1133
|
-
data = self.get(f"/labs/{lab_id}/
|
|
1131
|
+
data = self.get(f"/labs/{lab_id}/exports/{export_id}/")
|
|
1134
1132
|
return _parse(ExportStatus, data)
|
|
1135
1133
|
|
|
1136
1134
|
def create_agent_run(
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
from contextlib import contextmanager
|
|
16
16
|
from typing import Any
|
|
17
|
+
from uuid import UUID
|
|
17
18
|
|
|
18
19
|
import typer
|
|
19
20
|
|
|
@@ -40,6 +41,13 @@ def resolve_lab(config: Config, override: str | int | None) -> str | int | None:
|
|
|
40
41
|
|
|
41
42
|
if override is not None:
|
|
42
43
|
return override
|
|
44
|
+
if config.default_lab_id is not None:
|
|
45
|
+
try:
|
|
46
|
+
UUID(str(config.default_lab_id))
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
return config.default_lab_id
|
|
43
51
|
if config.default_lab_slug:
|
|
44
52
|
return config.default_lab_slug
|
|
45
53
|
return config.default_lab_id
|
|
@@ -92,11 +92,33 @@ def login(
|
|
|
92
92
|
"--read-only",
|
|
93
93
|
help="Request a read-only API key - mutating commands will be rejected.",
|
|
94
94
|
),
|
|
95
|
+
force: bool = typer.Option(
|
|
96
|
+
False,
|
|
97
|
+
"--force",
|
|
98
|
+
help="Pair again and replace the API key already stored on this machine.",
|
|
99
|
+
),
|
|
95
100
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
96
101
|
) -> None:
|
|
97
102
|
"""Pair this terminal with your LabTab account."""
|
|
98
103
|
|
|
99
104
|
config = Config.load()
|
|
105
|
+
if config.api_key and not force:
|
|
106
|
+
if json_mode:
|
|
107
|
+
dump_json(
|
|
108
|
+
{
|
|
109
|
+
"ok": True,
|
|
110
|
+
"user_email": config.user_email,
|
|
111
|
+
"default_lab_slug": config.default_lab_slug,
|
|
112
|
+
"reused": True,
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
email = config.user_email or "your account"
|
|
118
|
+
print_info(f"Already signed in as {email}; using the stored API key.")
|
|
119
|
+
print_info("Run `labtab auth login --force` to pair again.")
|
|
120
|
+
return
|
|
121
|
+
|
|
100
122
|
client = LabtabClient.from_config(config)
|
|
101
123
|
console = Console()
|
|
102
124
|
requested_scopes = ["read"] if read_only else None
|
|
@@ -150,6 +172,7 @@ def login(
|
|
|
150
172
|
"user_email": result.user_email,
|
|
151
173
|
"default_lab_slug": result.default_lab_slug,
|
|
152
174
|
"read_only": read_only,
|
|
175
|
+
"reused": False,
|
|
153
176
|
},
|
|
154
177
|
)
|
|
155
178
|
return
|
|
@@ -4,7 +4,7 @@ The flow is:
|
|
|
4
4
|
|
|
5
5
|
1. ``POST /labs/<id>/export/`` with ``{format: zip|json}`` returns an
|
|
6
6
|
``ExportStatus`` (usually ``pending`` or ``processing``).
|
|
7
|
-
2. Poll ``GET /labs/<id>/
|
|
7
|
+
2. Poll ``GET /labs/<id>/exports/<export_id>/`` every ``--poll`` seconds
|
|
8
8
|
until status is ``ready`` (download URL available) or ``failed``.
|
|
9
9
|
3. Stream the signed URL to either ``./labtab-export-<timestamp>.<ext>``
|
|
10
10
|
or stdout when the user passes ``-`` for the output path.
|
|
@@ -150,27 +150,27 @@ def export(
|
|
|
150
150
|
bail(exc, json_mode=json_mode)
|
|
151
151
|
return
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
153
|
+
try:
|
|
154
|
+
if json_mode:
|
|
155
|
+
final = poll_until_ready(
|
|
156
|
+
client,
|
|
157
|
+
lab_id=lab_id,
|
|
158
|
+
export_id=pending.id,
|
|
159
|
+
poll_interval=poll_interval,
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
with Progress(
|
|
164
|
+
SpinnerColumn(),
|
|
165
|
+
TextColumn("[bold]{task.description}"),
|
|
166
|
+
transient=True,
|
|
167
|
+
console=console,
|
|
168
|
+
) as progress:
|
|
169
|
+
task = progress.add_task("Preparing export…", total=None)
|
|
170
|
+
|
|
171
|
+
def _update(status: ExportStatus) -> None:
|
|
172
|
+
progress.update(task, description=f"Export status: {status.status}")
|
|
173
|
+
|
|
174
174
|
final = poll_until_ready(
|
|
175
175
|
client,
|
|
176
176
|
lab_id=lab_id,
|
|
@@ -179,9 +179,9 @@ def export(
|
|
|
179
179
|
timeout=timeout,
|
|
180
180
|
on_update=_update,
|
|
181
181
|
)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
except LabtabError as exc:
|
|
183
|
+
bail(exc, json_mode=json_mode)
|
|
184
|
+
return
|
|
185
185
|
|
|
186
186
|
if not final.download_url:
|
|
187
187
|
bail(APIError("Export is ready but no download URL was returned."), json_mode=json_mode)
|
|
@@ -50,7 +50,7 @@ def list_members(
|
|
|
50
50
|
lab: str | None = typer.Option(
|
|
51
51
|
None,
|
|
52
52
|
"--lab",
|
|
53
|
-
help="
|
|
53
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
54
54
|
),
|
|
55
55
|
include_archived: bool = typer.Option(
|
|
56
56
|
False,
|
|
@@ -81,7 +81,7 @@ def show_member(
|
|
|
81
81
|
lab: str | None = typer.Option(
|
|
82
82
|
None,
|
|
83
83
|
"--lab",
|
|
84
|
-
help="
|
|
84
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
85
85
|
),
|
|
86
86
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
87
87
|
) -> None:
|
|
@@ -105,7 +105,7 @@ def set_member_role(
|
|
|
105
105
|
lab: str | None = typer.Option(
|
|
106
106
|
None,
|
|
107
107
|
"--lab",
|
|
108
|
-
help="
|
|
108
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
109
109
|
),
|
|
110
110
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
111
111
|
) -> None:
|
|
@@ -133,7 +133,7 @@ def invite_member(
|
|
|
133
133
|
lab: str | None = typer.Option(
|
|
134
134
|
None,
|
|
135
135
|
"--lab",
|
|
136
|
-
help="
|
|
136
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
137
137
|
),
|
|
138
138
|
role: str = typer.Option(
|
|
139
139
|
"Postdoc",
|
|
@@ -169,11 +169,11 @@ def invite_member(
|
|
|
169
169
|
|
|
170
170
|
@members_app.command("transfer-pi")
|
|
171
171
|
def transfer_pi(
|
|
172
|
-
to_user_id:
|
|
172
|
+
to_user_id: str = typer.Argument(..., help="Active member user UUID to make PI."),
|
|
173
173
|
lab: str | None = typer.Option(
|
|
174
174
|
None,
|
|
175
175
|
"--lab",
|
|
176
|
-
help="
|
|
176
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
177
177
|
),
|
|
178
178
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
179
179
|
) -> None:
|
|
@@ -201,7 +201,7 @@ def archive_member(
|
|
|
201
201
|
lab: str | None = typer.Option(
|
|
202
202
|
None,
|
|
203
203
|
"--lab",
|
|
204
|
-
help="
|
|
204
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
205
205
|
),
|
|
206
206
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
207
207
|
) -> None:
|
|
@@ -229,7 +229,7 @@ def restore_member(
|
|
|
229
229
|
lab: str | None = typer.Option(
|
|
230
230
|
None,
|
|
231
231
|
"--lab",
|
|
232
|
-
help="
|
|
232
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
233
233
|
),
|
|
234
234
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
235
235
|
) -> None:
|
|
@@ -256,7 +256,7 @@ def show_settings(
|
|
|
256
256
|
lab: str | None = typer.Option(
|
|
257
257
|
None,
|
|
258
258
|
"--lab",
|
|
259
|
-
help="
|
|
259
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
260
260
|
),
|
|
261
261
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
262
262
|
) -> None:
|
|
@@ -278,7 +278,7 @@ def update_settings(
|
|
|
278
278
|
lab: str | None = typer.Option(
|
|
279
279
|
None,
|
|
280
280
|
"--lab",
|
|
281
|
-
help="
|
|
281
|
+
help="Lab UUID. Defaults to the lab UUID saved by auth login.",
|
|
282
282
|
),
|
|
283
283
|
timezone: str | None = typer.Option(None, "--timezone", help="IANA timezone."),
|
|
284
284
|
supervision_reminder_minutes: int | None = typer.Option(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""``labtab meetings`` -
|
|
1
|
+
"""``labtab meetings`` - list / show / create commands."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -19,8 +19,9 @@ app = typer.Typer(help="Browse and create meetings.")
|
|
|
19
19
|
console = Console()
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
@app.command("upcoming")
|
|
23
|
-
|
|
22
|
+
@app.command("upcoming", hidden=True)
|
|
23
|
+
@app.command("list")
|
|
24
|
+
def list_meetings(
|
|
24
25
|
days: int = typer.Option(7, "--days", help="How many days ahead to look."),
|
|
25
26
|
lab: str | None = typer.Option(None, "--lab", help="Override the default lab."),
|
|
26
27
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON."),
|
|
@@ -153,7 +154,7 @@ def create_meeting(
|
|
|
153
154
|
location: str = typer.Option(
|
|
154
155
|
"", "--location", help="Physical or virtual meeting location."
|
|
155
156
|
),
|
|
156
|
-
lab: str = typer.Option(..., "--lab", help="Lab
|
|
157
|
+
lab: str = typer.Option(..., "--lab", help="Lab UUID this meeting belongs to."),
|
|
157
158
|
attendees: str | None = typer.Option(
|
|
158
159
|
None,
|
|
159
160
|
"--attendees",
|
|
@@ -66,7 +66,7 @@ def list_shares(
|
|
|
66
66
|
@app.command("create")
|
|
67
67
|
def create_share(
|
|
68
68
|
record_type: str = typer.Argument(..., help="Record type: paper, project, grant, meeting, note."),
|
|
69
|
-
object_id:
|
|
69
|
+
object_id: str = typer.Argument(..., help="UUID of the record to share."),
|
|
70
70
|
recipient_email: str = typer.Argument(..., help="Recipient email address."),
|
|
71
71
|
permission: str = typer.Option(
|
|
72
72
|
"view",
|
|
@@ -18,7 +18,7 @@ from ._common import bail, client_session, resolve_lab
|
|
|
18
18
|
app = typer.Typer(help="Browse and manage tasks.")
|
|
19
19
|
console = Console()
|
|
20
20
|
|
|
21
|
-
PRIORITY_CHOICES = {"low", "medium", "high"}
|
|
21
|
+
PRIORITY_CHOICES = {"low": "Low", "medium": "Medium", "high": "High"}
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
@app.command("list")
|
|
@@ -147,9 +147,9 @@ def create_task(
|
|
|
147
147
|
None, "--due-date", help="Due date in YYYY-MM-DD format."
|
|
148
148
|
),
|
|
149
149
|
project: str | None = typer.Option(
|
|
150
|
-
None, "--project", help="Project
|
|
150
|
+
None, "--project", help="Project UUID to associate with the task."
|
|
151
151
|
),
|
|
152
|
-
lab: str = typer.Option(..., "--lab", help="Lab
|
|
152
|
+
lab: str = typer.Option(..., "--lab", help="Lab UUID this task belongs to."),
|
|
153
153
|
) -> None:
|
|
154
154
|
"""Create a task in the given lab."""
|
|
155
155
|
|
|
@@ -159,12 +159,13 @@ def create_task(
|
|
|
159
159
|
raise typer.BadParameter(
|
|
160
160
|
f"--priority must be one of: {', '.join(sorted(PRIORITY_CHOICES))}."
|
|
161
161
|
)
|
|
162
|
+
priority = PRIORITY_CHOICES[priority]
|
|
162
163
|
|
|
163
164
|
due_iso = _parse_due_date(due_date)
|
|
164
165
|
|
|
165
166
|
config = Config.load()
|
|
166
167
|
with build_client(config) as client:
|
|
167
|
-
assignee_id: int | None = None
|
|
168
|
+
assignee_id: str | int | None = None
|
|
168
169
|
if assignee:
|
|
169
170
|
try:
|
|
170
171
|
assignee_id = _resolve_username(client, lab, assignee)
|
|
@@ -176,7 +177,7 @@ def create_task(
|
|
|
176
177
|
if description is not None:
|
|
177
178
|
payload["description"] = description
|
|
178
179
|
if assignee_id is not None:
|
|
179
|
-
payload["
|
|
180
|
+
payload["assignees"] = [assignee_id]
|
|
180
181
|
if priority is not None:
|
|
181
182
|
payload["priority"] = priority
|
|
182
183
|
if due_iso is not None:
|
|
@@ -210,13 +211,12 @@ def complete_task(
|
|
|
210
211
|
) -> None:
|
|
211
212
|
"""Mark a task completed (or pending, with --undo)."""
|
|
212
213
|
|
|
213
|
-
target_status = "pending" if undo else "completed"
|
|
214
214
|
config = Config.load()
|
|
215
215
|
with build_client(config) as client:
|
|
216
216
|
try:
|
|
217
217
|
response = client.patch(
|
|
218
218
|
f"/tasks/{task_id}/",
|
|
219
|
-
json={"
|
|
219
|
+
json={"is_complete": not undo},
|
|
220
220
|
)
|
|
221
221
|
except httpx.HTTPError as exc:
|
|
222
222
|
console.print(f"[red]Network error updating task:[/red] {exc}")
|
|
@@ -191,7 +191,7 @@ class Grant(_Base):
|
|
|
191
191
|
class Paper(_Base):
|
|
192
192
|
id: int | str
|
|
193
193
|
title: str
|
|
194
|
-
authors: list[str] | str | None = None
|
|
194
|
+
authors: list[User | str] | str | None = None
|
|
195
195
|
year: int | None = None
|
|
196
196
|
venue: str | None = None
|
|
197
197
|
journal: str | None = None
|
|
@@ -306,7 +306,7 @@ class Meeting(_Base):
|
|
|
306
306
|
end_time: datetime | None = Field(default=None, alias="end")
|
|
307
307
|
location: str | None = None
|
|
308
308
|
description: str | None = None
|
|
309
|
-
attendees: list[str] | None = None
|
|
309
|
+
attendees: list[User | str] | None = None
|
|
310
310
|
lab: int | str | None = None
|
|
311
311
|
|
|
312
312
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labtab
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Command-line interface for LabTab (Wetware Ltd). Manage your lab from the terminal.
|
|
5
5
|
Author-email: Wetware Ltd <support@wetware.ai>
|
|
6
6
|
License: MIT
|
|
@@ -59,6 +59,9 @@ labtab --show-completion
|
|
|
59
59
|
# Pair your terminal with your LabTab account (opens a browser window)
|
|
60
60
|
labtab auth login
|
|
61
61
|
|
|
62
|
+
# Later logins reuse the API key in ~/.config/labtab/config.toml; pair again only when needed
|
|
63
|
+
labtab auth login --force
|
|
64
|
+
|
|
62
65
|
# Pair with a read-only key instead - mutating commands will be rejected
|
|
63
66
|
labtab auth login --read-only
|
|
64
67
|
|
|
@@ -132,7 +135,7 @@ labtab grants list
|
|
|
132
135
|
labtab grants show gr_abc123
|
|
133
136
|
|
|
134
137
|
# Show the next 7 days of meetings
|
|
135
|
-
labtab meetings
|
|
138
|
+
labtab meetings list --days 7
|
|
136
139
|
|
|
137
140
|
# Get JSON for scripting
|
|
138
141
|
labtab papers list --json | jq '.[] | .title'
|
|
@@ -199,7 +202,7 @@ permission check.
|
|
|
199
202
|
| `labtab notes create <type> <id>` | Create a note from `--text` or `--file` |
|
|
200
203
|
| `labtab notes update <id>` | Replace note content from `--text` or `--file` |
|
|
201
204
|
| `labtab notes export <type> <id>` | Export entity notes as Markdown or text |
|
|
202
|
-
| `labtab auth login` | Pair
|
|
205
|
+
| `labtab auth login` | Pair once, then reuse the locally stored key; `--force` replaces it and `--read-only` requests a key that cannot modify data |
|
|
203
206
|
| `labtab auth logout` | Forget the local API key |
|
|
204
207
|
| `labtab auth whoami` | Show the signed-in user and default lab |
|
|
205
208
|
| `labtab auth set-base-url` | Update the stored API base URL |
|
|
@@ -246,7 +249,7 @@ permission check.
|
|
|
246
249
|
| `labtab results create` | Create a result, optionally uploading evidence files |
|
|
247
250
|
| `labtab results links <id>` | List research objects linked to a result |
|
|
248
251
|
| `labtab results link <id>` | Link a result to a paper, poster, talk, or grant |
|
|
249
|
-
| `labtab meetings
|
|
252
|
+
| `labtab meetings list` | Meetings in the next `--days` (default 7) |
|
|
250
253
|
| `labtab meetings show <id>` | Meeting detail card |
|
|
251
254
|
| `labtab meetings create` | Create a meeting; `--datetime` takes ISO-8601 or `today 14:30` / `tomorrow 10:00` / `monday 09:00` / `next monday 09:00` |
|
|
252
255
|
| `labtab tasks list` | Tasks, filter by `--status` |
|
|
@@ -146,6 +146,26 @@ def test_stream_to_file_copies_bytes(
|
|
|
146
146
|
total = client.stream_to_file(url, fh)
|
|
147
147
|
assert total == len(b"LABTAB-EXPORT")
|
|
148
148
|
assert dest.read_bytes() == b"LABTAB-EXPORT"
|
|
149
|
+
assert "authorization" not in httpx_mock.get_requests()[-1].headers
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_stream_to_file_resolves_api_relative_url(
|
|
153
|
+
httpx_mock: HTTPXMock, tmp_path
|
|
154
|
+
) -> None:
|
|
155
|
+
url = "/api/v1/labs/lab-1/exports/exp-1/download/?token=test"
|
|
156
|
+
httpx_mock.add_response(
|
|
157
|
+
url=f"https://api.test{url}",
|
|
158
|
+
content=b"LABTAB-EXPORT",
|
|
159
|
+
)
|
|
160
|
+
client = LabtabClient("https://api.test/api/v1", "lab_api_test")
|
|
161
|
+
dest = tmp_path / "out.zip"
|
|
162
|
+
|
|
163
|
+
with dest.open("wb") as fh:
|
|
164
|
+
total = client.stream_to_file(url, fh)
|
|
165
|
+
|
|
166
|
+
assert total == len(b"LABTAB-EXPORT")
|
|
167
|
+
assert dest.read_bytes() == b"LABTAB-EXPORT"
|
|
168
|
+
assert httpx_mock.get_requests()[-1].headers["authorization"] == "Bearer lab_api_test"
|
|
149
169
|
|
|
150
170
|
|
|
151
171
|
def test_stream_to_file_raises_on_error(
|
|
@@ -91,6 +91,70 @@ def test_login_happy_path(
|
|
|
91
91
|
assert reloaded.default_lab_slug == "demo-lab"
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
def test_login_reuses_stored_key_without_starting_pairing(
|
|
95
|
+
invoke,
|
|
96
|
+
httpx_mock: HTTPXMock,
|
|
97
|
+
signed_in_config: Path,
|
|
98
|
+
) -> None:
|
|
99
|
+
result = invoke("auth", "login")
|
|
100
|
+
|
|
101
|
+
assert result.exit_code == 0, result.output
|
|
102
|
+
assert "Already signed in as pi@example.com" in result.stdout
|
|
103
|
+
assert "using the stored API key" in result.stdout
|
|
104
|
+
assert "--force" in result.stdout
|
|
105
|
+
assert httpx_mock.get_requests() == []
|
|
106
|
+
assert Config.load().api_key == "lab_api_test"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_login_reuses_stored_key_in_json_mode(
|
|
110
|
+
invoke,
|
|
111
|
+
httpx_mock: HTTPXMock,
|
|
112
|
+
signed_in_config: Path,
|
|
113
|
+
) -> None:
|
|
114
|
+
result = invoke("auth", "login", "--json")
|
|
115
|
+
|
|
116
|
+
assert result.exit_code == 0, result.output
|
|
117
|
+
assert json.loads(result.stdout) == {
|
|
118
|
+
"ok": True,
|
|
119
|
+
"user_email": "pi@example.com",
|
|
120
|
+
"default_lab_slug": "smith-neuro-lab",
|
|
121
|
+
"reused": True,
|
|
122
|
+
}
|
|
123
|
+
assert httpx_mock.get_requests() == []
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_login_force_replaces_stored_key(
|
|
127
|
+
invoke,
|
|
128
|
+
httpx_mock: HTTPXMock,
|
|
129
|
+
signed_in_config: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
httpx_mock.add_response(
|
|
132
|
+
url="https://api.test/v1/cli/pair/start/",
|
|
133
|
+
method="POST",
|
|
134
|
+
json={
|
|
135
|
+
"pairing_code": "NEWK-EY12",
|
|
136
|
+
"confirm_url": "https://labtab.app/cli/confirm?code=NEWK-EY12",
|
|
137
|
+
"expires_in": 600,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
httpx_mock.add_response(
|
|
141
|
+
url="https://api.test/v1/cli/pair/poll/?code=NEWK-EY12",
|
|
142
|
+
json={
|
|
143
|
+
"status": "confirmed",
|
|
144
|
+
"api_key": "lab_api_replacement",
|
|
145
|
+
"user_email": "pi@example.com",
|
|
146
|
+
"default_lab_slug": "smith-neuro-lab",
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
result = invoke("auth", "login", "--force", "--no-browser", "--json")
|
|
151
|
+
|
|
152
|
+
assert result.exit_code == 0, result.output
|
|
153
|
+
assert json.loads(result.stdout)["reused"] is False
|
|
154
|
+
assert Config.load().api_key == "lab_api_replacement"
|
|
155
|
+
assert len(httpx_mock.get_requests(method="POST")) == 1
|
|
156
|
+
|
|
157
|
+
|
|
94
158
|
def test_login_read_only_sends_requested_scopes(
|
|
95
159
|
invoke,
|
|
96
160
|
httpx_mock: HTTPXMock,
|
|
@@ -28,7 +28,7 @@ def test_export_happy_path_writes_file(
|
|
|
28
28
|
json={"id": "exp_1", "status": "processing"},
|
|
29
29
|
)
|
|
30
30
|
httpx_mock.add_response(
|
|
31
|
-
url="https://api.test/v1/labs/smith-neuro-lab/
|
|
31
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_1/",
|
|
32
32
|
json={
|
|
33
33
|
"id": "exp_1",
|
|
34
34
|
"status": "ready",
|
|
@@ -63,15 +63,15 @@ def test_export_polls_until_ready(
|
|
|
63
63
|
)
|
|
64
64
|
# Two "processing" responses, then ready.
|
|
65
65
|
httpx_mock.add_response(
|
|
66
|
-
url="https://api.test/v1/labs/smith-neuro-lab/
|
|
66
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_2/",
|
|
67
67
|
json={"id": "exp_2", "status": "processing"},
|
|
68
68
|
)
|
|
69
69
|
httpx_mock.add_response(
|
|
70
|
-
url="https://api.test/v1/labs/smith-neuro-lab/
|
|
70
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_2/",
|
|
71
71
|
json={"id": "exp_2", "status": "processing"},
|
|
72
72
|
)
|
|
73
73
|
httpx_mock.add_response(
|
|
74
|
-
url="https://api.test/v1/labs/smith-neuro-lab/
|
|
74
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_2/",
|
|
75
75
|
json={
|
|
76
76
|
"id": "exp_2",
|
|
77
77
|
"status": "ready",
|
|
@@ -102,7 +102,7 @@ def test_export_failed_status(
|
|
|
102
102
|
json={"id": "exp_bad", "status": "pending"},
|
|
103
103
|
)
|
|
104
104
|
httpx_mock.add_response(
|
|
105
|
-
url="https://api.test/v1/labs/smith-neuro-lab/
|
|
105
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_bad/",
|
|
106
106
|
json={"id": "exp_bad", "status": "failed", "error": "S3 upload failed"},
|
|
107
107
|
)
|
|
108
108
|
|
|
@@ -125,7 +125,7 @@ def test_export_json_mode(
|
|
|
125
125
|
json={"id": "exp_3", "status": "processing"},
|
|
126
126
|
)
|
|
127
127
|
httpx_mock.add_response(
|
|
128
|
-
url="https://api.test/v1/labs/smith-neuro-lab/
|
|
128
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_3/",
|
|
129
129
|
json={
|
|
130
130
|
"id": "exp_3",
|
|
131
131
|
"status": "ready",
|
|
@@ -145,6 +145,31 @@ def test_export_json_mode(
|
|
|
145
145
|
assert payload["bytes"] == 1
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def test_export_json_mode_reports_poll_error_without_traceback(
|
|
149
|
+
invoke,
|
|
150
|
+
httpx_mock: HTTPXMock,
|
|
151
|
+
signed_in_config: Path,
|
|
152
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
153
|
+
) -> None:
|
|
154
|
+
monkeypatch.setattr(export_module.time, "sleep", lambda _s: None)
|
|
155
|
+
httpx_mock.add_response(
|
|
156
|
+
url="https://api.test/v1/labs/smith-neuro-lab/export/",
|
|
157
|
+
method="POST",
|
|
158
|
+
json={"id": "exp_missing", "status": "processing"},
|
|
159
|
+
)
|
|
160
|
+
httpx_mock.add_response(
|
|
161
|
+
url="https://api.test/v1/labs/smith-neuro-lab/exports/exp_missing/",
|
|
162
|
+
status_code=404,
|
|
163
|
+
json={"detail": "Not found."},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
result = invoke("export", "--json")
|
|
167
|
+
|
|
168
|
+
assert result.exit_code == 4
|
|
169
|
+
assert json.loads(result.stdout)["ok"] is False
|
|
170
|
+
assert "Traceback" not in result.output
|
|
171
|
+
|
|
172
|
+
|
|
148
173
|
def test_export_invalid_format(
|
|
149
174
|
invoke,
|
|
150
175
|
signed_in_config: Path,
|
|
@@ -194,7 +219,7 @@ def test_export_lab_override(
|
|
|
194
219
|
json={"id": "exp_o", "status": "pending"},
|
|
195
220
|
)
|
|
196
221
|
httpx_mock.add_response(
|
|
197
|
-
url="https://api.test/v1/labs/other-lab/
|
|
222
|
+
url="https://api.test/v1/labs/other-lab/exports/exp_o/",
|
|
198
223
|
json={
|
|
199
224
|
"id": "exp_o",
|
|
200
225
|
"status": "ready",
|
|
@@ -76,6 +76,25 @@ def test_lab_settings_show_defaults_to_configured_lab_id(
|
|
|
76
76
|
assert "AI local only" in result.stdout
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def test_lab_settings_prefers_saved_public_uuid(
|
|
80
|
+
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
81
|
+
) -> None:
|
|
82
|
+
from labtab.config import Config
|
|
83
|
+
|
|
84
|
+
lab_id = "019f52bb-34a4-7c07-b599-5612de343a4b"
|
|
85
|
+
config = Config.load()
|
|
86
|
+
config.default_lab_id = lab_id
|
|
87
|
+
config.save()
|
|
88
|
+
httpx_mock.add_response(
|
|
89
|
+
url=f"https://api.test/v1/labs/{lab_id}/settings/",
|
|
90
|
+
json={"id": "019f52bb-34a4-7c07-b599-5612de343a4c", "lab": lab_id},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
result = invoke("labs", "settings", "show", "--json")
|
|
94
|
+
|
|
95
|
+
assert result.exit_code == 0, result.output
|
|
96
|
+
|
|
97
|
+
|
|
79
98
|
def test_lab_settings_show_json_with_lab_override(
|
|
80
99
|
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
81
100
|
) -> None:
|
|
@@ -355,10 +374,11 @@ def test_lab_members_transfer_pi_posts_to_transfer_endpoint(
|
|
|
355
374
|
json=_membership_payload(role="PI"),
|
|
356
375
|
)
|
|
357
376
|
|
|
358
|
-
|
|
377
|
+
user_id = "019f52ba-7038-72e4-8f99-06eaadae87e6"
|
|
378
|
+
result = invoke("labs", "members", "transfer-pi", user_id)
|
|
359
379
|
|
|
360
380
|
assert result.exit_code == 0, result.output
|
|
361
381
|
request = httpx_mock.get_requests(method="POST")[0]
|
|
362
|
-
assert json.loads(request.content) == {"to_user_id":
|
|
382
|
+
assert json.loads(request.content) == {"to_user_id": user_id}
|
|
363
383
|
assert "Transferred PI role" in result.stdout
|
|
364
384
|
assert "PI" in result.stdout
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from pytest_httpx import HTTPXMock
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def
|
|
11
|
+
def test_meetings_list_default_days(
|
|
12
12
|
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
13
13
|
) -> None:
|
|
14
14
|
httpx_mock.add_response(
|
|
@@ -23,31 +23,31 @@ def test_meetings_upcoming_default_days(
|
|
|
23
23
|
}
|
|
24
24
|
],
|
|
25
25
|
)
|
|
26
|
-
result = invoke("meetings", "
|
|
26
|
+
result = invoke("meetings", "list")
|
|
27
27
|
assert result.exit_code == 0, result.output
|
|
28
28
|
assert "Group meeting" in result.stdout
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def
|
|
31
|
+
def test_meetings_list_custom_days(
|
|
32
32
|
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
33
33
|
) -> None:
|
|
34
34
|
httpx_mock.add_response(
|
|
35
35
|
url="https://api.test/v1/meetings/?lab=smith-neuro-lab&upcoming_days=14",
|
|
36
36
|
json=[],
|
|
37
37
|
)
|
|
38
|
-
result = invoke("meetings", "
|
|
38
|
+
result = invoke("meetings", "list", "--days", "14")
|
|
39
39
|
assert result.exit_code == 0, result.output
|
|
40
40
|
assert "No meetings" in result.stdout
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def
|
|
43
|
+
def test_meetings_list_json(
|
|
44
44
|
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
45
45
|
) -> None:
|
|
46
46
|
httpx_mock.add_response(
|
|
47
47
|
url="https://api.test/v1/meetings/?lab=smith-neuro-lab&upcoming_days=7",
|
|
48
48
|
json=[{"id": 1, "title": "Group meeting"}],
|
|
49
49
|
)
|
|
50
|
-
result = invoke("meetings", "
|
|
50
|
+
result = invoke("meetings", "list", "--json")
|
|
51
51
|
assert result.exit_code == 0, result.output
|
|
52
52
|
data = json.loads(result.stdout)
|
|
53
53
|
assert data[0]["title"] == "Group meeting"
|
|
@@ -72,6 +72,29 @@ def test_show_meeting(
|
|
|
72
72
|
assert "alice" in result.stdout
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_show_meeting_accepts_structured_attendees(
|
|
76
|
+
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
77
|
+
) -> None:
|
|
78
|
+
httpx_mock.add_response(
|
|
79
|
+
url="https://api.test/v1/meetings/10/",
|
|
80
|
+
json={
|
|
81
|
+
"id": "019f52c4-f7d0-72be-a07d-3ae4ad0496b0",
|
|
82
|
+
"title": "Group meeting",
|
|
83
|
+
"attendees": [
|
|
84
|
+
{
|
|
85
|
+
"id": "019f52ba-7038-72e4-8f99-06eaadae87e6",
|
|
86
|
+
"email": "alice@example.com",
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
result = invoke("meetings", "show", "10", "--json")
|
|
93
|
+
|
|
94
|
+
assert result.exit_code == 0, result.output
|
|
95
|
+
assert json.loads(result.stdout)["attendees"][0]["email"] == "alice@example.com"
|
|
96
|
+
|
|
97
|
+
|
|
75
98
|
def test_meetings_unauthenticated(invoke, config_path: Path) -> None:
|
|
76
99
|
result = invoke("meetings", "upcoming")
|
|
77
100
|
assert result.exit_code == 2
|
|
@@ -130,6 +130,7 @@ def test_notes_export_writes_markdown_file(
|
|
|
130
130
|
assert result.exit_code == 0, result.output
|
|
131
131
|
assert "meeting-42-notes.md" in result.output
|
|
132
132
|
assert destination.read_text() == "# Meeting Notes\n\nDecision captured.\n"
|
|
133
|
+
assert httpx_mock.get_requests()[0].headers["Accept"] == "application/json"
|
|
133
134
|
|
|
134
135
|
|
|
135
136
|
def test_notes_create_posts_tiptap_document_from_text(
|
|
@@ -67,6 +67,32 @@ def test_show_paper(
|
|
|
67
67
|
assert "10.1000/abc" in result.stdout
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def test_list_papers_accepts_structured_authors(
|
|
71
|
+
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
72
|
+
) -> None:
|
|
73
|
+
httpx_mock.add_response(
|
|
74
|
+
url="https://api.test/v1/papers/?lab=smith-neuro-lab",
|
|
75
|
+
json=[
|
|
76
|
+
{
|
|
77
|
+
"id": "019f8647-5eac-7273-848e-770bc181707e",
|
|
78
|
+
"title": "Discovery",
|
|
79
|
+
"authors": [
|
|
80
|
+
{
|
|
81
|
+
"id": "019f52ba-7038-72e4-8f99-06eaadae87e6",
|
|
82
|
+
"first_name": "Alice",
|
|
83
|
+
"last_name": "Chen",
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
result = invoke("papers", "list", "--json")
|
|
91
|
+
|
|
92
|
+
assert result.exit_code == 0, result.output
|
|
93
|
+
assert json.loads(result.stdout)[0]["authors"][0]["first_name"] == "Alice"
|
|
94
|
+
|
|
95
|
+
|
|
70
96
|
def test_show_paper_404(
|
|
71
97
|
invoke, httpx_mock: HTTPXMock, signed_in_config: Path
|
|
72
98
|
) -> None:
|
|
@@ -78,11 +78,12 @@ def test_shares_create_posts_share_request(
|
|
|
78
78
|
},
|
|
79
79
|
)
|
|
80
80
|
|
|
81
|
+
object_id = "019f8162-414e-7d70-a6e8-2b526cd20ed2"
|
|
81
82
|
result = invoke(
|
|
82
83
|
"shares",
|
|
83
84
|
"create",
|
|
84
85
|
"project",
|
|
85
|
-
|
|
86
|
+
object_id,
|
|
86
87
|
"collab@example.com",
|
|
87
88
|
"--permission",
|
|
88
89
|
"comment",
|
|
@@ -95,7 +96,7 @@ def test_shares_create_posts_share_request(
|
|
|
95
96
|
assert request.headers["X-Lab-ID"] == "smith-neuro-lab"
|
|
96
97
|
assert json.loads(request.content) == {
|
|
97
98
|
"record_type": "project",
|
|
98
|
-
"object_id":
|
|
99
|
+
"object_id": object_id,
|
|
99
100
|
"recipient_email": "collab@example.com",
|
|
100
101
|
"permission_level": "comment",
|
|
101
102
|
"expires_at": "2026-06-14T12:00:00Z",
|
|
@@ -20,27 +20,27 @@ def test_complete_happy_path(runner: CliRunner, httpx_mock: HTTPXMock) -> None:
|
|
|
20
20
|
httpx_mock.add_response(
|
|
21
21
|
method="PATCH",
|
|
22
22
|
url="https://api.test/v1/tasks/42/",
|
|
23
|
-
json={"id": 42, "
|
|
23
|
+
json={"id": 42, "is_complete": True},
|
|
24
24
|
)
|
|
25
25
|
result = runner.invoke(app, ["tasks", "complete", "42"])
|
|
26
26
|
assert result.exit_code == 0, result.output
|
|
27
27
|
assert "Completed task" in result.output
|
|
28
28
|
assert "42" in result.output
|
|
29
29
|
body = httpx_mock.get_requests(method="PATCH")[-1].read().decode()
|
|
30
|
-
assert '"
|
|
30
|
+
assert '"is_complete": true' in body or '"is_complete":true' in body
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def test_complete_undo_flag(runner: CliRunner, httpx_mock: HTTPXMock) -> None:
|
|
34
34
|
httpx_mock.add_response(
|
|
35
35
|
method="PATCH",
|
|
36
36
|
url="https://api.test/v1/tasks/99/",
|
|
37
|
-
json={"id": 99, "
|
|
37
|
+
json={"id": 99, "is_complete": False},
|
|
38
38
|
)
|
|
39
39
|
result = runner.invoke(app, ["tasks", "complete", "99", "--undo"])
|
|
40
40
|
assert result.exit_code == 0, result.output
|
|
41
41
|
assert "Reopened task" in result.output
|
|
42
42
|
body = httpx_mock.get_requests(method="PATCH")[-1].read().decode()
|
|
43
|
-
assert '"
|
|
43
|
+
assert '"is_complete": false' in body or '"is_complete":false' in body
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def test_complete_not_found(runner: CliRunner, httpx_mock: HTTPXMock) -> None:
|
|
@@ -76,8 +76,8 @@ def test_create_task_full(runner: CliRunner, httpx_mock: HTTPXMock) -> None:
|
|
|
76
76
|
)
|
|
77
77
|
assert result.exit_code == 0, result.output
|
|
78
78
|
body = httpx_mock.get_requests(method="POST")[-1].read().decode()
|
|
79
|
-
assert '"
|
|
80
|
-
assert '"priority": "
|
|
79
|
+
assert '"assignees": [11]' in body or '"assignees":[11]' in body
|
|
80
|
+
assert '"priority": "High"' in body or '"priority":"High"' in body
|
|
81
81
|
assert '"due_date": "2026-06-01"' in body or '"due_date":"2026-06-01"' in body
|
|
82
82
|
assert '"project": "kraken"' in body or '"project":"kraken"' in body
|
|
83
83
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|