labtab 0.2.0__tar.gz → 0.2.1__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.
Files changed (88) hide show
  1. {labtab-0.2.0/src/labtab.egg-info → labtab-0.2.1}/PKG-INFO +3 -3
  2. {labtab-0.2.0 → labtab-0.2.1}/README.md +2 -2
  3. {labtab-0.2.0 → labtab-0.2.1}/pyproject.toml +1 -1
  4. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/__init__.py +1 -1
  5. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/client.py +12 -14
  6. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/_common.py +8 -0
  7. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/export.py +25 -25
  8. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/labs.py +10 -10
  9. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/meetings.py +5 -4
  10. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/papers.py +1 -1
  11. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/shares.py +1 -1
  12. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/tasks.py +7 -7
  13. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/models/api.py +2 -2
  14. {labtab-0.2.0 → labtab-0.2.1/src/labtab.egg-info}/PKG-INFO +3 -3
  15. {labtab-0.2.0 → labtab-0.2.1}/tests/test_client.py +20 -0
  16. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_export.py +32 -7
  17. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_labs.py +22 -2
  18. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_meetings.py +29 -6
  19. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_notes.py +1 -0
  20. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_papers.py +26 -0
  21. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_shares.py +3 -2
  22. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_tasks_complete.py +4 -4
  23. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_tasks_create.py +2 -2
  24. {labtab-0.2.0 → labtab-0.2.1}/.gitignore +0 -0
  25. {labtab-0.2.0 → labtab-0.2.1}/LICENSE +0 -0
  26. {labtab-0.2.0 → labtab-0.2.1}/RELEASING.md +0 -0
  27. {labtab-0.2.0 → labtab-0.2.1}/setup.cfg +0 -0
  28. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/auth.py +0 -0
  29. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/cli.py +0 -0
  30. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/__init__.py +0 -0
  31. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/account.py +0 -0
  32. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/ai.py +0 -0
  33. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/api_keys.py +0 -0
  34. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/audit_logs.py +0 -0
  35. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/auth.py +0 -0
  36. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/deadlines.py +0 -0
  37. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/grants.py +0 -0
  38. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/home.py +0 -0
  39. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/letters.py +0 -0
  40. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/literature.py +0 -0
  41. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/notes.py +0 -0
  42. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/notifications.py +0 -0
  43. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/people.py +0 -0
  44. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/posters.py +0 -0
  45. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/projects.py +0 -0
  46. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/results.py +0 -0
  47. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/search.py +0 -0
  48. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/talks.py +0 -0
  49. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/commands/wiki.py +0 -0
  50. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/config.py +0 -0
  51. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/datetimes.py +0 -0
  52. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/errors.py +0 -0
  53. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/models/__init__.py +0 -0
  54. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/models/generated.py +0 -0
  55. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/output.py +0 -0
  56. {labtab-0.2.0 → labtab-0.2.1}/src/labtab/py.typed +0 -0
  57. {labtab-0.2.0 → labtab-0.2.1}/src/labtab.egg-info/SOURCES.txt +0 -0
  58. {labtab-0.2.0 → labtab-0.2.1}/src/labtab.egg-info/dependency_links.txt +0 -0
  59. {labtab-0.2.0 → labtab-0.2.1}/src/labtab.egg-info/entry_points.txt +0 -0
  60. {labtab-0.2.0 → labtab-0.2.1}/src/labtab.egg-info/requires.txt +0 -0
  61. {labtab-0.2.0 → labtab-0.2.1}/src/labtab.egg-info/top_level.txt +0 -0
  62. {labtab-0.2.0 → labtab-0.2.1}/tests/__init__.py +0 -0
  63. {labtab-0.2.0 → labtab-0.2.1}/tests/conftest.py +0 -0
  64. {labtab-0.2.0 → labtab-0.2.1}/tests/test_auth.py +0 -0
  65. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_account.py +0 -0
  66. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_ai.py +0 -0
  67. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_api_keys.py +0 -0
  68. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_audit_logs.py +0 -0
  69. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_auth.py +0 -0
  70. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_deadlines.py +0 -0
  71. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_grants.py +0 -0
  72. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_home.py +0 -0
  73. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_letters.py +0 -0
  74. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_literature.py +0 -0
  75. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_meetings_create.py +0 -0
  76. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_notifications.py +0 -0
  77. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_papers_import.py +0 -0
  78. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_people.py +0 -0
  79. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_posters.py +0 -0
  80. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_projects.py +0 -0
  81. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_results.py +0 -0
  82. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_search.py +0 -0
  83. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_talks.py +0 -0
  84. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_tasks.py +0 -0
  85. {labtab-0.2.0 → labtab-0.2.1}/tests/test_commands_wiki.py +0 -0
  86. {labtab-0.2.0 → labtab-0.2.1}/tests/test_config.py +0 -0
  87. {labtab-0.2.0 → labtab-0.2.1}/tests/test_datetimes.py +0 -0
  88. {labtab-0.2.0 → labtab-0.2.1}/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.0
3
+ Version: 0.2.1
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
@@ -132,7 +132,7 @@ labtab grants list
132
132
  labtab grants show gr_abc123
133
133
 
134
134
  # Show the next 7 days of meetings
135
- labtab meetings upcoming --days 7
135
+ labtab meetings list --days 7
136
136
 
