opensees-cli 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/PKG-INFO +1 -1
  2. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/pyproject.toml +1 -1
  3. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/__init__.py +1 -1
  4. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/admin.py +25 -4
  5. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/auth.py +52 -2
  6. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/config.py +31 -7
  7. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/main.py +4 -0
  8. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/PKG-INFO +1 -1
  9. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_admin_commands.py +8 -3
  10. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_auth_commands.py +21 -4
  11. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_config.py +13 -0
  12. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/README.md +0 -0
  13. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/setup.cfg +0 -0
  14. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/__main__.py +0 -0
  15. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/api.py +0 -0
  16. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/files.py +0 -0
  17. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/path_setup.py +0 -0
  18. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/run.py +0 -0
  19. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/version.py +0 -0
  20. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/SOURCES.txt +0 -0
  21. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
  22. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/entry_points.txt +0 -0
  23. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/requires.txt +0 -0
  24. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/top_level.txt +0 -0
  25. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_api.py +0 -0
  26. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_files_commands.py +0 -0
  27. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_main_commands.py +0 -0
  28. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_run_commands.py +0 -0
  29. {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_version_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Run OpenSees simulations in the cloud from the command line.
5
5
  Author: Minjie Zhu
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opensees_cli"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "Run OpenSees simulations in the cloud from the command line."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """OpenSees CLI — run structural simulations in the cloud."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
@@ -11,7 +11,7 @@ from rich.console import Console
11
11
  from rich.table import Table
12
12
  from typer.core import TyperGroup
13
13
 
14
- from opensees_cli import api, config
14
+ from opensees_cli import __version__, api, config
15
15
  from opensees_cli.auth import _to_local, print_quota
16
16
  from opensees_cli.files import download as files_download
17
17
  from opensees_cli.files import list_files as files_list
@@ -25,6 +25,15 @@ from opensees_cli.run import status as run_status
25
25
  _RUNTIME_TOKEN_RE = re.compile(r"^((\d+(?:\.\d+)?)(h|m|s))+$")
26
26
 
27
27
 
28
+ def _cli_cell(raw: Optional[str], current: str) -> str:
29
+ v = (raw or "").strip()
30
+ if not v:
31
+ return "-"
32
+ if config.is_older_version(v, current):
33
+ return f"[yellow]{v}[/yellow]"
34
+ return v
35
+
36
+
28
37
  def _parse_runtime_quota(raw: str) -> int:
29
38
  """Parse -r: plain digits = seconds; or duration tokens like 2h, 10m, 1h30m, 90s."""
30
39
  s = raw.strip().lower().replace(" ", "")
@@ -115,14 +124,17 @@ def list_users():
115
124
  console.print("[dim]No users found.[/dim]")
116
125
  return
117
126
 
118
- table = Table(title=f"Users ({len(users)})")
119
- table.add_column("Email", style="cyan")
127
+ current_cli = __version__
128
+ table = Table(title=f"Users ({len(users)}) · current CLI {current_cli}")
129
+ # Always show the full email (never ellipsize / crop).
130
+ table.add_column("Email", style="cyan", overflow="ignore", no_wrap=True)
120
131
  table.add_column("Status")
121
132
  table.add_column("Enabled")
122
133
  table.add_column("Admin")
123
134
  table.add_column("Last Login")
124
135
  table.add_column("Last Activity")
125
- table.add_column("Pinned")
136
+ table.add_column("CLI", overflow="ignore", no_wrap=True)
137
+ table.add_column("Pinned", overflow="ignore", no_wrap=True)
126
138
 
127
139
  for u in users:
128
140
  enabled = "[green]yes[/green]" if u.get("enabled") else "[red]no[/red]"
@@ -134,6 +146,7 @@ def list_users():
134
146
  admin,
135
147
  u.get("last_login_at", "") or "-",
136
148
  u.get("last_activity_at", "") or "-",
149
+ _cli_cell(u.get("last_cli_version"), current_cli),
137
150
  u.get("openseespy_version", "") or "-",
138
151
  )
139
152
  console.print(table)
@@ -205,6 +218,14 @@ def _print_user_info(email: str) -> None:
205
218
  console.print(f" OpenSeesPy: {pin} (pinned)")
