deepsieve-cli 0.1.0__tar.gz → 0.2.0__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.
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/.gitignore +20 -2
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/PKG-INFO +3 -2
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/README.md +1 -0
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/pyproject.toml +1 -1
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/src/deepsieve_cli/main.py +78 -4
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/tests/test_cli.py +155 -4
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/src/deepsieve_cli/__init__.py +0 -0
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/src/deepsieve_cli/__main__.py +0 -0
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/src/deepsieve_cli/client.py +0 -0
- {deepsieve_cli-0.1.0 → deepsieve_cli-0.2.0}/src/deepsieve_cli/config.py +0 -0
|
@@ -17,7 +17,20 @@
|
|
|
17
17
|
.aws/
|
|
18
18
|
|
|
19
19
|
# --- Node / Next.js (site) ---
|
|
20
|
-
site/node_modules
|
|
20
|
+
# Unanchored on purpose: `site/node_modules/` alone left a ROOT-level
|
|
21
|
+
# node_modules/ tracked, and running vitest from the repo root (rather than from
|
|
22
|
+
# site/) creates one — `node_modules/.vite/vitest/**/results.json` reached
|
|
23
|
+
# `development` in #320 through a `git add -A`, passing review and CI without
|
|
24
|
+
# anything failing. A rule that covers the path someone already hit is not a rule.
|
|
25
|
+
# ⚠️ NO TRAILING SLASH, and that is load-bearing too. `node_modules/` matches
|
|
26
|
+
# DIRECTORIES ONLY, so a SYMLINKED node_modules is not ignored and `git add -A`
|
|
27
|
+
# stages the link itself — an absolute path that resolves for nobody else (gh
|
|
28
|
+
# #756). Symlinking it is the documented way to share one 733 MB install across
|
|
29
|
+
# git worktrees on a full disk, so the ignore rule and the disk guidance
|
|
30
|
+
# disagreed silently. Verified by controlled test: with `node_modules/`,
|
|
31
|
+
# `git add -A` reports `A node_modules`; without the slash it reports nothing.
|
|
32
|
+
node_modules
|
|
33
|
+
site/node_modules
|
|
21
34
|
site/.next/
|
|
22
35
|
site/.next-*/
|
|
23
36
|
site/out/
|
|
@@ -113,4 +126,9 @@ api/_stress/
|
|
|
113
126
|
# Stray Playwright accessibility-snapshot dumps from browser-driven QA.
|
|
114
127
|
ctx*-run.md
|
|
115
128
|
page-*.yml
|
|
116
|
-
|
|
129
|
+
|
|
130
|
+
# Antigravity worktrees and state
|
|
131
|
+
.gemini/
|
|
132
|
+
|
|
133
|
+
# Antigravity (Gemini) agent local config — per-machine, like .claude/settings.local.json
|
|
134
|
+
.agents/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: deepsieve-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: DeepSieve CLI — run cited deep research from your terminal
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: httpx>=0.27
|
|
@@ -29,6 +29,7 @@ deepsieve runs create --query "..." --wait # real run: spends credits
|
|
|
29
29
|
deepsieve runs list
|
|
30
30
|
deepsieve runs get <id> # exit 4 while still running
|
|
31
31
|
deepsieve runs cancel <id>
|
|
32
|
+
deepsieve members list # who is in the workspace + seat usage
|
|
32
33
|
deepsieve data catalog # entities + columns (never guess)
|
|
33
34
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
34
35
|
deepsieve setup mcp # MCP registration for this origin
|
|
@@ -21,6 +21,7 @@ deepsieve runs create --query "..." --wait # real run: spends credits
|
|
|
21
21
|
deepsieve runs list
|
|
22
22
|
deepsieve runs get <id> # exit 4 while still running
|
|
23
23
|
deepsieve runs cancel <id>
|
|
24
|
+
deepsieve members list # who is in the workspace + seat usage
|
|
24
25
|
deepsieve data catalog # entities + columns (never guess)
|
|
25
26
|
deepsieve data get companies --receipts # rows with per-cell citations
|
|
26
27
|
deepsieve setup mcp # MCP registration for this origin
|
|
@@ -238,8 +238,11 @@ def cmd_runs_get(args) -> int:
|
|
|
238
238
|
|
|
239
239
|
def cmd_runs_create(args) -> int:
|
|
240
240
|
client, prof = _require_auth(args)
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
# At most one. NEITHER is a paid broad sweep of the whole Blueprint schema
|
|
242
|
+
# (#1005), which the API accepts; the CLI used to refuse it as a usage
|
|
243
|
+
# error and could not express the feature at all.
|
|
244
|
+
if args.query and args.seeds:
|
|
245
|
+
err("pass at most one of --query or --seeds")
|
|
243
246
|
return EXIT_USAGE
|
|
244
247
|
|
|
245
248
|
body: dict[str, Any] = {"depth": args.depth, "dry_run": args.dry_run, "monitored": args.monitored}
|
|
@@ -322,10 +325,70 @@ def cmd_runs_cancel(args) -> int:
|
|
|
322
325
|
return EXIT_OK
|
|
323
326
|
|
|
324
327
|
|
|
328
|
+
def cmd_members_list(args) -> int:
|
|
329
|
+
"""Who is in this workspace and how many editor seats are in use (#985).
|
|
330
|
+
Read-only; inviting is a human-admin action in the UI."""
|
|
331
|
+
client, _ = _require_auth(args)
|
|
332
|
+
try:
|
|
333
|
+
body = client.request("GET", "/v1/members")
|
|
334
|
+
except ApiError as exc:
|
|
335
|
+
err(str(exc))
|
|
336
|
+
return EXIT_FAIL
|
|
337
|
+
if args.json:
|
|
338
|
+
out(body, True)
|
|
339
|
+
return EXIT_OK
|
|
340
|
+
for m in body.get("data", []):
|
|
341
|
+
print(f"{m.get('email')} {m.get('role')} ({m.get('status')})")
|
|
342
|
+
seats = body.get("seats") or {}
|
|
343
|
+
inc = seats.get("editor_seats_included")
|
|
344
|
+
used = seats.get("editor_seats_used")
|
|
345
|
+
print(f"\neditor seats: {used} used / {'unlimited' if inc is None else inc} included")
|
|
346
|
+
return EXIT_OK
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _blueprint_id(client, args) -> str | None:
|
|
350
|
+
"""The Blueprint these commands address: ``--blueprint``, else the active one.
|
|
351
|
+
|
|
352
|
+
`/v1/data` was removed (pre-GA breaking change): every dataset read now names
|
|
353
|
+
its Blueprint in the path, because a read against the wrong one returns a
|
|
354
|
+
well-formed dataset about the wrong domain and nothing in the response says
|
|
355
|
+
so. The CLI keeps its zero-argument form by resolving the active Blueprint
|
|
356
|
+
here — one extra request, and only when no id was given.
|
|
357
|
+
|
|
358
|
+
Returns None after printing the reason, so callers exit non-zero rather than
|
|
359
|
+
falling back to some other Blueprint.
|
|
360
|
+
"""
|
|
361
|
+
if getattr(args, "blueprint", None):
|
|
362
|
+
return args.blueprint
|
|
363
|
+
try:
|
|
364
|
+
body = client.request("GET", "/v1/blueprints")
|
|
365
|
+
except ApiError as exc:
|
|
366
|
+
err(str(exc))
|
|
367
|
+
return None
|
|
368
|
+
rows = body.get("data") or []
|
|
369
|
+
active = next((r for r in rows if r.get("is_active")), None)
|
|
370
|
+
if active is None:
|
|
371
|
+
# Say which of the two states this is. "No Blueprints at all" and "none
|
|
372
|
+
# of them is active" need different actions from the user, and an
|
|
373
|
+
# unqualified "not found" would send them looking for the wrong problem.
|
|
374
|
+
if not rows:
|
|
375
|
+
err("no Blueprints yet — design one in the app first.")
|
|
376
|
+
else:
|
|
377
|
+
err(
|
|
378
|
+
"no active Blueprint. Pass --blueprint <id>; "
|
|
379
|
+
"`deepsieve data catalog --blueprint <id>` lists ids via /v1/blueprints."
|
|
380
|
+
)
|
|
381
|
+
return None
|
|
382
|
+
return active.get("id")
|
|
383
|
+
|
|
384
|
+
|
|
325
385
|
def cmd_data_catalog(args) -> int:
|
|
326
386
|
client, _ = _require_auth(args)
|
|
387
|
+
bp_id = _blueprint_id(client, args)
|
|
388
|
+
if bp_id is None:
|
|
389
|
+
return EXIT_FAIL
|
|
327
390
|
try:
|
|
328
|
-
body = client.request("GET", "/v1/
|
|
391
|
+
body = client.request("GET", f"/v1/blueprints/{bp_id}")
|
|
329
392
|
except ApiError as exc:
|
|
330
393
|
err(str(exc))
|
|
331
394
|
return EXIT_FAIL
|
|
@@ -340,13 +403,18 @@ def cmd_data_catalog(args) -> int:
|
|
|
340
403
|
|
|
341
404
|
def cmd_data_get(args) -> int:
|
|
342
405
|
client, _ = _require_auth(args)
|
|
406
|
+
bp_id = _blueprint_id(client, args)
|
|
407
|
+
if bp_id is None:
|
|
408
|
+
return EXIT_FAIL
|
|
343
409
|
params: dict[str, Any] = {"limit": args.limit, "receipts": args.receipts}
|
|
344
410
|
if args.cursor:
|
|
345
411
|
params["cursor"] = args.cursor
|
|
346
412
|
if args.updated_since:
|
|
347
413
|
params["updated_since"] = args.updated_since
|
|
348
414
|
try:
|
|
349
|
-
body = client.request(
|
|
415
|
+
body = client.request(
|
|
416
|
+
"GET", f"/v1/blueprints/{bp_id}/entities/{args.entity}", params=params
|
|
417
|
+
)
|
|
350
418
|
except ApiError as exc:
|
|
351
419
|
err(str(exc))
|
|
352
420
|
return EXIT_FAIL
|
|
@@ -484,9 +552,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
484
552
|
rx.add_argument("run_id")
|
|
485
553
|
rx.set_defaults(func=cmd_runs_cancel)
|
|
486
554
|
|
|
555
|
+
members = sub.add_parser("members", help="your workspace's team", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
556
|
+
ml = members.add_parser("list", help="who is in the workspace, and seat usage", parents=[common])
|
|
557
|
+
ml.set_defaults(func=cmd_members_list)
|
|
558
|
+
|
|
487
559
|
data = sub.add_parser("data", help="your cited dataset", parents=[common]).add_subparsers(dest="sub", required=True)
|
|
488
560
|
|
|
489
561
|
dc = data.add_parser("catalog", help="entities and columns (never guess these)", parents=[common])
|
|
562
|
+
dc.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
490
563
|
dc.set_defaults(func=cmd_data_catalog)
|
|
491
564
|
|
|
492
565
|
dg = data.add_parser("get", help="rows for one entity", parents=[common])
|
|
@@ -495,6 +568,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
495
568
|
dg.add_argument("--cursor")
|
|
496
569
|
dg.add_argument("--updated-since")
|
|
497
570
|
dg.add_argument("--receipts", action="store_true", help="include per-cell citations")
|
|
571
|
+
dg.add_argument("--blueprint", help="Blueprint id (default: the active one)")
|
|
498
572
|
dg.set_defaults(func=cmd_data_get)
|
|
499
573
|
|
|
500
574
|
setup = sub.add_parser("setup", help="connect other tools", parents=[common]).add_subparsers(
|
|
@@ -207,16 +207,41 @@ def test_dry_run_needs_no_confirmation(monkeypatch):
|
|
|
207
207
|
assert seen["idem"], "runs cost money — every create must carry an idempotency key"
|
|
208
208
|
|
|
209
209
|
|
|
210
|
-
def
|
|
210
|
+
def test_run_create_refuses_both_query_and_seeds(monkeypatch, capsys):
|
|
211
211
|
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
212
|
-
assert main(["runs", "create"]) == 2
|
|
213
|
-
assert "
|
|
212
|
+
assert main(["runs", "create", "--query", "x", "--seeds", "a,b"]) == 2
|
|
213
|
+
assert "at most one" in capsys.readouterr().err
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_run_create_with_neither_sends_an_empty_broad_sweep(monkeypatch):
|
|
217
|
+
"""#1005: neither flag is a schema-derived broad sweep, not a usage error.
|
|
218
|
+
The body must carry NEITHER key — the API treats their absence as the
|
|
219
|
+
sweep, and a `query: ""` would be a validation error instead."""
|
|
220
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
221
|
+
seen = {}
|
|
222
|
+
|
|
223
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
224
|
+
seen["body"] = json.loads(request.content)
|
|
225
|
+
return httpx.Response(202, json={"id": "run_1", "status": "queued"})
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
228
|
+
assert main(["runs", "create", "--dry-run"]) == 0
|
|
229
|
+
assert "query" not in seen["body"] and "seeds" not in seen["body"]
|
|
230
|
+
assert seen["body"]["dry_run"] is True
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# A workspace with one Blueprint, active. `data` commands resolve through this
|
|
234
|
+
# listing since #1282 removed the ambient /v1/data — so a handler that does not
|
|
235
|
+
# serve it is a handler the data commands cannot get past.
|
|
236
|
+
_ONE_ACTIVE_BP = {"data": [{"id": "bp_1", "name": "Finance", "is_active": True}]}
|
|
214
237
|
|
|
215
238
|
|
|
216
239
|
def test_truncated_dataset_is_called_out(monkeypatch, capsys):
|
|
217
240
|
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
218
241
|
|
|
219
|
-
def handler(
|
|
242
|
+
def handler(r):
|
|
243
|
+
if r.url.path == "/v1/blueprints":
|
|
244
|
+
return httpx.Response(200, json=_ONE_ACTIVE_BP)
|
|
220
245
|
return httpx.Response(200, json={"data": [], "truncated": True, "preview_row_cap": 10})
|
|
221
246
|
|
|
222
247
|
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
@@ -224,6 +249,59 @@ def test_truncated_dataset_is_called_out(monkeypatch, capsys):
|
|
|
224
249
|
assert "PREVIEW" in capsys.readouterr().err
|
|
225
250
|
|
|
226
251
|
|
|
252
|
+
def test_data_get_addresses_the_active_blueprint_by_id(monkeypatch):
|
|
253
|
+
"""The path must NAME a Blueprint. #1282 removed the ambient `/v1/data/{key}`,
|
|
254
|
+
so a CLI still calling it would 404 against any current deployment."""
|
|
255
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
256
|
+
seen = {}
|
|
257
|
+
|
|
258
|
+
def handler(r):
|
|
259
|
+
if r.url.path == "/v1/blueprints":
|
|
260
|
+
return httpx.Response(200, json=_ONE_ACTIVE_BP)
|
|
261
|
+
seen["path"] = r.url.path
|
|
262
|
+
return httpx.Response(200, json={"data": []})
|
|
263
|
+
|
|
264
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
265
|
+
assert main(["data", "get", "companies"]) == 0
|
|
266
|
+
assert seen["path"] == "/v1/blueprints/bp_1/entities/companies"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_blueprint_flag_names_one_without_consulting_the_listing(monkeypatch):
|
|
270
|
+
"""`--blueprint` is the whole point of the flag: it must not be second-guessed
|
|
271
|
+
against the active one, and it must not cost a listing call."""
|
|
272
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
273
|
+
paths = []
|
|
274
|
+
|
|
275
|
+
def handler(r):
|
|
276
|
+
paths.append(r.url.path)
|
|
277
|
+
return httpx.Response(200, json={"data": []})
|
|
278
|
+
|
|
279
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
280
|
+
assert main(["data", "get", "companies", "--blueprint", "bp_other"]) == 0
|
|
281
|
+
assert paths == ["/v1/blueprints/bp_other/entities/companies"]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_no_active_blueprint_fails_rather_than_picking_one(monkeypatch, capsys):
|
|
285
|
+
"""⚠️ The failure mode this helper exists to prevent.
|
|
286
|
+
|
|
287
|
+
With several Blueprints and none active, reading "whichever" returns a
|
|
288
|
+
well-formed dataset about the wrong domain — indistinguishable from the
|
|
289
|
+
right answer. So it exits NON-ZERO and names the flag, rather than falling
|
|
290
|
+
back to the first row.
|
|
291
|
+
"""
|
|
292
|
+
cfg.save_profile(cfg.Profile(name="default", origin="https://x.test", api_key="k"))
|
|
293
|
+
listed = {"data": [{"id": "a", "is_active": False}, {"id": "b", "is_active": False}]}
|
|
294
|
+
|
|
295
|
+
def handler(r):
|
|
296
|
+
if r.url.path == "/v1/blueprints":
|
|
297
|
+
return httpx.Response(200, json=listed)
|
|
298
|
+
raise AssertionError(f"must not read data without a Blueprint: {r.url.path}")
|
|
299
|
+
|
|
300
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
301
|
+
assert main(["data", "get", "companies"]) != 0
|
|
302
|
+
assert "--blueprint" in capsys.readouterr().err
|
|
303
|
+
|
|
304
|
+
|
|
227
305
|
def test_setup_mcp_names_this_profiles_origin(monkeypatch, capsys):
|
|
228
306
|
cfg.save_profile(cfg.Profile(name="staging", origin="https://staging.test", api_key="k"))
|
|
229
307
|
assert main(["--profile", "staging", "setup", "mcp"]) == 0
|
|
@@ -354,3 +432,76 @@ def test_failed_run_exits_failure_not_success(monkeypatch):
|
|
|
354
432
|
|
|
355
433
|
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
356
434
|
assert main(["runs", "get", "r"]) == 1
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── members list (#985) ──────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _members_body(*, included=10, viewers=True):
|
|
441
|
+
return {
|
|
442
|
+
"object": "list",
|
|
443
|
+
"data": [
|
|
444
|
+
{"object": "member", "id": "m1", "user_id": "u1", "email": "owner@example.com",
|
|
445
|
+
"role": "owner", "status": "active", "workspace_ids": [], "created_at": None},
|
|
446
|
+
{"object": "member", "id": "m2", "user_id": "u2", "email": "reader@example.com",
|
|
447
|
+
"role": "viewer", "status": "active", "workspace_ids": ["ws1"], "created_at": None},
|
|
448
|
+
],
|
|
449
|
+
"has_more": False,
|
|
450
|
+
"next_cursor": None,
|
|
451
|
+
"seats": {"editor_seats_used": 1, "editor_seats_included": included,
|
|
452
|
+
"editor_seats_free_now": None if included is None else included - 1,
|
|
453
|
+
"viewer_seats_available": viewers},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _signed_in(monkeypatch):
|
|
458
|
+
monkeypatch.setenv(cfg.ENV_KEY, "ds_live_test")
|
|
459
|
+
monkeypatch.setenv(cfg.ENV_ORIGIN, "https://ci.test")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_members_list_prints_the_roster_and_seat_usage(monkeypatch, capsys):
|
|
463
|
+
"""Review of #1022 found `members list` shipped with no test at all. Drives
|
|
464
|
+
the real command through the real client against a mocked /v1/members."""
|
|
465
|
+
_signed_in(monkeypatch)
|
|
466
|
+
seen = {}
|
|
467
|
+
|
|
468
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
469
|
+
seen["path"] = request.url.path
|
|
470
|
+
seen["auth"] = request.headers.get("authorization")
|
|
471
|
+
return httpx.Response(200, json=_members_body())
|
|
472
|
+
|
|
473
|
+
monkeypatch.setattr(httpx, "Client", _mock(handler))
|
|
474
|
+
assert main(["members", "list"]) == 0
|
|
475
|
+
out_ = capsys.readouterr().out
|
|
476
|
+
assert seen["path"] == "/v1/members"
|
|
477
|
+
assert seen["auth"] == "Bearer ds_live_test"
|
|
478
|
+
assert "owner@example.com owner (active)" in out_
|
|
479
|
+
assert "reader@example.com viewer (active)" in out_
|
|
480
|
+
assert "editor seats: 1 used / 10 included" in out_
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def test_members_list_json_is_the_raw_body_and_unlimited_reads_as_such(monkeypatch, capsys):
|
|
484
|
+
_signed_in(monkeypatch)
|
|
485
|
+
body = _members_body(included=None) # Enterprise: null = unlimited
|
|
486
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(200, json=body)))
|
|
487
|
+
assert main(["members", "list", "--json"]) == 0
|
|
488
|
+
assert json.loads(capsys.readouterr().out) == body
|
|
489
|
+
# and the human form renders null as "unlimited", never "None". Client is
|
|
490
|
+
# still patched from above — patching again would wrap the patched factory
|
|
491
|
+
# and pass `transport` twice (that exact TypeError shipped in a red commit).
|
|
492
|
+
assert main(["members", "list"]) == 0
|
|
493
|
+
text = capsys.readouterr().out
|
|
494
|
+
assert "unlimited included" in text and "None" not in text
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_members_list_surfaces_a_scope_refusal_as_a_typed_error(monkeypatch, capsys):
|
|
498
|
+
"""An `agent`-preset key lacks members:read; the CLI must show the API's
|
|
499
|
+
code + message and exit non-zero, not stack-trace or print an empty roster."""
|
|
500
|
+
_signed_in(monkeypatch)
|
|
501
|
+
err_body = {"error": {"type": "permission_error", "code": "insufficient_scope",
|
|
502
|
+
"message": "this key lacks members:read", "retriable": False}}
|
|
503
|
+
monkeypatch.setattr(httpx, "Client", _mock(lambda r: httpx.Response(403, json=err_body)))
|
|
504
|
+
assert main(["members", "list"]) == 1
|
|
505
|
+
captured = capsys.readouterr()
|
|
506
|
+
assert "insufficient_scope" in captured.err or "members:read" in captured.err
|
|
507
|
+
assert "owner@example.com" not in captured.out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|