137
137
  # Get JSON for scripting
138
138
  labtab papers list --json | jq '.[] | .title'
@@ -246,7 +246,7 @@ permission check.
246
246
  | `labtab results create` | Create a result, optionally uploading evidence files |
247
247
  | `labtab results links <id>` | List research objects linked to a result |
248
248
  | `labtab results link <id>` | Link a result to a paper, poster, talk, or grant |
249
- | `labtab meetings upcoming` | Meetings in the next `--days` (default 7) |
249
+ | `labtab meetings list` | Meetings in the next `--days` (default 7) |
250
250
  | `labtab meetings show <id>` | Meeting detail card |
251
251
  | `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
252
  | `labtab tasks list` | Tasks, filter by `--status` |
@@ -106,7 +106,7 @@ labtab grants list
106
106
  labtab grants show gr_abc123
107
107
 
108
108
  # Show the next 7 days of meetings
109
- labtab meetings upcoming --days 7
109
+ labtab meetings list --days 7
110
110
 
111
111
  # Get JSON for scripting
112
112
  labtab papers list --json | jq '.[] | .title'
@@ -220,7 +220,7 @@ permission check.
220
220
  | `labtab results create` | Create a result, optionally uploading evidence files |
221
221
  | `labtab results links <id>` | List research objects linked to a result |
222
222
  | `labtab results link <id>` | Link a result to a paper, poster, talk, or grant |
223
- | `labtab meetings upcoming` | Meetings in the next `--days` (default 7) |
223
+ | `labtab meetings list` | Meetings in the next `--days` (default 7) |
224
224
  | `labtab meetings show <id>` | Meeting detail card |
225
225
  | `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
226
  | `labtab tasks list` | Tasks, filter by `--status` |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "labtab"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Command-line interface for LabTab (Wetware Ltd). Manage your lab from the terminal."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,5 +1,5 @@
1
1
  """labtab - command-line interface for LabTab (Wetware Ltd)."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -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
- """Minimal typed wrapper over :class:`httpx.Client`.
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
- ``url`` is used as-is - this is intended for signed S3-style URLs, not
184
- authenticated API calls. Returns the total byte count written.
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", url, timeout=60.0) as resp:
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}/export/{export_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
@@ -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>/export/<export_id>/`` every ``--poll`` seconds
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
- 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:
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
- except LabtabError as exc:
183
- bail(exc, json_mode=json_mode)
184
- return
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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: int = typer.Argument(..., help="Active member user ID to make PI."),
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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="Numeric lab ID. Defaults to the lab ID saved by auth login.",
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`` - upcoming / show / create commands."""
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
- def upcoming(
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 slug this meeting belongs to."),
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",
@@ -219,7 +219,7 @@ def import_bibtex(
219
219
  lab: str = typer.Option(
220
220
  ...,
221
221
  "--lab",
222
- help="Lab slug or numeric id to import into.",
222
+ help="Lab UUID or slug to import into.",
223
223
  ),
224
224
  source: str = typer.Option(
225
225
  "unknown",
@@ -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: int = typer.Argument(..., help="Numeric ID of the record to share."),
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 slug to associate with the task."
150
+ None, "--project", help="Project UUID to associate with the task."
151
151
  ),
152
- lab: str = typer.Option(..., "--lab", help="Lab slug this task belongs to."),
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["assignee"] = assignee_id
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={"status": target_status},
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.0
3
+ Version: 0.2.1
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
@@ -132,7 +132,7 @@ labtab grants list
132
132
  labtab grants show gr_abc123
133
133
 
134
134
  # Show the next 7 days of meetings
135
- labtab meetings upcoming --days 7
135
+ labtab meetings list --days 7
136
136
 
137
137
  # Get JSON for scripting
138
138
  labtab papers list --json | jq '.[] | .title'
@@ -246,7 +246,7 @@ permission check.
246
246
  | `labtab results create` | Create a result, optionally uploading evidence files |
247
247
  | `labtab results links <id>` | List research objects linked to a result |
248
248
  | `labtab results link <id>` | Link a result to a paper, poster, talk, or grant |
249
- | `labtab meetings upcoming` | Meetings in the next `--days` (default 7) |
249
+ | `labtab meetings list` | Meetings in the next `--days` (default 7) |
250
250
  | `labtab meetings show <id>` | Meeting detail card |
251
251
  | `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
252
  | `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(
@@ -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/export/exp_1/",
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/export/exp_2/",
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/export/exp_2/",
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/export/exp_2/",
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/export/exp_bad/",
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/export/exp_3/",
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/export/exp_o/",
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
- result = invoke("labs", "members", "transfer-pi", "22")
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": 22}
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 test_meetings_upcoming_default_days(
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", "upcoming")
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 test_meetings_upcoming_custom_days(
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", "upcoming", "--days", "14")
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 test_meetings_upcoming_json(
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", "upcoming", "--json")
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
- "4",
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": 4,
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, "status": "completed"},
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 '"status": "completed"' in body or '"status":"completed"' in body
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, "status": "pending"},
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 '"status": "pending"' in body or '"status":"pending"' in body
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 '"assignee": 11' in body or '"assignee":11' in body
80
- assert '"priority": "high"' in body or '"priority":"high"' in body
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