206
219
  else:
207
220
  console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
221
+ cli_ver = (r.get("last_cli_version") or "").strip()
222
+ if cli_ver:
223
+ if config.is_older_version(cli_ver, __version__):
224
+ console.print(f" CLI: [yellow]{cli_ver}[/yellow] (behind {__version__})")
225
+ else:
226
+ console.print(f" CLI: {cli_ver}")
227
+ else:
228
+ console.print(" CLI: [dim]-[/dim]")
208
229
  if r.get("last_ip"):
209
230
  console.print(f" Last IP: {r['last_ip']}")
210
231
 
@@ -2,15 +2,65 @@
2
2
 
3
3
  import json
4
4
  import sys
5
+ import time
5
6
  from typing import Any, Dict, Optional, Sequence
6
7
 
8
+ import httpx
7
9
  import typer
8
10
  from rich.console import Console
9
11
 
10
- from opensees_cli import api, config
12
+ from opensees_cli import __version__, api, config
11
13
 
12
14
  console = Console()
13
15
 
16
+ _PYPI_CLI_URL = "https://pypi.org/pypi/opensees_cli/json"
17
+ _PYPI_CACHE_TTL_SEC = 24 * 3600
18
+
19
+
20
+ def maybe_print_cli_upgrade(*, latest: Optional[str] = None) -> None:
21
+ """If a newer opensees_cli is on PyPI, print a one-line pip upgrade hint."""
22
+ newest = (latest or _pypi_latest_cli_version() or "").strip()
23
+ if not newest or not config.is_older_version(__version__, newest):
24
+ return
25
+ console.print(
26
+ f"[yellow]A newer opensees_cli is available ({newest}; you have {__version__}).[/yellow]"
27
+ )
28
+ console.print("[yellow]Upgrade: pip install -U opensees_cli[/yellow]")
29
+
30
+
31
+ def _pypi_latest_cli_version() -> Optional[str]:
32
+ """Latest PyPI version, cached ~24h. Best-effort; never raises."""
33
+ if "pytest" in sys.modules:
34
+ return None
35
+ cache_path = config.CONFIG_DIR / "pypi_cli_latest.json"
36
+ now = time.time()
37
+ try:
38
+ cached = json.loads(cache_path.read_text(encoding="utf-8"))
39
+ ver = str(cached.get("version") or "").strip()
40
+ checked = float(cached.get("checked_at") or 0)
41
+ if ver and now - checked < _PYPI_CACHE_TTL_SEC:
42
+ return ver
43
+ except (OSError, TypeError, ValueError, json.JSONDecodeError):
44
+ pass
45
+ try:
46
+ r = httpx.get(_PYPI_CLI_URL, timeout=2.0)
47
+ if r.status_code != 200:
48
+ return None
49
+ ver = str((r.json().get("info") or {}).get("version") or "").strip()
50
+ except Exception:
51
+ return None
52
+ if not ver:
53
+ return None
54
+ try:
55
+ config._ensure_dir()
56
+ cache_path.write_text(
57
+ json.dumps({"version": ver, "checked_at": now}),
58
+ encoding="utf-8",
59
+ )
60
+ except OSError:
61
+ pass
62
+ return ver
63
+
14
64
  _CONFIRM_CLI_HINT = (
15
65
  "[dim]Use [bold]ops auth confirm[/bold] to confirm the verification code.[/dim]"
16
66
  )
@@ -457,7 +507,6 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
457
507
  password = _ask_password()
458
508
 
459
509
  try:
