morphy-cli 0.5.0__py3-none-any.whl
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.
- morphy_cli/__init__.py +1 -0
- morphy_cli/__main__.py +3 -0
- morphy_cli/auth.py +321 -0
- morphy_cli/client.py +192 -0
- morphy_cli/completer.py +65 -0
- morphy_cli/control.py +323 -0
- morphy_cli/formatting.py +143 -0
- morphy_cli/main.py +113 -0
- morphy_cli/queries.py +75 -0
- morphy_cli/shell.py +747 -0
- morphy_cli-0.5.0.dist-info/METADATA +136 -0
- morphy_cli-0.5.0.dist-info/RECORD +15 -0
- morphy_cli-0.5.0.dist-info/WHEEL +5 -0
- morphy_cli-0.5.0.dist-info/entry_points.txt +3 -0
- morphy_cli-0.5.0.dist-info/top_level.txt +1 -0
morphy_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
morphy_cli/__main__.py
ADDED
morphy_cli/auth.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Authentication and session management for MORPHY CLI using Supabase Auth and OS keyring."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
import httpx
|
|
9
|
+
import keyring
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = Path.home() / ".morphy"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
13
|
+
FALLBACK_TOKEN_FILE = CONFIG_DIR / ".tokens"
|
|
14
|
+
KEYRING_SERVICE = "morphy-cli"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(Exception):
|
|
18
|
+
"""Authentication or session error with user-actionable message."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Session:
|
|
23
|
+
"""Holds authenticated session details."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
access_token: str,
|
|
28
|
+
refresh_token: str,
|
|
29
|
+
user_id: str,
|
|
30
|
+
email: str,
|
|
31
|
+
expires_at: float,
|
|
32
|
+
):
|
|
33
|
+
self.access_token = access_token
|
|
34
|
+
self.refresh_token = refresh_token
|
|
35
|
+
self.user_id = user_id
|
|
36
|
+
self.email = email
|
|
37
|
+
self.expires_at = expires_at
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_expired(self) -> bool:
|
|
41
|
+
"""Returns True if the access token has expired or is expiring in <= 60s."""
|
|
42
|
+
return time.time() >= (self.expires_at - 60)
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"access_token": self.access_token,
|
|
47
|
+
"refresh_token": self.refresh_token,
|
|
48
|
+
"user_id": self.user_id,
|
|
49
|
+
"email": self.email,
|
|
50
|
+
"expires_at": self.expires_at,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> "Session":
|
|
55
|
+
return cls(
|
|
56
|
+
access_token=data["access_token"],
|
|
57
|
+
refresh_token=data["refresh_token"],
|
|
58
|
+
user_id=data["user_id"],
|
|
59
|
+
email=data["email"],
|
|
60
|
+
expires_at=data["expires_at"],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_config() -> tuple[str, str]:
|
|
65
|
+
"""Load Supabase URL and anon key from env vars or ~/.morphy/config.json."""
|
|
66
|
+
url = os.getenv("MORPHY_SUPABASE_URL")
|
|
67
|
+
key = os.getenv("MORPHY_SUPABASE_ANON_KEY") or os.getenv("MORPHY_SUPABASE_PUBLIC_KEY")
|
|
68
|
+
|
|
69
|
+
if not url or not key:
|
|
70
|
+
if CONFIG_FILE.is_file():
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
73
|
+
url = url or data.get("supabase_url")
|
|
74
|
+
key = key or data.get("supabase_anon_key")
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
url = (url or "http://127.0.0.1:54321").rstrip("/")
|
|
79
|
+
key = key or ""
|
|
80
|
+
return url, key
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(supabase_url: str, supabase_anon_key: str) -> None:
|
|
84
|
+
"""Save Supabase configuration to ~/.morphy/config.json."""
|
|
85
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
payload = {
|
|
87
|
+
"supabase_url": supabase_url.rstrip("/"),
|
|
88
|
+
"supabase_anon_key": supabase_anon_key,
|
|
89
|
+
}
|
|
90
|
+
CONFIG_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AuthManager:
|
|
94
|
+
"""Manages Supabase sessions, token refreshing, and OS credential storage."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, supabase_url: Optional[str] = None, supabase_key: Optional[str] = None):
|
|
97
|
+
cfg_url, cfg_key = load_config()
|
|
98
|
+
self.supabase_url = (supabase_url or cfg_url).rstrip("/")
|
|
99
|
+
self.supabase_key = supabase_key if supabase_key is not None else cfg_key
|
|
100
|
+
self.current_session: Optional[Session] = None
|
|
101
|
+
self._keyring_key = f"refresh:{self.supabase_url}"
|
|
102
|
+
|
|
103
|
+
def _headers(self) -> dict[str, str]:
|
|
104
|
+
headers = {"apikey": self.supabase_key}
|
|
105
|
+
if self.current_session and self.current_session.access_token:
|
|
106
|
+
headers["Authorization"] = f"Bearer {self.current_session.access_token}"
|
|
107
|
+
return headers
|
|
108
|
+
|
|
109
|
+
def _store_refresh_token(self, refresh_token: str, session: Session) -> None:
|
|
110
|
+
stored = False
|
|
111
|
+
try:
|
|
112
|
+
keyring.set_password(KEYRING_SERVICE, self._keyring_key, refresh_token)
|
|
113
|
+
stored = True
|
|
114
|
+
except Exception:
|
|
115
|
+
stored = False
|
|
116
|
+
|
|
117
|
+
# Fallback file if keyring fails (e.g. headless CI or container)
|
|
118
|
+
if not stored:
|
|
119
|
+
try:
|
|
120
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
FALLBACK_TOKEN_FILE.write_text(
|
|
122
|
+
json.dumps(session.to_dict()),
|
|
123
|
+
encoding="utf-8"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(FALLBACK_TOKEN_FILE, 0o600)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def _get_stored_refresh_token(self) -> Optional[str]:
|
|
133
|
+
try:
|
|
134
|
+
token = keyring.get_password(KEYRING_SERVICE, self._keyring_key)
|
|
135
|
+
if token:
|
|
136
|
+
return token
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
if FALLBACK_TOKEN_FILE.is_file():
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(FALLBACK_TOKEN_FILE.read_text(encoding="utf-8"))
|
|
143
|
+
return data.get("refresh_token")
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _clear_stored_tokens(self) -> None:
|
|
149
|
+
try:
|
|
150
|
+
keyring.delete_password(KEYRING_SERVICE, self._keyring_key)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
if FALLBACK_TOKEN_FILE.is_file():
|
|
155
|
+
try:
|
|
156
|
+
FALLBACK_TOKEN_FILE.unlink(missing_ok=True)
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def login(self, email: str, password: str) -> Session:
|
|
161
|
+
"""Authenticate with Supabase GoTrue using email and password."""
|
|
162
|
+
if not self.supabase_key:
|
|
163
|
+
raise AuthError(
|
|
164
|
+
"Supabase Anon Key is not configured. Set MORPHY_SUPABASE_ANON_KEY or run config."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
endpoint = f"{self.supabase_url}/auth/v1/token?grant_type=password"
|
|
168
|
+
payload = {"email": email, "password": password}
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
with httpx.Client(timeout=15.0) as client:
|
|
172
|
+
res = client.post(endpoint, json=payload, headers={"apikey": self.supabase_key})
|
|
173
|
+
except httpx.HTTPError as exc:
|
|
174
|
+
raise AuthError(f"Network error during authentication: {exc}")
|
|
175
|
+
|
|
176
|
+
if res.status_code != 200:
|
|
177
|
+
try:
|
|
178
|
+
err_data = res.json()
|
|
179
|
+
detail = err_data.get("msg") or err_data.get("error_description") or res.text
|
|
180
|
+
except Exception:
|
|
181
|
+
detail = res.text
|
|
182
|
+
raise AuthError(f"Login failed ({res.status_code}): {detail}")
|
|
183
|
+
|
|
184
|
+
data = res.json()
|
|
185
|
+
expires_in = data.get("expires_in", 3600)
|
|
186
|
+
user_info = data.get("user", {})
|
|
187
|
+
|
|
188
|
+
session = Session(
|
|
189
|
+
access_token=data["access_token"],
|
|
190
|
+
refresh_token=data["refresh_token"],
|
|
191
|
+
user_id=user_info.get("id", ""),
|
|
192
|
+
email=user_info.get("email", email),
|
|
193
|
+
expires_at=time.time() + expires_in,
|
|
194
|
+
)
|
|
195
|
+
self.current_session = session
|
|
196
|
+
self._store_refresh_token(session.refresh_token, session)
|
|
197
|
+
return session
|
|
198
|
+
|
|
199
|
+
def refresh(self) -> Session:
|
|
200
|
+
"""Refresh current session using refresh token."""
|
|
201
|
+
refresh_token = (
|
|
202
|
+
self.current_session.refresh_token
|
|
203
|
+
if self.current_session
|
|
204
|
+
else self._get_stored_refresh_token()
|
|
205
|
+
)
|
|
206
|
+
if not refresh_token:
|
|
207
|
+
raise AuthError("No active session or refresh token found. Please /login.")
|
|
208
|
+
|
|
209
|
+
endpoint = f"{self.supabase_url}/auth/v1/token?grant_type=refresh_token"
|
|
210
|
+
payload = {"refresh_token": refresh_token}
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
with httpx.Client(timeout=15.0) as client:
|
|
214
|
+
res = client.post(endpoint, json=payload, headers={"apikey": self.supabase_key})
|
|
215
|
+
except httpx.HTTPError as exc:
|
|
216
|
+
raise AuthError(f"Network error during token refresh: {exc}")
|
|
217
|
+
|
|
218
|
+
if res.status_code != 200:
|
|
219
|
+
self._clear_stored_tokens()
|
|
220
|
+
self.current_session = None
|
|
221
|
+
raise AuthError("Session expired or refresh token invalid. Please /login again.")
|
|
222
|
+
|
|
223
|
+
data = res.json()
|
|
224
|
+
expires_in = data.get("expires_in", 3600)
|
|
225
|
+
user_info = data.get("user", {})
|
|
226
|
+
|
|
227
|
+
session = Session(
|
|
228
|
+
access_token=data["access_token"],
|
|
229
|
+
refresh_token=data["refresh_token"],
|
|
230
|
+
user_id=user_info.get("id", self.current_session.user_id if self.current_session else ""),
|
|
231
|
+
email=user_info.get("email", self.current_session.email if self.current_session else ""),
|
|
232
|
+
expires_at=time.time() + expires_in,
|
|
233
|
+
)
|
|
234
|
+
self.current_session = session
|
|
235
|
+
self._store_refresh_token(session.refresh_token, session)
|
|
236
|
+
return session
|
|
237
|
+
|
|
238
|
+
def restore_session(self) -> Optional[Session]:
|
|
239
|
+
"""Attempt to restore or refresh a session from saved token."""
|
|
240
|
+
env_token = os.getenv("MORPHY_ACCESS_TOKEN")
|
|
241
|
+
if env_token:
|
|
242
|
+
self.current_session = Session(
|
|
243
|
+
access_token=env_token,
|
|
244
|
+
refresh_token="",
|
|
245
|
+
user_id="",
|
|
246
|
+
email="",
|
|
247
|
+
expires_at=time.time() + 86400,
|
|
248
|
+
)
|
|
249
|
+
return self.current_session
|
|
250
|
+
|
|
251
|
+
refresh_token = self._get_stored_refresh_token()
|
|
252
|
+
if not refresh_token:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
return self.refresh()
|
|
257
|
+
except Exception:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def get_valid_token(self) -> str:
|
|
261
|
+
"""Return a valid access token, refreshing if necessary."""
|
|
262
|
+
if not self.current_session:
|
|
263
|
+
restored = self.restore_session()
|
|
264
|
+
if not restored:
|
|
265
|
+
env_token = os.getenv("MORPHY_ACCESS_TOKEN")
|
|
266
|
+
if env_token:
|
|
267
|
+
return env_token
|
|
268
|
+
raise AuthError("Authentication required. Please run /login.")
|
|
269
|
+
|
|
270
|
+
if self.current_session.is_expired:
|
|
271
|
+
self.refresh()
|
|
272
|
+
|
|
273
|
+
return self.current_session.access_token
|
|
274
|
+
|
|
275
|
+
def logout(self) -> None:
|
|
276
|
+
"""Sign out and invalidate session credentials."""
|
|
277
|
+
if self.current_session and self.current_session.access_token and self.supabase_key:
|
|
278
|
+
try:
|
|
279
|
+
endpoint = f"{self.supabase_url}/auth/v1/logout"
|
|
280
|
+
with httpx.Client(timeout=8.0) as client:
|
|
281
|
+
client.post(
|
|
282
|
+
endpoint,
|
|
283
|
+
headers={
|
|
284
|
+
"apikey": self.supabase_key,
|
|
285
|
+
"Authorization": f"Bearer {self.current_session.access_token}",
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
self._clear_stored_tokens()
|
|
292
|
+
self.current_session = None
|
|
293
|
+
|
|
294
|
+
def status(self) -> dict[str, Any]:
|
|
295
|
+
"""Return current status dictionary."""
|
|
296
|
+
token = os.getenv("MORPHY_ACCESS_TOKEN")
|
|
297
|
+
if token and not self.current_session:
|
|
298
|
+
return {
|
|
299
|
+
"authenticated": True,
|
|
300
|
+
"source": "MORPHY_ACCESS_TOKEN environment variable",
|
|
301
|
+
"email": "injected-environment-session",
|
|
302
|
+
"supabase_url": self.supabase_url,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if not self.current_session:
|
|
306
|
+
restored = self.restore_session()
|
|
307
|
+
if not restored:
|
|
308
|
+
return {
|
|
309
|
+
"authenticated": False,
|
|
310
|
+
"supabase_url": self.supabase_url,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
remaining_sec = max(0, int(self.current_session.expires_at - time.time()))
|
|
314
|
+
return {
|
|
315
|
+
"authenticated": True,
|
|
316
|
+
"email": self.current_session.email,
|
|
317
|
+
"user_id": self.current_session.user_id,
|
|
318
|
+
"expires_in_seconds": remaining_sec,
|
|
319
|
+
"supabase_url": self.supabase_url,
|
|
320
|
+
}
|
|
321
|
+
|
morphy_cli/client.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Shared HTTP client for the MORPHY engine."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Callable, Optional, Union
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EngineError(Exception):
|
|
11
|
+
"""Error returned by the MORPHY engine with an actionable message."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status_code: int, detail: str):
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.detail = detail
|
|
16
|
+
super().__init__(detail)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_STATUS_HINTS = {
|
|
20
|
+
401: "Sign in again — your session may have expired.",
|
|
21
|
+
403: "Access denied. Check your database permissions.",
|
|
22
|
+
409: "An administrator must connect this database first.",
|
|
23
|
+
413: "The input or result is too large. Narrow your query or CSV.",
|
|
24
|
+
422: "Validation or generation failed. Check the SQL syntax or permitted schema.",
|
|
25
|
+
502: "The database is unreachable. Check the connection and credentials.",
|
|
26
|
+
503: "The engine or a required service is unavailable.",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MorphyClient:
|
|
31
|
+
"""Thin wrapper around httpx for the MORPHY engine API."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
base_url: str,
|
|
36
|
+
database: Optional[Union[UUID, str]] = None,
|
|
37
|
+
token: Optional[Union[str, Callable[[], str]]] = None,
|
|
38
|
+
):
|
|
39
|
+
self.base_url = base_url.rstrip("/")
|
|
40
|
+
self.database: Optional[UUID] = UUID(str(database)) if database else None
|
|
41
|
+
self._token_source = token
|
|
42
|
+
self._validate_endpoint(self.base_url)
|
|
43
|
+
|
|
44
|
+
self._http = httpx.Client(
|
|
45
|
+
base_url=self.base_url,
|
|
46
|
+
timeout=195.0,
|
|
47
|
+
follow_redirects=False,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _validate_endpoint(self, url: str) -> None:
|
|
51
|
+
if self.database:
|
|
52
|
+
endpoint = urlsplit(url)
|
|
53
|
+
if endpoint.scheme != "https" and not (
|
|
54
|
+
endpoint.scheme == "http"
|
|
55
|
+
and endpoint.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
56
|
+
):
|
|
57
|
+
raise SystemExit(
|
|
58
|
+
"Use HTTPS or a loopback address for authenticated requests."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _get_token(self) -> Optional[str]:
|
|
62
|
+
if callable(self._token_source):
|
|
63
|
+
try:
|
|
64
|
+
return self._token_source()
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
if self._token_source:
|
|
68
|
+
return str(self._token_source)
|
|
69
|
+
return os.getenv("MORPHY_ACCESS_TOKEN")
|
|
70
|
+
|
|
71
|
+
def set_token_source(self, source: Union[str, Callable[[], str], None]) -> None:
|
|
72
|
+
self._token_source = source
|
|
73
|
+
|
|
74
|
+
def set_database(self, database: Optional[Union[UUID, str]]) -> None:
|
|
75
|
+
if database is None:
|
|
76
|
+
self.database = None
|
|
77
|
+
else:
|
|
78
|
+
try:
|
|
79
|
+
self.database = UUID(str(database))
|
|
80
|
+
except ValueError:
|
|
81
|
+
self.database = str(database) # type: ignore
|
|
82
|
+
self._validate_endpoint(self.base_url)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def prefix(self) -> str:
|
|
86
|
+
return f"/api/engine/databases/{self.database}" if self.database else "/api/sample"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_live(self) -> bool:
|
|
90
|
+
return self.database is not None
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._http.close()
|
|
94
|
+
|
|
95
|
+
def _build_headers(self) -> dict[str, str]:
|
|
96
|
+
headers: dict[str, str] = {}
|
|
97
|
+
if self.database:
|
|
98
|
+
token = self._get_token()
|
|
99
|
+
if not token:
|
|
100
|
+
raise SystemExit(
|
|
101
|
+
"Set MORPHY_ACCESS_TOKEN or run /login to authenticate live database queries."
|
|
102
|
+
)
|
|
103
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
104
|
+
return headers
|
|
105
|
+
|
|
106
|
+
def _request(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
path: str,
|
|
110
|
+
payload: Optional[Any] = None,
|
|
111
|
+
content: Optional[bytes] = None,
|
|
112
|
+
headers: Optional[dict[str, str]] = None,
|
|
113
|
+
) -> Any:
|
|
114
|
+
req_headers = self._build_headers()
|
|
115
|
+
if headers:
|
|
116
|
+
req_headers.update(headers)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if method == "GET":
|
|
120
|
+
response = self._http.get(path, headers=req_headers)
|
|
121
|
+
elif method == "POST":
|
|
122
|
+
response = self._http.post(path, json=payload, headers=req_headers)
|
|
123
|
+
elif method == "PUT":
|
|
124
|
+
if content is not None:
|
|
125
|
+
response = self._http.put(path, content=content, headers=req_headers)
|
|
126
|
+
else:
|
|
127
|
+
response = self._http.put(path, json=payload, headers=req_headers)
|
|
128
|
+
else:
|
|
129
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
130
|
+
except httpx.ConnectError as err:
|
|
131
|
+
raise EngineError(503, f"Cannot connect to engine at {self.base_url}. Service unavailable.") from err
|
|
132
|
+
except httpx.TimeoutException as err:
|
|
133
|
+
raise EngineError(504, "Engine request timed out.") from err
|
|
134
|
+
except httpx.HTTPError as err:
|
|
135
|
+
raise EngineError(500, f"HTTP communication failed: {err}") from err
|
|
136
|
+
|
|
137
|
+
if response.is_error:
|
|
138
|
+
try:
|
|
139
|
+
detail = response.json().get("detail", response.text)
|
|
140
|
+
except Exception:
|
|
141
|
+
detail = response.text
|
|
142
|
+
hint = _STATUS_HINTS.get(response.status_code, "")
|
|
143
|
+
message = str(detail)
|
|
144
|
+
if hint and hint not in message:
|
|
145
|
+
message = f"{message}\n{hint}"
|
|
146
|
+
raise EngineError(response.status_code, message)
|
|
147
|
+
return response.json()
|
|
148
|
+
|
|
149
|
+
def health(self) -> dict[str, Any]:
|
|
150
|
+
return self._request("GET", "/api/health")
|
|
151
|
+
|
|
152
|
+
def schema(self) -> dict[str, Any]:
|
|
153
|
+
return self._request("GET", self.prefix + "/schema")
|
|
154
|
+
|
|
155
|
+
def generate(self, question: str) -> dict[str, Any]:
|
|
156
|
+
return self._request("POST", self.prefix + "/generate", payload={"question": question})
|
|
157
|
+
|
|
158
|
+
def query(self, sql: str) -> dict[str, Any]:
|
|
159
|
+
return self._request("POST", self.prefix + "/query", payload={"sql": sql})
|
|
160
|
+
|
|
161
|
+
def connect_database(self, uri: str) -> dict[str, Any]:
|
|
162
|
+
"""Save and test PostgreSQL connection URI on the active database."""
|
|
163
|
+
if not self.database:
|
|
164
|
+
raise EngineError(400, "Select a database first using /use-db <id>")
|
|
165
|
+
return self._request(
|
|
166
|
+
"PUT",
|
|
167
|
+
f"/api/engine/databases/{self.database}/connection",
|
|
168
|
+
payload={"uri": uri},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def import_csv(self, csv_data: Union[str, bytes]) -> dict[str, Any]:
|
|
172
|
+
"""Import UTF-8 CSV data into the active SQLite/CSV database."""
|
|
173
|
+
if not self.database:
|
|
174
|
+
raise EngineError(400, "Select a database first using /use-db <id>")
|
|
175
|
+
raw_bytes = csv_data.encode("utf-8") if isinstance(csv_data, str) else csv_data
|
|
176
|
+
if len(raw_bytes) > 1_048_576:
|
|
177
|
+
raise EngineError(413, "Choose a UTF-8 CSV up to 1 MB.")
|
|
178
|
+
return self._request(
|
|
179
|
+
"PUT",
|
|
180
|
+
f"/api/engine/databases/{self.database}/import",
|
|
181
|
+
content=raw_bytes,
|
|
182
|
+
headers={"Content-Type": "text/csv; charset=utf-8"},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def render_panel(
|
|
186
|
+
self, dashboard_id: Union[UUID, str], query_id: Union[UUID, str]
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
"""Render a saved dashboard panel under the caller's RLS policy."""
|
|
189
|
+
return self._request(
|
|
190
|
+
"POST",
|
|
191
|
+
f"/api/engine/dashboards/{dashboard_id}/panels/{query_id}/render",
|
|
192
|
+
)
|
morphy_cli/completer.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Slash-command completion for the MORPHY interactive shell."""
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Commands available in the interactive shell.
|
|
7
|
+
_ALL_COMMANDS = [
|
|
8
|
+
("/help", "Show available commands"),
|
|
9
|
+
("/schema", "Display the permitted database schema"),
|
|
10
|
+
("/examples", "List curated example questions"),
|
|
11
|
+
("/ask", "Generate SQL from a natural-language question"),
|
|
12
|
+
("/sql", "Execute a read-only SQL query"),
|
|
13
|
+
("/login", "Sign in with email and password"),
|
|
14
|
+
("/logout", "Sign out and clear saved credentials"),
|
|
15
|
+
("/status", "Display authentication, active org and database status"),
|
|
16
|
+
("/orgs", "List your organizations"),
|
|
17
|
+
("/use-org", "Switch active organization context (/use-org <id>)"),
|
|
18
|
+
("/dbs", "List databases in active organization"),
|
|
19
|
+
("/use-db", "Switch active target database context (/use-db <id|sample>)"),
|
|
20
|
+
("/connect", "Save and verify PostgreSQL connection (/connect <uri>)"),
|
|
21
|
+
("/import", "Import UTF-8 CSV into active database (/import <file.csv>)"),
|
|
22
|
+
("/profile", "View or claim your user profile"),
|
|
23
|
+
("/members", "List members of active organization"),
|
|
24
|
+
("/invites", "List pending invitations"),
|
|
25
|
+
("/invite", "Invite a user to active organization (/invite <username>)"),
|
|
26
|
+
("/accept", "Accept an organization invitation (/accept <id>)"),
|
|
27
|
+
("/withdraw", "Withdraw a pending invitation (/withdraw <id>)"),
|
|
28
|
+
("/save-query", "Save current or specified SQL query (/save-query <name> [sql])"),
|
|
29
|
+
("/queries", "List saved queries"),
|
|
30
|
+
("/run-query", "Run a saved query by name (/run-query <name>)"),
|
|
31
|
+
("/delete-query", "Delete a saved query (/delete-query <name>)"),
|
|
32
|
+
("/dashboards", "List saved dashboards"),
|
|
33
|
+
("/dashboard", "Display summary dashboard or render a saved dashboard (/dashboard [render <id>])"),
|
|
34
|
+
("/dashboard-create", "Create a saved dashboard (/dashboard-create <title> [shared])"),
|
|
35
|
+
("/dashboard-add", "Add a query panel to dashboard (/dashboard-add <dash_id> <query_id>)"),
|
|
36
|
+
("/permissions", "Show effective permissions on active database"),
|
|
37
|
+
("/perm-revoke", "Revoke permissions for a role or user (/perm-revoke <role|user>)"),
|
|
38
|
+
("/export", "Export last query result to CSV file (/export <file.csv>)"),
|
|
39
|
+
("/json", "Display last query result formatted as JSON"),
|
|
40
|
+
("/exit", "Leave the session"),
|
|
41
|
+
("/quit", "Leave the session"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SlashCompleter(Completer):
|
|
46
|
+
"""Tab-complete slash commands in the MORPHY REPL."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, is_live: bool = False):
|
|
49
|
+
self._commands = list(_ALL_COMMANDS)
|
|
50
|
+
|
|
51
|
+
def get_completions(self, document, complete_event):
|
|
52
|
+
text = document.text_before_cursor.lstrip()
|
|
53
|
+
# Only complete at the beginning of the line and when starting with '/'.
|
|
54
|
+
if document.cursor_position != len(document.text_before_cursor.lstrip()):
|
|
55
|
+
return
|
|
56
|
+
if not text.startswith("/"):
|
|
57
|
+
return
|
|
58
|
+
for name, description in self._commands:
|
|
59
|
+
if name.startswith(text):
|
|
60
|
+
yield Completion(
|
|
61
|
+
name,
|
|
62
|
+
start_position=-len(text),
|
|
63
|
+
display=name,
|
|
64
|
+
display_meta=description,
|
|
65
|
+
)
|