opensees-cli 0.1.0a1__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.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: opensees-cli
3
+ Version: 0.1.0a1
4
+ Summary: Run OpenSees simulations in the cloud from the command line.
5
+ Author: Minjie Zhu
6
+ License: Proprietary
7
+ Project-URL: Documentation, https://opensees.run/docs
8
+ Project-URL: Support, https://opensees.run/support
9
+ Keywords: opensees,structural-engineering,cloud,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: typer>=0.12
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: rich>=13
19
+
20
+ # OpenSees CLI Documentation
21
+
22
+ Command-line interface for authentication and simulation runs.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install opensees-cli
28
+ ```
29
+
30
+ For local development from this repo:
31
+
32
+ ```bash
33
+ pip install -e cli/
34
+ ```
35
+
36
+ ## Configure API URL (optional)
37
+
38
+ By default, the CLI uses the production API URL baked into the package.
39
+
40
+ To override (for dev/staging):
41
+
42
+ ```bash
43
+ export OPENSEES_API_URL="https://your-api-id.execute-api.us-west-2.amazonaws.com/prod"
44
+ ```
45
+
46
+ ## Quick Start
47
+
48
+ ```bash
49
+ # 1) Create account
50
+ ops auth signup --email you@example.com
51
+
52
+ # 2) Confirm account
53
+ ops auth confirm --email you@example.com --code 123456
54
+
55
+ # 3) Log in
56
+ ops auth login --email you@example.com
57
+
58
+ # 4) Submit a simulation
59
+ ops run submit ./model.py --timeout 300 --wait
60
+ ```
61
+
62
+ ## Top-Level Commands
63
+
64
+ ```bash
65
+ ops version
66
+ ops status
67
+ ops quota
68
+ ops help
69
+ ```
70
+
71
+ - `version`: print CLI version
72
+ - `status`: show current login state and local config path
73
+ - `quota`: show your current run quota
74
+
75
+ ## Auth Commands
76
+
77
+ ```bash
78
+ ops auth signup --email you@example.com
79
+ ops auth confirm --email you@example.com --code 123456
80
+ ops auth resend-code --email you@example.com
81
+ ops auth login --email you@example.com
82
+ ops auth logout
83
+ ops auth status
84
+ ops auth whoami
85
+ ops auth forgot-password --email you@example.com
86
+ ops auth reset-password --email you@example.com --code 123456
87
+ ops auth change-password
88
+ ops auth help
89
+ ```
90
+
91
+ Notes:
92
+ - Password prompts are interactive and masked.
93
+ - Credentials are stored in `~/.ops/credentials.json`.
94
+ - Access tokens are refreshed automatically when possible.
95
+
96
+ ## Run Commands
97
+
98
+ ```bash
99
+ ops run submit ./model.py --timeout 120 --wait
100
+ ops run status <run_id>
101
+ ops run output <run_id>
102
+ ops run result <run_id>
103
+ ops run cancel <run_id>
104
+ ops run list --limit 20
105
+ ops run help
106
+ ```
107
+
108
+ ### `run submit` options
109
+
110
+ - `file` (required positional): path to a `.py` simulation script
111
+ - `--timeout`, `-t`: max runtime in seconds (default `120`, backend max `900`)
112
+ - `--wait/--no-wait`: stream output until completion (default `--wait`)
113
+
114
+ Validation enforced by CLI:
115
+ - file must exist
116
+ - file extension must be `.py`
117
+ - file size must be <= 200 KB
118
+
119
+ ## Common Workflows
120
+
121
+ ### Check account + quota
122
+
123
+ ```bash
124
+ ops auth whoami
125
+ ops quota
126
+ ```
127
+
128
+ ### Submit and monitor a run later
129
+
130
+ ```bash
131
+ ops run submit ./model.py --no-wait
132
+ ops run status <run_id>
133
+ ops run output <run_id>
134
+ ops run result <run_id>
135
+ ```
136
+
137
+ ### Reset password
138
+
139
+ ```bash
140
+ ops auth forgot-password --email you@example.com
141
+ ops auth reset-password --email you@example.com --code 123456
142
+ ```
@@ -0,0 +1,123 @@
1
+ # OpenSees CLI Documentation
2
+
3
+ Command-line interface for authentication and simulation runs.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install opensees-cli
9
+ ```
10
+
11
+ For local development from this repo:
12
+
13
+ ```bash
14
+ pip install -e cli/
15
+ ```
16
+
17
+ ## Configure API URL (optional)
18
+
19
+ By default, the CLI uses the production API URL baked into the package.
20
+
21
+ To override (for dev/staging):
22
+
23
+ ```bash
24
+ export OPENSEES_API_URL="https://your-api-id.execute-api.us-west-2.amazonaws.com/prod"
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ # 1) Create account
31
+ ops auth signup --email you@example.com
32
+
33
+ # 2) Confirm account
34
+ ops auth confirm --email you@example.com --code 123456
35
+
36
+ # 3) Log in
37
+ ops auth login --email you@example.com
38
+
39
+ # 4) Submit a simulation
40
+ ops run submit ./model.py --timeout 300 --wait
41
+ ```
42
+
43
+ ## Top-Level Commands
44
+
45
+ ```bash
46
+ ops version
47
+ ops status
48
+ ops quota
49
+ ops help
50
+ ```
51
+
52
+ - `version`: print CLI version
53
+ - `status`: show current login state and local config path
54
+ - `quota`: show your current run quota
55
+
56
+ ## Auth Commands
57
+
58
+ ```bash
59
+ ops auth signup --email you@example.com
60
+ ops auth confirm --email you@example.com --code 123456
61
+ ops auth resend-code --email you@example.com
62
+ ops auth login --email you@example.com
63
+ ops auth logout
64
+ ops auth status
65
+ ops auth whoami
66
+ ops auth forgot-password --email you@example.com
67
+ ops auth reset-password --email you@example.com --code 123456
68
+ ops auth change-password
69
+ ops auth help
70
+ ```
71
+
72
+ Notes:
73
+ - Password prompts are interactive and masked.
74
+ - Credentials are stored in `~/.ops/credentials.json`.
75
+ - Access tokens are refreshed automatically when possible.
76
+
77
+ ## Run Commands
78
+
79
+ ```bash
80
+ ops run submit ./model.py --timeout 120 --wait
81
+ ops run status <run_id>
82
+ ops run output <run_id>
83
+ ops run result <run_id>
84
+ ops run cancel <run_id>
85
+ ops run list --limit 20
86
+ ops run help
87
+ ```
88
+
89
+ ### `run submit` options
90
+
91
+ - `file` (required positional): path to a `.py` simulation script
92
+ - `--timeout`, `-t`: max runtime in seconds (default `120`, backend max `900`)
93
+ - `--wait/--no-wait`: stream output until completion (default `--wait`)
94
+
95
+ Validation enforced by CLI:
96
+ - file must exist
97
+ - file extension must be `.py`
98
+ - file size must be <= 200 KB
99
+
100
+ ## Common Workflows
101
+
102
+ ### Check account + quota
103
+
104
+ ```bash
105
+ ops auth whoami
106
+ ops quota
107
+ ```
108
+
109
+ ### Submit and monitor a run later
110
+
111
+ ```bash
112
+ ops run submit ./model.py --no-wait
113
+ ops run status <run_id>
114
+ ops run output <run_id>
115
+ ops run result <run_id>
116
+ ```
117
+
118
+ ### Reset password
119
+
120
+ ```bash
121
+ ops auth forgot-password --email you@example.com
122
+ ops auth reset-password --email you@example.com --code 123456
123
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opensees-cli"
7
+ version = "0.1.0a1"
8
+ description = "Run OpenSees simulations in the cloud from the command line."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "Proprietary"}
12
+ authors = [{name = "Minjie Zhu"}]
13
+ keywords = ["opensees", "structural-engineering", "cloud", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Topic :: Scientific/Engineering",
18
+ "Programming Language :: Python :: 3",
19
+ ]
20
+ dependencies = [
21
+ "typer>=0.12",
22
+ "httpx>=0.27",
23
+ "rich>=13",
24
+ ]
25
+
26
+ [project.scripts]
27
+ ops = "opensees_cli.main:app"
28
+ opensees = "opensees_cli.main:app"
29
+
30
+ [project.urls]
31
+ Documentation = "https://opensees.run/docs"
32
+ Support = "https://opensees.run/support"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """OpenSees CLI — run structural simulations in the cloud."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -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)
@@ -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