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.
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/PKG-INFO +1 -1
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/pyproject.toml +1 -1
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/__init__.py +1 -1
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/admin.py +25 -4
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/auth.py +52 -2
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/config.py +31 -7
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/main.py +4 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/PKG-INFO +1 -1
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_admin_commands.py +8 -3
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_auth_commands.py +21 -4
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_config.py +13 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/README.md +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/setup.cfg +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/__main__.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/api.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/files.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/path_setup.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/run.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli/version.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/SOURCES.txt +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/entry_points.txt +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/requires.txt +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/top_level.txt +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_api.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_files_commands.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_main_commands.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_run_commands.py +0 -0
- {opensees_cli-0.2.3 → opensees_cli-0.2.4}/tests/test_version_commands.py +0 -0
|
@@ -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
|
-
|
|
119
|
-
table
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
+
path = _openseespy_version_file()
|
|
184
|
+
if not path.exists():
|
|
183
185
|
return None
|
|
184
186
|
try:
|
|
185
|
-
return
|
|
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
|
-
|
|
193
|
-
|
|
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
|
|
|
@@ -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@
|
|
26
|
+
assert "alice@test.com" in result.output
|
|
26
27
|
assert "3.7.1.0" in result.output
|
|
27
|
-
assert "
|
|
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 "
|
|
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
|
|
97
|
-
mock_router.add("POST", "/prod/auth/login", status=400, json={
|
|
98
|
-
|
|
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 "
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|