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.
Files changed (29) hide show
  1. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/PKG-INFO +2 -2
  2. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/README.md +1 -1
  3. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/pyproject.toml +1 -1
  4. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/__init__.py +1 -1
  5. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/admin.py +65 -4
  6. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/auth.py +6 -1
  7. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/files.py +31 -5
  8. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/run.py +41 -8
  9. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/version.py +34 -17
  10. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/PKG-INFO +2 -2
  11. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/SOURCES.txt +2 -1
  12. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_admin_commands.py +46 -1
  13. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_files_commands.py +14 -0
  14. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_run_commands.py +21 -0
  15. opensees_cli-0.2.2/tests/test_version_commands.py +72 -0
  16. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/setup.cfg +0 -0
  17. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/__main__.py +0 -0
  18. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/api.py +0 -0
  19. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/config.py +0 -0
  20. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/main.py +0 -0
  21. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli/path_setup.py +0 -0
  22. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
  23. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/entry_points.txt +0 -0
  24. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/requires.txt +0 -0
  25. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/src/opensees_cli.egg-info/top_level.txt +0 -0
  26. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_api.py +0 -0
  27. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_auth_commands.py +0 -0
  28. {opensees_cli-0.2.0 → opensees_cli-0.2.2}/tests/test_config.py +0 -0
  29. {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.0
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 size must be <= 200 KB
124
+ - upload uses the cloud file API (same as `ops files upload`; up to 100 MB per file)
125
125
 
126
126
  ## Common Workflows
127
127
 
@@ -98,7 +98,7 @@ ops run help
98
98
  Validation enforced by CLI (local uploads):
99
99
  - path must exist on disk and be a regular file
100
100
  - file extension must be `.py`
101
- - file size must be <= 200 KB
101
+ - upload uses the cloud file API (same as `ops files upload`; up to 100 MB per file)
102
102
 
103
103
  ## Common Workflows
104
104
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensees_cli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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.0"
3
+ __version__ = "0.2.2"
@@ -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("CLI Version")
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("last_cli_version", "") or "-",
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 quota."""
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
- {"email": email, "password": password, "cli_version": __version__},
463
+ {
464
+ "email": email,
465
+ "password": password,
466
+ "cli_version": __version__,
467
+ "openseespy_version": config.get_openseespy_version() or "",
468
+ },
464
469
  auth=False,
465
470
  )
466
471
  config.save_credentials(
@@ -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
- """All files in the user file system."""
33
- r = api.get("/files/list")
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. Prints nothing on stdout when there are no files (non-JSON)."""
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 | None) -> str:
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 = f"{end.strftime('%b %-d, %Y')}"
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.strftime('%b %-d')}–{end.strftime('%-d, %Y')}"
1113
- return f"{start.strftime('%b %-d')} – {end_str}"
1114
- return f"{start.strftime('%b %-d, %Y')} – {end_str}"
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.strftime('%b %-d')}–{end.strftime('%-d, %Y')}"
1120
- return f"{start.strftime('%b %-d')} – {end.strftime('%b %-d, %Y')}"
1121
- return f"{start.strftime('%b %-d, %Y')} – {end.strftime('%b %-d, %Y')}"
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["status"]
156
+ status_str = _status_label(v)
136
157
  if status_str == "active":
137
158
  status_str = f"[green]{status_str}[/green]"
138
159
  elif status_str == "inactive":
@@ -144,12 +165,7 @@ def list_versions(
144
165
  if updated:
145
166
  updated = updated.split("T")[0]
146
167
 
147
- row = [version_str, status_str, updated]
148
- if show_arns:
149
- row.append(v.get("lambda_arn", ""))
150
- row.append(v.get("fargate_task_def", ""))
151
-
152
- table.add_row(*row)
168
+ table.add_row(version_str, status_str, updated)
153
169
 
154
170
  console.print(table)
155
171
 
@@ -165,6 +181,7 @@ def clear_version():
165
181
  ops version clear
166
182
  """
167
183
  config.clear_openseespy_version()
184
+ _sync_pin_to_server(None)
168
185
  console.print("[green]Pin cleared — will use API default (latest active)[/green]")
169
186
 
170
187
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.0
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 size must be <= 200 KB
124
+ - upload uses the cloud file API (same as `ops files upload`; up to 100 MB per file)
125
125
 
126
126
  ## Common Workflows
127
127
 
@@ -23,4 +23,5 @@ tests/test_auth_commands.py
23
23
  tests/test_config.py
24
24
  tests/test_files_commands.py
25
25
  tests/test_main_commands.py
26
- tests/test_run_commands.py
26
+ tests/test_run_commands.py
27
+ tests/test_version_commands.py
@@ -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
- "last_cli_version": "0.1.0a2",
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