opensees-cli 0.2.2__tar.gz → 0.2.4__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.4}/PKG-INFO +1 -1
  2. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/pyproject.toml +1 -1
  3. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/__init__.py +1 -1
  4. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/admin.py +239 -69
  5. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/api.py +35 -2
  6. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/auth.py +52 -2
  7. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/config.py +31 -7
  8. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/main.py +5 -1
  9. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/PKG-INFO +1 -1
  10. opensees_cli-0.2.4/tests/test_admin_commands.py +296 -0
  11. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_api.py +22 -0
  12. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_auth_commands.py +21 -4
  13. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_config.py +13 -0
  14. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_files_commands.py +4 -0
  15. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_run_commands.py +13 -0
  16. opensees_cli-0.2.2/tests/test_admin_commands.py +0 -150
  17. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/README.md +0 -0
  18. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/setup.cfg +0 -0
  19. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/__main__.py +0 -0
  20. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/files.py +0 -0
  21. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/path_setup.py +0 -0
  22. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/run.py +0 -0
  23. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/version.py +0 -0
  24. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/SOURCES.txt +0 -0
  25. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
  26. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/entry_points.txt +0 -0
  27. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/requires.txt +0 -0
  28. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/top_level.txt +0 -0
  29. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_main_commands.py +0 -0
  30. {opensees_cli-0.2.2 → opensees_cli-0.2.4}/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.4
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.4"
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.4"
@@ -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
@@ -10,12 +11,29 @@ from rich.console import Console
10
11
  from rich.table import Table
11
12
  from typer.core import TyperGroup
12
13
 
13
- from opensees_cli import api, config
14
- from opensees_cli.auth import _to_local, _fmt_bytes, print_quota
14
+ from opensees_cli import __version__, api, config
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
 
18
27
 
28
+ def _cli_cell(raw: Optional[str], current: str) -> str:
29
+ v = (raw or "").strip()
30
+ if not v:
31
+ return "-"
32
+ if config.is_older_version(v, current):
33
+ return f"[yellow]{v}[/yellow]"
34
+ return v
35
+
36
+
19
37
  def _parse_runtime_quota(raw: str) -> int:
20
38
  """Parse -r: plain digits = seconds; or duration tokens like 2h, 10m, 1h30m, 90s."""
21
39
  s = raw.strip().lower().replace(" ", "")
@@ -106,14 +124,17 @@ def list_users():
106
124
  console.print("[dim]No users found.[/dim]")
107
125
  return
108
126
 
109
- table = Table(title=f"Users ({len(users)})")
110
- table.add_column("Email", style="cyan")
127
+ current_cli = __version__
128
+ table = Table(title=f"Users ({len(users)}) · current CLI {current_cli}")
129
+ # Always show the full email (never ellipsize / crop).
130
+ table.add_column("Email", style="cyan", overflow="ignore", no_wrap=True)
111
131
  table.add_column("Status")
112
132
  table.add_column("Enabled")
113
133
  table.add_column("Admin")
114
134
  table.add_column("Last Login")
115
135
  table.add_column("Last Activity")
116
- table.add_column("Pinned")
136
+ table.add_column("CLI", overflow="ignore", no_wrap=True)
137
+ table.add_column("Pinned", overflow="ignore", no_wrap=True)
117
138
 
118
139
  for u in users:
119
140
  enabled = "[green]yes[/green]" if u.get("enabled") else "[red]no[/red]"
@@ -125,6 +146,7 @@ def list_users():
125
146
  admin,
126
147
  u.get("last_login_at", "") or "-",
127
148
  u.get("last_activity_at", "") or "-",
149
+ _cli_cell(u.get("last_cli_version"), current_cli),
128
150
  u.get("openseespy_version", "") or "-",
129
151
  )
130
152
  console.print(table)
@@ -175,11 +197,7 @@ def delete_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
175
197
  raise typer.Exit(1)
176
198
 
177
199
 
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")
200
+ def _print_user_info(email: str) -> None:
183
201
  try:
184
202
  r = api.post("/admin/user-info", {"email": email})
185
203
  except api.ApiError as e:
@@ -200,8 +218,14 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
200
218
  console.print(f" OpenSeesPy: {pin} (pinned)")
201
219
  else:
202
220
  console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