460
- from opensees_cli import __version__
461
510
  r = api.post(
462
511
  "/auth/login",
463
512
  {
@@ -478,6 +527,7 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
478
527
  )
479
528
  api.invalidate_session_cache()
480
529
  console.print(f"[green]Logged in as [bold]{email}[/bold][/green]")
530
+ maybe_print_cli_upgrade()
481
531
  except api.ApiError as e:
482
532
  console.print(f"[red]{e.message}[/red]")
483
533
  raise typer.Exit(1)
@@ -14,7 +14,7 @@ import re
14
14
  import sys
15
15
  import time
16
16
  from pathlib import Path
17
- from typing import Any, Dict, Optional
17
+ from typing import Any, Dict, Optional, Tuple
18
18
 
19
19
  CONFIG_DIR = Path.home() / ".opensees"
20
20
  CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
@@ -168,26 +168,50 @@ def get_api_url() -> str:
168
168
  # OpenSeesPy version (sticky local preference)
169
169
  # ---------------------------------------------------------------------------
170
170
 
171
- OPENSEESPY_VERSION_FILE = CONFIG_DIR / "openseespy_version"
171
+ def _openseespy_version_file() -> Path:
172
+ return CONFIG_DIR / "openseespy_version"
172
173
 
173
174
 
174
175
  def set_openseespy_version(version: str) -> None:
175
176
  """Save the sticky OpenSeesPy version preference."""
176
177
  _ensure_dir()
177
- OPENSEESPY_VERSION_FILE.write_text(version.strip())
178
+ _openseespy_version_file().write_text(version.strip())
178
179
 
179
180
 
180
181
  def get_openseespy_version() -> Optional[str]:
181
182
  """Return the sticky OpenSeesPy version, or None if not set."""
182
- if not OPENSEESPY_VERSION_FILE.exists():
183
+ path = _openseespy_version_file()
184
+ if not path.exists():
183
185
  return None
184
186
  try:
185
- return OPENSEESPY_VERSION_FILE.read_text().strip() or None
187
+ return path.read_text().strip() or None
186
188
  except OSError:
187
189
  return None
188
190
 
189
191
 
190
192
  def clear_openseespy_version() -> None:
191
193
  """Clear the sticky OpenSeesPy version preference."""
192
- if OPENSEESPY_VERSION_FILE.exists():
193
- OPENSEESPY_VERSION_FILE.unlink()
194
+ path = _openseespy_version_file()
195
+ if path.exists():
196
+ path.unlink()
197
+
198
+
199
+ def version_tuple(raw: str) -> Tuple[int, ...]:
200
+ """Numeric dotted version parts, or empty if none (e.g. ``web/1.0.0`` still parses)."""
201
+ parts: list[int] = []
202
+ for token in re.findall(r"\d+", (raw or "").strip()):
203
+ parts.append(int(token))
204
+ if len(parts) >= 4:
205
+ break
206
+ return tuple(parts)
207
+
208
+
209
+ def is_older_version(installed: str, latest: str) -> bool:
210
+ """True when *installed* is a lower dotted version than *latest*."""
211
+ a, b = version_tuple(installed), version_tuple(latest)
212
+ if not a or not b:
213
+ return False
214
+ n = max(len(a), len(b))
215
+ a = a + (0,) * (n - len(a))
216
+ b = b + (0,) * (n - len(b))
217
+ return a < b
@@ -8,6 +8,7 @@ from opensees_cli import __version__
8
8
  from opensees_cli.auth import (
9
9
  app as auth_app,
10
10
  format_status_json,
11
+ maybe_print_cli_upgrade,
11
12
  print_me,
12
13
  print_quota,
13
14
  )
@@ -161,6 +162,7 @@ def _print_help() -> None:
161
162
 
162
163
  console.print()
163
164
  console.print("[dim]Run any command with --help for more details.[/dim]")
165
+ maybe_print_cli_upgrade()
164
166
 
165
167
 
166
168
  @app.callback(invoke_without_command=True)
@@ -221,7 +223,9 @@ def status(
221
223
  q = r.get("quota")
222
224
  if not q:
223
225
  console.print("[dim]No quota info available.[/dim]")
226
+ maybe_print_cli_upgrade()
224
227
  return
225
228
  console.print()
226
229
  print_quota(q)
230
+ maybe_print_cli_upgrade()
227
231
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensees_cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Run OpenSees simulations in the cloud from the command line.
5
5
  Author: Minjie Zhu
6
6
  License: Proprietary
@@ -16,16 +16,20 @@ 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.2.1",
19
20
  "openseespy_version": "3.7.1.0",
20
21
  },
21
22
  ]
22
23
  })
23
24
  result = cli_runner.invoke(app, ["admin", "list-users"])
24
25
  assert result.exit_code == 0
25
- assert "alice@te" in result.output
26
+ assert "alice@test.com" in result.output
26
27
  assert "3.7.1.0" in result.output
