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.
Files changed (39) hide show
  1. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/PKG-INFO +3 -4
  2. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/README.md +1 -3
  3. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/pyproject.toml +2 -2
  4. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/__init__.py +1 -1
  5. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/api.py +32 -22
  6. powerbase_cli-0.2.2/src/powerbase_cli/http_client.py +99 -0
  7. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/session.py +13 -24
  8. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/transport.py +46 -25
  9. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/PKG-INFO +3 -4
  10. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/SOURCES.txt +3 -0
  11. powerbase_cli-0.2.2/src/powerbase_cli.egg-info/requires.txt +1 -0
  12. powerbase_cli-0.2.2/tests/test_http_client.py +33 -0
  13. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_session.py +31 -19
  14. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_transport.py +45 -29
  15. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/setup.cfg +0 -0
  16. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/__main__.py +0 -0
  17. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/cli.py +0 -0
  18. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/__init__.py +0 -0
  19. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/agent.py +0 -0
  20. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/auth.py +0 -0
  21. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/branch.py +0 -0
  22. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/config_cmd.py +0 -0
  23. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/context.py +0 -0
  24. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/database.py +0 -0
  25. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/instance.py +0 -0
  26. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/org.py +0 -0
  27. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/parser.py +0 -0
  28. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/publish.py +0 -0
  29. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sandbox.py +0 -0
  30. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/shared.py +0 -0
  31. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/commands/sql.py +0 -0
  32. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli/config.py +0 -0
  33. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/dependency_links.txt +0 -0
  34. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/entry_points.txt +0 -0
  35. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/src/powerbase_cli.egg-info/top_level.txt +0 -0
  36. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_api.py +0 -0
  37. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_cli_commands.py +0 -0
  38. {powerbase_cli-0.2.0 → powerbase_cli-0.2.2}/tests/test_cli_help.py +0 -0
  39. {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.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,12 +42,10 @@ Database model:
41
42
  Install from PyPI:
42
43
 
43
44
  ```bash
44
- python -m pip install --upgrade powerbase-cli==0.2.0
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
- python -m pip install --upgrade powerbase-cli==0.2.0
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.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"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.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()
@@ -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
- with self._urlopen(req) as resp:
93
- data = json.loads(resp.read().decode("utf-8"))
94
- except HTTPError as exc: # pragma: no cover - exercised via tests with patched urlopen
95
- body = exc.read().decode("utf-8", errors="replace")
96
- raise SessionError(f"Failed to refresh session: {body}") from exc
97
- except URLError as exc: # pragma: no cover - depends on runtime/network setup
98
- raise SessionError(f"Failed to refresh session: {exc.reason}") from exc
99
- except ssl.SSLError as exc: # pragma: no cover - depends on runtime/network setup
100
- 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
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(self, req: request.Request, *, stream: bool) -> Any:
60
- 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
+ )
61
70
  if stream:
62
71
  return resp
63
- raw = resp.read().decode("utf-8")
72
+ raw = resp.text
64
73
  return json.loads(raw) if raw else {}
65
74
 
66
- def _parse_http_error(self, exc: HTTPError) -> ApiError:
67
- 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)
68
77
  try:
69
78
  data = json.loads(body_text)
70
79
  except json.JSONDecodeError:
71
80
  data = {"error": body_text}
72
- message = data.get("error") or body_text or exc.reason
73
- 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:
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.code)
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
- req = request.Request(url, data=payload, headers=request_headers, method=method.upper())
121
+ request_method = method.upper()
108
122
 
109
123
  try:
110
- return self._send(req, stream=stream)
111
- except HTTPError as exc:
112
- 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:
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(retry_req, stream=stream)
125
- 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:
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.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,12 +42,10 @@ Database model:
41
42
  Install from PyPI:
42
43
 
43
44
  ```bash
44
- python -m pip install --upgrade powerbase-cli==0.2.0
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,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.request.urlopen", return_value=FakeResponse({
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.request.urlopen", return_value=FakeResponse({
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, payload: dict[str, object]) -> None:
21
- self.payload = payload
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
- def build_http_error(status: int, payload: dict[str, object]) -> HTTPError:
28
- return HTTPError(
29
- url="https://console.example.com/functions/v1/instances",
30
- code=status,
31
- msg="error",
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.request.urlopen") as urlopen_mock:
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(urlopen_mock.call_count, 0)
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.request.urlopen") as urlopen_mock:
83
- urlopen_mock.side_effect = [
84
- build_http_error(401, {"error": "expired"}),
85
- FakeResponse({"success": True, "data": {"ok": True}}),
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(urlopen_mock.call_count, 2)
93
- first_auth = urlopen_mock.call_args_list[0].args[0].headers["Authorization"]
94
- second_auth = urlopen_mock.call_args_list[1].args[0].headers["Authorization"]
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.request.urlopen",
115
- side_effect=build_http_error(401, {"error": "expired"}),
116
- ) as urlopen_mock:
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(urlopen_mock.call_count, 1)
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("powerbase_cli.transport.request.urlopen", side_effect=URLError("tls failed")):
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