opensees-cli 0.2.0__tar.gz → 0.2.2__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.0 → opensees_cli-0.2.2}/PKG-INFO +2 -2
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/README.md +1 -1
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/pyproject.toml +1 -1
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/__init__.py +1 -1
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/admin.py +65 -4
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/auth.py +6 -1
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/files.py +31 -5
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/run.py +41 -8
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/version.py +34 -17
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/PKG-INFO +2 -2
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/SOURCES.txt +2 -1
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_admin_commands.py +46 -1
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_files_commands.py +14 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_run_commands.py +21 -0
- opensees_cli-0.2.2/tests/test_version_commands.py +72 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/setup.cfg +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/__main__.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/api.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/config.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/main.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/path_setup.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/entry_points.txt +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/requires.txt +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/top_level.txt +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_api.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_auth_commands.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_config.py +0 -0
- {opensees_cli-0.2.0 → opensees_cli-0.2.2}/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.2
|
|
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
|
|
|
@@ -11,7 +11,7 @@ from rich.table import Table
|
|
|
11
11
|
from typer.core import TyperGroup
|
|
12
12
|
|
|
13
13
|
from opensees_cli import api, config
|
|
14
|
-
from opensees_cli.auth import _to_local, print_quota
|
|
14
|
+
from opensees_cli.auth import _to_local, _fmt_bytes, print_quota
|
|
15
15
|
|
|
16
16
|
_RUNTIME_TOKEN_RE = re.compile(r"^((\d+(?:\.\d+)?)(h|m|s))+$")
|
|
17
17
|
|
|
@@ -113,7 +113,7 @@ def list_users():
|
|
|
113
113
|
table.add_column("Admin")
|
|
114
114
|
table.add_column("Last Login")
|
|
115
115
|
table.add_column("Last Activity")
|
|
116
|
-
table.add_column("
|
|
116
|
+
table.add_column("Pinned")
|
|
117
117
|
|
|
118
118
|
for u in users:
|
|
119
119
|
enabled = "[green]yes[/green]" if u.get("enabled") else "[red]no[/red]"
|
|
@@ -125,7 +125,7 @@ def list_users():
|
|
|
125
125
|
admin,
|
|
126
126
|
u.get("last_login_at", "") or "-",
|
|
127
127
|
u.get("last_activity_at", "") or "-",
|
|
128
|
-
u.get("
|
|
128
|
+
u.get("openseespy_version", "") or "-",
|
|
129
129
|
)
|
|
130
130
|
console.print(table)
|
|
131
131
|
|
|
@@ -177,7 +177,7 @@ def delete_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
177
177
|
|
|
178
178
|
@app.command("user-info")
|
|
179
179
|
def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
180
|
-
"""Show a user's account details and
|
|
180
|
+
"""Show a user's account details, quota, analyses, and files."""
|
|
181
181
|
if not email:
|
|
182
182
|
email = typer.prompt("Email")
|
|
183
183
|
try:
|
|
@@ -195,6 +195,11 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
195
195
|
console.print(f" Last login: {_to_local(r['last_login_at'])}")
|
|
196
196
|
if r.get("last_activity_at"):
|
|
197
197
|
console.print(f" Last active: {_to_local(r['last_activity_at'])}")
|
|
198
|
+
pin = r.get("openseespy_version") or ""
|
|
199
|
+
if pin:
|
|
200
|
+
console.print(f" OpenSeesPy: {pin} (pinned)")
|
|
201
|
+
else:
|
|
202
|
+
console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
|
|
198
203
|
if r.get("last_cli_version"):
|
|
199
204
|
console.print(f" CLI version: {r['last_cli_version']}")
|
|
200
205
|
if r.get("last_ip"):
|
|
@@ -205,6 +210,62 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
205
210
|
console.print()
|
|
206
211
|
print_quota(q)
|
|
207
212
|
|
|
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
|
+
|
|
208
269
|
|
|
209
270
|
@app.command("set-quota")
|
|
210
271
|
def set_quota(
|
|
@@ -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(
|
|
@@ -28,14 +28,35 @@ def _files_callback(ctx: typer.Context) -> None:
|
|
|
28
28
|
raise typer.Exit(0)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
def _files_list_items() -> list[dict]:
|
|
32
|
-
"""
|
|
33
|
-
|
|
31
|
+
def _files_list_items(folder: Optional[str] = None) -> list[dict]:
|
|
32
|
+
"""List files in the user file system, optionally under ``folder``."""
|
|
33
|
+
params = {}
|
|
34
|
+
if folder:
|
|
35
|
+
params["folder"] = folder.strip().strip("/")
|
|
36
|
+
r = api.get("/files/list", params=params or None)
|
|
34
37
|
return list(r.get("files", []))
|
|
35
38
|
|
|
36
39
|
|
|
40
|
+
def _filter_under_folder(items: list[dict], folder: str) -> list[dict]:
|
|
41
|
+
"""Keep files at or under ``folder`` (client-side fallback)."""
|
|
42
|
+
root = folder.strip().strip("/")
|
|
43
|
+
if not root:
|
|
44
|
+
return items
|
|
45
|
+
prefix = root + "/"
|
|
46
|
+
out = []
|
|
47
|
+
for f in items:
|
|
48
|
+
path = str(f.get("path", ""))
|
|
49
|
+
if path == root or path.startswith(prefix):
|
|
50
|
+
out.append(f)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
37
54
|
@app.command("list")
|
|
38
55
|
def list_files(
|
|
56
|
+
folder: Optional[str] = typer.Argument(
|
|
57
|
+
None,
|
|
58
|
+
help="Only list files in this folder (and its subfolders), e.g. truss or examples/dambreak",
|
|
59
|
+
),
|
|
39
60
|
as_json: bool = typer.Option(
|
|
40
61
|
False,
|
|
41
62
|
"--json",
|
|
@@ -44,9 +65,12 @@ def list_files(
|
|
|
44
65
|
is_flag=True,
|
|
45
66
|
),
|
|
46
67
|
):
|
|
47
|
-
"""List files.
|
|
68
|
+
"""List files. With FOLDER, only show files under that path. Prints nothing when empty (non-JSON)."""
|
|
48
69
|
try:
|
|
49
|
-
items = _files_list_items()
|
|
70
|
+
items = _files_list_items(folder)
|
|
71
|
+
# Older APIs ignore ?folder= — filter locally so the CLI still works.
|
|
72
|
+
if folder:
|
|
73
|
+
items = _filter_under_folder(items, folder)
|
|
50
74
|
except api.ApiError as e:
|
|
51
75
|
console.print(f"[red]{e.message}[/red]")
|
|
52
76
|
raise typer.Exit(1)
|
|
@@ -66,6 +90,8 @@ def list_files(
|
|
|
66
90
|
"file_count": len(rows),
|
|
67
91
|
"total_size": total,
|
|
68
92
|
}
|
|
93
|
+
if folder:
|
|
94
|
+
obj["folder"] = folder.strip().strip("/")
|
|
69
95
|
typer.echo(json.dumps(obj, indent=2).rstrip("\n"))
|
|
70
96
|
return
|
|
71
97
|
|
|
@@ -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 "",
|
|
@@ -1093,32 +1096,39 @@ def _week_range(week: int) -> tuple[datetime, datetime]:
|
|
|
1093
1096
|
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
|
|
1094
1097
|
|
|
1095
1098
|
|
|
1096
|
-
def _fmt_date_span(after_dt: datetime, before_dt: datetime
|
|
1099
|
+
def _fmt_date_span(after_dt: datetime, before_dt: Optional[datetime]) -> str:
|
|
1097
1100
|
"""Return a compact human-readable date span string in local time.
|
|
1098
1101
|
|
|
1099
1102
|
``before_dt`` is exclusive (the day after the last day), so we display
|
|
1100
1103
|
``before_dt - 1 day`` as the end date. If ``before_dt`` is ``None`` the
|
|
1101
1104
|
end is displayed as "today".
|
|
1102
1105
|
Example: "Jan 5 – Mar 8, 2026" or "May 4–10, 2026" (same year/month).
|
|
1106
|
+
|
|
1107
|
+
Avoids ``%-d`` / ``%#d`` strftime flags (Unix vs Windows) by formatting
|
|
1108
|
+
the day with ``dt.day`` instead.
|
|
1103
1109
|
"""
|
|
1110
|
+
def _md(dt: datetime, *, year: bool = False) -> str:
|
|
1111
|
+
s = f"{dt.strftime('%b')} {dt.day}"
|
|
1112
|
+
return f"{s}, {dt.year}" if year else s
|
|
1113
|
+
|
|
1104
1114
|
local = datetime.now().astimezone().tzinfo
|
|
1105
1115
|
start = after_dt.astimezone(local)
|
|
1106
1116
|
|
|
1107
1117
|
if before_dt is None:
|
|
1108
1118
|
end = datetime.now().astimezone(local)
|
|
1109
|
-
end_str =
|
|
1119
|
+
end_str = _md(end, year=True)
|
|
1110
1120
|
if start.year == end.year:
|
|
1111
1121
|
if start.month == end.month:
|
|
1112
|
-
return f"{start
|
|
1113
|
-
return f"{start
|
|
1114
|
-
return f"{start
|
|
1122
|
+
return f"{_md(start)}–{end.day}, {end.year}"
|
|
1123
|
+
return f"{_md(start)} – {end_str}"
|
|
1124
|
+
return f"{_md(start, year=True)} – {end_str}"
|
|
1115
1125
|
|
|
1116
1126
|
end = (before_dt - timedelta(seconds=1)).astimezone(local)
|
|
1117
1127
|
if start.year == end.year:
|
|
1118
1128
|
if start.month == end.month:
|
|
1119
|
-
return f"{start
|
|
1120
|
-
return f"{start
|
|
1121
|
-
return f"{start
|
|
1129
|
+
return f"{_md(start)}–{end.day}, {end.year}"
|
|
1130
|
+
return f"{_md(start)} – {_md(end, year=True)}"
|
|
1131
|
+
return f"{_md(start, year=True)} – {_md(end, year=True)}"
|
|
1122
1132
|
|
|
1123
1133
|
|
|
1124
1134
|
def _week_span_range(w1: int, w2: int) -> tuple[datetime, datetime]:
|
|
@@ -1964,6 +1974,23 @@ def _status_style(s: str) -> str:
|
|
|
1964
1974
|
return styles.get(s, s)
|
|
1965
1975
|
|
|
1966
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
|
+
|
|
1967
1994
|
def _print_status(r: dict) -> None:
|
|
1968
1995
|
"""Print analysis summary, per-task list, or individual task detail."""
|
|
1969
1996
|
# Multi-task analysis
|
|
@@ -1971,6 +1998,9 @@ def _print_status(r: dict) -> None:
|
|
|
1971
1998
|
analysis_id = r.get("analysis_id", "")
|
|
1972
1999
|
console.print(f" Analysis: [dim]{analysis_id}[/dim]")
|
|
1973
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}")
|
|
1974
2004
|
total = r["total_tasks"]
|
|
1975
2005
|
completed = r.get("completed", 0)
|
|
1976
2006
|
failed = r.get("failed", 0)
|
|
@@ -2024,6 +2054,9 @@ def _print_status(r: dict) -> None:
|
|
|
2024
2054
|
console.print(f" Analysis: [dim]{analysis_id}[/dim]")
|
|
2025
2055
|
|
|
2026
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}")
|
|
2027
2060
|
if r.get("filename"):
|
|
2028
2061
|
console.print(f" File: {r['filename']}")
|
|
2029
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.2
|
|
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
|
|
|
@@ -16,13 +16,16 @@ class TestListUsers:
|
|
|
16
16
|
"is_admin": False,
|
|
17
17
|
"last_login_at": "2025-01-01T00:00:00Z",
|
|
18
18
|
"last_activity_at": "",
|
|
19
|
-
"
|
|
19
|
+
"openseespy_version": "3.7.1.0",
|
|
20
20
|
},
|
|
21
21
|
]
|
|
22
22
|
})
|
|
23
23
|
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
24
24
|
assert result.exit_code == 0
|
|
25
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
|
|
26
29
|
|
|
27
30
|
def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
28
31
|
mock_router.add("GET", "/prod/admin/users", json={"users": []})
|
|
@@ -66,6 +69,7 @@ class TestUserInfo:
|
|
|
66
69
|
"email": "alice@test.com",
|
|
67
70
|
"is_admin": False,
|
|
68
71
|
"is_enabled": True,
|
|
72
|
+
"openseespy_version": "3.7.1.0",
|
|
69
73
|
"quota": {
|
|
70
74
|
"max_concurrent_runs": 5,
|
|
71
75
|
"max_tasks_per_analysis": 100,
|
|
@@ -75,10 +79,51 @@ class TestUserInfo:
|
|
|
75
79
|
"max_monthly_storage": 1073741824,
|
|
76
80
|
"storage_used": 0,
|
|
77
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
|
+
],
|
|
78
99
|
})
|
|
79
100
|
result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
|
|
80
101
|
assert result.exit_code == 0
|
|
81
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
|
|
82
127
|
|
|
83
128
|
|
|
84
129
|
class TestSetQuota:
|
|
@@ -27,6 +27,20 @@ class TestFilesList:
|
|
|
27
27
|
assert "model.py" in result.output
|
|
28
28
|
assert "data.csv" in result.output
|
|
29
29
|
|
|
30
|
+
def test_list_folder_filters(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
|
|
31
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
32
|
+
"files": [
|
|
33
|
+
{"path": "truss/Truss.py", "size": 100, "last_modified": "2025-01-01T00:00:00Z"},
|
|
34
|
+
{"path": "truss/data.csv", "size": 200, "last_modified": "2025-01-01T00:00:00Z"},
|
|
35
|
+
{"path": "other/model.py", "size": 300, "last_modified": "2025-01-01T00:00:00Z"},
|
|
36
|
+
]
|
|
37
|
+
})
|
|
38
|
+
result = cli_runner.invoke(app, ["files", "list", "truss"])
|
|
39
|
+
assert result.exit_code == 0
|
|
40
|
+
assert "truss/Truss.py" in result.output
|
|
41
|
+
assert "truss/data.csv" in result.output
|
|
42
|
+
assert "other/model.py" not in result.output
|
|
43
|
+
|
|
30
44
|
|
|
31
45
|
class TestFilesUpload:
|
|
32
46
|
def test_upload_single_file(self, cli_runner, logged_in_creds, patch_httpx, mock_router, tmp_file):
|
|
@@ -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": []})
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|