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.
@@ -0,0 +1,3 @@
1
+ """OpenSees CLI — run structural simulations in the cloud."""
2
+
3
+ __version__ = "0.1.0a1"
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