opensees-cli 0.2.2__tar.gz → 0.2.3__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 (30) hide show
  1. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/PKG-INFO +1 -1
  2. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/pyproject.toml +1 -1
  3. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/__init__.py +1 -1
  4. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/admin.py +214 -65
  5. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/api.py +35 -2
  6. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/main.py +1 -1
  7. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/PKG-INFO +1 -1
  8. opensees_cli-0.2.3/tests/test_admin_commands.py +291 -0
  9. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_api.py +22 -0
  10. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_files_commands.py +4 -0
  11. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_run_commands.py +13 -0
  12. opensees_cli-0.2.2/tests/test_admin_commands.py +0 -150
  13. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/README.md +0 -0
  14. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/setup.cfg +0 -0
  15. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/__main__.py +0 -0
  16. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/auth.py +0 -0
  17. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/config.py +0 -0
  18. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/files.py +0 -0
  19. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/path_setup.py +0 -0
  20. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/run.py +0 -0
  21. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/version.py +0 -0
  22. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/SOURCES.txt +0 -0
  23. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
  24. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/entry_points.txt +0 -0
  25. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/requires.txt +0 -0
  26. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/top_level.txt +0 -0
  27. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_auth_commands.py +0 -0
  28. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_config.py +0 -0
  29. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_main_commands.py +0 -0
  30. {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_version_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Run OpenSees simulations in the cloud from the command line.
5
5
  Author: Minjie Zhu
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensees_cli"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Run OpenSees simulations in the cloud from the command line."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """OpenSees CLI — run structural simulations in the cloud."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -2,7 +2,8 @@
2
2
 
3
3
  import json
4
4
  import re
5
- from typing import Any, Optional
5
+ from pathlib import Path
6
+ from typing import Any, List, Optional
6
7
 
7
8
  import click
8
9
  import typer
@@ -11,7 +12,15 @@ from rich.table import Table
11
12
  from typer.core import TyperGroup
12
13
 
13
14
  from opensees_cli import api, config
14
- from opensees_cli.auth import _to_local, _fmt_bytes, print_quota
15
+ from opensees_cli.auth import _to_local, print_quota
16
+ from opensees_cli.files import download as files_download
17
+ from opensees_cli.files import list_files as files_list
18
+ from opensees_cli.run import _BareDefaultCommand
19
+ from opensees_cli.run import data as run_data
20
+ from opensees_cli.run import list_analyses as run_list
21
+ from opensees_cli.run import output as run_output
22
+ from opensees_cli.run import stats as run_stats
23
+ from opensees_cli.run import status as run_status
15
24
 
16
25
  _RUNTIME_TOKEN_RE = re.compile(r"^((\d+(?:\.\d+)?)(h|m|s))+$")
17
26
 
@@ -175,11 +184,7 @@ def delete_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
175
184
  raise typer.Exit(1)
176
185
 
177
186
 
178
- @app.command("user-info")
179
- def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
180
- """Show a user's account details, quota, analyses, and files."""
181
- if not email:
182
- email = typer.prompt("Email")
187
+ def _print_user_info(email: str) -> None:
183
188
  try:
184
189
  r = api.post("/admin/user-info", {"email": email})
185
190
  except api.ApiError as e:
@@ -200,8 +205,6 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
200
205
  console.print(f" OpenSeesPy: {pin} (pinned)")
201
206
  else:
202
207
  console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
203
- if r.get("last_cli_version"):
204
- console.print(f" CLI version: {r['last_cli_version']}")
205
208
  if r.get("last_ip"):
206
209
  console.print(f" Last IP: {r['last_ip']}")
207
210
 
@@ -210,62 +213,6 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
210
213
  console.print()
211
214
  print_quota(q)
212
215
 
213
- analyses = r.get("analyses") or []
214
- analyses_total = int(r.get("analyses_total") or 0)
215
- analyses_running = int(r.get("analyses_running") or 0)
216
- console.print()
217
- extra = f", {analyses_running} running" if analyses_running else ""
218
- console.print(f"[bold]Analyses[/bold] [dim]{analyses_total} total{extra}[/dim]")
219
- if not analyses:
220
- console.print(" [dim]No analyses.[/dim]")
221
- else:
222
- table = Table(show_header=True)
223
- table.add_column("Analysis", style="dim", max_width=8)
224
- table.add_column("Status")
225
- table.add_column("Type")
226
- table.add_column("File")
227
- table.add_column("Tasks")
228
- table.add_column("Created")
229
- for a in analyses:
230
- aid = (a.get("analysis_id") or "")[:8]
231
- total = int(a.get("total_tasks") or 1)
232
- table.add_row(
233
- aid,
234
- str(a.get("status") or ""),
235
- str(a.get("type") or ""),
236
- str(a.get("filename") or ""),
237
- str(total),
238
- _to_local(a["created_at"]) if a.get("created_at") else "",
239
- )
240
- console.print(table)
241
- if analyses_total > len(analyses):
242
- console.print(f" [dim]Showing {len(analyses)} of {analyses_total}.[/dim]")
243
-
244
- files = r.get("files") or []
245
- files_total = int(r.get("files_total") or 0)
246
- files_size = int(r.get("files_size") or 0)
247
- console.print()
248
- console.print(
249
- f"[bold]Files[/bold] [dim]{files_total} file"
250
- f"{'' if files_total == 1 else 's'}, {_fmt_bytes(files_size)}[/dim]"
251
- )
252
- if not files:
253
- console.print(" [dim]No files.[/dim]")
254
- else:
255
- table = Table(show_header=True)
256
- table.add_column("Path")
257
- table.add_column("Size", justify="right")
258
- table.add_column("Modified")
259
- for f in files:
260
- table.add_row(
261
- str(f.get("path") or ""),
262
- _fmt_bytes(f.get("size") or 0),
263
- _to_local(f["last_modified"]) if f.get("last_modified") else "",
264
- )
265
- console.print(table)
266
- if files_total > len(files):
267
- console.print(f" [dim]Showing {len(files)} of {files_total}.[/dim]")
268
-
269
216
 
270
217
  @app.command("set-quota")
271
218
  def set_quota(
@@ -415,3 +362,205 @@ def workers(
415
362
  _to_local(t["started_at"]) if t.get("started_at") else "-",
416
363
  )
417
364
  console.print(table)
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # user-info: account details, plus read-only run / files inspect
369
+ # ---------------------------------------------------------------------------
370
+
371
+ user_info_grp = typer.Typer(help="Show a user's account details; run / files inspect that user.")
372
+ run_grp = typer.Typer(help="Inspect the user's analyses (read-only).")
373
+ files_grp = typer.Typer(help="Inspect the user's files (read-only).")
374
+
375
+
376
+ def _email_from_ctx(ctx: typer.Context) -> Optional[str]:
377
+ cur: Optional[click.Context] = ctx
378
+ while cur is not None:
379
+ obj = cur.obj
380
+ if isinstance(obj, dict) and obj.get("email"):
381
+ return str(obj["email"])
382
+ cur = cur.parent
383
+ return None
384
+
385
+
386
+ def _resolve_email(ctx: typer.Context, email: Optional[str]) -> str:
387
+ resolved = (email or _email_from_ctx(ctx) or "").strip()
388
+ if not resolved:
389
+ console.print("[red]Provide --email / -e for the target user.[/red]")
390
+ raise typer.Exit(1)
391
+ return resolved
392
+
393
+
394
+ def _email_opt() -> Any:
395
+ return typer.Option(None, "--email", "-e", help="Target user's email")
396
+
397
+
398
+ @user_info_grp.callback(invoke_without_command=True)
399
+ def user_info(
400
+ ctx: typer.Context,
401
+ email: Optional[str] = typer.Option(None, "--email", "-e", help="Target user's email"),
402
+ ):
403
+ """Show a user's account details and quota."""
404
+ ctx.ensure_object(dict)
405
+ if email:
406
+ ctx.obj["email"] = email.strip()
407
+ if ctx.invoked_subcommand is not None:
408
+ return
409
+ if not email:
410
+ email = typer.prompt("Email")
411
+ ctx.obj["email"] = email.strip()
412
+ _print_user_info(email)
413
+
414
+
415
+ @run_grp.callback(invoke_without_command=True)
416
+ def _admin_run_callback(ctx: typer.Context) -> None:
417
+ ctx.ensure_object(dict)
418
+ parent_email = _email_from_ctx(ctx)
419
+ if parent_email:
420
+ ctx.obj["email"] = parent_email
421
+ if ctx.invoked_subcommand is None:
422
+ typer.echo(ctx.get_help())
423
+ raise typer.Exit(0)
424
+
425
+
426
+ @files_grp.callback(invoke_without_command=True)
427
+ def _admin_files_callback(ctx: typer.Context) -> None:
428
+ ctx.ensure_object(dict)
429
+ parent_email = _email_from_ctx(ctx)
430
+ if parent_email:
431
+ ctx.obj["email"] = parent_email
432
+ if ctx.invoked_subcommand is None:
433
+ typer.echo(ctx.get_help())
434
+ raise typer.Exit(0)
435
+
436
+
437
+ @run_grp.command("list", cls=_BareDefaultCommand)
438
+ def admin_run_list(
439
+ ctx: typer.Context,
440
+ email: Optional[str] = _email_opt(),
441
+ limit: int = typer.Option(20, "--limit", "-n", help="Max number of analyses to show"),
442
+ after: Optional[str] = typer.Option(None, "--after", help="Show analyses after date (YYYY-MM-DD)"),
443
+ before: Optional[str] = typer.Option(None, "--before", help="Show analyses before date (YYYY-MM-DD)"),
444
+ week: Optional[str] = typer.Option(
445
+ None, "--week", "-w",
446
+ help="Week filter: 0=this week; 1-52=ISO week; negative=-k; W1-W2=range.",
447
+ ),
448
+ month: Optional[str] = typer.Option(
449
+ None, "--month", "-m",
450
+ help="Month filter: 0=this month; 1-12; negative=-k; M-N=range.",
451
+ ),
452
+ year: Optional[str] = typer.Option(
453
+ None, "--year", "-y",
454
+ help="Year filter: 0=this year; positive=absolute year; negative=-k; Y1-Y2=range.",
455
+ ),
456
+ all_: bool = typer.Option(False, "--all", "-a", help="Show all analyses (no date filter)"),
457
+ as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
458
+ ):
459
+ """List the user's analyses.
460
+
461
+ Same flags as ``ops run list``. Example: ops admin user-info run list -e alice@test.com
462
+ """
463
+ with api.acting_as(_resolve_email(ctx, email)):
464
+ run_list(
465
+ limit=limit,
466
+ after=after,
467
+ before=before,
468
+ week=week,
469
+ month=month,
470
+ year=year,
471
+ all_=all_,
472
+ as_json=as_json,
473
+ )
474
+
475
+
476
+ @run_grp.command("status")
477
+ def admin_run_status(
478
+ ctx: typer.Context,
479
+ id: Optional[str] = typer.Argument(None, help="Analysis ID (omit to list running analyses)"),
480
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
481
+ email: Optional[str] = _email_opt(),
482
+ as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
483
+ ):
484
+ """Show the user's analysis or task status."""
485
+ with api.acting_as(_resolve_email(ctx, email)):
486
+ run_status(id=id, range_=range_, as_json=as_json)
487
+
488
+
489
+ @run_grp.command("output")
490
+ def admin_run_output(
491
+ ctx: typer.Context,
492
+ id: str = typer.Argument(..., help="Analysis ID"),
493
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
494
+ email: Optional[str] = _email_opt(),
495
+ ):
496
+ """Show the user's printed output (stdout)."""
497
+ with api.acting_as(_resolve_email(ctx, email)):
498
+ run_output(id=id, range_=range_)
499
+
500
+
501
+ @run_grp.command("stats")
502
+ def admin_run_stats(
503
+ ctx: typer.Context,
504
+ id: str = typer.Argument(..., help="Analysis ID"),
505
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
506
+ email: Optional[str] = _email_opt(),
507
+ ):
508
+ """Show the user's task execution stats."""
509
+ with api.acting_as(_resolve_email(ctx, email)):
510
+ run_stats(id=id, range_=range_)
511
+
512
+
513
+ @run_grp.command("data")
514
+ def admin_run_data(
515
+ ctx: typer.Context,
516
+ id: str = typer.Argument(..., help="Analysis ID"),
517
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
518
+ email: Optional[str] = _email_opt(),
519
+ download: Optional[str] = typer.Option(
520
+ None, "--download", "-d", metavar="PATH",
521
+ help="Download mode (omit to list only). Same as ``ops run data -d``.",
522
+ ),
523
+ output_dir: str = typer.Option(
524
+ ".", "--output", "-o",
525
+ help="Directory to write downloaded files.",
526
+ ),
527
+ ):
528
+ """List or download the user's task data files."""
529
+ with api.acting_as(_resolve_email(ctx, email)):
530
+ run_data(id=id, range_=range_, download=download, output_dir=output_dir)
531
+
532
+
533
+ @files_grp.command("list")
534
+ def admin_files_list(
535
+ ctx: typer.Context,
536
+ folder: Optional[str] = typer.Argument(
537
+ None,
538
+ help="Only list files in this folder (and its subfolders)",
539
+ ),
540
+ email: Optional[str] = _email_opt(),
541
+ as_json: bool = typer.Option(False, "--json", "-j", help="Emit JSON instead of a table"),
542
+ ):
543
+ """List the user's files."""
544
+ email = _resolve_email(ctx, email)
545
+ console.print(f"[dim]Files for {email}[/dim]")
546
+ with api.acting_as(email):
547
+ files_list(folder=folder, as_json=as_json)
548
+
549
+
550
+ @files_grp.command("download")
551
+ def admin_files_download(
552
+ ctx: typer.Context,
553
+ remote_paths: List[str] = typer.Argument(..., help="Remote file path(s) or glob pattern(s)"),
554
+ email: Optional[str] = _email_opt(),
555
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Local output path"),
556
+ ):
557
+ """Download file(s) from the user's file system."""
558
+ with api.acting_as(_resolve_email(ctx, email)):
559
+ files_download(remote_paths=remote_paths, output=output)
560
+
561
+
562
+ user_info_grp.add_typer(run_grp, name="run")
563
+ user_info_grp.add_typer(files_grp, name="files")
564
+ app.add_typer(user_info_grp, name="user-info")
565
+
566
+
@@ -4,7 +4,9 @@ Handles auth headers and automatic token refresh when the server reports an
4
4
  expired session (HTTP 401, or HTTP 400 with code ``auth_error`` — e.g. /auth/me).
5
5
  """
6
6
 
7
- from typing import Any, Dict, Optional
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
9
+ from typing import Any, Dict, Iterator, Optional
8
10
 
9
11
  import httpx
10
12
 
@@ -22,6 +24,7 @@ TIMEOUT = 30.0
22
24
  # Seconds before access-token expiry to refresh proactively (when expires_at is known).
23
25
  _REFRESH_BUFFER_SEC = 300
24
26
  _session_ok: Optional[bool] = None
27
+ _as_user: ContextVar[Optional[str]] = ContextVar("as_user", default=None)
25
28
 
26
29
 
27
30
  class ApiError(Exception):
@@ -32,6 +35,25 @@ class ApiError(Exception):
32
35
  super().__init__(message)
33
36
 
34
37
 
38
+ @contextmanager
39
+ def acting_as(email: str) -> Iterator[None]:
40
+ """Send ``as_user=<email>`` on subsequent GET requests (admin impersonation)."""
41
+ token = _as_user.set((email or "").strip().lower() or None)
42
+ try:
43
+ yield
44
+ finally:
45
+ _as_user.reset(token)
46
+
47
+
48
+ def _merge_as_user(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
49
+ email = _as_user.get()
50
+ if not email:
51
+ return params
52
+ merged = dict(params or {})
53
+ merged["as_user"] = email
54
+ return merged
55
+
56
+
35
57
  def invalidate_session_cache() -> None:
36
58
  global _session_ok
37
59
  _session_ok = None
@@ -164,6 +186,9 @@ def _headers(token: Optional[str] = None) -> Dict[str, str]:
164
186
  h["Authorization"] = f"Bearer {id_tok}"
165
187
  if access_tok:
166
188
  h["X-Access-Token"] = access_tok
189
+ as_user = _as_user.get()
190
+ if as_user:
191
+ h["X-As-User"] = as_user
167
192
  return h
168
193
 
169
194
 
@@ -288,13 +313,21 @@ def post(path: str, body: Dict[str, Any], auth: bool = True) -> Dict[str, Any]:
288
313
 
289
314
  def get(path: str, params: Optional[Dict[str, Any]] = None, auth: bool = True) -> Dict[str, Any]:
290
315
  hdrs = _headers() if auth else _headers(token="")
316
+ params = _merge_as_user(params)
291
317
  resp = httpx.get(_url(path), params=params, headers=hdrs, timeout=TIMEOUT)
292
318
  if auth and _needs_auth_refresh(resp):
293
319
  if _try_refresh():
294
320
  resp = httpx.get(_url(path), params=params, headers=_headers(), timeout=TIMEOUT)
295
321
  else:
296
322
  _auth_failure()
297
- return _parse(resp)
323
+ data = _parse(resp)
324
+ expected = _as_user.get()
325
+ if expected and str(data.get("as_user") or "").strip().lower() != expected:
326
+ raise ApiError(
327
+ "Server did not switch to the target user. Deploy the API (./deploy-api.sh) and try again.",
328
+ status=resp.status_code,
329
+ )
330
+ return data
298
331
 
299
332
 
300
333
  def upload_file(presigned_url: str, file_bytes: bytes, content_type: str = "text/x-python") -> None:
@@ -140,7 +140,7 @@ def _print_help() -> None:
140
140
  console.print()
141
141
  console.print("[bold]ops admin[/bold]\n")
142
142
  console.print(" [bold]list-users[/bold] List all users")
143
- console.print(" [bold]user-info[/bold] Show a user's details")
143
+ console.print(" [bold]user-info[/bold] Account details; run / files inspect that user")
144
144
  console.print(" [bold]set-quota[/bold] Update a user's quota")
145
145
  console.print(" [bold]enable-user[/bold] Re-enable a user")
146
146
  console.print(" [bold]disable-user[/bold] Disable a user")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Run OpenSees simulations in the cloud from the command line.
5
5
  Author: Minjie Zhu
6
6
  License: Proprietary
@@ -0,0 +1,291 @@
1
+ """Tests for ops admin subcommands — one test per command, using admin creds."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from opensees_cli.main import app
6
+
7
+
8
+ class TestListUsers:
9
+ def test_list_users(self, cli_runner, admin_creds, patch_httpx, mock_router):
10
+ mock_router.add("GET", "/prod/admin/users", json={
11
+ "users": [
12
+ {
13
+ "email": "alice@test.com",
14
+ "status": "CONFIRMED",
15
+ "enabled": True,
16
+ "is_admin": False,
17
+ "last_login_at": "2025-01-01T00:00:00Z",
18
+ "last_activity_at": "",
19
+ "openseespy_version": "3.7.1.0",
20
+ },
21
+ ]
22
+ })
23
+ result = cli_runner.invoke(app, ["admin", "list-users"])
24
+ assert result.exit_code == 0
25
+ assert "alice@te" in result.output
26
+ assert "3.7.1.0" in result.output
27
+ assert "CLI Version" not in result.output
28
+ assert "Pinned" in result.output
29
+
30
+ def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
31
+ mock_router.add("GET", "/prod/admin/users", json={"users": []})
32
+ result = cli_runner.invoke(app, ["admin", "list-users"])
33
+ assert result.exit_code == 0
34
+ assert "No users" in result.output
35
+
36
+
37
+ class TestDisableUser:
38
+ def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
39
+ mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
40
+ result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
41
+ assert result.exit_code == 0
42
+ assert "disabled" in result.output.lower()
43
+
44
+
45
+ class TestEnableUser:
46
+ def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
47
+ mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
48
+ result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
49
+ assert result.exit_code == 0
50
+ assert "enabled" in result.output.lower()
51
+
52
+
53
+ class TestDeleteUser:
54
+ def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
55
+ mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
56
+ result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
57
+ assert result.exit_code == 0
58
+ assert "deleted" in result.output.lower()
59
+
60
+ def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
61
+ result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
62
+ assert result.exit_code == 0
63
+ assert "Cancelled" in result.output
64
+
65
+
66
+ class TestUserInfo:
67
+ def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
68
+ mock_router.add("POST", "/prod/admin/user-info", json={
69
+ "email": "alice@test.com",
70
+ "is_admin": False,
71
+ "is_enabled": True,
72
+ "openseespy_version": "3.7.1.0",
73
+ "last_cli_version": "0.2.2",
74
+ "quota": {
75
+ "max_concurrent_runs": 5,
76
+ "max_tasks_per_analysis": 100,
77
+ "max_monthly_runtime": 3600,
78
+ "monthly_runtime_used": 0,
79
+ "monthly_runtime_remaining": 3600,
80
+ "max_monthly_storage": 1073741824,
81
+ "storage_used": 0,
82
+ },
83
+ "analyses_total": 2,
84
+ "analyses_running": 1,
85
+ "analyses": [
86
+ {
87
+ "analysis_id": "aaa11111-0000-0000-0000-000000000000",
88
+ "status": "running",
89
+ "filename": "Truss.py",
90
+ "type": "fast",
91
+ "total_tasks": 1,
92
+ "created_at": "2026-09-01T00:00:00Z",
93
+ },
94
+ ],
95
+ "files_total": 1,
96
+ "files_size": 128,
97
+ "files": [
98
+ {"path": "Truss.py", "size": 128, "last_modified": "2026-09-01T00:00:00Z"},
99
+ ],
100
+ })
101
+ result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
102
+ assert result.exit_code == 0
103
+ assert "alice@test.com" in result.output
104
+ assert "3.7.1.0" in result.output
105
+ assert "pinned" in result.output.lower()
106
+ assert "CLI version" not in result.output
107
+ assert "Analyses" not in result.output
108
+ assert "Files" not in result.output
109
+
110
+ def test_user_info_not_pinned(self, cli_runner, admin_creds, patch_httpx, mock_router):
111
+ mock_router.add("POST", "/prod/admin/user-info", json={
112
+ "email": "bob@test.com",
113
+ "is_admin": False,
114
+ "is_enabled": True,
115
+ "openseespy_version": None,
116
+ })
117
+ result = cli_runner.invoke(app, ["admin", "user-info", "-e", "bob@test.com"])
118
+ assert result.exit_code == 0
119
+ assert "not pinned" in result.output.lower()
120
+ assert "No analyses" not in result.output
121
+ assert "No files" not in result.output
122
+
123
+
124
+ class TestSetQuota:
125
+ def test_set_quota_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
126
+ mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
127
+ result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com", "-r", "2h"])
128
+ assert result.exit_code == 0
129
+ assert "updated" in result.output.lower()
130
+
131
+ def test_set_quota_no_flags(self, cli_runner, admin_creds, patch_httpx, mock_router):
132
+ result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com"])
133
+ assert result.exit_code == 0
134
+ assert "Provide at least one" in result.output
135
+
136
+ def test_set_quota_invalid_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
137
+ result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-r", "abc"])
138
+ assert result.exit_code == 1
139
+ assert "Invalid" in result.output
140
+
141
+ def test_set_quota_storage(self, cli_runner, admin_creds, patch_httpx, mock_router):
142
+ mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
143
+ result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-s", "10mb"])
144
+ assert result.exit_code == 0
145
+ assert "updated" in result.output.lower()
146
+
147
+
148
+ def _query_of(url: str) -> dict:
149
+ from urllib.parse import parse_qs, urlparse
150
+ return {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
151
+
152
+
153
+ def _get_calls(mock_router, path_suffix: str) -> list:
154
+ return [
155
+ c for c in mock_router.calls
156
+ if c["method"] == "GET" and path_suffix in c["url"].split("?")[0]
157
+ ]
158
+
159
+
160
+ class TestAdminRun:
161
+ def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
162
+ result = cli_runner.invoke(app, ["admin", "run", "list", "-e", "alice@test.com"])
163
+ assert result.exit_code != 0
164
+
165
+ def test_help_lists_read_commands(self, cli_runner, admin_creds, patch_httpx, mock_router):
166
+ result = cli_runner.invoke(app, ["admin", "user-info", "--help"])
167
+ assert result.exit_code == 0
168
+ assert "run" in result.output
169
+ assert "files" in result.output
170
+
171
+ def test_run_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
172
+ result = cli_runner.invoke(app, ["admin", "user-info", "run", "--help"])
173
+ assert result.exit_code == 0
174
+ assert "list" in result.output
175
+ assert "status" in result.output
176
+ assert "submit" not in result.output
177
+ assert "cancel" not in result.output
178
+
179
+ def test_submit_not_a_command(self, cli_runner, admin_creds, patch_httpx, mock_router):
180
+ result = cli_runner.invoke(
181
+ app, ["admin", "user-info", "run", "submit", "-e", "alice@test.com", "model.py"]
182
+ )
183
+ assert result.exit_code != 0
184
+
185
+ def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
186
+ mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
187
+ result = cli_runner.invoke(
188
+ app, ["admin", "user-info", "run", "list", "-e", "alice@test.com", "--all"]
189
+ )
190
+ assert result.exit_code == 0
191
+ calls = _get_calls(mock_router, "/prod/run/list")
192
+ assert calls
193
+ assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
194
+
195
+ def test_list_parent_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
196
+ mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
197
+ result = cli_runner.invoke(
198
+ app, ["admin", "user-info", "-e", "alice@test.com", "run", "list", "--all"]
199
+ )
200
+ assert result.exit_code == 0
201
+ calls = _get_calls(mock_router, "/prod/run/list")
202
+ assert calls
203
+ assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
204
+
205
+ def test_list_requires_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
206
+ result = cli_runner.invoke(app, ["admin", "user-info", "run", "list"])
207
+ assert result.exit_code != 0
208
+
209
+ def test_status_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
210
+ mock_router.add("GET", "/prod/run/status", json={
211
+ "task_id": "ttt55555",
212
+ "analysis_id": "aaa55555",
213
+ "status": "completed",
214
+ "filename": "model.py",
215
+ "type": "fast",
216
+ "total_tasks": 1,
217
+ "as_user": "alice@test.com",
218
+ })
219
+ result = cli_runner.invoke(
220
+ app, ["admin", "user-info", "run", "status", "aaa55555", "-e", "alice@test.com"]
221
+ )
222
+ assert result.exit_code == 0
223
+ calls = _get_calls(mock_router, "/prod/run/status")
224
+ assert calls
225
+ q = _query_of(calls[0]["url"])
226
+ assert q["as_user"] == "alice@test.com"
227
+ assert q["id"] == "aaa55555"
228
+
229
+
230
+ class TestAdminFiles:
231
+ def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
232
+ result = cli_runner.invoke(app, ["admin", "files", "list", "-e", "alice@test.com"])
233
+ assert result.exit_code != 0
234
+
235
+ def test_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
236
+ result = cli_runner.invoke(app, ["admin", "user-info", "files", "--help"])
237
+ assert result.exit_code == 0
238
+ assert "list" in result.output
239
+ assert "download" in result.output
240
+ assert "upload" not in result.output
241
+ assert "delete" not in result.output
242
+
243
+ def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
244
+ mock_router.add("GET", "/prod/files/list", json={
245
+ "files": [{"path": "model.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
246
+ "as_user": "alice@test.com",
247
+ })
248
+ result = cli_runner.invoke(
249
+ app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
250
+ )
251
+ assert result.exit_code == 0
252
+ assert "model.py" in result.output
253
+ calls = _get_calls(mock_router, "/prod/files/list")
254
+ assert calls
255
+ assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
256
+ assert "Files for alice@test.com" in result.output
257
+
258
+ def test_list_old_api_does_not_show_caller_files(
259
+ self, cli_runner, admin_creds, patch_httpx, mock_router
260
+ ):
261
+ mock_router.add("GET", "/prod/files/list", json={
262
+ "files": [{"path": "mine.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
263
+ })
264
+ result = cli_runner.invoke(
265
+ app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
266
+ )
267
+ assert result.exit_code == 1
268
+ assert "mine.py" not in result.output
269
+ assert "Deploy the API" in result.output
270
+
271
+ def test_download_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router, tmp_path):
272
+ mock_router.add("GET", "/prod/files/list", json={
273
+ "files": [{"path": "model.py", "size": 5, "last_modified": "2026-01-01T00:00:00Z"}],
274
+ "as_user": "alice@test.com",
275
+ })
276
+ mock_router.add("GET", "/prod/files/download", json={
277
+ "download_url": "https://s3.example.com/presigned-get?sig=abc",
278
+ "as_user": "alice@test.com",
279
+ })
280
+ mock_router.add_any_url("GET", status=200, content=b"hello")
281
+ out = tmp_path / "model.py"
282
+ result = cli_runner.invoke(
283
+ app,
284
+ ["admin", "user-info", "files", "download", "model.py", "-e", "alice@test.com", "-o", str(out)],
285
+ )
286
+ assert result.exit_code == 0
287
+ list_calls = _get_calls(mock_router, "/prod/files/list")
288
+ dl_calls = _get_calls(mock_router, "/prod/files/download")
289
+ assert list_calls and dl_calls
290
+ assert _query_of(list_calls[0]["url"])["as_user"] == "alice@test.com"
291
+ assert _query_of(dl_calls[0]["url"])["as_user"] == "alice@test.com"
@@ -8,6 +8,7 @@ import pytest
8
8
  from opensees_cli.api import (
9
9
  ApiError,
10
10
  _parse,
11
+ acting_as,
11
12
  post,
12
13
  get,
13
14
  upload_file,
@@ -248,3 +249,24 @@ class TestFetchS3Range:
248
249
  data, complete = fetch_s3_range("https://s3.example.com/stdout?sig=abc")
249
250
  assert data == b""
250
251
  assert complete is False
252
+
253
+
254
+ class TestActingAs:
255
+ def test_get_includes_as_user(self, logged_in_creds, patch_httpx, mock_router):
256
+ mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
257
+ with acting_as("Alice@Test.com"):
258
+ get("/run/list")
259
+ urls = [c["url"] for c in mock_router.calls if "/prod/run/list" in c["url"]]
260
+ assert urls
261
+ assert "as_user=alice%40test.com" in urls[0]
262
+
263
+ def test_get_clears_after_context(self, logged_in_creds, patch_httpx, mock_router):
264
+ mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
265
+ with acting_as("alice@test.com"):
266
+ get("/run/list")
267
+ mock_router.add("GET", "/prod/run/list", json={"analyses": []})
268
+ get("/run/list")
269
+ urls = [c["url"] for c in mock_router.calls if "/prod/run/list" in c["url"]]
270
+ assert len(urls) == 2
271
+ assert "as_user" in urls[0]
272
+ assert "as_user" not in urls[1]
@@ -15,6 +15,10 @@ class TestFilesList:
15
15
  assert result.exit_code == 0
16
16
  assert result.output == ""
17
17
 
18
+ def test_list_rejects_user_flag(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
19
+ result = cli_runner.invoke(app, ["files", "list", "--user", "alice@test.com"])
20
+ assert result.exit_code != 0
21
+
18
22
  def test_list_with_files(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
19
23
  mock_router.add("GET", "/prod/files/list", json={
20
24
  "files": [
@@ -342,3 +342,16 @@ class TestList:
342
342
  result = cli_runner.invoke(app, ["run", "list"])
343
343
  assert result.exit_code == 0
344
344
  assert "No analyses" in result.output
345
+
346
+ def test_list_rejects_user_flag(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
347
+ result = cli_runner.invoke(app, ["run", "list", "--user", "alice@test.com"])
348
+ assert result.exit_code != 0
349
+ assert "as_user" not in " ".join(c["url"] for c in mock_router.calls)
350
+
351
+ def test_list_does_not_send_as_user(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
352
+ mock_router.add("GET", "/prod/run/list", json={"analyses": []})
353
+ result = cli_runner.invoke(app, ["run", "list", "--all"])
354
+ assert result.exit_code == 0
355
+ urls = [c["url"] for c in mock_router.calls if "/prod/run/list" in c["url"]]
356
+ assert urls
357
+ assert "as_user" not in urls[0]
@@ -1,150 +0,0 @@
1
- """Tests for ops admin subcommands — one test per command, using admin creds."""
2
-
3
- from unittest.mock import patch
4
-
5
- from opensees_cli.main import app
6
-
7
-
8
- class TestListUsers:
9
- def test_list_users(self, cli_runner, admin_creds, patch_httpx, mock_router):
10
- mock_router.add("GET", "/prod/admin/users", json={
11
- "users": [
12
- {
13
- "email": "alice@test.com",
14
- "status": "CONFIRMED",
15
- "enabled": True,
16
- "is_admin": False,
17
- "last_login_at": "2025-01-01T00:00:00Z",
18
- "last_activity_at": "",
19
- "openseespy_version": "3.7.1.0",
20
- },
21
- ]
22
- })
23
- result = cli_runner.invoke(app, ["admin", "list-users"])
24
- assert result.exit_code == 0
25
- assert "alice@te" in result.output
26
- assert "3.7.1.0" in result.output
27
- assert "CLI Version" not in result.output
28
- assert "Pinned" in result.output
29
-
30
- def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
31
- mock_router.add("GET", "/prod/admin/users", json={"users": []})
32
- result = cli_runner.invoke(app, ["admin", "list-users"])
33
- assert result.exit_code == 0
34
- assert "No users" in result.output
35
-
36
-
37
- class TestDisableUser:
38
- def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
39
- mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
40
- result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
41
- assert result.exit_code == 0
42
- assert "disabled" in result.output.lower()
43
-
44
-
45
- class TestEnableUser:
46
- def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
47
- mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
48
- result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
49
- assert result.exit_code == 0
50
- assert "enabled" in result.output.lower()
51
-
52
-
53
- class TestDeleteUser:
54
- def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
55
- mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
56
- result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
57
- assert result.exit_code == 0
58
- assert "deleted" in result.output.lower()
59
-
60
- def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
61
- result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
62
- assert result.exit_code == 0
63
- assert "Cancelled" in result.output
64
-
65
-
66
- class TestUserInfo:
67
- def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
68
- mock_router.add("POST", "/prod/admin/user-info", json={
69
- "email": "alice@test.com",
70
- "is_admin": False,
71
- "is_enabled": True,
72
- "openseespy_version": "3.7.1.0",
73
- "quota": {
74
- "max_concurrent_runs": 5,
75
- "max_tasks_per_analysis": 100,
76
- "max_monthly_runtime": 3600,
77
- "monthly_runtime_used": 0,
78
- "monthly_runtime_remaining": 3600,
79
- "max_monthly_storage": 1073741824,
80
- "storage_used": 0,
81
- },
82
- "analyses_total": 2,
83
- "analyses_running": 1,
84
- "analyses": [
85
- {
86
- "analysis_id": "aaa11111-0000-0000-0000-000000000000",
87
- "status": "running",
88
- "filename": "Truss.py",
89
- "type": "fast",
90
- "total_tasks": 1,
91
- "created_at": "2026-09-01T00:00:00Z",
92
- },
93
- ],
94
- "files_total": 1,
95
- "files_size": 128,
96
- "files": [
97
- {"path": "Truss.py", "size": 128, "last_modified": "2026-09-01T00:00:00Z"},
98
- ],
99
- })
100
- result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
101
- assert result.exit_code == 0
102
- assert "alice@test.com" in result.output
103
- assert "3.7.1.0" in result.output
104
- assert "pinned" in result.output.lower()
105
- assert "Truss.py" in result.output
106
- assert "Analyses" in result.output
107
- assert "Files" in result.output
108
-
109
- def test_user_info_not_pinned(self, cli_runner, admin_creds, patch_httpx, mock_router):
110
- mock_router.add("POST", "/prod/admin/user-info", json={
111
- "email": "bob@test.com",
112
- "is_admin": False,
113
- "is_enabled": True,
114
- "openseespy_version": None,
115
- "analyses_total": 0,
116
- "analyses_running": 0,
117
- "analyses": [],
118
- "files_total": 0,
119
- "files_size": 0,
120
- "files": [],
121
- })
122
- result = cli_runner.invoke(app, ["admin", "user-info", "-e", "bob@test.com"])
123
- assert result.exit_code == 0
124
- assert "not pinned" in result.output.lower()
125
- assert "No analyses" in result.output
126
- assert "No files" in result.output
127
-
128
-
129
- class TestSetQuota:
130
- def test_set_quota_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
131
- mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
132
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com", "-r", "2h"])
133
- assert result.exit_code == 0
134
- assert "updated" in result.output.lower()
135
-
136
- def test_set_quota_no_flags(self, cli_runner, admin_creds, patch_httpx, mock_router):
137
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com"])
138
- assert result.exit_code == 0
139
- assert "Provide at least one" in result.output
140
-
141
- def test_set_quota_invalid_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
142
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-r", "abc"])
143
- assert result.exit_code == 1
144
- assert "Invalid" in result.output
145
-
146
- def test_set_quota_storage(self, cli_runner, admin_creds, patch_httpx, mock_router):
147
- mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
148
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-s", "10mb"])
149
- assert result.exit_code == 0
150
- assert "updated" in result.output.lower()
File without changes
File without changes