27
- assert "CLI Version" not in result.output
28
+ assert "0.2.1" in result.output
29
+ assert "CLI" in result.output
28
30
  assert "Pinned" in result.output
31
+ from opensees_cli import __version__
32
+ assert f"current CLI {__version__}" in result.output
29
33
 
30
34
  def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
31
35
  mock_router.add("GET", "/prod/admin/users", json={"users": []})
@@ -103,7 +107,8 @@ class TestUserInfo:
103
107
  assert "alice@test.com" in result.output
104
108
  assert "3.7.1.0" in result.output
105
109
  assert "pinned" in result.output.lower()
106
- assert "CLI version" not in result.output
110
+ assert "0.2.2" in result.output
111
+ assert "CLI" in result.output
107
112
  assert "Analyses" not in result.output
108
113
  assert "Files" not in result.output
109
114
 
@@ -5,6 +5,7 @@ from unittest.mock import patch
5
5
 
6
6
  from opensees_cli.auth import app as auth_app
7
7
  from opensees_cli.main import app
8
+ from opensees_cli.auth import maybe_print_cli_upgrade
8
9
 
9
10
 
10
11
  def _patch_password(*passwords):
@@ -93,12 +94,15 @@ class TestLogin:
93
94
  assert result.exit_code == 0
94
95
  assert "Logged in" in result.output
95
96
 
96
- def test_login_bad_credentials(self, cli_runner, patch_httpx, mock_router):
97
- mock_router.add("POST", "/prod/auth/login", status=400, json={"error": "Incorrect username or password."})
98
- with patch("opensees_cli.auth._ask_password", return_value="wrong"):
97
+ def test_login_stale_cli(self, cli_runner, patch_httpx, mock_router):
98
+ mock_router.add("POST", "/prod/auth/login", status=400, json={
99
+ "error": "This CLI (0.2.3) is out of date. Upgrade with: pip install -U opensees_cli",
100
+ "code": "auth_error",
101
+ })
102
+ with patch("opensees_cli.auth._ask_password", return_value="Pass1!"):
99
103
  result = cli_runner.invoke(app, ["auth", "login", "-e", "u@t.com"])
100
104
  assert result.exit_code == 1
101
- assert "Incorrect" in result.output
105
+ assert "pip install -U opensees_cli" in result.output
102
106
 
103
107
 
104
108
  class TestLogout:
@@ -165,3 +169,16 @@ class TestChangePassword:
165
169
  result = cli_runner.invoke(app, ["auth", "change-password"])
166
170
  assert result.exit_code == 1
167
171
  assert "do not match" in result.output
172
+
173
+
174
+ class TestCliUpgradeNag:
175
+ def test_prints_when_behind(self, capsys):
176
+ maybe_print_cli_upgrade(latest="99.0.0")
177
+ out = capsys.readouterr().out
178
+ assert "pip install -U opensees_cli" in out
179
+ assert "99.0.0" in out
180
+
181
+ def test_silent_when_current(self, capsys):
182
+ from opensees_cli import __version__
183
+ maybe_print_cli_upgrade(latest=__version__)
184
+ assert capsys.readouterr().out == ""
@@ -8,6 +8,8 @@ from opensees_cli.config import (
8
8
  get_refresh_token,
9
9
  get_api_url,
10
10
  is_admin,
11
+ is_older_version,
12
+ version_tuple,
11
13
  )
12
14
 
13
15
 
@@ -82,3 +84,14 @@ class TestApiUrl:
82
84
  def test_env_override(self, monkeypatch):
83
85
  monkeypatch.setenv("OPENSEES_API_URL", "https://custom.example/v1")
84
86
  assert get_api_url() == "https://custom.example/v1"
87
+
88
+
89
+ class TestVersionCompare:
90
+ def test_older(self):
91
+ assert is_older_version("0.2.1", "0.2.3")
92
+ assert not is_older_version("0.2.3", "0.2.3")
93
+ assert not is_older_version("0.2.4", "0.2.3")
94
+
95
+ def test_non_numeric_prefix(self):
96
+ assert version_tuple("web/1.0.0") == (1, 0, 0)
97
+ assert not is_older_version("web/1.0.0", "0.2.3")
File without changes
File without changes