powerbase-cli 0.2.0__tar.gz → 0.2.2__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.
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/PKG-INFO +3 -4
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/README.md +1 -3
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/pyproject.toml +2 -2
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/__init__.py +1 -1
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/api.py +32 -22
- powerbase_cli-0.2.2/src/powerbase_cli/http_client.py +99 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/session.py +13 -24
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/transport.py +46 -25
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/PKG-INFO +3 -4
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/SOURCES.txt +3 -0
- powerbase_cli-0.2.2/src/powerbase_cli.egg-info/requires.txt +1 -0
- powerbase_cli-0.2.2/tests/test_http_client.py +33 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_session.py +31 -19
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_transport.py +45 -29
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/setup.cfg +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/__main__.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/cli.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/__init__.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/agent.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/auth.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/branch.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/config_cmd.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/context.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/database.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/instance.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/org.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/parser.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/publish.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sandbox.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/shared.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sql.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/config.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/dependency_links.txt +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/entry_points.txt +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/top_level.txt +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_api.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_cli_commands.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_cli_help.py +0 -0
- {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_config.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: powerbase-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: CLI for operating Powerbase console workflows
|
|
5
5
|
Author: Powerbase
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: curl_cffi
|
|
8
9
|
|
|
9
10
|
# powerbase-cli
|
|
10
11
|
|
|
@@ -41,12 +42,10 @@ Database model:
|
|
|
41
42
|
Install from PyPI:
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
|
-
|
|
45
|
+
python3 -m pip install powerbase-cli
|
|
45
46
|
powerbase --help
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
Version `0.2.x` is the production-pinned release line and always targets `https://console.appbuild.chat`.
|
|
49
|
-
|
|
50
49
|
From the repository root during development:
|
|
51
50
|
|
|
52
51
|
```bash
|
|
@@ -33,12 +33,10 @@ Database model:
|
|
|
33
33
|
Install from PyPI:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
|
|
36
|
+
python3 -m pip install powerbase-cli
|
|
37
37
|
powerbase --help
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Version `0.2.x` is the production-pinned release line and always targets `https://console.appbuild.chat`.
|
|
41
|
-
|
|
42
40
|
From the repository root during development:
|
|
43
41
|
|
|
44
42
|
```bash
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "powerbase-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "CLI for operating Powerbase console workflows"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
authors = [{ name = "Powerbase" }]
|
|
12
|
-
dependencies = []
|
|
12
|
+
dependencies = ["curl_cffi"]
|
|
13
13
|
|
|
14
14
|
[project.scripts]
|
|
15
15
|
powerbase = "powerbase_cli.cli:main"
|
|
@@ -137,19 +137,24 @@ class PowerbaseApi:
|
|
|
137
137
|
instance_id=instance_id,
|
|
138
138
|
stream=True,
|
|
139
139
|
)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
140
|
+
try:
|
|
141
|
+
buffer = ""
|
|
142
|
+
while True:
|
|
143
|
+
chunk = resp.readline()
|
|
144
|
+
if not chunk:
|
|
145
|
+
break
|
|
146
|
+
line = chunk.decode("utf-8")
|
|
147
|
+
if line == "\n":
|
|
148
|
+
buffer = ""
|
|
149
|
+
continue
|
|
150
|
+
if line.startswith("data:"):
|
|
151
|
+
payload = line[5:].strip()
|
|
152
|
+
if payload:
|
|
153
|
+
yield json.loads(payload)
|
|
154
|
+
finally:
|
|
155
|
+
close = getattr(resp, "close", None)
|
|
156
|
+
if callable(close):
|
|
157
|
+
close()
|
|
153
158
|
|
|
154
159
|
def sandbox_files_list(self, instance_id: str, path: str = "/", include_hidden: bool = False) -> Any:
|
|
155
160
|
params = {
|
|
@@ -337,12 +342,17 @@ class PowerbaseApi:
|
|
|
337
342
|
instance_id=instance_id,
|
|
338
343
|
stream=True,
|
|
339
344
|
)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
345
|
+
try:
|
|
346
|
+
while True:
|
|
347
|
+
chunk = resp.readline()
|
|
348
|
+
if not chunk:
|
|
349
|
+
break
|
|
350
|
+
line = chunk.decode("utf-8")
|
|
351
|
+
if line.startswith("data:"):
|
|
352
|
+
payload = line[5:].strip()
|
|
353
|
+
if payload:
|
|
354
|
+
yield json.loads(payload)
|
|
355
|
+
finally:
|
|
356
|
+
close = getattr(resp, "close", None)
|
|
357
|
+
if callable(close):
|
|
358
|
+
close()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from curl_cffi import requests
|
|
8
|
+
from curl_cffi.requests.exceptions import RequestException
|
|
9
|
+
|
|
10
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
11
|
+
DEFAULT_IMPERSONATION = "chrome"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HttpClientError(RuntimeError):
|
|
16
|
+
message: str
|
|
17
|
+
status_code: int | None = None
|
|
18
|
+
body_text: str | None = None
|
|
19
|
+
reason: str | None = None
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return self.message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StreamResponseAdapter:
|
|
26
|
+
def __init__(self, response: requests.Response) -> None:
|
|
27
|
+
self._response = response
|
|
28
|
+
self._lines = response.iter_lines()
|
|
29
|
+
|
|
30
|
+
def readline(self) -> bytes:
|
|
31
|
+
try:
|
|
32
|
+
line = next(self._lines)
|
|
33
|
+
except StopIteration:
|
|
34
|
+
self.close()
|
|
35
|
+
return b""
|
|
36
|
+
if isinstance(line, str):
|
|
37
|
+
return line.encode("utf-8") + b"\n"
|
|
38
|
+
return line + b"\n"
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
self._response.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def send_request(
|
|
45
|
+
method: str,
|
|
46
|
+
url: str,
|
|
47
|
+
*,
|
|
48
|
+
headers: dict[str, str] | None = None,
|
|
49
|
+
body: bytes | None = None,
|
|
50
|
+
stream: bool = False,
|
|
51
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
52
|
+
) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
response = requests.request(
|
|
55
|
+
method=method,
|
|
56
|
+
url=url,
|
|
57
|
+
headers=headers,
|
|
58
|
+
data=body,
|
|
59
|
+
stream=stream,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
impersonate=DEFAULT_IMPERSONATION,
|
|
62
|
+
)
|
|
63
|
+
except RequestException as exc:
|
|
64
|
+
raise HttpClientError(
|
|
65
|
+
message=str(exc),
|
|
66
|
+
reason=str(exc),
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
if response.status_code >= 400:
|
|
70
|
+
body_text = response.text
|
|
71
|
+
raise HttpClientError(
|
|
72
|
+
message=body_text or f"HTTP {response.status_code}",
|
|
73
|
+
status_code=response.status_code,
|
|
74
|
+
body_text=body_text,
|
|
75
|
+
reason=body_text or response.reason,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if stream:
|
|
79
|
+
return StreamResponseAdapter(response)
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def request_json(
|
|
84
|
+
method: str,
|
|
85
|
+
url: str,
|
|
86
|
+
*,
|
|
87
|
+
headers: dict[str, str] | None = None,
|
|
88
|
+
body: bytes | None = None,
|
|
89
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
response = send_request(
|
|
92
|
+
method,
|
|
93
|
+
url,
|
|
94
|
+
headers=headers,
|
|
95
|
+
body=body,
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
)
|
|
98
|
+
raw = response.text
|
|
99
|
+
return json.loads(raw) if raw else {}
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import ssl
|
|
5
4
|
import time
|
|
6
5
|
from contextlib import contextmanager
|
|
7
6
|
from dataclasses import replace
|
|
8
7
|
from datetime import datetime, timezone
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
from typing import Iterator
|
|
11
|
-
from urllib import request
|
|
12
|
-
from urllib.error import HTTPError, URLError
|
|
13
10
|
|
|
14
11
|
from .config import AuthState, ConfigStore, env_auth_state
|
|
12
|
+
from .http_client import HttpClientError, request_json
|
|
15
13
|
|
|
16
14
|
try:
|
|
17
15
|
import fcntl
|
|
@@ -41,9 +39,6 @@ class SessionManager:
|
|
|
41
39
|
"run `powerbase auth wait --login-id ... --json`."
|
|
42
40
|
)
|
|
43
41
|
|
|
44
|
-
def _urlopen(self, req: request.Request):
|
|
45
|
-
return request.urlopen(req)
|
|
46
|
-
|
|
47
42
|
def get_auth_state(self) -> AuthState | None:
|
|
48
43
|
return env_auth_state() or self.store.load_auth()
|
|
49
44
|
|
|
@@ -79,25 +74,19 @@ class SessionManager:
|
|
|
79
74
|
raise SessionError("base_url and anon_key are required to refresh the session.")
|
|
80
75
|
|
|
81
76
|
payload = json.dumps({"refresh_token": auth.session.refresh_token}).encode("utf-8")
|
|
82
|
-
req = request.Request(
|
|
83
|
-
f"{self.base_url.rstrip('/')}/auth/v1/token?grant_type=refresh_token",
|
|
84
|
-
data=payload,
|
|
85
|
-
headers={
|
|
86
|
-
"apikey": self.anon_key,
|
|
87
|
-
"Content-Type": "application/json",
|
|
88
|
-
},
|
|
89
|
-
method="POST",
|
|
90
|
-
)
|
|
91
77
|
try:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
78
|
+
data = request_json(
|
|
79
|
+
"POST",
|
|
80
|
+
f"{self.base_url.rstrip('/')}/auth/v1/token?grant_type=refresh_token",
|
|
81
|
+
body=payload,
|
|
82
|
+
headers={
|
|
83
|
+
"apikey": self.anon_key,
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
except HttpClientError as exc: # pragma: no cover - exercised via tests with patched request_json
|
|
88
|
+
detail = exc.body_text or exc.reason or str(exc)
|
|
89
|
+
raise SessionError(f"Failed to refresh session: {detail}") from exc
|
|
101
90
|
|
|
102
91
|
new_auth = AuthState(
|
|
103
92
|
source=auth.source,
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import ssl
|
|
5
4
|
from dataclasses import dataclass
|
|
6
5
|
from typing import Any
|
|
7
|
-
from urllib import request
|
|
8
|
-
from urllib.error import HTTPError, URLError
|
|
9
6
|
|
|
10
7
|
from .config import AppConfig
|
|
8
|
+
from .http_client import HttpClientError, send_request
|
|
11
9
|
from .session import SessionError, SessionManager
|
|
12
10
|
|
|
13
11
|
|
|
@@ -34,9 +32,6 @@ class PowerbaseTransport:
|
|
|
34
32
|
"run `powerbase auth wait --login-id ... --json`."
|
|
35
33
|
)
|
|
36
34
|
|
|
37
|
-
def _urlopen(self, req: request.Request):
|
|
38
|
-
return request.urlopen(req)
|
|
39
|
-
|
|
40
35
|
def _build_headers(
|
|
41
36
|
self,
|
|
42
37
|
*,
|
|
@@ -56,24 +51,43 @@ class PowerbaseTransport:
|
|
|
56
51
|
request_headers.update(headers)
|
|
57
52
|
return request_headers
|
|
58
53
|
|
|
59
|
-
def _send(
|
|
60
|
-
|
|
54
|
+
def _send(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
method: str,
|
|
58
|
+
url: str,
|
|
59
|
+
payload: bytes | None,
|
|
60
|
+
headers: dict[str, str],
|
|
61
|
+
stream: bool,
|
|
62
|
+
) -> Any:
|
|
63
|
+
resp = send_request(
|
|
64
|
+
method=method,
|
|
65
|
+
url=url,
|
|
66
|
+
body=payload,
|
|
67
|
+
headers=headers,
|
|
68
|
+
stream=stream,
|
|
69
|
+
)
|
|
61
70
|
if stream:
|
|
62
71
|
return resp
|
|
63
|
-
raw = resp.
|
|
72
|
+
raw = resp.text
|
|
64
73
|
return json.loads(raw) if raw else {}
|
|
65
74
|
|
|
66
|
-
def _parse_http_error(self, exc:
|
|
67
|
-
body_text = exc.
|
|
75
|
+
def _parse_http_error(self, exc: HttpClientError) -> ApiError:
|
|
76
|
+
body_text = exc.body_text or exc.reason or str(exc)
|
|
68
77
|
try:
|
|
69
78
|
data = json.loads(body_text)
|
|
70
79
|
except json.JSONDecodeError:
|
|
71
80
|
data = {"error": body_text}
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
error_text = str(data.get("error") or "").strip()
|
|
82
|
+
detail_text = str(data.get("message") or "").strip()
|
|
83
|
+
if error_text and detail_text and detail_text != error_text:
|
|
84
|
+
message = f"{error_text}: {detail_text}"
|
|
85
|
+
else:
|
|
86
|
+
message = error_text or detail_text or body_text or exc.reason
|
|
87
|
+
if exc.status_code == 401:
|
|
74
88
|
base_message = str(message).strip() or "Authentication failed."
|
|
75
89
|
message = f"{base_message} {self._login_guidance()}"
|
|
76
|
-
return ApiError(str(message), exc.
|
|
90
|
+
return ApiError(str(message), exc.status_code)
|
|
77
91
|
|
|
78
92
|
def invoke(
|
|
79
93
|
self,
|
|
@@ -104,12 +118,18 @@ class PowerbaseTransport:
|
|
|
104
118
|
headers=headers,
|
|
105
119
|
instance_id=instance_id,
|
|
106
120
|
)
|
|
107
|
-
|
|
121
|
+
request_method = method.upper()
|
|
108
122
|
|
|
109
123
|
try:
|
|
110
|
-
return self._send(
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
return self._send(
|
|
125
|
+
method=request_method,
|
|
126
|
+
url=url,
|
|
127
|
+
payload=payload,
|
|
128
|
+
headers=request_headers,
|
|
129
|
+
stream=stream,
|
|
130
|
+
)
|
|
131
|
+
except HttpClientError as exc:
|
|
132
|
+
if exc.status_code == 401 and auth and auth.session.refresh_token:
|
|
113
133
|
try:
|
|
114
134
|
refreshed_auth = self.session_manager.refresh(auth)
|
|
115
135
|
except SessionError as refresh_error:
|
|
@@ -119,14 +139,15 @@ class PowerbaseTransport:
|
|
|
119
139
|
headers=headers,
|
|
120
140
|
instance_id=instance_id,
|
|
121
141
|
)
|
|
122
|
-
retry_req = request.Request(url, data=payload, headers=retry_headers, method=method.upper())
|
|
123
142
|
try:
|
|
124
|
-
return self._send(
|
|
125
|
-
|
|
143
|
+
return self._send(
|
|
144
|
+
method=request_method,
|
|
145
|
+
url=url,
|
|
146
|
+
payload=payload,
|
|
147
|
+
headers=retry_headers,
|
|
148
|
+
stream=stream,
|
|
149
|
+
)
|
|
150
|
+
except HttpClientError as retry_exc:
|
|
126
151
|
raise self._parse_http_error(retry_exc) from retry_exc
|
|
127
152
|
raise self._parse_http_error(exc) from exc
|
|
128
|
-
except URLError as exc:
|
|
129
|
-
raise ApiError(f"Request failed: {exc.reason}") from exc
|
|
130
|
-
except ssl.SSLError as exc:
|
|
131
|
-
raise ApiError(f"Request failed: {exc}") from exc
|
|
132
153
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: powerbase-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: CLI for operating Powerbase console workflows
|
|
5
5
|
Author: Powerbase
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: curl_cffi
|
|
8
9
|
|
|
9
10
|
# powerbase-cli
|
|
10
11
|
|
|
@@ -41,12 +42,10 @@ Database model:
|
|
|
41
42
|
Install from PyPI:
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
|
-
|
|
45
|
+
python3 -m pip install powerbase-cli
|
|
45
46
|
powerbase --help
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
Version `0.2.x` is the production-pinned release line and always targets `https://console.appbuild.chat`.
|
|
49
|
-
|
|
50
49
|
From the repository root during development:
|
|
51
50
|
|
|
52
51
|
```bash
|
|
@@ -5,12 +5,14 @@ src/powerbase_cli/__main__.py
|
|
|
5
5
|
src/powerbase_cli/api.py
|
|
6
6
|
src/powerbase_cli/cli.py
|
|
7
7
|
src/powerbase_cli/config.py
|
|
8
|
+
src/powerbase_cli/http_client.py
|
|
8
9
|
src/powerbase_cli/session.py
|
|
9
10
|
src/powerbase_cli/transport.py
|
|
10
11
|
src/powerbase_cli.egg-info/PKG-INFO
|
|
11
12
|
src/powerbase_cli.egg-info/SOURCES.txt
|
|
12
13
|
src/powerbase_cli.egg-info/dependency_links.txt
|
|
13
14
|
src/powerbase_cli.egg-info/entry_points.txt
|
|
15
|
+
src/powerbase_cli.egg-info/requires.txt
|
|
14
16
|
src/powerbase_cli.egg-info/top_level.txt
|
|
15
17
|
src/powerbase_cli/commands/__init__.py
|
|
16
18
|
src/powerbase_cli/commands/agent.py
|
|
@@ -30,5 +32,6 @@ tests/test_api.py
|
|
|
30
32
|
tests/test_cli_commands.py
|
|
31
33
|
tests/test_cli_help.py
|
|
32
34
|
tests/test_config.py
|
|
35
|
+
tests/test_http_client.py
|
|
33
36
|
tests/test_session.py
|
|
34
37
|
tests/test_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
curl_cffi
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
8
|
+
|
|
9
|
+
from powerbase_cli.http_client import StreamResponseAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeStreamResponse:
|
|
13
|
+
def __init__(self, lines: list[bytes | str]) -> None:
|
|
14
|
+
self.lines = lines
|
|
15
|
+
self.closed = False
|
|
16
|
+
|
|
17
|
+
def iter_lines(self):
|
|
18
|
+
yield from self.lines
|
|
19
|
+
|
|
20
|
+
def close(self) -> None:
|
|
21
|
+
self.closed = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HttpClientTests(unittest.TestCase):
|
|
25
|
+
def test_stream_response_adapter_reads_lines_and_closes(self) -> None:
|
|
26
|
+
response = FakeStreamResponse([b"data: one", b"", "data: two"])
|
|
27
|
+
adapter = StreamResponseAdapter(response)
|
|
28
|
+
|
|
29
|
+
self.assertEqual(adapter.readline(), b"data: one\n")
|
|
30
|
+
self.assertEqual(adapter.readline(), b"\n")
|
|
31
|
+
self.assertEqual(adapter.readline(), b"data: two\n")
|
|
32
|
+
self.assertEqual(adapter.readline(), b"")
|
|
33
|
+
self.assertTrue(response.closed)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import sys
|
|
6
5
|
import tempfile
|
|
@@ -11,23 +10,10 @@ from unittest import mock
|
|
|
11
10
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
12
11
|
|
|
13
12
|
from powerbase_cli.config import AuthSession, AuthState, ConfigStore
|
|
13
|
+
from powerbase_cli.http_client import HttpClientError
|
|
14
14
|
from powerbase_cli.session import SessionError, SessionManager
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class FakeResponse:
|
|
18
|
-
def __init__(self, payload: dict[str, object]) -> None:
|
|
19
|
-
self.payload = payload
|
|
20
|
-
|
|
21
|
-
def __enter__(self) -> "FakeResponse":
|
|
22
|
-
return self
|
|
23
|
-
|
|
24
|
-
def __exit__(self, exc_type, exc, tb) -> None:
|
|
25
|
-
return None
|
|
26
|
-
|
|
27
|
-
def read(self) -> bytes:
|
|
28
|
-
return json.dumps(self.payload).encode("utf-8")
|
|
29
|
-
|
|
30
|
-
|
|
31
17
|
class SessionManagerTests(unittest.TestCase):
|
|
32
18
|
def test_refresh_without_auth_instructs_user_to_log_in_again(self) -> None:
|
|
33
19
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -58,13 +44,13 @@ class SessionManagerTests(unittest.TestCase):
|
|
|
58
44
|
)
|
|
59
45
|
manager = SessionManager(store, "https://console.example.com", "anon")
|
|
60
46
|
|
|
61
|
-
with mock.patch("powerbase_cli.session.
|
|
47
|
+
with mock.patch("powerbase_cli.session.request_json", return_value={
|
|
62
48
|
"access_token": "new-access",
|
|
63
49
|
"refresh_token": "new-refresh",
|
|
64
50
|
"expires_at": 9999999999,
|
|
65
51
|
"token_type": "bearer",
|
|
66
52
|
"user": {"id": "user-1"},
|
|
67
|
-
})
|
|
53
|
+
}):
|
|
68
54
|
refreshed = manager.refresh()
|
|
69
55
|
|
|
70
56
|
self.assertEqual(refreshed.session.access_token, "new-access")
|
|
@@ -81,12 +67,12 @@ class SessionManagerTests(unittest.TestCase):
|
|
|
81
67
|
os.environ["POWERBASE_ACCESS_TOKEN"] = "env-access"
|
|
82
68
|
os.environ["POWERBASE_REFRESH_TOKEN"] = "env-refresh"
|
|
83
69
|
os.environ["POWERBASE_EXPIRES_AT"] = "1"
|
|
84
|
-
with mock.patch("powerbase_cli.session.
|
|
70
|
+
with mock.patch("powerbase_cli.session.request_json", return_value={
|
|
85
71
|
"access_token": "env-new-access",
|
|
86
72
|
"refresh_token": "env-new-refresh",
|
|
87
73
|
"expires_at": 9999999999,
|
|
88
74
|
"token_type": "bearer",
|
|
89
|
-
})
|
|
75
|
+
}):
|
|
90
76
|
refreshed = manager.ensure_valid()
|
|
91
77
|
assert refreshed is not None
|
|
92
78
|
self.assertEqual(refreshed.session.access_token, "env-new-access")
|
|
@@ -95,3 +81,29 @@ class SessionManagerTests(unittest.TestCase):
|
|
|
95
81
|
os.environ.clear()
|
|
96
82
|
os.environ.update(old_env)
|
|
97
83
|
|
|
84
|
+
def test_refresh_wraps_http_client_errors(self) -> None:
|
|
85
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
86
|
+
store = ConfigStore(Path(temp_dir))
|
|
87
|
+
store.save_auth(
|
|
88
|
+
AuthState(
|
|
89
|
+
source="login",
|
|
90
|
+
base_url="https://console.example.com",
|
|
91
|
+
anon_key="anon",
|
|
92
|
+
session=AuthSession(
|
|
93
|
+
access_token="old-access",
|
|
94
|
+
refresh_token="old-refresh",
|
|
95
|
+
expires_at=1,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
manager = SessionManager(store, "https://console.example.com", "anon")
|
|
100
|
+
|
|
101
|
+
with mock.patch(
|
|
102
|
+
"powerbase_cli.session.request_json",
|
|
103
|
+
side_effect=HttpClientError(message="tls failed", reason="tls failed"),
|
|
104
|
+
):
|
|
105
|
+
with self.assertRaises(SessionError) as ctx:
|
|
106
|
+
manager.refresh()
|
|
107
|
+
|
|
108
|
+
self.assertIn("tls failed", str(ctx.exception))
|
|
109
|
+
|
|
@@ -1,36 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import io
|
|
4
|
-
import json
|
|
5
3
|
import sys
|
|
6
4
|
import tempfile
|
|
7
5
|
import unittest
|
|
8
6
|
from pathlib import Path
|
|
9
7
|
from unittest import mock
|
|
10
|
-
from urllib.error import HTTPError, URLError
|
|
11
8
|
|
|
12
9
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
13
10
|
|
|
14
11
|
from powerbase_cli.config import AppConfig, AuthSession, AuthState, ConfigStore
|
|
12
|
+
from powerbase_cli.http_client import HttpClientError
|
|
15
13
|
from powerbase_cli.session import SessionManager
|
|
16
14
|
from powerbase_cli.transport import ApiError, PowerbaseTransport
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
class FakeResponse:
|
|
20
|
-
def __init__(self,
|
|
21
|
-
self.
|
|
18
|
+
def __init__(self, text: str) -> None:
|
|
19
|
+
self.text = text
|
|
22
20
|
|
|
23
|
-
def read(self) -> bytes:
|
|
24
|
-
return json.dumps(self.payload).encode("utf-8")
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
hdrs=None,
|
|
33
|
-
fp=io.BytesIO(json.dumps(payload).encode("utf-8")),
|
|
22
|
+
def build_http_error(status: int, payload_text: str) -> HttpClientError:
|
|
23
|
+
return HttpClientError(
|
|
24
|
+
message=payload_text,
|
|
25
|
+
status_code=status,
|
|
26
|
+
body_text=payload_text,
|
|
27
|
+
reason=payload_text,
|
|
34
28
|
)
|
|
35
29
|
|
|
36
30
|
|
|
@@ -41,7 +35,7 @@ class PowerbaseTransportTests(unittest.TestCase):
|
|
|
41
35
|
manager = SessionManager(store, "https://console.example.com", "anon")
|
|
42
36
|
transport = PowerbaseTransport(AppConfig(base_url="https://console.example.com", anon_key="anon"), manager)
|
|
43
37
|
|
|
44
|
-
with mock.patch("powerbase_cli.transport.
|
|
38
|
+
with mock.patch("powerbase_cli.transport.send_request") as request_mock:
|
|
45
39
|
with self.assertRaises(ApiError) as ctx:
|
|
46
40
|
transport.invoke("instances", method="GET")
|
|
47
41
|
|
|
@@ -49,7 +43,7 @@ class PowerbaseTransportTests(unittest.TestCase):
|
|
|
49
43
|
self.assertIn("No authentication session available", str(ctx.exception))
|
|
50
44
|
self.assertIn("powerbase auth login --json", str(ctx.exception))
|
|
51
45
|
self.assertIn("powerbase auth wait --login-id ... --json", str(ctx.exception))
|
|
52
|
-
self.assertEqual(
|
|
46
|
+
self.assertEqual(request_mock.call_count, 0)
|
|
53
47
|
|
|
54
48
|
def test_invoke_refreshes_and_retries_once_on_401(self) -> None:
|
|
55
49
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -79,19 +73,19 @@ class PowerbaseTransportTests(unittest.TestCase):
|
|
|
79
73
|
expires_at=9999999999,
|
|
80
74
|
),
|
|
81
75
|
)) as refresh_mock:
|
|
82
|
-
with mock.patch("powerbase_cli.transport.
|
|
83
|
-
|
|
84
|
-
build_http_error(401, {"error":
|
|
85
|
-
FakeResponse({"success":
|
|
76
|
+
with mock.patch("powerbase_cli.transport.send_request") as request_mock:
|
|
77
|
+
request_mock.side_effect = [
|
|
78
|
+
build_http_error(401, '{"error":"expired"}'),
|
|
79
|
+
FakeResponse('{"success": true, "data": {"ok": true}}'),
|
|
86
80
|
]
|
|
87
81
|
|
|
88
82
|
result = transport.invoke("instances", method="GET")
|
|
89
83
|
|
|
90
84
|
self.assertEqual(result["data"]["ok"], True)
|
|
91
85
|
self.assertEqual(refresh_mock.call_count, 1)
|
|
92
|
-
self.assertEqual(
|
|
93
|
-
first_auth =
|
|
94
|
-
second_auth =
|
|
86
|
+
self.assertEqual(request_mock.call_count, 2)
|
|
87
|
+
first_auth = request_mock.call_args_list[0].kwargs["headers"]["Authorization"]
|
|
88
|
+
second_auth = request_mock.call_args_list[1].kwargs["headers"]["Authorization"]
|
|
95
89
|
self.assertEqual(first_auth, "Bearer stale-access")
|
|
96
90
|
self.assertEqual(second_auth, "Bearer fresh-access")
|
|
97
91
|
|
|
@@ -111,9 +105,9 @@ class PowerbaseTransportTests(unittest.TestCase):
|
|
|
111
105
|
|
|
112
106
|
with mock.patch.object(manager, "refresh") as refresh_mock:
|
|
113
107
|
with mock.patch(
|
|
114
|
-
"powerbase_cli.transport.
|
|
115
|
-
side_effect=build_http_error(401, {"error":
|
|
116
|
-
) as
|
|
108
|
+
"powerbase_cli.transport.send_request",
|
|
109
|
+
side_effect=build_http_error(401, '{"error":"expired"}'),
|
|
110
|
+
) as request_mock:
|
|
117
111
|
with self.assertRaises(ApiError) as ctx:
|
|
118
112
|
transport.invoke("instances", method="GET")
|
|
119
113
|
|
|
@@ -121,7 +115,7 @@ class PowerbaseTransportTests(unittest.TestCase):
|
|
|
121
115
|
self.assertIn("powerbase auth login --json", str(ctx.exception))
|
|
122
116
|
self.assertIn("powerbase auth wait --login-id ... --json", str(ctx.exception))
|
|
123
117
|
self.assertEqual(refresh_mock.call_count, 0)
|
|
124
|
-
self.assertEqual(
|
|
118
|
+
self.assertEqual(request_mock.call_count, 1)
|
|
125
119
|
|
|
126
120
|
def test_invoke_wraps_url_errors(self) -> None:
|
|
127
121
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -129,8 +123,30 @@ class PowerbaseTransportTests(unittest.TestCase):
|
|
|
129
123
|
manager = SessionManager(store, "https://console.example.com", "anon")
|
|
130
124
|
transport = PowerbaseTransport(AppConfig(base_url="https://console.example.com", anon_key="anon"), manager)
|
|
131
125
|
|
|
132
|
-
with mock.patch(
|
|
126
|
+
with mock.patch(
|
|
127
|
+
"powerbase_cli.transport.send_request",
|
|
128
|
+
side_effect=HttpClientError(message="tls failed", reason="tls failed"),
|
|
129
|
+
):
|
|
133
130
|
with self.assertRaises(ApiError) as ctx:
|
|
134
131
|
transport.invoke("instances", method="GET", requires_auth=False)
|
|
135
132
|
|
|
136
133
|
self.assertIn("tls failed", str(ctx.exception))
|
|
134
|
+
|
|
135
|
+
def test_invoke_preserves_server_error_detail(self) -> None:
|
|
136
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
137
|
+
store = ConfigStore(Path(temp_dir))
|
|
138
|
+
manager = SessionManager(store, "https://console.example.com", "anon")
|
|
139
|
+
transport = PowerbaseTransport(AppConfig(base_url="https://console.example.com", anon_key="anon"), manager)
|
|
140
|
+
|
|
141
|
+
with mock.patch(
|
|
142
|
+
"powerbase_cli.transport.send_request",
|
|
143
|
+
side_effect=build_http_error(
|
|
144
|
+
500,
|
|
145
|
+
'{"error":"Internal Server Error","message":"worker boot error: failed to bootstrap runtime"}',
|
|
146
|
+
),
|
|
147
|
+
):
|
|
148
|
+
with self.assertRaises(ApiError) as ctx:
|
|
149
|
+
transport.invoke("cli-auth/start", method="POST", body={}, requires_auth=False)
|
|
150
|
+
|
|
151
|
+
self.assertIn("Internal Server Error", str(ctx.exception))
|
|
152
|
+
self.assertIn("worker boot error", str(ctx.exception))
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|