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.
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/PKG-INFO +2 -2
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/README.md +1 -1
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/pyproject.toml +1 -1
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/__init__.py +1 -1
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/admin.py +220 -10
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/api.py +35 -2
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/auth.py +6 -1
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/main.py +1 -1
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/run.py +26 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/version.py +34 -17
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/PKG-INFO +2 -2
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/SOURCES.txt +2 -1
- opensees_cli-0.2.3/tests/test_admin_commands.py +291 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_api.py +22 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_files_commands.py +4 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_run_commands.py +34 -0
- opensees_cli-0.2.3/tests/test_version_commands.py +72 -0
- opensees_cli-0.2.1/tests/test_admin_commands.py +0 -105
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/setup.cfg +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/__main__.py +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/config.py +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/files.py +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli/path_setup.py +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/entry_points.txt +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/requires.txt +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/top_level.txt +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_auth_commands.py +0 -0
- {opensees_cli-0.2.1 → opensees_cli-0.2.3}/tests/test_config.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
-
from
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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]
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|