opensees-cli 0.2.1__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.1 → opensees_cli-0.2.3}/PKG-INFO +2 -2
  2. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/README.md +1 -1
  3. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/pyproject.toml +1 -1
  4. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/__init__.py +1 -1
  5. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/admin.py +220 -10
  6. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/api.py +35 -2
  7. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/auth.py +6 -1
  8. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/main.py +1 -1
  9. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/run.py +26 -0
  10. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/version.py +34 -17
  11. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/PKG-INFO +2 -2
  12. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/SOURCES.txt +2 -1
  13. opensees_cli-0.2.3/tests/test_admin_commands.py +291 -0
  14. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_api.py +22 -0
  15. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_files_commands.py +4 -0
  16. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_run_commands.py +34 -0
  17. opensees_cli-0.2.3/tests/test_version_commands.py +72 -0
  18. opensees_cli-0.2.1/tests/test_admin_commands.py +0 -105
  19. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/setup.cfg +0 -0
  20. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/__main__.py +0 -0
  21. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/config.py +0 -0
  22. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/files.py +0 -0
  23. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/path_setup.py +0 -0
  24. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
  25. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/entry_points.txt +0 -0
  26. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/requires.txt +0 -0
  27. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/top_level.txt +0 -0
  28. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_auth_commands.py +0 -0
  29. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_config.py +0 -0
  30. {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_main_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.1
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
@@ -121,7 +121,7 @@ ops run help
121
121
  Validation enforced by CLI (local uploads):
122
122
  - path must exist on disk and be a regular file
123
123
  - file extension must be `.py`
124
- - file size must be <= 200 KB
124
+ - upload uses the cloud file API (same as `ops files upload`; up to 100 MB per file)
125
125
 
126
126
  ## Common Workflows
127
127
 
@@ -98,7 +98,7 @@ ops run help
98
98
  Validation enforced by CLI (local uploads):
99
99
  - path must exist on disk and be a regular file
100
100
  - file extension must be `.py`
101
- - file size must be <= 200 KB
101
+ - upload uses the cloud file API (same as `ops files upload`; up to 100 MB per file)
102
102
 
103
103
  ## Common Workflows
104
104
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensees_cli"
7
- version = "0.2.1"
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.1"
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
@@ -12,6 +13,14 @@ from typer.core import TyperGroup
12
13
 
13
14
  from opensees_cli import api, config
14
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
 
@@ -113,7 +122,7 @@ def list_users():
113
122
  table.add_column("Admin")
114
123
  table.add_column("Last Login")
115
124
  table.add_column("Last Activity")
116
- table.add_column("CLI Version")
125
+ table.add_column("Pinned")
117
126
 
118
127
  for u in users:
119
128
  enabled = "[green]yes[/green]" if u.get("enabled") else "[red]no[/red]"
@@ -125,7 +134,7 @@ def list_users():
125
134
  admin,
126
135
  u.get("last_login_at", "") or "-",
127
136
  u.get("last_activity_at", "") or "-",
128
- u.get("last_cli_version", "") or "-",
137
+ u.get("openseespy_version", "") or "-",
129
138
  )
130
139
  console.print(table)
131
140
 
