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.
- opensees_cli-0.1.0a1/PKG-INFO +142 -0
- opensees_cli-0.1.0a1/README.md +123 -0
- opensees_cli-0.1.0a1/pyproject.toml +35 -0
- opensees_cli-0.1.0a1/setup.cfg +4 -0
- opensees_cli-0.1.0a1/src/opensees_cli/__init__.py +3 -0
- opensees_cli-0.1.0a1/src/opensees_cli/admin.py +170 -0
- opensees_cli-0.1.0a1/src/opensees_cli/api.py +170 -0
- opensees_cli-0.1.0a1/src/opensees_cli/auth.py +385 -0
- opensees_cli-0.1.0a1/src/opensees_cli/config.py +78 -0
- opensees_cli-0.1.0a1/src/opensees_cli/files.py +315 -0
- opensees_cli-0.1.0a1/src/opensees_cli/main.py +146 -0
- opensees_cli-0.1.0a1/src/opensees_cli/run.py +1728 -0
- opensees_cli-0.1.0a1/src/opensees_cli.egg-info/PKG-INFO +142 -0
- opensees_cli-0.1.0a1/src/opensees_cli.egg-info/SOURCES.txt +16 -0
- opensees_cli-0.1.0a1/src/opensees_cli.egg-info/dependency_links.txt +1 -0
- opensees_cli-0.1.0a1/src/opensees_cli.egg-info/entry_points.txt +3 -0
- opensees_cli-0.1.0a1/src/opensees_cli.egg-info/requires.txt +3 -0
- opensees_cli-0.1.0a1/src/opensees_cli.egg-info/top_level.txt +1 -0
|
@@ -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,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
|