203
- if r.get("last_cli_version"):
204
- console.print(f" CLI version: {r['last_cli_version']}")
221
+ cli_ver = (r.get("last_cli_version") or "").strip()
222
+ if cli_ver:
223
+ if config.is_older_version(cli_ver, __version__):
224
+ console.print(f" CLI: [yellow]{cli_ver}[/yellow] (behind {__version__})")
225
+ else:
226
+ console.print(f" CLI: {cli_ver}")
227
+ else:
228
+ console.print(" CLI: [dim]-[/dim]")
205
229
  if r.get("last_ip"):
206
230
  console.print(f" Last IP: {r['last_ip']}")
207
231
 
@@ -210,62 +234,6 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
210
234
  console.print()
211
235
  print_quota(q)
212
236
 
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
237
 
270
238
  @app.command("set-quota")
271
239
  def set_quota(
@@ -415,3 +383,205 @@ def workers(
415
383
  _to_local(t["started_at"]) if t.get("started_at") else "-",
416
384
  )
417
385
  console.print(table)
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # user-info: account details, plus read-only run / files inspect
390
+ # ---------------------------------------------------------------------------
391
+
392
+ user_info_grp = typer.Typer(help="Show a user's account details; run / files inspect that user.")
393
+ run_grp = typer.Typer(help="Inspect the user's analyses (read-only).")
394
+ files_grp = typer.Typer(help="Inspect the user's files (read-only).")
395
+
396
+
397
+ def _email_from_ctx(ctx: typer.Context) -> Optional[str]:
398
+ cur: Optional[click.Context] = ctx
399
+ while cur is not None:
400
+ obj = cur.obj
401
+ if isinstance(obj, dict) and obj.get("email"):
402
+ return str(obj["email"])
403
+ cur = cur.parent
404
+ return None
405
+
406
+
407
+ def _resolve_email(ctx: typer.Context, email: Optional[str]) -> str:
408
+ resolved = (email or _email_from_ctx(ctx) or "").strip()
409
+ if not resolved:
410
+ console.print("[red]Provide --email / -e for the target user.[/red]")
411
+ raise typer.Exit(1)
412
+ return resolved
413
+
414
+
415
+ def _email_opt() -> Any:
416
+ return typer.Option(None, "--email", "-e", help="Target user's email")
417
+
418
+
419
+ @user_info_grp.callback(invoke_without_command=True)
420
+ def user_info(
421
+ ctx: typer.Context,
422
+ email: Optional[str] = typer.Option(None, "--email", "-e", help="Target user's email"),
423
+ ):
424
+ """Show a user's account details and quota."""
425
+ ctx.ensure_object(dict)
426
+ if email:
427
+ ctx.obj["email"] = email.strip()
428
+ if ctx.invoked_subcommand is not None:
429
+ return
430
+ if not email:
431
+ email = typer.prompt("Email")
432
+ ctx.obj["email"] = email.strip()
433
+ _print_user_info(email)
434
+
435
+
436
+ @run_grp.callback(invoke_without_command=True)
437
+ def _admin_run_callback(ctx: typer.Context) -> None:
438
+ ctx.ensure_object(dict)
439
+ parent_email = _email_from_ctx(ctx)
440
+ if parent_email:
441
+ ctx.obj["email"] = parent_email
442
+ if ctx.invoked_subcommand is None:
443
+ typer.echo(ctx.get_help())
444
+ raise typer.Exit(0)
445
+
446
+
447
+ @files_grp.callback(invoke_without_command=True)
448
+ def _admin_files_callback(ctx: typer.Context) -> None:
449
+ ctx.ensure_object(dict)
450
+ parent_email = _email_from_ctx(ctx)
451
+ if parent_email:
452
+ ctx.obj["email"] = parent_email
453
+ if ctx.invoked_subcommand is None:
454
+ typer.echo(ctx.get_help())
455
+ raise typer.Exit(0)
456
+
457
+
458
+ @run_grp.command("list", cls=_BareDefaultCommand)
459
+ def admin_run_list(
460
+ ctx: typer.Context,
461
+ email: Optional[str] = _email_opt(),
462
+ limit: int = typer.Option(20, "--limit", "-n", help="Max number of analyses to show"),
463
+ after: Optional[str] = typer.Option(None, "--after", help="Show analyses after date (YYYY-MM-DD)"),
464
+ before: Optional[str] = typer.Option(None, "--before", help="Show analyses before date (YYYY-MM-DD)"),
465
+ week: Optional[str] = typer.Option(
466
+ None, "--week", "-w",
467
+ help="Week filter: 0=this week; 1-52=ISO week; negative=-k; W1-W2=range.",
468
+ ),
469
+ month: Optional[str] = typer.Option(
470
+ None, "--month", "-m",
471
+ help="Month filter: 0=this month; 1-12; negative=-k; M-N=range.",
472
+ ),
473
+ year: Optional[str] = typer.Option(
474
+ None, "--year", "-y",
475
+ help="Year filter: 0=this year; positive=absolute year; negative=-k; Y1-Y2=range.",
476
+ ),
477
+ all_: bool = typer.Option(False, "--all", "-a", help="Show all analyses (no date filter)"),
478
+ as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
479
+ ):
480
+ """List the user's analyses.
481
+
482
+ Same flags as ``ops run list``. Example: ops admin user-info run list -e alice@test.com
483
+ """
484
+ with api.acting_as(_resolve_email(ctx, email)):
485
+ run_list(
486
+ limit=limit,
487
+ after=after,
488
+ before=before,
489
+ week=week,
490
+ month=month,
491
+ year=year,
492
+ all_=all_,
493
+ as_json=as_json,
494
+ )
495
+
496
+
497
+ @run_grp.command("status")
498
+ def admin_run_status(
499
+ ctx: typer.Context,
500
+ id: Optional[str] = typer.Argument(None, help="Analysis ID (omit to list running analyses)"),
501
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
502
+ email: Optional[str] = _email_opt(),
503
+ as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
504
+ ):
505
+ """Show the user's analysis or task status."""
506
+ with api.acting_as(_resolve_email(ctx, email)):
507
+ run_status(id=id, range_=range_, as_json=as_json)
508
+
509
+
510
+ @run_grp.command("output")
511
+ def admin_run_output(
512
+ ctx: typer.Context,
513
+ id: str = typer.Argument(..., help="Analysis ID"),
514
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
515
+ email: Optional[str] = _email_opt(),
516
+ ):
517
+ """Show the user's printed output (stdout)."""
518
+ with api.acting_as(_resolve_email(ctx, email)):
519
+ run_output(id=id, range_=range_)
520
+
521
+
522
+ @run_grp.command("stats")
523
+ def admin_run_stats(
524
+ ctx: typer.Context,
525
+ id: str = typer.Argument(..., help="Analysis ID"),
526
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
527
+ email: Optional[str] = _email_opt(),
528
+ ):
529
+ """Show the user's task execution stats."""
530
+ with api.acting_as(_resolve_email(ctx, email)):
531
+ run_stats(id=id, range_=range_)
532
+
533
+
534
+ @run_grp.command("data")
535
+ def admin_run_data(
536
+ ctx: typer.Context,
537
+ id: str = typer.Argument(..., help="Analysis ID"),
538
+ range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
539
+ email: Optional[str] = _email_opt(),
540
+ download: Optional[str] = typer.Option(
541
+ None, "--download", "-d", metavar="PATH",
542
+ help="Download mode (omit to list only). Same as ``ops run data -d``.",
543
+ ),
544
+ output_dir: str = typer.Option(
545
+ ".", "--output", "-o",
546
+ help="Directory to write downloaded files.",
547
+ ),
548
+ ):
549
+ """List or download the user's task data files."""
550
+ with api.acting_as(_resolve_email(ctx, email)):
551
+ run_data(id=id, range_=range_, download=download, output_dir=output_dir)
552
+
553
+
554
+ @files_grp.command("list")
555
+ def admin_files_list(
556
+ ctx: typer.Context,
557
+ folder: Optional[str] = typer.Argument(
558
+ None,
559
+ help="Only list files in this folder (and its subfolders)",
560
+ ),
561
+ email: Optional[str] = _email_opt(),
562
+ as_json: bool = typer.Option(False, "--json", "-j", help="Emit JSON instead of a table"),
563
+ ):
564
+ """List the user's files."""
565
+ email = _resolve_email(ctx, email)
566
+ console.print(f"[dim]Files for {email}[/dim]")
567
+ with api.acting_as(email):
568
+ files_list(folder=folder, as_json=as_json)
569
+
570
+
571
+ @files_grp.command("download")
572
+ def admin_files_download(
573
+ ctx: typer.Context,
574
+ remote_paths: List[str] = typer.Argument(..., help="Remote file path(s) or glob pattern(s)"),
575
+ email: Optional[str] = _email_opt(),
576
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Local output path"),
577
+ ):
578
+ """Download file(s) from the user's file system."""
579
+ with api.acting_as(_resolve_email(ctx, email)):
580
+ files_download(remote_paths=remote_paths, output=output)
581
+
582
+
583
+ user_info_grp.add_typer(run_grp, name="run")
584
+ user_info_grp.add_typer(files_grp, name="files")
585
+ app.add_typer(user_info_grp, name="user-info")
586
+
587
+
@@ -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:
@@ -2,15 +2,65 @@
2
2
 
3
3
  import json
4
4
  import sys
5
+ import time
5
6
  from typing import Any, Dict, Optional, Sequence
6
7
 
8
+ import httpx
7
9
  import typer
8
10
  from rich.console import Console
9
11
 
10
- from opensees_cli import api, config
12
+ from opensees_cli import __version__, api, config
11
13
 
12
14
  console = Console()
13
15
 
16
+ _PYPI_CLI_URL = "https://pypi.org/pypi/opensees_cli/json"
17
+ _PYPI_CACHE_TTL_SEC = 24 * 3600
18
+
19
+
20
+ def maybe_print_cli_upgrade(*, latest: Optional[str] = None) -> None:
21
+ """If a newer opensees_cli is on PyPI, print a one-line pip upgrade hint."""
22
+ newest = (latest or _pypi_latest_cli_version() or "").strip()
23
+ if not newest or not config.is_older_version(__version__, newest):
24
+ return
25
+ console.print(
26
+ f"[yellow]A newer opensees_cli is available ({newest}; you have {__version__}).[/yellow]"
27
+ )
28
+ console.print("[yellow]Upgrade: pip install -U opensees_cli[/yellow]")
29
+
30
+
31
+ def _pypi_latest_cli_version() -> Optional[str]:
32
+ """Latest PyPI version, cached ~24h. Best-effort; never raises."""
33
+ if "pytest" in sys.modules:
34
+ return None
35
+ cache_path = config.CONFIG_DIR / "pypi_cli_latest.json"
36
+ now = time.time()
37
+ try:
38
+ cached = json.loads(cache_path.read_text(encoding="utf-8"))
39
+ ver = str(cached.get("version") or "").strip()
40
+ checked = float(cached.get("checked_at") or 0)
41
+ if ver and now - checked < _PYPI_CACHE_TTL_SEC:
42
+ return ver
43
+ except (OSError, TypeError, ValueError, json.JSONDecodeError):
44
+ pass
45
+ try:
46
+ r = httpx.get(_PYPI_CLI_URL, timeout=2.0)
47
+ if r.status_code != 200:
48
+ return None
49
+ ver = str((r.json().get("info") or {}).get("version") or "").strip()
50
+ except Exception:
51
+ return None
52
+ if not ver:
53
+ return None
54
+ try:
55
+ config._ensure_dir()
56
+ cache_path.write_text(
57
+ json.dumps({"version": ver, "checked_at": now}),
58
+ encoding="utf-8",
59
+ )
60
+ except OSError:
61
+ pass
62
+ return ver
63
+
14
64
  _CONFIRM_CLI_HINT = (
15
65
  "[dim]Use [bold]ops auth confirm[/bold] to confirm the verification code.[/dim]"
16
66
  )
@@ -457,7 +507,6 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
457
507
  password = _ask_password()
458
508
 
459
509
  try:
460
- from opensees_cli import __version__
461
510
  r = api.post(
462
511
  "/auth/login",
463
512
  {
@@ -478,6 +527,7 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
478
527
  )
479
528
  api.invalidate_session_cache()
480
529
  console.print(f"[green]Logged in as [bold]{email}[/bold][/green]")
530
+ maybe_print_cli_upgrade()
481
531
  except api.ApiError as e:
482
532
  console.print(f"[red]{e.message}[/red]")
483
533
  raise typer.Exit(1)
@@ -14,7 +14,7 @@ import re
14
14
  import sys
15
15
  import time
16
16
  from pathlib import Path
17
- from typing import Any, Dict, Optional
17
+ from typing import Any, Dict, Optional, Tuple
18
18
 
19
19
  CONFIG_DIR = Path.home() / ".opensees"
20
20
  CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
@@ -168,26 +168,50 @@ def get_api_url() -> str:
168
168
  # OpenSeesPy version (sticky local preference)
169
169
  # ---------------------------------------------------------------------------
170
170
 
171
- OPENSEESPY_VERSION_FILE = CONFIG_DIR / "openseespy_version"
171
+ def _openseespy_version_file() -> Path:
172
+ return CONFIG_DIR / "openseespy_version"
172
173
 
173
174
 
174
175
  def set_openseespy_version(version: str) -> None:
175
176
  """Save the sticky OpenSeesPy version preference."""
176
177
  _ensure_dir()
177
- OPENSEESPY_VERSION_FILE.write_text(version.strip())
178
+ _openseespy_version_file().write_text(version.strip())
178
179
 
179
180
 
180
181
  def get_openseespy_version() -> Optional[str]:
181
182
  """Return the sticky OpenSeesPy version, or None if not set."""
182
- if not OPENSEESPY_VERSION_FILE.exists():
183
+ path = _openseespy_version_file()
184
+ if not path.exists():
183
185
  return None
184
186
  try:
185
- return OPENSEESPY_VERSION_FILE.read_text().strip() or None
187
+ return path.read_text().strip() or None
186
188
  except OSError:
187
189
  return None
188
190
 
189
191
 
190
192
  def clear_openseespy_version() -> None:
191
193
  """Clear the sticky OpenSeesPy version preference."""
192
- if OPENSEESPY_VERSION_FILE.exists():
193
- OPENSEESPY_VERSION_FILE.unlink()
194
+ path = _openseespy_version_file()
195
+ if path.exists():
196
+ path.unlink()
197
+
198
+
199
+ def version_tuple(raw: str) -> Tuple[int, ...]:
200
+ """Numeric dotted version parts, or empty if none (e.g. ``web/1.0.0`` still parses)."""
201
+ parts: list[int] = []
202
+ for token in re.findall(r"\d+", (raw or "").strip()):
203
+ parts.append(int(token))
204
+ if len(parts) >= 4:
205
+ break
206
+ return tuple(parts)
207
+
208
+
209
+ def is_older_version(installed: str, latest: str) -> bool:
210
+ """True when *installed* is a lower dotted version than *latest*."""
211
+ a, b = version_tuple(installed), version_tuple(latest)
212
+ if not a or not b:
213
+ return False
214
+ n = max(len(a), len(b))
215
+ a = a + (0,) * (n - len(a))
216
+ b = b + (0,) * (n - len(b))
217
+ return a < b
@@ -8,6 +8,7 @@ from opensees_cli import __version__
8
8
  from opensees_cli.auth import (
9
9
  app as auth_app,
10
10
  format_status_json,
11
+ maybe_print_cli_upgrade,
11
12
  print_me,
12
13
  print_quota,
13
14
  )
@@ -140,7 +141,7 @@ def _print_help() -> None:
140
141
  console.print()
141
142
  console.print("[bold]ops admin[/bold]\n")
142
143
  console.print(" [bold]list-users[/bold] List all users")
143
- console.print(" [bold]user-info[/bold] Show a user's details")
144
+ console.print(" [bold]user-info[/bold] Account details; run / files inspect that user")
144
145
  console.print(" [bold]set-quota[/bold] Update a user's quota")
145
146
  console.print(" [bold]enable-user[/bold] Re-enable a user")
146
147
  console.print(" [bold]disable-user[/bold] Disable a user")
@@ -161,6 +162,7 @@ def _print_help() -> None:
161
162
 
162
163
  console.print()
163
164
  console.print("[dim]Run any command with --help for more details.[/dim]")
165
+ maybe_print_cli_upgrade()
164
166
 
165
167
 
166
168
  @app.callback(invoke_without_command=True)
@@ -221,7 +223,9 @@ def status(
221
223
  q = r.get("quota")
222
224
  if not q:
223
225
  console.print("[dim]No quota info available.[/dim]")
226
+ maybe_print_cli_upgrade()
224
227
  return
225
228
  console.print()
226
229
  print_quota(q)
230
+ maybe_print_cli_upgrade()
227
231
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
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,296 @@
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.2.1",
20
+ "openseespy_version": "3.7.1.0",
21
+ },
22
+ ]
23
+ })
24
+ result = cli_runner.invoke(app, ["admin", "list-users"])
25
+ assert result.exit_code == 0
26
+ assert "alice@test.com" in result.output
27
+ assert "3.7.1.0" in result.output
28
+ assert "0.2.1" in result.output
29
+ assert "CLI" in result.output
30
+ assert "Pinned" in result.output
31
+ from opensees_cli import __version__
32
+ assert f"current CLI {__version__}" in result.output
33
+
34
+ def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
35
+ mock_router.add("GET", "/prod/admin/users", json={"users": []})
36
+ result = cli_runner.invoke(app, ["admin", "list-users"])
37
+ assert result.exit_code == 0
38
+ assert "No users" in result.output
39
+
40
+
41
+ class TestDisableUser:
42
+ def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
43
+ mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
44
+ result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
45
+ assert result.exit_code == 0
46
+ assert "disabled" in result.output.lower()
47
+
48
+
49
+ class TestEnableUser:
50
+ def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
51
+ mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
52
+ result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
53
+ assert result.exit_code == 0
54
+ assert "enabled" in result.output.lower()
55
+
56
+
57
+ class TestDeleteUser:
58
+ def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
59
+ mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
60
+ result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
61
+ assert result.exit_code == 0
62
+ assert "deleted" in result.output.lower()
63
+
64
+ def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
65
+ result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
66
+ assert result.exit_code == 0
67
+ assert "Cancelled" in result.output
68
+
69
+
70
+ class TestUserInfo:
71
+ def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
72
+ mock_router.add("POST", "/prod/admin/user-info", json={
73
+ "email": "alice@test.com",
74
+ "is_admin": False,
75
+ "is_enabled": True,
76
+ "openseespy_version": "3.7.1.0",
77
+ "last_cli_version": "0.2.2",
78
+ "quota": {
79
+ "max_concurrent_runs": 5,
80
+ "max_tasks_per_analysis": 100,
81
+ "max_monthly_runtime": 3600,
82
+ "monthly_runtime_used": 0,
83
+ "monthly_runtime_remaining": 3600,
84
+ "max_monthly_storage": 1073741824,
85
+ "storage_used": 0,
86
+ },
87
+ "analyses_total": 2,
88
+ "analyses_running": 1,
89
+ "analyses": [
90
+ {
91
+ "analysis_id": "aaa11111-0000-0000-0000-000000000000",
92
+ "status": "running",
93
+ "filename": "Truss.py",
94
+ "type": "fast",
95
+ "total_tasks": 1,
96
+ "created_at": "2026-09-01T00:00:00Z",
97
+ },
98
+ ],
99
+ "files_total": 1,
100
+ "files_size": 128,
101
+ "files": [
102
+ {"path": "Truss.py", "size": 128, "last_modified": "2026-09-01T00:00:00Z"},
103
+ ],
104
+ })
105
+ result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
106
+ assert result.exit_code == 0
107
+ assert "alice@test.com" in result.output
108
+ assert "3.7.1.0" in result.output
109
+ assert "pinned" in result.output.lower()
110
+ assert "0.2.2" in result.output
111
+ assert "CLI" in result.output
112
+ assert "Analyses" not in result.output
113
+ assert "Files" not in result.output
114
+
115
+ def test_user_info_not_pinned(self, cli_runner, admin_creds, patch_httpx, mock_router):
116
+ mock_router.add("POST", "/prod/admin/user-info", json={
117
+ "email": "bob@test.com",
118
+ "is_admin": False,
119
+ "is_enabled": True,
120
+ "openseespy_version": None,
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" not in result.output
126
+ assert "No files" not 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()
151
+
152
+
153
+ def _query_of(url: str) -> dict:
154
+ from urllib.parse import parse_qs, urlparse
155
+ return {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
156
+
157
+
158
+ def _get_calls(mock_router, path_suffix: str) -> list:
159
+ return [
160
+ c for c in mock_router.calls
161
+ if c["method"] == "GET" and path_suffix in c["url"].split("?")[0]
162
+ ]
163
+
164
+
165
+ class TestAdminRun:
166
+ def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
167
+ result = cli_runner.invoke(app, ["admin", "run", "list", "-e", "alice@test.com"])
168
+ assert result.exit_code != 0
169
+
170
+ def test_help_lists_read_commands(self, cli_runner, admin_creds, patch_httpx, mock_router):
171
+ result = cli_runner.invoke(app, ["admin", "user-info", "--help"])
172
+ assert result.exit_code == 0
173
+ assert "run" in result.output
174
+ assert "files" in result.output
175
+
176
+ def test_run_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
177
+ result = cli_runner.invoke(app, ["admin", "user-info", "run", "--help"])
178
+ assert result.exit_code == 0
179
+ assert "list" in result.output
180
+ assert "status" in result.output
181
+ assert "submit" not in result.output
182
+ assert "cancel" not in result.output
183
+
184
+ def test_submit_not_a_command(self, cli_runner, admin_creds, patch_httpx, mock_router):
185
+ result = cli_runner.invoke(
186
+ app, ["admin", "user-info", "run", "submit", "-e", "alice@test.com", "model.py"]
187
+ )
188
+ assert result.exit_code != 0
189
+
190
+ def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
191
+ mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
192
+ result = cli_runner.invoke(
193
+ app, ["admin", "user-info", "run", "list", "-e", "alice@test.com", "--all"]
194
+ )
195
+ assert result.exit_code == 0
196
+ calls = _get_calls(mock_router, "/prod/run/list")
197
+ assert calls
198
+ assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
199
+
200
+ def test_list_parent_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
201
+ mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
202
+ result = cli_runner.invoke(
203
+ app, ["admin", "user-info", "-e", "alice@test.com", "run", "list", "--all"]
204
+ )
205
+ assert result.exit_code == 0
206
+ calls = _get_calls(mock_router, "/prod/run/list")
207
+ assert calls
208
+ assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
209
+
210
+ def test_list_requires_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
211
+ result = cli_runner.invoke(app, ["admin", "user-info", "run", "list"])
212
+ assert result.exit_code != 0
213
+
214
+ def test_status_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
215
+ mock_router.add("GET", "/prod/run/status", json={
216
+ "task_id": "ttt55555",
217
+ "analysis_id": "aaa55555",
218
+ "status": "completed",
219
+ "filename": "model.py",
220
+ "type": "fast",
221
+ "total_tasks": 1,
222
+ "as_user": "alice@test.com",
223
+ })
224
+ result = cli_runner.invoke(
225
+ app, ["admin", "user-info", "run", "status", "aaa55555", "-e", "alice@test.com"]
226
+ )
227
+ assert result.exit_code == 0
228
+ calls = _get_calls(mock_router, "/prod/run/status")
229
+ assert calls
230
+ q = _query_of(calls[0]["url"])
231
+ assert q["as_user"] == "alice@test.com"
232
+ assert q["id"] == "aaa55555"
233
+
234
+
235
+ class TestAdminFiles:
236
+ def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
237
+ result = cli_runner.invoke(app, ["admin", "files", "list", "-e", "alice@test.com"])
238
+ assert result.exit_code != 0
239
+
240
+ def test_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
241
+ result = cli_runner.invoke(app, ["admin", "user-info", "files", "--help"])
242
+ assert result.exit_code == 0
243
+ assert "list" in result.output
244
+ assert "download" in result.output
245
+ assert "upload" not in result.output
246
+ assert "delete" not in result.output
247
+
248
+ def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
249
+ mock_router.add("GET", "/prod/files/list", json={
250
+ "files": [{"path": "model.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
251
+ "as_user": "alice@test.com",
252
+ })
253
+ result = cli_runner.invoke(
254
+ app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
255
+ )
256
+ assert result.exit_code == 0
257
+ assert "model.py" in result.output
258
+ calls = _get_calls(mock_router, "/prod/files/list")
259
+ assert calls
260
+ assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
261
+ assert "Files for alice@test.com" in result.output
262
+
263
+ def test_list_old_api_does_not_show_caller_files(
264
+ self, cli_runner, admin_creds, patch_httpx, mock_router
265
+ ):
266
+ mock_router.add("GET", "/prod/files/list", json={
267
+ "files": [{"path": "mine.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
268
+ })
269
+ result = cli_runner.invoke(
270
+ app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
271
+ )
272
+ assert result.exit_code == 1
273
+ assert "mine.py" not in result.output
274
+ assert "Deploy the API" in result.output
275
+
276
+ def test_download_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router, tmp_path):
277
+ mock_router.add("GET", "/prod/files/list", json={
278
+ "files": [{"path": "model.py", "size": 5, "last_modified": "2026-01-01T00:00:00Z"}],
279
+ "as_user": "alice@test.com",
280
+ })
281
+ mock_router.add("GET", "/prod/files/download", json={
282
+ "download_url": "https://s3.example.com/presigned-get?sig=abc",
283
+ "as_user": "alice@test.com",
284
+ })
285
+ mock_router.add_any_url("GET", status=200, content=b"hello")
286
+ out = tmp_path / "model.py"
287
+ result = cli_runner.invoke(
288
+ app,
289
+ ["admin", "user-info", "files", "download", "model.py", "-e", "alice@test.com", "-o", str(out)],
290
+ )
291
+ assert result.exit_code == 0
292
+ list_calls = _get_calls(mock_router, "/prod/files/list")
293
+ dl_calls = _get_calls(mock_router, "/prod/files/download")
294
+ assert list_calls and dl_calls
295
+ assert _query_of(list_calls[0]["url"])["as_user"] == "alice@test.com"
296
+ 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]
@@ -5,6 +5,7 @@ from unittest.mock import patch
5
5
 
6
6
  from opensees_cli.auth import app as auth_app
7
7
  from opensees_cli.main import app
8
+ from opensees_cli.auth import maybe_print_cli_upgrade
8
9
 
9
10
 
10
11
  def _patch_password(*passwords):
@@ -93,12 +94,15 @@ class TestLogin:
93
94
  assert result.exit_code == 0
94
95
  assert "Logged in" in result.output
95
96
 
96
- def test_login_bad_credentials(self, cli_runner, patch_httpx, mock_router):
97
- mock_router.add("POST", "/prod/auth/login", status=400, json={"error": "Incorrect username or password."})
98
- with patch("opensees_cli.auth._ask_password", return_value="wrong"):
97
+ def test_login_stale_cli(self, cli_runner, patch_httpx, mock_router):
98
+ mock_router.add("POST", "/prod/auth/login", status=400, json={
99
+ "error": "This CLI (0.2.3) is out of date. Upgrade with: pip install -U opensees_cli",
100
+ "code": "auth_error",
101
+ })
102
+ with patch("opensees_cli.auth._ask_password", return_value="Pass1!"):
99
103
  result = cli_runner.invoke(app, ["auth", "login", "-e", "u@t.com"])
100
104
  assert result.exit_code == 1
101
- assert "Incorrect" in result.output
105
+ assert "pip install -U opensees_cli" in result.output
102
106
 
103
107
 
104
108
  class TestLogout:
@@ -165,3 +169,16 @@ class TestChangePassword:
165
169
  result = cli_runner.invoke(app, ["auth", "change-password"])
166
170
  assert result.exit_code == 1
167
171
  assert "do not match" in result.output
172
+
173
+
174
+ class TestCliUpgradeNag:
175
+ def test_prints_when_behind(self, capsys):
176
+ maybe_print_cli_upgrade(latest="99.0.0")
177
+ out = capsys.readouterr().out
178
+ assert "pip install -U opensees_cli" in out
179
+ assert "99.0.0" in out
180
+
181
+ def test_silent_when_current(self, capsys):
182
+ from opensees_cli import __version__
183
+ maybe_print_cli_upgrade(latest=__version__)
184
+ assert capsys.readouterr().out == ""
@@ -8,6 +8,8 @@ from opensees_cli.config import (
8
8
  get_refresh_token,
9
9
  get_api_url,
10
10
  is_admin,
11
+ is_older_version,
12
+ version_tuple,
11
13
  )
12
14
 
13
15
 
@@ -82,3 +84,14 @@ class TestApiUrl:
82
84
  def test_env_override(self, monkeypatch):
83
85
  monkeypatch.setenv("OPENSEES_API_URL", "https://custom.example/v1")
84
86
  assert get_api_url() == "https://custom.example/v1"
87
+
88
+
89
+ class TestVersionCompare:
90
+ def test_older(self):
91
+ assert is_older_version("0.2.1", "0.2.3")
92
+ assert not is_older_version("0.2.3", "0.2.3")
93
+ assert not is_older_version("0.2.4", "0.2.3")
94
+
95
+ def test_non_numeric_prefix(self):
96
+ assert version_tuple("web/1.0.0") == (1, 0, 0)
97
+ assert not is_older_version("web/1.0.0", "0.2.3")
@@ -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