@@ -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 and quota."""
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:
@@ -195,8 +200,11 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
195
200
  console.print(f" Last login: {_to_local(r['last_login_at'])}")
196
201
  if r.get("last_activity_at"):
197
202
  console.print(f" Last active: {_to_local(r['last_activity_at'])}")
198
- if r.get("last_cli_version"):
199
- console.print(f" CLI version: {r['last_cli_version']}")
203
+ pin = r.get("openseespy_version") or ""
204
+ if pin:
205
+ console.print(f" OpenSeesPy: {pin} (pinned)")
206
+ else:
207
+ console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
200
208
  if r.get("last_ip"):
201
209
  console.print(f" Last IP: {r['last_ip']}")
202
210
 
@@ -354,3 +362,205 @@ def workers(
354
362
  _to_local(t["started_at"]) if t.get("started_at") else "-",
355
363
  )
356
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:
@@ -460,7 +460,12 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
460
460
  from opensees_cli import __version__
461
461
  r = api.post(
462
462
  "/auth/login",
463
- {"email": email, "password": password, "cli_version": __version__},
463
+ {
464
+ "email": email,
465
+ "password": password,
466
+ "cli_version": __version__,
467
+ "openseespy_version": config.get_openseespy_version() or "",
468
+ },
464
469
  auth=False,
465
470
  )
466
471
  config.save_credentials(
@@ -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")
@@ -739,6 +739,7 @@ def status(
739
739
  "analysis_id": aid,
740
740
  "status": a.get("status"),
741
741
  "filename": a.get("filename"),
742
+ "type": _run_type(a),
742
743
  "total_tasks": total_tasks,
743
744
  "completed_tasks": completed,
744
745
  "failed_tasks": failed,
@@ -758,6 +759,7 @@ def status(
758
759
  table = Table(show_header=True, title="Running analyses")
759
760
  table.add_column("Analysis", style="dim", max_width=8)
760
761
  table.add_column("Status")
762
+ table.add_column("Type")
761
763
  table.add_column("File")
762
764
  table.add_column("Tasks")
763
765
  table.add_column("Created")
@@ -773,6 +775,7 @@ def status(
773
775
  table.add_row(
774
776
  aid,
775
777
  _status_style(a.get("status", "")),
778
+ _run_type(a),
776
779
  a.get("filename", ""),
777
780
  tasks_str,
778
781
  _to_local(a.get("created_at", "")) if a.get("created_at") else "",
@@ -1971,6 +1974,23 @@ def _status_style(s: str) -> str:
1971
1974
  return styles.get(s, s)
1972
1975
 
1973
1976
 
1977
+ _LAMBDA_MAX_TIMEOUT = 900
1978
+
1979
+
1980
+ def _run_type(r: dict) -> str:
1981
+ """Worker type shown to users: ``fast`` (Lambda) or ``long`` (Fargate)."""
1982
+ t = r.get("type")
1983
+ if t in ("fast", "long"):
1984
+ return t
1985
+ timeout = r.get("timeout")
1986
+ if timeout is not None:
1987
+ try:
1988
+ return "long" if int(timeout) > _LAMBDA_MAX_TIMEOUT else "fast"
1989
+ except (TypeError, ValueError):
1990
+ pass
1991
+ return ""
1992
+
1993
+
1974
1994
  def _print_status(r: dict) -> None:
1975
1995
  """Print analysis summary, per-task list, or individual task detail."""
1976
1996
  # Multi-task analysis
@@ -1978,6 +1998,9 @@ def _print_status(r: dict) -> None:
1978
1998
  analysis_id = r.get("analysis_id", "")
1979
1999
  console.print(f" Analysis: [dim]{analysis_id}[/dim]")
1980
2000
  console.print(f" File: {r.get('filename', '')}")
2001
+ run_type = _run_type(r)
2002
+ if run_type:
2003
+ console.print(f" Type: {run_type}")
1981
2004
  total = r["total_tasks"]
1982
2005
  completed = r.get("completed", 0)
1983
2006
  failed = r.get("failed", 0)
@@ -2031,6 +2054,9 @@ def _print_status(r: dict) -> None:
2031
2054
  console.print(f" Analysis: [dim]{analysis_id}[/dim]")
2032
2055
 
2033
2056
  console.print(f" Status: {_status_style(r.get('status', ''))}")
2057
+ run_type = _run_type(r)
2058
+ if run_type:
2059
+ console.print(f" Type: {run_type}")
2034
2060
  if r.get("filename"):
2035
2061
  console.print(f" File: {r['filename']}")
2036
2062
  if r.get("created_at"):
@@ -9,6 +9,18 @@ from rich.table import Table
9
9
  from opensees_cli import api, config
10
10
 
11
11
  console = Console()
12
+
13
+
14
+ def _sync_pin_to_server(version: Optional[str]) -> None:
15
+ """Best-effort: store the local pin on the user record for admin list-users."""
16
+ if not api.has_stored_credentials():
17
+ return
18
+ try:
19
+ api.post("/auth/me", {"openseespy_version": version or ""})
20
+ except api.ApiError:
21
+ pass
22
+
23
+
12
24
  app = typer.Typer(
13
25
  name="version",
14
26
  help="Manage OpenSeesPy versions",
@@ -43,6 +55,7 @@ def set_version(version: str):
43
55
  console.print(f"[yellow]Warning: could not verify version: {e.message}[/yellow]")
44
56
 
45
57
  config.set_openseespy_version(version)
58
+ _sync_pin_to_server(version)
46
59
  console.print(f"[green]OpenSeesPy version pinned to: {version}[/green]")
47
60
 
48
61
 
@@ -74,6 +87,24 @@ def get_version():
74
87
  raise typer.Exit(1)
75
88
 
76
89
 
90
+ def _status_label(v: dict) -> str:
91
+ """Human status for the list table. Partial rows are numbered, not named.
92
+
93
+ ``partial 1`` — only the first worker is registered (Lambda).
94
+ ``partial 2`` — only the second worker is registered (Fargate).
95
+ """
96
+ status = (v.get("status") or "").strip() or "unknown"
97
+ if status != "partial":
98
+ return status
99
+ has_first = bool(v.get("lambda_arn"))
100
+ has_second = bool(v.get("fargate_task_def"))
101
+ if has_first and not has_second:
102
+ return "partial 1"
103
+ if has_second and not has_first:
104
+ return "partial 2"
105
+ return "partial"
106
+
107
+
77
108
  @app.command("list")
78
109
  def list_versions(
79
110
  status: str = typer.Option(
@@ -82,12 +113,6 @@ def list_versions(
82
113
  "-s",
83
114
  help="Filter by status: active, inactive, partial",
84
115
  ),
85
- show_arns: bool = typer.Option(
86
- False,
87
- "--arns",
88
- "-a",
89
- help="Show Lambda and Fargate ARNs",
90
- ),
91
116
  ):
92
117
  """
