powerbase-cli 0.1.5__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.1.5 → powerbase_cli-0.2.2}/PKG-INFO +4 -3
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/README.md +2 -2
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/pyproject.toml +2 -5
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/__init__.py +1 -1
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/api.py +32 -22
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/parser.py +0 -16
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/shared.py +3 -15
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/config.py +4 -54
- powerbase_cli-0.2.2/src/powerbase_cli/http_client.py +99 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/session.py +14 -44
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/transport.py +47 -40
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/PKG-INFO +4 -3
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/SOURCES.txt +3 -1
- powerbase_cli-0.2.2/src/powerbase_cli.egg-info/requires.txt +1 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_cli_commands.py +5 -26
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_cli_help.py +4 -4
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_config.py +1 -15
- powerbase_cli-0.2.2/tests/test_http_client.py +33 -0
- powerbase_cli-0.2.2/tests/test_session.py +109 -0
- powerbase_cli-0.2.2/tests/test_transport.py +152 -0
- powerbase_cli-0.1.5/src/powerbase_cli/certs/powerbase-test-ca.pem +0 -21
- powerbase_cli-0.1.5/tests/test_session.py +0 -194
- powerbase_cli-0.1.5/tests/test_transport.py +0 -201
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/setup.cfg +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/__main__.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/cli.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/__init__.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/agent.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/auth.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/branch.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/config_cmd.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/context.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/database.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/instance.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/org.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/publish.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sandbox.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sql.py +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/dependency_links.txt +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/entry_points.txt +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/top_level.txt +0 -0
- {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_api.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: powerbase-cli
|
|
3
|
-
Version: 0.
|
|
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,7 +42,7 @@ 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
|
|
|
@@ -121,7 +122,7 @@ powerbase auth token-set \
|
|
|
121
122
|
--expires-at 1760000000
|
|
122
123
|
```
|
|
123
124
|
|
|
124
|
-
|
|
125
|
+
The CLI is pinned to the production Powerbase console deployment and no longer exposes endpoint override flags.
|
|
125
126
|
|
|
126
127
|
### Check Auth State
|
|
127
128
|
|
|
@@ -33,7 +33,7 @@ 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
|
|
|
@@ -113,7 +113,7 @@ powerbase auth token-set \
|
|
|
113
113
|
--expires-at 1760000000
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
The CLI is pinned to the production Powerbase console deployment and no longer exposes endpoint override flags.
|
|
117
117
|
|
|
118
118
|
### Check Auth State
|
|
119
119
|
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "powerbase-cli"
|
|
7
|
-
version = "0.
|
|
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"
|
|
@@ -17,8 +17,5 @@ powerbase = "powerbase_cli.cli:main"
|
|
|
17
17
|
[tool.setuptools]
|
|
18
18
|
package-dir = { "" = "src" }
|
|
19
19
|
|
|
20
|
-
[tool.setuptools.package-data]
|
|
21
|
-
powerbase_cli = ["certs/*.pem"]
|
|
22
|
-
|
|
23
20
|
[tool.setuptools.packages.find]
|
|
24
21
|
where = ["src"]
|
|
@@ -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()
|
|
@@ -19,10 +19,6 @@ from .sql import register_sql_commands
|
|
|
19
19
|
|
|
20
20
|
GLOBAL_OPTION_ARITY = {
|
|
21
21
|
"--config-dir": 1,
|
|
22
|
-
"--base-url": 1,
|
|
23
|
-
"--anon-key": 1,
|
|
24
|
-
"--ca-cert": 1,
|
|
25
|
-
"--insecure": 0,
|
|
26
22
|
"--json": 0,
|
|
27
23
|
}
|
|
28
24
|
|
|
@@ -74,18 +70,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
74
70
|
),
|
|
75
71
|
)
|
|
76
72
|
parser.add_argument("--config-dir", help="Override the config directory. Defaults to ~/.config/powerbase.")
|
|
77
|
-
parser.add_argument("--base-url", help="Console base URL. Overrides the built-in default console endpoint.")
|
|
78
|
-
parser.add_argument("--anon-key", help="Supabase anon key used for functions and auth requests. Overrides the built-in default.")
|
|
79
|
-
parser.add_argument(
|
|
80
|
-
"--ca-cert",
|
|
81
|
-
dest="ca_cert_file",
|
|
82
|
-
help="Path to a CA certificate PEM file to trust for HTTPS requests.",
|
|
83
|
-
)
|
|
84
|
-
parser.add_argument(
|
|
85
|
-
"--insecure",
|
|
86
|
-
action="store_true",
|
|
87
|
-
help="Disable TLS certificate verification for HTTPS requests. Use only for testing with self-signed certs.",
|
|
88
|
-
)
|
|
89
73
|
parser.add_argument("--json", action="store_true", help="Print JSON output.")
|
|
90
74
|
|
|
91
75
|
subparsers = parser.add_subparsers(dest="command")
|
|
@@ -8,7 +8,6 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from ..api import PowerbaseApi
|
|
10
10
|
from ..config import (
|
|
11
|
-
BUNDLED_CA_CERT_SENTINEL,
|
|
12
11
|
DEFAULT_ANON_KEY,
|
|
13
12
|
DEFAULT_BASE_URL,
|
|
14
13
|
AppConfig,
|
|
@@ -16,8 +15,6 @@ from ..config import (
|
|
|
16
15
|
AuthState,
|
|
17
16
|
ConfigStore,
|
|
18
17
|
ContextState,
|
|
19
|
-
load_bundled_ca_cert,
|
|
20
|
-
merge_config_with_env,
|
|
21
18
|
merge_context_with_env,
|
|
22
19
|
)
|
|
23
20
|
from ..session import SessionManager
|
|
@@ -33,18 +30,11 @@ def build_store(args: argparse.Namespace) -> ConfigStore:
|
|
|
33
30
|
|
|
34
31
|
|
|
35
32
|
def resolve_config(args: argparse.Namespace, store: ConfigStore) -> AppConfig:
|
|
36
|
-
config =
|
|
37
|
-
saved_auth = store.load_auth()
|
|
38
|
-
explicit_ca_cert_file = getattr(args, "ca_cert_file", None) or config.ca_cert_file
|
|
33
|
+
config = store.load_config()
|
|
39
34
|
return AppConfig(
|
|
40
|
-
base_url=
|
|
41
|
-
anon_key=
|
|
35
|
+
base_url=DEFAULT_BASE_URL,
|
|
36
|
+
anon_key=DEFAULT_ANON_KEY,
|
|
42
37
|
output="json" if args.json else config.output,
|
|
43
|
-
tls_insecure=bool(getattr(args, "insecure", False) or config.tls_insecure),
|
|
44
|
-
# Keep explicit CA settings first. The bundled test CA is only the final fallback for the
|
|
45
|
-
# current self-signed test environment and should be removed once the deployment uses a
|
|
46
|
-
# publicly trusted certificate.
|
|
47
|
-
ca_cert_file=explicit_ca_cert_file or (BUNDLED_CA_CERT_SENTINEL if load_bundled_ca_cert() else None),
|
|
48
38
|
)
|
|
49
39
|
|
|
50
40
|
|
|
@@ -60,8 +50,6 @@ def build_api(args: argparse.Namespace) -> tuple[ConfigStore, AppConfig, Context
|
|
|
60
50
|
store,
|
|
61
51
|
config.base_url,
|
|
62
52
|
config.anon_key,
|
|
63
|
-
tls_insecure=config.tls_insecure,
|
|
64
|
-
ca_cert_file=config.ca_cert_file,
|
|
65
53
|
)
|
|
66
54
|
api = PowerbaseApi(PowerbaseTransport(config, session_manager))
|
|
67
55
|
return store, config, context, session_manager, api
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import importlib.resources
|
|
4
3
|
import json
|
|
5
4
|
import os
|
|
6
5
|
from dataclasses import asdict, dataclass
|
|
@@ -12,13 +11,8 @@ try:
|
|
|
12
11
|
except ModuleNotFoundError: # pragma: no cover
|
|
13
12
|
tomllib = None # type: ignore[assignment]
|
|
14
13
|
|
|
15
|
-
DEFAULT_BASE_URL = "https://console.
|
|
14
|
+
DEFAULT_BASE_URL = "https://console.appbuild.chat"
|
|
16
15
|
DEFAULT_ANON_KEY = "reallyreallyreallyreallyverysafe"
|
|
17
|
-
# This bundled CA is only for the self-signed test deployment so `pip install powerbase-cli`
|
|
18
|
-
# can work out of the box. Remove it after the deployed console switches to a publicly trusted
|
|
19
|
-
# TLS certificate, then delete the bundled PEM file and its package-data entry as well.
|
|
20
|
-
BUNDLED_CA_CERT_SENTINEL = "<bundled test CA>"
|
|
21
|
-
BUNDLED_CA_CERT_RESOURCE = "certs/powerbase-test-ca.pem"
|
|
22
16
|
|
|
23
17
|
|
|
24
18
|
def default_config_dir() -> Path:
|
|
@@ -33,8 +27,6 @@ class AppConfig:
|
|
|
33
27
|
base_url: str | None = DEFAULT_BASE_URL
|
|
34
28
|
anon_key: str | None = DEFAULT_ANON_KEY
|
|
35
29
|
output: str = "text"
|
|
36
|
-
tls_insecure: bool = False
|
|
37
|
-
ca_cert_file: str | None = None
|
|
38
30
|
|
|
39
31
|
|
|
40
32
|
@dataclass
|
|
@@ -69,26 +61,6 @@ def _as_path(path: str | Path | None) -> Path:
|
|
|
69
61
|
return Path(path).expanduser()
|
|
70
62
|
|
|
71
63
|
|
|
72
|
-
def env_flag(name: str) -> bool:
|
|
73
|
-
value = os.environ.get(name)
|
|
74
|
-
if value is None:
|
|
75
|
-
return False
|
|
76
|
-
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def load_bundled_ca_cert() -> str | None:
|
|
80
|
-
# The PEM contents are loaded at runtime so test builds can trust the packaged self-signed CA
|
|
81
|
-
# without asking end users to pass `--ca-cert`. Once the server uses a trusted certificate,
|
|
82
|
-
# this helper and its callers can be removed.
|
|
83
|
-
try:
|
|
84
|
-
resource = importlib.resources.files("powerbase_cli").joinpath(BUNDLED_CA_CERT_RESOURCE)
|
|
85
|
-
if not resource.is_file():
|
|
86
|
-
return None
|
|
87
|
-
return resource.read_text(encoding="utf-8")
|
|
88
|
-
except (FileNotFoundError, ModuleNotFoundError):
|
|
89
|
-
return None
|
|
90
|
-
|
|
91
|
-
|
|
92
64
|
class ConfigStore:
|
|
93
65
|
def __init__(self, base_dir: str | Path | None = None) -> None:
|
|
94
66
|
self.base_dir = _as_path(base_dir)
|
|
@@ -101,31 +73,19 @@ class ConfigStore:
|
|
|
101
73
|
|
|
102
74
|
def load_config(self) -> AppConfig:
|
|
103
75
|
if not self.config_path.exists():
|
|
104
|
-
return AppConfig(
|
|
76
|
+
return AppConfig()
|
|
105
77
|
if tomllib is None:
|
|
106
78
|
raise RuntimeError("tomllib is required to read config.toml")
|
|
107
79
|
data = tomllib.loads(self.config_path.read_text(encoding="utf-8"))
|
|
108
80
|
return AppConfig(
|
|
109
|
-
base_url=data.get("base_url"),
|
|
110
|
-
anon_key=data.get("anon_key"),
|
|
111
81
|
output=data.get("output", "text"),
|
|
112
|
-
tls_insecure=bool(data.get("tls_insecure", False)),
|
|
113
|
-
ca_cert_file=data.get("ca_cert_file"),
|
|
114
82
|
)
|
|
115
83
|
|
|
116
84
|
def save_config(self, config: AppConfig) -> None:
|
|
117
85
|
self.ensure_base_dir()
|
|
118
86
|
lines = []
|
|
119
|
-
if config.base_url and config.base_url != DEFAULT_BASE_URL:
|
|
120
|
-
lines.append(f'base_url = "{config.base_url}"')
|
|
121
|
-
if config.anon_key and config.anon_key != DEFAULT_ANON_KEY:
|
|
122
|
-
lines.append(f'anon_key = "{config.anon_key}"')
|
|
123
87
|
if config.output:
|
|
124
88
|
lines.append(f'output = "{config.output}"')
|
|
125
|
-
if config.tls_insecure:
|
|
126
|
-
lines.append("tls_insecure = true")
|
|
127
|
-
if config.ca_cert_file:
|
|
128
|
-
lines.append(f'ca_cert_file = "{config.ca_cert_file}"')
|
|
129
89
|
self.config_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
130
90
|
|
|
131
91
|
def load_auth(self) -> AuthState | None:
|
|
@@ -210,8 +170,8 @@ def env_auth_state() -> AuthState | None:
|
|
|
210
170
|
expires_at = int(expires_at_raw) if expires_at_raw and expires_at_raw.isdigit() else None
|
|
211
171
|
return AuthState(
|
|
212
172
|
source="env",
|
|
213
|
-
base_url=
|
|
214
|
-
anon_key=
|
|
173
|
+
base_url=DEFAULT_BASE_URL,
|
|
174
|
+
anon_key=DEFAULT_ANON_KEY,
|
|
215
175
|
session=AuthSession(
|
|
216
176
|
access_token=access_token,
|
|
217
177
|
refresh_token=refresh_token,
|
|
@@ -220,16 +180,6 @@ def env_auth_state() -> AuthState | None:
|
|
|
220
180
|
)
|
|
221
181
|
|
|
222
182
|
|
|
223
|
-
def merge_config_with_env(config: AppConfig) -> AppConfig:
|
|
224
|
-
return AppConfig(
|
|
225
|
-
base_url=os.environ.get("POWERBASE_BASE_URL") or config.base_url,
|
|
226
|
-
anon_key=os.environ.get("POWERBASE_ANON_KEY") or config.anon_key,
|
|
227
|
-
output=os.environ.get("POWERBASE_OUTPUT") or config.output,
|
|
228
|
-
tls_insecure=env_flag("POWERBASE_TLS_INSECURE") or config.tls_insecure,
|
|
229
|
-
ca_cert_file=os.environ.get("POWERBASE_CA_CERT_FILE") or config.ca_cert_file,
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
|
|
233
183
|
def merge_context_with_env(context: ContextState) -> ContextState:
|
|
234
184
|
return ContextState(
|
|
235
185
|
instance_id=os.environ.get("POWERBASE_INSTANCE_ID") or context.instance_id,
|
|
@@ -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
|
-
from .config import
|
|
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
|
|
@@ -29,15 +27,10 @@ class SessionManager:
|
|
|
29
27
|
store: ConfigStore,
|
|
30
28
|
base_url: str | None,
|
|
31
29
|
anon_key: str | None,
|
|
32
|
-
*,
|
|
33
|
-
tls_insecure: bool = False,
|
|
34
|
-
ca_cert_file: str | None = None,
|
|
35
30
|
) -> None:
|
|
36
31
|
self.store = store
|
|
37
32
|
self.base_url = base_url
|
|
38
33
|
self.anon_key = anon_key
|
|
39
|
-
self.tls_insecure = tls_insecure
|
|
40
|
-
self.ca_cert_file = ca_cert_file
|
|
41
34
|
|
|
42
35
|
def _login_guidance(self) -> str:
|
|
43
36
|
return (
|
|
@@ -46,23 +39,6 @@ class SessionManager:
|
|
|
46
39
|
"run `powerbase auth wait --login-id ... --json`."
|
|
47
40
|
)
|
|
48
41
|
|
|
49
|
-
def _urlopen(self, req: request.Request):
|
|
50
|
-
if self.tls_insecure:
|
|
51
|
-
context = ssl.create_default_context()
|
|
52
|
-
context.check_hostname = False
|
|
53
|
-
context.verify_mode = ssl.CERT_NONE
|
|
54
|
-
return request.urlopen(req, context=context)
|
|
55
|
-
if self.ca_cert_file:
|
|
56
|
-
if self.ca_cert_file == BUNDLED_CA_CERT_SENTINEL:
|
|
57
|
-
bundled_ca_cert = load_bundled_ca_cert()
|
|
58
|
-
if not bundled_ca_cert:
|
|
59
|
-
raise SessionError("Bundled test CA certificate is unavailable.")
|
|
60
|
-
context = ssl.create_default_context(cadata=bundled_ca_cert)
|
|
61
|
-
else:
|
|
62
|
-
context = ssl.create_default_context(cafile=self.ca_cert_file)
|
|
63
|
-
return request.urlopen(req, context=context)
|
|
64
|
-
return request.urlopen(req)
|
|
65
|
-
|
|
66
42
|
def get_auth_state(self) -> AuthState | None:
|
|
67
43
|
return env_auth_state() or self.store.load_auth()
|
|
68
44
|
|
|
@@ -98,25 +74,19 @@ class SessionManager:
|
|
|
98
74
|
raise SessionError("base_url and anon_key are required to refresh the session.")
|
|
99
75
|
|
|
100
76
|
payload = json.dumps({"refresh_token": auth.session.refresh_token}).encode("utf-8")
|
|
101
|
-
req = request.Request(
|
|
102
|
-
f"{self.base_url.rstrip('/')}/auth/v1/token?grant_type=refresh_token",
|
|
103
|
-
data=payload,
|
|
104
|
-
headers={
|
|
105
|
-
"apikey": self.anon_key,
|
|
106
|
-
"Content-Type": "application/json",
|
|
107
|
-
},
|
|
108
|
-
method="POST",
|
|
109
|
-
)
|
|
110
77
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
120
90
|
|
|
121
91
|
new_auth = AuthState(
|
|
122
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
|
-
from .config import
|
|
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,23 +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
|
-
if self.config.tls_insecure:
|
|
39
|
-
context = ssl.create_default_context()
|
|
40
|
-
context.check_hostname = False
|
|
41
|
-
context.verify_mode = ssl.CERT_NONE
|
|
42
|
-
return request.urlopen(req, context=context)
|
|
43
|
-
if self.config.ca_cert_file:
|
|
44
|
-
if self.config.ca_cert_file == BUNDLED_CA_CERT_SENTINEL:
|
|
45
|
-
bundled_ca_cert = load_bundled_ca_cert()
|
|
46
|
-
if not bundled_ca_cert:
|
|
47
|
-
raise ApiError("Bundled test CA certificate is unavailable.")
|
|
48
|
-
context = ssl.create_default_context(cadata=bundled_ca_cert)
|
|
49
|
-
else:
|
|
50
|
-
context = ssl.create_default_context(cafile=self.config.ca_cert_file)
|
|
51
|
-
return request.urlopen(req, context=context)
|
|
52
|
-
return request.urlopen(req)
|
|
53
|
-
|
|
54
35
|
def _build_headers(
|
|
55
36
|
self,
|
|
56
37
|
*,
|
|
@@ -70,24 +51,43 @@ class PowerbaseTransport:
|
|
|
70
51
|
request_headers.update(headers)
|
|
71
52
|
return request_headers
|
|
72
53
|
|
|
73
|
-
def _send(
|
|
74
|
-
|
|
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
|
+
)
|
|
75
70
|
if stream:
|
|
76
71
|
return resp
|
|
77
|
-
raw = resp.
|
|
72
|
+
raw = resp.text
|
|
78
73
|
return json.loads(raw) if raw else {}
|
|
79
74
|
|
|
80
|
-
def _parse_http_error(self, exc:
|
|
81
|
-
body_text = exc.
|
|
75
|
+
def _parse_http_error(self, exc: HttpClientError) -> ApiError:
|
|
76
|
+
body_text = exc.body_text or exc.reason or str(exc)
|
|
82
77
|
try:
|
|
83
78
|
data = json.loads(body_text)
|
|
84
79
|
except json.JSONDecodeError:
|
|
85
80
|
data = {"error": body_text}
|
|
86
|
-
|
|
87
|
-
|
|
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:
|
|
88
88
|
base_message = str(message).strip() or "Authentication failed."
|
|
89
89
|
message = f"{base_message} {self._login_guidance()}"
|
|
90
|
-
return ApiError(str(message), exc.
|
|
90
|
+
return ApiError(str(message), exc.status_code)
|
|
91
91
|
|
|
92
92
|
def invoke(
|
|
93
93
|
self,
|
|
@@ -118,12 +118,18 @@ class PowerbaseTransport:
|
|
|
118
118
|
headers=headers,
|
|
119
119
|
instance_id=instance_id,
|
|
120
120
|
)
|
|
121
|
-
|
|
121
|
+
request_method = method.upper()
|
|
122
122
|
|
|
123
123
|
try:
|
|
124
|
-
return self._send(
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
127
133
|
try:
|
|
128
134
|
refreshed_auth = self.session_manager.refresh(auth)
|
|
129
135
|
except SessionError as refresh_error:
|
|
@@ -133,14 +139,15 @@ class PowerbaseTransport:
|
|
|
133
139
|
headers=headers,
|
|
134
140
|
instance_id=instance_id,
|
|
135
141
|
)
|
|
136
|
-
retry_req = request.Request(url, data=payload, headers=retry_headers, method=method.upper())
|
|
137
142
|
try:
|
|
138
|
-
return self._send(
|
|
139
|
-
|
|
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:
|
|
140
151
|
raise self._parse_http_error(retry_exc) from retry_exc
|
|
141
152
|
raise self._parse_http_error(exc) from exc
|
|
142
|
-
except URLError as exc:
|
|
143
|
-
raise ApiError(f"Request failed: {exc.reason}") from exc
|
|
144
|
-
except ssl.SSLError as exc:
|
|
145
|
-
raise ApiError(f"Request failed: {exc}") from exc
|
|
146
153
|
|