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.
Files changed (42) hide show
  1. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/PKG-INFO +4 -3
  2. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/README.md +2 -2
  3. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/pyproject.toml +2 -5
  4. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/__init__.py +1 -1
  5. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/api.py +32 -22
  6. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/parser.py +0 -16
  7. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/shared.py +3 -15
  8. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/config.py +4 -54
  9. powerbase_cli-0.2.2/src/powerbase_cli/http_client.py +99 -0
  10. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/session.py +14 -44
  11. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/transport.py +47 -40
  12. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/PKG-INFO +4 -3
  13. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/SOURCES.txt +3 -1
  14. powerbase_cli-0.2.2/src/powerbase_cli.egg-info/requires.txt +1 -0
  15. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_cli_commands.py +5 -26
  16. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_cli_help.py +4 -4
  17. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/tests/test_config.py +1 -15
  18. powerbase_cli-0.2.2/tests/test_http_client.py +33 -0
  19. powerbase_cli-0.2.2/tests/test_session.py +109 -0
  20. powerbase_cli-0.2.2/tests/test_transport.py +152 -0
  21. powerbase_cli-0.1.5/src/powerbase_cli/certs/powerbase-test-ca.pem +0 -21
  22. powerbase_cli-0.1.5/tests/test_session.py +0 -194
  23. powerbase_cli-0.1.5/tests/test_transport.py +0 -201
  24. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/setup.cfg +0 -0
  25. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/__main__.py +0 -0
  26. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/cli.py +0 -0
  27. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/__init__.py +0 -0
  28. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/agent.py +0 -0
  29. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/auth.py +0 -0
  30. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/branch.py +0 -0
  31. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/config_cmd.py +0 -0
  32. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/context.py +0 -0
  33. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/database.py +0 -0
  34. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/instance.py +0 -0
  35. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/org.py +0 -0
  36. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/publish.py +0 -0
  37. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sandbox.py +0 -0
  38. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sql.py +0 -0
  39. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/dependency_links.txt +0 -0
  40. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/entry_points.txt +0 -0
  41. {powerbase_cli-0.1.5 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/top_level.txt +0 -0
  42. {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.1.5
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
- python -m pip install powerbase-cli
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
- Use `--base-url` or `--anon-key` here only when you intentionally want to override the built-in deployment defaults.
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
- python -m pip install powerbase-cli
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
- Use `--base-url` or `--anon-key` here only when you intentionally want to override the built-in deployment defaults.
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.1.5"
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"]
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.1"
@@ -137,19 +137,24 @@ class PowerbaseApi:
137
137
  instance_id=instance_id,
138
138
  stream=True,
139
139
  )
140
- buffer = ""
141
- while True:
142
- chunk = resp.readline()
143
- if not chunk:
144
- break
145
- line = chunk.decode("utf-8")
146
- if line == "\n":
147
- buffer = ""
148
- continue
149
- if line.startswith("data:"):
150
- payload = line[5:].strip()
151
- if payload:
152
- yield json.loads(payload)
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
- while True:
341
- chunk = resp.readline()
342
- if not chunk:
343
- break
344
- line = chunk.decode("utf-8")
345
- if line.startswith("data:"):
346
- payload = line[5:].strip()
347
- if payload:
348
- yield json.loads(payload)
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 = merge_config_with_env(store.load_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=args.base_url or config.base_url or (saved_auth.base_url if saved_auth else None) or DEFAULT_BASE_URL,
41
- anon_key=args.anon_key or config.anon_key or (saved_auth.anon_key if saved_auth else None) or DEFAULT_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.6.12.235.165.nip.io"
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(base_url=None, anon_key=None)
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=os.environ.get("POWERBASE_BASE_URL"),
214
- anon_key=os.environ.get("POWERBASE_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 BUNDLED_CA_CERT_SENTINEL, AuthState, ConfigStore, env_auth_state, load_bundled_ca_cert
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
- with self._urlopen(req) as resp:
112
- data = json.loads(resp.read().decode("utf-8"))
113
- except HTTPError as exc: # pragma: no cover - exercised via tests with patched urlopen
114
- body = exc.read().decode("utf-8", errors="replace")
115
- raise SessionError(f"Failed to refresh session: {body}") from exc
116
- except URLError as exc: # pragma: no cover - depends on runtime/network setup
117
- raise SessionError(f"Failed to refresh session: {exc.reason}") from exc
118
- except ssl.SSLError as exc: # pragma: no cover - depends on runtime/network setup
119
- raise SessionError(f"Failed to refresh session: {exc}") from exc
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 BUNDLED_CA_CERT_SENTINEL, AppConfig, load_bundled_ca_cert
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(self, req: request.Request, *, stream: bool) -> Any:
74
- resp = self._urlopen(req)
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.read().decode("utf-8")
72
+ raw = resp.text
78
73
  return json.loads(raw) if raw else {}
79
74
 
80
- def _parse_http_error(self, exc: HTTPError) -> ApiError:
81
- body_text = exc.read().decode("utf-8", errors="replace")
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
- message = data.get("error") or body_text or exc.reason
87
- if exc.code == 401:
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.code)
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
- req = request.Request(url, data=payload, headers=request_headers, method=method.upper())
121
+ request_method = method.upper()
122
122
 
123
123
  try:
124
- return self._send(req, stream=stream)
125
- except HTTPError as exc:
126
- if exc.code == 401 and auth and auth.session.refresh_token:
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(retry_req, stream=stream)
139
- except HTTPError as retry_exc:
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