93
118
  List available OpenSeesPy versions in the registry.
@@ -95,7 +120,6 @@ def list_versions(
95
120
  Example:
96
121
  ops version list
97
122
  ops version list --status active
98
- ops version list --arns
99
123
  """
100
124
  try:
101
125
  params = {}
@@ -118,9 +142,6 @@ def list_versions(
118
142
  table.add_column("Version")
119
143
  table.add_column("Status")
120
144
  table.add_column("Updated")
121
- if show_arns:
122
- table.add_column("Lambda ARN", overflow="fold")
123
- table.add_column("Fargate Task Def", overflow="fold")
124
145
 
125
146
  for v in versions:
126
147
  version_str = v["version"]
@@ -132,7 +153,7 @@ def list_versions(
132
153
  if tags:
133
154
  version_str = f"[bold green]{version_str} ({', '.join(tags)})[/bold green]"
134
155
 
135
- status_str = v["status"]
156
+ status_str = _status_label(v)
136
157
  if status_str == "active":
137
158
  status_str = f"[green]{status_str}[/green]"
138
159
  elif status_str == "inactive":
@@ -144,12 +165,7 @@ def list_versions(
144
165
  if updated:
145
166
  updated = updated.split("T")[0]
146
167
 
147
- row = [version_str, status_str, updated]
148
- if show_arns:
149
- row.append(v.get("lambda_arn", ""))
150
- row.append(v.get("fargate_task_def", ""))
151
-
152
- table.add_row(*row)
168
+ table.add_row(version_str, status_str, updated)
153
169
 
154
170
  console.print(table)
155
171
 
@@ -165,6 +181,7 @@ def clear_version():
165
181
  ops version clear
166
182
  """
167
183
  config.clear_openseespy_version()
184
+ _sync_pin_to_server(None)
168
185
  console.print("[green]Pin cleared — will use API default (latest active)[/green]")
169
186
 
170
187
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.1
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
@@ -121,7 +121,7 @@ ops run help
121
121
  Validation enforced by CLI (local uploads):
122
122
  - path must exist on disk and be a regular file
123
123
  - file extension must be `.py`
124
- - file size must be <= 200 KB
124
+ - upload uses the cloud file API (same as `ops files upload`; up to 100 MB per file)
125
125
 
126
126
  ## Common Workflows
127
127
 
@@ -23,4 +23,5 @@ tests/test_auth_commands.py
23
23
  tests/test_config.py
24
24
  tests/test_files_commands.py
25
25
  tests/test_main_commands.py
26
- tests/test_run_commands.py
26
+ tests/test_run_commands.py
27
+ tests/test_version_commands.py
@@ -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": [
@@ -183,6 +183,7 @@ class TestRunStatus:
183
183
  "analysis_id": "aaa55555",
184
184
  "status": "completed",
185
185
  "filename": "model.py",
186
+ "type": "fast",
186
187
  "total_tasks": 1,
187
188
  "created_at": "2025-06-01T00:00:00Z",
188
189
  "runtime_seconds": 12.5,
@@ -191,6 +192,26 @@ class TestRunStatus:
191
192
  result = cli_runner.invoke(app, ["run", "status", "aaa55555"])
192
193
  assert result.exit_code == 0
193
194
  assert "completed" in result.output.lower()
195
+ assert "Type:" in result.output
196
+ assert "fast" in result.output
197
+ assert "lambda" not in result.output.lower()
198
+ assert "fargate" not in result.output.lower()
199
+
200
+ def test_status_long_type(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
201
+ mock_router.add("GET", "/prod/run/status", json={
202
+ "task_id": "ttt55556",
203
+ "analysis_id": "aaa55556",
204
+ "status": "running",
205
+ "filename": "model.py",
206
+ "type": "long",
207
+ "timeout": 1800,
208
+ "total_tasks": 1,
209
+ })
210
+ result = cli_runner.invoke(app, ["run", "status", "aaa55556"])
211
+ assert result.exit_code == 0
212
+ assert "Type:" in result.output
213
+ assert "long" in result.output
214
+ assert "fargate" not in result.output.lower()
194
215
 
195
216
  def test_status_no_running(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
196
217
  mock_router.add("GET", "/prod/run/list", json={"analyses": []})
@@ -321,3 +342,16 @@ class TestList:
321
342
  result = cli_runner.invoke(app, ["run", "list"])
322
343
  assert result.exit_code == 0
323
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]
@@ -0,0 +1,72 @@
1
+ """Tests for ops version list status labels."""
2
+
3
+ from opensees_cli.main import app
4
+ from opensees_cli.version import _status_label
5
+
6
+
7
+ def test_status_label_partial_1():
8
+ assert _status_label({
9
+ "status": "partial",
10
+ "lambda_arn": "arn:aws:lambda:us-west-2:1:function:opensees",
11
+ "fargate_task_def": "",
12
+ }) == "partial 1"
13
+
14
+
15
+ def test_status_label_partial_2():
16
+ assert _status_label({
17
+ "status": "partial",
18
+ "lambda_arn": "",
19
+ "fargate_task_def": "arn:aws:ecs:us-west-2:1:task-definition/opensees:1",
20
+ }) == "partial 2"
21
+
22
+
23
+ def test_status_label_active_and_inactive():
24
+ assert _status_label({"status": "active", "lambda_arn": "x", "fargate_task_def": "y"}) == "active"
25
+ assert _status_label({"status": "inactive"}) == "inactive"
26
+
27
+
28
+ def test_version_list_shows_partial_numbers(cli_runner, patch_httpx, mock_router):
29
+ mock_router.add("GET", "/prod/run/versions", json={
30
+ "default": "3.7.0.0",
31
+ "versions": [
32
+ {
33
+ "version": "3.7.0.0",
34
+ "status": "active",
35
+ "lambda_arn": "arn:aws:lambda:fn-a",
36
+ "fargate_task_def": "arn:aws:ecs:td-a",
37
+ "updated_at": "2026-09-01T00:00:00Z",
38
+ "is_default": True,
39
+ },
40
+ {
41
+ "version": "3.6.0.0",
42
+ "status": "partial",
43
+ "lambda_arn": "arn:aws:lambda:fn-b",
44
+ "fargate_task_def": "",
45
+ "updated_at": "2026-08-01T00:00:00Z",
46
+ },
47
+ {
48
+ "version": "3.5.0.0",
49
+ "status": "partial",
50
+ "lambda_arn": "",
51
+ "fargate_task_def": "arn:aws:ecs:td-c",
52
+ "updated_at": "2026-07-01T00:00:00Z",
53
+ },
54
+ ],
55
+ })
56
+ result = cli_runner.invoke(app, ["version", "list"])
57
+ assert result.exit_code == 0
58
+ assert "partial 1" in result.output
59
+ assert "partial 2" in result.output
60
+ assert "active" in result.output
61
+ assert "arn:aws:lambda" not in result.output
62
+ assert "arn:aws:ecs" not in result.output
63
+ assert "Lambda" not in result.output
64
+ assert "Fargate" not in result.output
65
+
66
+
67
+ def test_version_list_rejects_arns_flag(cli_runner, patch_httpx, mock_router):
68
+ result = cli_runner.invoke(app, ["version", "list", "-a"])
69
+ assert result.exit_code != 0
70
+ assert "No such option" in result.output or "no such option" in result.output.lower()
71
+ result = cli_runner.invoke(app, ["version", "list", "--arns"])
72
+ assert result.exit_code != 0
@@ -1,105 +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
- "last_cli_version": "0.1.0a2",
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
-
27
- def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
28
- mock_router.add("GET", "/prod/admin/users", json={"users": []})
29
- result = cli_runner.invoke(app, ["admin", "list-users"])
30
- assert result.exit_code == 0
31
- assert "No users" in result.output
32
-
33
-
34
- class TestDisableUser:
35
- def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
36
- mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
37
- result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
38
- assert result.exit_code == 0
39
- assert "disabled" in result.output.lower()
40
-
41
-
42
- class TestEnableUser:
43
- def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
44
- mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
45
- result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
46
- assert result.exit_code == 0
47
- assert "enabled" in result.output.lower()
48
-
49
-
50
- class TestDeleteUser:
51
- def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
52
- mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
53
- result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
54
- assert result.exit_code == 0
55
- assert "deleted" in result.output.lower()
56
-
57
- def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
58
- result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
59
- assert result.exit_code == 0
60
- assert "Cancelled" in result.output
61
-
62
-
63
- class TestUserInfo:
64
- def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
65
- mock_router.add("POST", "/prod/admin/user-info", json={
66
- "email": "alice@test.com",
67
- "is_admin": False,
68
- "is_enabled": True,
69
- "quota": {
70
- "max_concurrent_runs": 5,
71
- "max_tasks_per_analysis": 100,
72
- "max_monthly_runtime": 3600,
73
- "monthly_runtime_used": 0,
74
- "monthly_runtime_remaining": 3600,
75
- "max_monthly_storage": 1073741824,
76
- "storage_used": 0,
77
- },
78
- })
79
- result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
80
- assert result.exit_code == 0
81
- assert "alice@test.com" in result.output
82
-
83
-
84
- class TestSetQuota:
85
- def test_set_quota_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
86
- mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
87
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com", "-r", "2h"])
88
- assert result.exit_code == 0
89
- assert "updated" in result.output.lower()
90
-
91
- def test_set_quota_no_flags(self, cli_runner, admin_creds, patch_httpx, mock_router):
92
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com"])
93
- assert result.exit_code == 0
94
- assert "Provide at least one" in result.output
95
-
96
- def test_set_quota_invalid_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
97
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-r", "abc"])
98
- assert result.exit_code == 1
99
- assert "Invalid" in result.output
100
-
101
- def test_set_quota_storage(self, cli_runner, admin_creds, patch_httpx, mock_router):
102
- mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
103
- result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-s", "10mb"])
104
- assert result.exit_code == 0
105
- assert "updated" in result.output.lower()
File without changes