opensees-cli 0.1.0a1__py3-none-any.whl
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/__init__.py +3 -0
- opensees_cli/admin.py +170 -0
- opensees_cli/api.py +170 -0
- opensees_cli/auth.py +385 -0
- opensees_cli/config.py +78 -0
- opensees_cli/files.py +315 -0
- opensees_cli/main.py +146 -0
- opensees_cli/run.py +1728 -0
- opensees_cli-0.1.0a1.dist-info/METADATA +142 -0
- opensees_cli-0.1.0a1.dist-info/RECORD +13 -0
- opensees_cli-0.1.0a1.dist-info/WHEEL +5 -0
- opensees_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
- opensees_cli-0.1.0a1.dist-info/top_level.txt +1 -0
opensees_cli/__init__.py
ADDED
opensees_cli/admin.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Admin CLI commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from opensees_cli import api
|
|
10
|
+
from opensees_cli.auth import _to_local, print_quota
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
help="Admin operations (requires admin privileges).",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback(invoke_without_command=True)
|
|
19
|
+
def _admin_callback(ctx: typer.Context) -> None:
|
|
20
|
+
api.require_login()
|
|
21
|
+
if ctx.invoked_subcommand is None:
|
|
22
|
+
typer.echo(ctx.get_help())
|
|
23
|
+
raise typer.Exit(0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("list-users")
|
|
27
|
+
def list_users():
|
|
28
|
+
"""List all registered users."""
|
|
29
|
+
try:
|
|
30
|
+
r = api.get("/admin/users")
|
|
31
|
+
except api.ApiError as e:
|
|
32
|
+
console.print(f"[red]{e.message}[/red]")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
users = r.get("users", [])
|
|
36
|
+
if not users:
|
|
37
|
+
console.print("[dim]No users found.[/dim]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
table = Table(title=f"Users ({len(users)})")
|
|
41
|
+
table.add_column("Email", style="cyan")
|
|
42
|
+
table.add_column("Status")
|
|
43
|
+
table.add_column("Enabled")
|
|
44
|
+
table.add_column("Admin")
|
|
45
|
+
table.add_column("Last Login")
|
|
46
|
+
table.add_column("Last Activity")
|
|
47
|
+
table.add_column("CLI Version")
|
|
48
|
+
|
|
49
|
+
for u in users:
|
|
50
|
+
enabled = "[green]yes[/green]" if u.get("enabled") else "[red]no[/red]"
|
|
51
|
+
admin = "[yellow]yes[/yellow]" if u.get("is_admin") else "no"
|
|
52
|
+
table.add_row(
|
|
53
|
+
u.get("email", ""),
|
|
54
|
+
u.get("status", ""),
|
|
55
|
+
enabled,
|
|
56
|
+
admin,
|
|
57
|
+
u.get("last_login_at", "") or "-",
|
|
58
|
+
u.get("last_activity_at", "") or "-",
|
|
59
|
+
u.get("last_cli_version", "") or "-",
|
|
60
|
+
)
|
|
61
|
+
console.print(table)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("disable-user")
|
|
65
|
+
def disable_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
66
|
+
"""Disable a user account (prevents login)."""
|
|
67
|
+
if not email:
|
|
68
|
+
email = typer.prompt("Email to disable")
|
|
69
|
+
try:
|
|
70
|
+
r = api.post("/admin/disable-user", {"email": email})
|
|
71
|
+
console.print(f"[green]{r.get('message', 'User disabled.')}[/green]")
|
|
72
|
+
except api.ApiError as e:
|
|
73
|
+
console.print(f"[red]{e.message}[/red]")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("enable-user")
|
|
78
|
+
def enable_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
79
|
+
"""Re-enable a disabled user account."""
|
|
80
|
+
if not email:
|
|
81
|
+
email = typer.prompt("Email to enable")
|
|
82
|
+
try:
|
|
83
|
+
r = api.post("/admin/enable-user", {"email": email})
|
|
84
|
+
console.print(f"[green]{r.get('message', 'User enabled.')}[/green]")
|
|
85
|
+
except api.ApiError as e:
|
|
86
|
+
console.print(f"[red]{e.message}[/red]")
|
|
87
|
+
raise typer.Exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command("delete-user")
|
|
91
|
+
def delete_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
92
|
+
"""Permanently delete a user account."""
|
|
93
|
+
if not email:
|
|
94
|
+
email = typer.prompt("Email to delete")
|
|
95
|
+
|
|
96
|
+
confirmed = typer.confirm(f"Permanently delete {email}? This cannot be undone")
|
|
97
|
+
if not confirmed:
|
|
98
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
99
|
+
raise typer.Exit(0)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
r = api.post("/admin/delete-user", {"email": email})
|
|
103
|
+
console.print(f"[green]{r.get('message', 'User deleted.')}[/green]")
|
|
104
|
+
except api.ApiError as e:
|
|
105
|
+
console.print(f"[red]{e.message}[/red]")
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("user-info")
|
|
110
|
+
def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
111
|
+
"""Show a user's account details and quota."""
|
|
112
|
+
if not email:
|
|
113
|
+
email = typer.prompt("Email")
|
|
114
|
+
try:
|
|
115
|
+
r = api.post("/admin/user-info", {"email": email})
|
|
116
|
+
except api.ApiError as e:
|
|
117
|
+
console.print(f"[red]{e.message}[/red]")
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
console.print(f" Email: [bold]{r.get('email', '')}[/bold]")
|
|
121
|
+
console.print(f" Admin: {'[yellow]yes[/yellow]' if r.get('is_admin') else 'no'}")
|
|
122
|
+
console.print(f" Enabled: {'[green]yes[/green]' if r.get('is_enabled') else '[red]no[/red]'}")
|
|
123
|
+
if r.get("created_at"):
|
|
124
|
+
console.print(f" Member since: {_to_local(r['created_at'])}")
|
|
125
|
+
if r.get("last_login_at"):
|
|
126
|
+
console.print(f" Last login: {_to_local(r['last_login_at'])}")
|
|
127
|
+
if r.get("last_activity_at"):
|
|
128
|
+
console.print(f" Last active: {_to_local(r['last_activity_at'])}")
|
|
129
|
+
if r.get("last_cli_version"):
|
|
130
|
+
console.print(f" CLI version: {r['last_cli_version']}")
|
|
131
|
+
if r.get("last_ip"):
|
|
132
|
+
console.print(f" Last IP: {r['last_ip']}")
|
|
133
|
+
|
|
134
|
+
q = r.get("quota")
|
|
135
|
+
if q:
|
|
136
|
+
console.print()
|
|
137
|
+
print_quota(q)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("set-quota")
|
|
141
|
+
def set_quota(
|
|
142
|
+
email: Optional[str] = typer.Option(None, "--email", "-e"),
|
|
143
|
+
concurrent: Optional[int] = typer.Option(None, "--concurrent", "-c", help="Max concurrent tasks"),
|
|
144
|
+
tasks: Optional[int] = typer.Option(None, "--tasks", help="Max tasks per analysis"),
|
|
145
|
+
runtime: Optional[int] = typer.Option(None, "--runtime", "-r", help="Max monthly runtime in seconds"),
|
|
146
|
+
storage: Optional[int] = typer.Option(None, "--storage", "-s", help="Max monthly storage in bytes"),
|
|
147
|
+
):
|
|
148
|
+
"""Update a user's quota limits."""
|
|
149
|
+
if not email:
|
|
150
|
+
email = typer.prompt("Email")
|
|
151
|
+
if concurrent is None and tasks is None and runtime is None and storage is None:
|
|
152
|
+
console.print("Provide at least one of --concurrent, --tasks, --runtime, or --storage.")
|
|
153
|
+
raise typer.Exit(0)
|
|
154
|
+
|
|
155
|
+
body: dict = {"email": email}
|
|
156
|
+
if concurrent is not None:
|
|
157
|
+
body["max_concurrent_runs"] = concurrent
|
|
158
|
+
if tasks is not None:
|
|
159
|
+
body["max_tasks_per_analysis"] = tasks
|
|
160
|
+
if runtime is not None:
|
|
161
|
+
body["max_monthly_runtime"] = runtime
|
|
162
|
+
if storage is not None:
|
|
163
|
+
body["max_monthly_storage"] = storage
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
r = api.post("/admin/set-quota", body)
|
|
167
|
+
console.print(f"[green]{r.get('message', 'Quota updated.')}[/green]")
|
|
168
|
+
except api.ApiError as e:
|
|
169
|
+
console.print(f"[red]{e.message}[/red]")
|
|
170
|
+
raise typer.Exit(1)
|
opensees_cli/api.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""HTTP client for the OpenSees CLI REST API.
|
|
2
|
+
|
|
3
|
+
Handles auth headers and automatic token refresh on 401.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from opensees_cli import __version__
|
|
11
|
+
from opensees_cli.config import (
|
|
12
|
+
get_access_token,
|
|
13
|
+
get_api_url,
|
|
14
|
+
get_refresh_token,
|
|
15
|
+
load_credentials,
|
|
16
|
+
save_credentials,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
TIMEOUT = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiError(Exception):
|
|
23
|
+
def __init__(self, message: str, status: int = 0, code: Optional[str] = None):
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.code = code
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _not_logged_in() -> None:
|
|
31
|
+
"""Print a login prompt and exit."""
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
c = Console()
|
|
34
|
+
c.print("Not logged in. To get started:\n")
|
|
35
|
+
c.print(" [bold]ops auth signup[/bold] Create a new account")
|
|
36
|
+
c.print(" [bold]ops auth login[/bold] Log in to an existing account")
|
|
37
|
+
raise SystemExit(0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_login() -> None:
|
|
41
|
+
"""Exit early if the user has no stored credentials."""
|
|
42
|
+
if not load_credentials() or not load_credentials().get("access_token"):
|
|
43
|
+
_not_logged_in()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_no_login() -> None:
|
|
47
|
+
"""Exit early if the user is already logged in."""
|
|
48
|
+
from rich.console import Console
|
|
49
|
+
creds = load_credentials()
|
|
50
|
+
if creds and creds.get("access_token"):
|
|
51
|
+
Console().print(
|
|
52
|
+
f"Already logged in as [bold]{creds.get('email', '')}[/bold]. "
|
|
53
|
+
"Run [bold]ops auth logout[/bold] first."
|
|
54
|
+
)
|
|
55
|
+
raise SystemExit(0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _url(path: str) -> str:
|
|
59
|
+
return f"{get_api_url().rstrip('/')}{path}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _headers(token: Optional[str] = None) -> Dict[str, str]:
|
|
63
|
+
h: Dict[str, str] = {"Content-Type": "application/json", "User-Agent": f"opensees-cli/{__version__}"}
|
|
64
|
+
if token is not None:
|
|
65
|
+
if token:
|
|
66
|
+
h["Authorization"] = f"Bearer {token}"
|
|
67
|
+
return h
|
|
68
|
+
creds = load_credentials() or {}
|
|
69
|
+
id_tok = creds.get("id_token")
|
|
70
|
+
access_tok = creds.get("access_token")
|
|
71
|
+
if id_tok:
|
|
72
|
+
h["Authorization"] = f"Bearer {id_tok}"
|
|
73
|
+
if access_tok:
|
|
74
|
+
h["X-Access-Token"] = access_tok
|
|
75
|
+
return h
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse(resp: httpx.Response) -> Dict[str, Any]:
|
|
79
|
+
try:
|
|
80
|
+
data = resp.json()
|
|
81
|
+
except Exception:
|
|
82
|
+
if resp.status_code >= 400:
|
|
83
|
+
raise ApiError(f"HTTP {resp.status_code}", resp.status_code)
|
|
84
|
+
return {}
|
|
85
|
+
if resp.status_code >= 400:
|
|
86
|
+
raise ApiError(
|
|
87
|
+
data.get("error", f"HTTP {resp.status_code}"),
|
|
88
|
+
resp.status_code,
|
|
89
|
+
data.get("code"),
|
|
90
|
+
)
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _try_refresh() -> bool:
|
|
95
|
+
rt = get_refresh_token()
|
|
96
|
+
if not rt:
|
|
97
|
+
return False
|
|
98
|
+
try:
|
|
99
|
+
data = post("/auth/refresh", {"refresh_token": rt}, auth=False)
|
|
100
|
+
creds = load_credentials() or {}
|
|
101
|
+
save_credentials(
|
|
102
|
+
access_token=data["access_token"],
|
|
103
|
+
refresh_token=creds.get("refresh_token", rt),
|
|
104
|
+
id_token=data.get("id_token"),
|
|
105
|
+
email=creds.get("email"),
|
|
106
|
+
is_admin=creds.get("is_admin", False),
|
|
107
|
+
)
|
|
108
|
+
return True
|
|
109
|
+
except Exception:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def post(path: str, body: Dict[str, Any], auth: bool = True) -> Dict[str, Any]:
|
|
114
|
+
hdrs = _headers() if auth else _headers(token="")
|
|
115
|
+
resp = httpx.post(_url(path), json=body, headers=hdrs, timeout=TIMEOUT)
|
|
116
|
+
if resp.status_code == 401 and auth:
|
|
117
|
+
if _try_refresh():
|
|
118
|
+
resp = httpx.post(_url(path), json=body, headers=_headers(), timeout=TIMEOUT)
|
|
119
|
+
else:
|
|
120
|
+
_not_logged_in()
|
|
121
|
+
return _parse(resp)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get(path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
125
|
+
resp = httpx.get(_url(path), params=params, headers=_headers(), timeout=TIMEOUT)
|
|
126
|
+
if resp.status_code == 401:
|
|
127
|
+
if _try_refresh():
|
|
128
|
+
resp = httpx.get(_url(path), params=params, headers=_headers(), timeout=TIMEOUT)
|
|
129
|
+
else:
|
|
130
|
+
_not_logged_in()
|
|
131
|
+
return _parse(resp)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def upload_file(presigned_url: str, file_bytes: bytes, content_type: str = "text/x-python") -> None:
|
|
135
|
+
"""Upload raw bytes to a remote URL."""
|
|
136
|
+
resp = httpx.put(
|
|
137
|
+
presigned_url,
|
|
138
|
+
content=file_bytes,
|
|
139
|
+
headers={"Content-Type": content_type},
|
|
140
|
+
timeout=60.0,
|
|
141
|
+
follow_redirects=True,
|
|
142
|
+
)
|
|
143
|
+
if not (200 <= resp.status_code < 300):
|
|
144
|
+
raise ApiError(f"Upload failed (HTTP {resp.status_code})", resp.status_code)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fetch_s3_range(presigned_url: str, start_byte: int = 0) -> tuple[bytes, bool]:
|
|
148
|
+
"""GET bytes from a presigned S3 URL using Range header.
|
|
149
|
+
|
|
150
|
+
Returns (data, is_complete):
|
|
151
|
+
- data: bytes from start_byte onward (empty if object doesn't exist yet)
|
|
152
|
+
- is_complete: False if the object doesn't exist (404) or range is unsatisfiable
|
|
153
|
+
"""
|
|
154
|
+
headers = {}
|
|
155
|
+
if start_byte > 0:
|
|
156
|
+
headers["Range"] = f"bytes={start_byte}-"
|
|
157
|
+
try:
|
|
158
|
+
resp = httpx.get(presigned_url, headers=headers, timeout=10.0)
|
|
159
|
+
except httpx.TimeoutException:
|
|
160
|
+
return b"", False
|
|
161
|
+
except httpx.RequestError:
|
|
162
|
+
return b"", False
|
|
163
|
+
if resp.status_code == 404 or resp.status_code == 403:
|
|
164
|
+
return b"", False
|
|
165
|
+
if resp.status_code == 416:
|
|
166
|
+
# Range not satisfiable — no new data since last read
|
|
167
|
+
return b"", True
|
|
168
|
+
if resp.status_code in (200, 206):
|
|
169
|
+
return resp.content, True
|
|
170
|
+
return b"", False
|