polygres-cli 0.1.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.
- polygres_cli/__init__.py +1 -0
- polygres_cli/cli.py +1919 -0
- polygres_cli/cli_auth.py +89 -0
- polygres_cli/cli_client.py +427 -0
- polygres_cli/cli_config.py +91 -0
- polygres_cli/cli_errors.py +88 -0
- polygres_cli/cli_output.py +54 -0
- polygres_cli/cli_secrets.py +60 -0
- polygres_cli-0.1.0.dist-info/METADATA +95 -0
- polygres_cli-0.1.0.dist-info/RECORD +13 -0
- polygres_cli-0.1.0.dist-info/WHEEL +4 -0
- polygres_cli-0.1.0.dist-info/entry_points.txt +2 -0
- polygres_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
polygres_cli/cli.py
ADDED
|
@@ -0,0 +1,1919 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
from polygres_cli.cli_auth import clear_auth, validate_start_response, validated_approved_auth
|
|
19
|
+
from polygres_cli.cli_client import VERSION, CliControlPlaneClient
|
|
20
|
+
from polygres_cli.cli_config import (
|
|
21
|
+
ConfigStore,
|
|
22
|
+
access_token,
|
|
23
|
+
env_access_token_set,
|
|
24
|
+
refresh_token,
|
|
25
|
+
resolve_api_base_url,
|
|
26
|
+
)
|
|
27
|
+
from polygres_cli.cli_errors import (
|
|
28
|
+
AUTH,
|
|
29
|
+
CONFLICT,
|
|
30
|
+
GENERAL_FAILURE,
|
|
31
|
+
LOCAL_DEPENDENCY,
|
|
32
|
+
NOT_FOUND,
|
|
33
|
+
SUCCESS,
|
|
34
|
+
UNAVAILABLE,
|
|
35
|
+
USAGE,
|
|
36
|
+
CliError,
|
|
37
|
+
UsageError,
|
|
38
|
+
)
|
|
39
|
+
from polygres_cli.cli_output import print_kv, print_table, write_error, write_json
|
|
40
|
+
from polygres_cli.cli_secrets import redact
|
|
41
|
+
|
|
42
|
+
PROJECT_ID_RE = re.compile(r"^p[a-z0-9]{23}$")
|
|
43
|
+
UUID_LIKE_RE = re.compile(
|
|
44
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
45
|
+
)
|
|
46
|
+
SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
47
|
+
MIGRATION_NAME_RE = SQL_IDENTIFIER_RE
|
|
48
|
+
MAX_CSV_UPLOAD_BYTES = 1024**3
|
|
49
|
+
GRAPH_CONFIGURATION_KEYS = {
|
|
50
|
+
"registered_tables",
|
|
51
|
+
"registered_relationships",
|
|
52
|
+
"filter_columns",
|
|
53
|
+
"runtime_settings",
|
|
54
|
+
}
|
|
55
|
+
GRAPH_CONFIGURATION_READ_ONLY_KEYS = {
|
|
56
|
+
"id",
|
|
57
|
+
"project_id",
|
|
58
|
+
"build_status",
|
|
59
|
+
"build_id",
|
|
60
|
+
"last_built_at",
|
|
61
|
+
"needs_rebuild",
|
|
62
|
+
"invalid_reason",
|
|
63
|
+
"created_at",
|
|
64
|
+
"updated_at",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CliArgumentParser(argparse.ArgumentParser):
|
|
69
|
+
def error(self, message: str) -> None:
|
|
70
|
+
code = "INVALID_USAGE" if "invalid choice" in message else "VALIDATION_ERROR"
|
|
71
|
+
raise UsageError(message, code=code)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: list[str] | None = None) -> int:
|
|
75
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
76
|
+
json_output = "--json" in argv
|
|
77
|
+
parser = build_parser()
|
|
78
|
+
try:
|
|
79
|
+
args = parser.parse_args(argv)
|
|
80
|
+
if getattr(args, "version", False):
|
|
81
|
+
sys.stdout.write(f"polygres {VERSION}\n")
|
|
82
|
+
return SUCCESS
|
|
83
|
+
if not hasattr(args, "func"):
|
|
84
|
+
parser.print_help()
|
|
85
|
+
return SUCCESS
|
|
86
|
+
ctx = _context(args)
|
|
87
|
+
with ctx.client:
|
|
88
|
+
return int(args.func(ctx, args))
|
|
89
|
+
except SystemExit as exc:
|
|
90
|
+
return int(exc.code or SUCCESS)
|
|
91
|
+
except CliError as exc:
|
|
92
|
+
write_error(exc, json_output=json_output)
|
|
93
|
+
return exc.exit_code
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
97
|
+
parser = CliArgumentParser(prog="polygres", description="Polygres command line tool")
|
|
98
|
+
parser.add_argument("--version", action="store_true", help="print the installed CLI version")
|
|
99
|
+
parser.add_argument("--json", action="store_true", help="write machine-readable JSON")
|
|
100
|
+
parser.add_argument("--project", help="project ID or exact project name")
|
|
101
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI color output")
|
|
102
|
+
parser.add_argument("--quiet", action="store_true", help="suppress non-essential output")
|
|
103
|
+
parser.add_argument("--verbose", action="store_true", help="print redacted request traces")
|
|
104
|
+
subparsers = parser.add_subparsers(dest="resource", metavar="<resource>")
|
|
105
|
+
|
|
106
|
+
_add_auth_parsers(subparsers)
|
|
107
|
+
_add_projects_parsers(subparsers)
|
|
108
|
+
_add_env_parser(subparsers)
|
|
109
|
+
_add_db_parsers(subparsers)
|
|
110
|
+
_add_keys_parsers(subparsers)
|
|
111
|
+
_add_import_parsers(subparsers)
|
|
112
|
+
_add_migration_parsers(subparsers)
|
|
113
|
+
_add_graph_parsers(subparsers)
|
|
114
|
+
_add_vector_parsers(subparsers)
|
|
115
|
+
_add_text_parsers(subparsers)
|
|
116
|
+
_add_ready_parser(subparsers)
|
|
117
|
+
_add_config_parsers(subparsers)
|
|
118
|
+
return parser
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Context:
|
|
122
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
123
|
+
self.args = args
|
|
124
|
+
self.store = ConfigStore()
|
|
125
|
+
self.config = self.store.load()
|
|
126
|
+
stored_refresh_token = None if env_access_token_set() else refresh_token(self.config)
|
|
127
|
+
self.client = CliControlPlaneClient(
|
|
128
|
+
base_url=resolve_api_base_url(self.config),
|
|
129
|
+
access_token=access_token(self.config),
|
|
130
|
+
refresh_token=stored_refresh_token,
|
|
131
|
+
on_token_refresh=self.store_refreshed_auth,
|
|
132
|
+
on_refresh_auth_failure=self.clear_stored_auth,
|
|
133
|
+
verbose=bool(args.verbose),
|
|
134
|
+
trace=lambda line: sys.stderr.write(line + "\n"),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def json(self) -> bool:
|
|
139
|
+
return bool(self.args.json)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def quiet(self) -> bool:
|
|
143
|
+
return bool(self.args.quiet)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def selected_project_id(self) -> str | None:
|
|
147
|
+
value = self.config.get("selected_project_id")
|
|
148
|
+
return value if isinstance(value, str) else None
|
|
149
|
+
|
|
150
|
+
def save(self) -> None:
|
|
151
|
+
self.store.save(self.config)
|
|
152
|
+
|
|
153
|
+
def store_refreshed_auth(self, payload: dict[str, Any]) -> None:
|
|
154
|
+
user = payload.get("user")
|
|
155
|
+
self.config["auth"] = {
|
|
156
|
+
"access_token": payload["access_token"],
|
|
157
|
+
"refresh_token": payload["refresh_token"],
|
|
158
|
+
"expires_at": payload.get("expires_at"),
|
|
159
|
+
"user": user if isinstance(user, dict) else {},
|
|
160
|
+
}
|
|
161
|
+
self.save()
|
|
162
|
+
|
|
163
|
+
def clear_stored_auth(self) -> None:
|
|
164
|
+
clear_auth(self.config)
|
|
165
|
+
self.save()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _context(args: argparse.Namespace) -> Context:
|
|
169
|
+
return Context(args)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _add_auth_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
173
|
+
login = subparsers.add_parser("login", help="sign in through the browser")
|
|
174
|
+
login.add_argument("--timeout", type=_timeout_seconds, default=600)
|
|
175
|
+
login.set_defaults(func=handle_login)
|
|
176
|
+
logout = subparsers.add_parser("logout", help="sign out and remove local credentials")
|
|
177
|
+
logout.set_defaults(func=handle_logout)
|
|
178
|
+
whoami = subparsers.add_parser("whoami", help="show authenticated user")
|
|
179
|
+
whoami.set_defaults(func=handle_whoami)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _add_projects_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
183
|
+
parser = subparsers.add_parser("projects", help="manage projects")
|
|
184
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
185
|
+
list_parser = sub.add_parser("list", help="list projects")
|
|
186
|
+
list_parser.set_defaults(func=handle_projects_list)
|
|
187
|
+
use_parser = sub.add_parser("use", help="select a project")
|
|
188
|
+
use_parser.add_argument("project")
|
|
189
|
+
use_parser.set_defaults(func=handle_projects_use)
|
|
190
|
+
create_parser = sub.add_parser("create", help="create a project")
|
|
191
|
+
create_parser.add_argument("name")
|
|
192
|
+
create_parser.add_argument("--no-wait", action="store_true")
|
|
193
|
+
create_parser.add_argument("--timeout", type=_timeout_seconds, default=600)
|
|
194
|
+
create_parser.set_defaults(func=handle_projects_create)
|
|
195
|
+
status_parser = sub.add_parser("status", help="show project status")
|
|
196
|
+
status_parser.add_argument("project", nargs="?")
|
|
197
|
+
status_parser.set_defaults(func=handle_projects_status)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _add_env_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
201
|
+
parser = subparsers.add_parser("env", help="print project environment variables")
|
|
202
|
+
parser.set_defaults(func=handle_env)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _add_db_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
206
|
+
parser = subparsers.add_parser("db", help="database commands")
|
|
207
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
208
|
+
info = sub.add_parser("info", help="show database connection metadata")
|
|
209
|
+
info.set_defaults(func=handle_db_info)
|
|
210
|
+
psql = sub.add_parser("psql", help="open psql")
|
|
211
|
+
psql.set_defaults(func=handle_db_psql)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _add_keys_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
215
|
+
parser = subparsers.add_parser("keys", help="manage runtime API keys")
|
|
216
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
217
|
+
create = sub.add_parser("create", help="create an API key")
|
|
218
|
+
create.add_argument("name")
|
|
219
|
+
create.set_defaults(func=handle_keys_create)
|
|
220
|
+
list_parser = sub.add_parser("list", help="list API keys")
|
|
221
|
+
list_parser.set_defaults(func=handle_keys_list)
|
|
222
|
+
revoke = sub.add_parser("revoke", help="revoke an API key")
|
|
223
|
+
revoke.add_argument("key_id")
|
|
224
|
+
revoke.add_argument("--yes", action="store_true")
|
|
225
|
+
revoke.set_defaults(func=handle_keys_revoke)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _add_import_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
229
|
+
parser = subparsers.add_parser("import", help="import data")
|
|
230
|
+
sub = parser.add_subparsers(dest="kind", required=True)
|
|
231
|
+
csv_parser = sub.add_parser("csv", help="import CSV data")
|
|
232
|
+
csv_parser.add_argument("file")
|
|
233
|
+
csv_parser.add_argument("--table", required=True)
|
|
234
|
+
csv_parser.add_argument("--schema", default="public")
|
|
235
|
+
csv_parser.add_argument(
|
|
236
|
+
"--mode",
|
|
237
|
+
choices=["create_table", "append_existing", "replace_existing"],
|
|
238
|
+
default="create_table",
|
|
239
|
+
)
|
|
240
|
+
csv_parser.add_argument("--encoding", choices=["utf-8", "utf-8-sig"], default="utf-8")
|
|
241
|
+
csv_parser.add_argument("--delimiter", type=_delimiter)
|
|
242
|
+
csv_parser.add_argument("--quote-char", type=_one_char)
|
|
243
|
+
csv_parser.add_argument("--escape-char", type=_one_char)
|
|
244
|
+
csv_parser.add_argument("--no-header", action="store_true")
|
|
245
|
+
csv_parser.add_argument("--wait", action="store_true")
|
|
246
|
+
csv_parser.add_argument("--timeout", type=_timeout_seconds, default=1800)
|
|
247
|
+
csv_parser.set_defaults(func=handle_import_csv)
|
|
248
|
+
status = sub.add_parser("status", help="show import status")
|
|
249
|
+
status.add_argument("job_id", nargs="?")
|
|
250
|
+
status.set_defaults(func=handle_import_status)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _add_migration_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
254
|
+
parser = subparsers.add_parser("migrations", help="manage migrations")
|
|
255
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
256
|
+
list_parser = sub.add_parser("list", help="list migrations")
|
|
257
|
+
list_parser.set_defaults(func=handle_migrations_list)
|
|
258
|
+
apply = sub.add_parser("apply", help="apply a SQL migration")
|
|
259
|
+
apply.add_argument("--file", required=True)
|
|
260
|
+
apply.add_argument("--name")
|
|
261
|
+
apply.set_defaults(func=handle_migrations_apply)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _add_graph_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
265
|
+
parser = subparsers.add_parser("graph", help="manage graph retrieval")
|
|
266
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
267
|
+
discover = sub.add_parser("discover", help="discover graph configuration")
|
|
268
|
+
discover.set_defaults(func=handle_graph_discover)
|
|
269
|
+
config = sub.add_parser("config", help="graph configuration")
|
|
270
|
+
config_sub = config.add_subparsers(dest="config_action", required=True)
|
|
271
|
+
export = config_sub.add_parser("export", help="export graph configuration")
|
|
272
|
+
export.set_defaults(func=handle_graph_config_export)
|
|
273
|
+
apply = config_sub.add_parser("apply", help="apply graph configuration")
|
|
274
|
+
apply.add_argument("--file", required=True)
|
|
275
|
+
apply.set_defaults(func=handle_graph_config_apply)
|
|
276
|
+
build = sub.add_parser("build", help="build graph index")
|
|
277
|
+
build.set_defaults(func=handle_graph_build)
|
|
278
|
+
status = sub.add_parser("status", help="show graph status")
|
|
279
|
+
status.set_defaults(func=handle_graph_status)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _add_vector_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
283
|
+
parser = subparsers.add_parser("vector", help="manage vector retrieval")
|
|
284
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
285
|
+
configs = sub.add_parser("configs", help="vector configurations")
|
|
286
|
+
configs_sub = configs.add_subparsers(dest="configs_action", required=True)
|
|
287
|
+
list_parser = configs_sub.add_parser("list", help="list vector configurations")
|
|
288
|
+
list_parser.set_defaults(func=handle_vector_configs_list)
|
|
289
|
+
create = configs_sub.add_parser("create", help="create vector configuration")
|
|
290
|
+
create.add_argument("name")
|
|
291
|
+
create.add_argument("--table", required=True)
|
|
292
|
+
create.add_argument("--embedding-column", required=True)
|
|
293
|
+
create.add_argument("--dimensions", type=_dimensions, required=True)
|
|
294
|
+
create.add_argument("--schema", default="public")
|
|
295
|
+
create.add_argument("--row-id-column", default="id")
|
|
296
|
+
create.add_argument("--metric", choices=["cosine", "inner_product", "l2"], default="cosine")
|
|
297
|
+
create.add_argument("--index-kind", choices=["hnsw", "none"], default="hnsw")
|
|
298
|
+
create.add_argument("--metadata-column", action="append", default=[])
|
|
299
|
+
create.add_argument("--filter-column", action="append", default=[])
|
|
300
|
+
create.set_defaults(func=handle_vector_configs_create)
|
|
301
|
+
delete = configs_sub.add_parser("delete", help="delete vector configuration")
|
|
302
|
+
delete.add_argument("config_id")
|
|
303
|
+
delete.add_argument("--yes", action="store_true")
|
|
304
|
+
delete.set_defaults(func=handle_vector_configs_delete)
|
|
305
|
+
reindex = sub.add_parser("reindex", help="reindex vector configuration")
|
|
306
|
+
reindex.add_argument("config_id")
|
|
307
|
+
reindex.set_defaults(func=handle_vector_reindex)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _add_text_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
311
|
+
parser = subparsers.add_parser("text", help="manage text retrieval")
|
|
312
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
313
|
+
configs = sub.add_parser("configs", help="text configurations")
|
|
314
|
+
configs_sub = configs.add_subparsers(dest="configs_action", required=True)
|
|
315
|
+
list_parser = configs_sub.add_parser("list", help="list text configurations")
|
|
316
|
+
list_parser.set_defaults(func=handle_text_configs_list)
|
|
317
|
+
tsv = configs_sub.add_parser("create-tsvector", help="create TSVector configuration")
|
|
318
|
+
tsv.add_argument("name")
|
|
319
|
+
tsv.add_argument("--table", required=True)
|
|
320
|
+
tsv.add_argument("--tsvector-column")
|
|
321
|
+
tsv.add_argument("--text-column")
|
|
322
|
+
tsv.add_argument("--generated-column")
|
|
323
|
+
tsv.add_argument("--schema", default="public")
|
|
324
|
+
tsv.add_argument("--row-id-column", default="id")
|
|
325
|
+
tsv.add_argument("--language", default="english")
|
|
326
|
+
tsv.add_argument("--metadata-column", action="append", default=[])
|
|
327
|
+
tsv.add_argument("--filter-column", action="append", default=[])
|
|
328
|
+
tsv.add_argument("--yes", action="store_true")
|
|
329
|
+
tsv.set_defaults(func=handle_text_create_tsvector)
|
|
330
|
+
fuzzy = configs_sub.add_parser("create-fuzzy", help="create fuzzy text configuration")
|
|
331
|
+
fuzzy.add_argument("name")
|
|
332
|
+
fuzzy.add_argument("--table", required=True)
|
|
333
|
+
fuzzy.add_argument("--text-column", required=True)
|
|
334
|
+
fuzzy.add_argument("--schema", default="public")
|
|
335
|
+
fuzzy.add_argument("--row-id-column", default="id")
|
|
336
|
+
fuzzy.add_argument("--language", default="english")
|
|
337
|
+
fuzzy.add_argument("--similarity-threshold", type=_similarity_threshold, default=0.3)
|
|
338
|
+
fuzzy.add_argument("--metadata-column", action="append", default=[])
|
|
339
|
+
fuzzy.add_argument("--filter-column", action="append", default=[])
|
|
340
|
+
fuzzy.set_defaults(func=handle_text_create_fuzzy)
|
|
341
|
+
delete = configs_sub.add_parser("delete", help="delete text configuration")
|
|
342
|
+
delete.add_argument("config_id")
|
|
343
|
+
delete.add_argument("--yes", action="store_true")
|
|
344
|
+
delete.set_defaults(func=handle_text_configs_delete)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _add_ready_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
348
|
+
parser = subparsers.add_parser("ready", help="show retrieval readiness")
|
|
349
|
+
parser.set_defaults(func=handle_ready)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _add_config_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
353
|
+
parser = subparsers.add_parser("config", help="local CLI configuration")
|
|
354
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
355
|
+
path = sub.add_parser("path", help="print config path")
|
|
356
|
+
path.set_defaults(func=handle_config_path)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def handle_login(ctx: Context, args: argparse.Namespace) -> int:
|
|
360
|
+
started = ctx.client.start_login({"name": "polygres-cli", "version": VERSION})
|
|
361
|
+
session_id, browser_url, poll_token, expires_at, interval = validate_start_response(started)
|
|
362
|
+
opened = False
|
|
363
|
+
try:
|
|
364
|
+
opened = bool(webbrowser.open(browser_url))
|
|
365
|
+
except Exception: # Browser integrations are platform-specific and fallback is required.
|
|
366
|
+
opened = False
|
|
367
|
+
if not ctx.json and not args.quiet:
|
|
368
|
+
if opened:
|
|
369
|
+
sys.stdout.write("Opened a browser to complete sign-in.\n")
|
|
370
|
+
else:
|
|
371
|
+
sys.stdout.write("Open this URL to complete sign-in:\n")
|
|
372
|
+
sys.stdout.write(f"{browser_url}\n")
|
|
373
|
+
sys.stdout.write(f"Expires: {started['expires_at']}\n")
|
|
374
|
+
elif not opened:
|
|
375
|
+
sys.stderr.write("Open this URL to complete sign-in:\n")
|
|
376
|
+
sys.stderr.write(f"{browser_url}\n")
|
|
377
|
+
sys.stderr.write(f"Expires: {started['expires_at']}\n")
|
|
378
|
+
|
|
379
|
+
now = datetime.now(timezone.utc)
|
|
380
|
+
session_seconds = max((expires_at - now).total_seconds(), 0.0)
|
|
381
|
+
deadline = time.monotonic() + min(float(args.timeout), session_seconds)
|
|
382
|
+
status = "pending"
|
|
383
|
+
last_payload: dict[str, Any] = {"status": status}
|
|
384
|
+
while time.monotonic() < deadline:
|
|
385
|
+
_sleep_until_deadline(interval, deadline)
|
|
386
|
+
if time.monotonic() >= deadline:
|
|
387
|
+
break
|
|
388
|
+
last_payload = ctx.client.poll_login(session_id, poll_token, deadline=deadline)
|
|
389
|
+
status = last_payload.get("status")
|
|
390
|
+
if status == "pending":
|
|
391
|
+
interval = _poll_interval(last_payload)
|
|
392
|
+
continue
|
|
393
|
+
if status == "approved":
|
|
394
|
+
auth = validated_approved_auth(last_payload)
|
|
395
|
+
ctx.config["auth"] = auth
|
|
396
|
+
ctx.save()
|
|
397
|
+
output = {"authenticated": True, "user": auth["user"]}
|
|
398
|
+
if ctx.json:
|
|
399
|
+
write_json(output)
|
|
400
|
+
elif not args.quiet:
|
|
401
|
+
user = auth["user"]
|
|
402
|
+
sys.stdout.write(f"Signed in as {user.get('email') or user.get('id') or 'user'}.\n")
|
|
403
|
+
return SUCCESS
|
|
404
|
+
if status == "denied":
|
|
405
|
+
raise CliError("AUTH_DENIED", "Sign-in was denied.", exit_code=AUTH)
|
|
406
|
+
if status == "expired":
|
|
407
|
+
raise CliError("AUTH_EXPIRED", "Sign-in session expired.", exit_code=AUTH)
|
|
408
|
+
raise CliError(
|
|
409
|
+
"AUTH_RESPONSE_INVALID",
|
|
410
|
+
"Authentication poll returned an unknown status.",
|
|
411
|
+
exit_code=AUTH,
|
|
412
|
+
details={"status": status},
|
|
413
|
+
)
|
|
414
|
+
if expires_at <= datetime.now(timezone.utc):
|
|
415
|
+
raise CliError("AUTH_EXPIRED", "Sign-in session expired.", exit_code=AUTH)
|
|
416
|
+
raise CliError(
|
|
417
|
+
"AUTH_TIMEOUT",
|
|
418
|
+
"Timed out waiting for sign-in approval.",
|
|
419
|
+
exit_code=UNAVAILABLE,
|
|
420
|
+
details={"status": status, "expires_at": started.get("expires_at")},
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def handle_logout(ctx: Context, args: argparse.Namespace) -> int:
|
|
425
|
+
token = refresh_token(ctx.config)
|
|
426
|
+
if token:
|
|
427
|
+
try:
|
|
428
|
+
ctx.client.revoke_login(token)
|
|
429
|
+
except CliError:
|
|
430
|
+
pass
|
|
431
|
+
clear_auth(ctx.config)
|
|
432
|
+
ctx.save()
|
|
433
|
+
if ctx.json:
|
|
434
|
+
write_json({"logged_out": True})
|
|
435
|
+
elif not args.quiet:
|
|
436
|
+
sys.stdout.write("Logged out.\n")
|
|
437
|
+
return SUCCESS
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def handle_whoami(ctx: Context, args: argparse.Namespace) -> int:
|
|
441
|
+
payload = ctx.client.me()
|
|
442
|
+
output = {
|
|
443
|
+
"profile": payload.get("profile") or payload.get("user") or {},
|
|
444
|
+
"organization": payload.get("organization") or {},
|
|
445
|
+
"membership": payload.get("membership") or {},
|
|
446
|
+
"project_count": payload.get("project_count", 0),
|
|
447
|
+
"gate_destination": payload.get("gate_destination"),
|
|
448
|
+
"request_id": payload.get("request_id"),
|
|
449
|
+
}
|
|
450
|
+
organization = output["organization"]
|
|
451
|
+
org_label = ""
|
|
452
|
+
if isinstance(organization, dict):
|
|
453
|
+
org_label = str(organization.get("name") or organization.get("id") or "")
|
|
454
|
+
role = organization.get("role")
|
|
455
|
+
if role:
|
|
456
|
+
org_label = f"{org_label} ({role})" if org_label else str(role)
|
|
457
|
+
return _emit(
|
|
458
|
+
ctx,
|
|
459
|
+
output,
|
|
460
|
+
[("User", output["profile"].get("email", "")), ("Organization", org_label)],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def handle_projects_list(ctx: Context, args: argparse.Namespace) -> int:
|
|
465
|
+
payload = ctx.client.list_projects()
|
|
466
|
+
projects = _items(payload, "projects")
|
|
467
|
+
output = {
|
|
468
|
+
"projects": projects,
|
|
469
|
+
"selected_project_id": ctx.selected_project_id,
|
|
470
|
+
"request_id": payload.get("request_id"),
|
|
471
|
+
}
|
|
472
|
+
if ctx.json:
|
|
473
|
+
write_json(output)
|
|
474
|
+
elif not ctx.quiet:
|
|
475
|
+
columns = (
|
|
476
|
+
["external_id", "name", "status"]
|
|
477
|
+
if _has_external_ids(projects)
|
|
478
|
+
else ["id", "name", "status"]
|
|
479
|
+
)
|
|
480
|
+
print_table(output["projects"], columns)
|
|
481
|
+
return SUCCESS
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def handle_projects_use(ctx: Context, args: argparse.Namespace) -> int:
|
|
485
|
+
project = _resolve_project(ctx, args.project)
|
|
486
|
+
project_id = _project_api_id(project)
|
|
487
|
+
ctx.config["selected_project_id"] = project_id
|
|
488
|
+
ctx.save()
|
|
489
|
+
output = {
|
|
490
|
+
"project": project,
|
|
491
|
+
"selected_project_id": project_id,
|
|
492
|
+
"request_id": project.get("request_id"),
|
|
493
|
+
}
|
|
494
|
+
if ctx.json:
|
|
495
|
+
write_json(output)
|
|
496
|
+
elif not ctx.quiet:
|
|
497
|
+
sys.stdout.write(
|
|
498
|
+
f"Selected project: {project.get('name', project_id)} ({project_id})\n"
|
|
499
|
+
)
|
|
500
|
+
return SUCCESS
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def handle_projects_create(ctx: Context, args: argparse.Namespace) -> int:
|
|
504
|
+
deadline = time.monotonic() + args.timeout
|
|
505
|
+
payload = ctx.client.create_project(
|
|
506
|
+
args.name, request_timeout=float(args.timeout), deadline=deadline
|
|
507
|
+
)
|
|
508
|
+
project = _object(payload, "project")
|
|
509
|
+
status = payload.get("status") if isinstance(payload.get("status"), dict) else None
|
|
510
|
+
project_id = _project_api_id(project)
|
|
511
|
+
if not args.no_wait:
|
|
512
|
+
try:
|
|
513
|
+
status = _poll_project_status(ctx, project_id, deadline=deadline)
|
|
514
|
+
except CliError as exc:
|
|
515
|
+
raise _project_create_wait_error(
|
|
516
|
+
project=project,
|
|
517
|
+
project_id=project_id,
|
|
518
|
+
create_request_id=payload.get("request_id"),
|
|
519
|
+
cause=exc,
|
|
520
|
+
) from exc
|
|
521
|
+
output = {"project": project, "request_id": payload.get("request_id")}
|
|
522
|
+
if status is not None:
|
|
523
|
+
output["status"] = status
|
|
524
|
+
return _emit(
|
|
525
|
+
ctx,
|
|
526
|
+
output,
|
|
527
|
+
[
|
|
528
|
+
("Project", project.get("external_id") or project.get("id", "")),
|
|
529
|
+
(
|
|
530
|
+
"Status",
|
|
531
|
+
(status.get("project") or status.get("status"))
|
|
532
|
+
if isinstance(status, dict)
|
|
533
|
+
else project.get("status", ""),
|
|
534
|
+
),
|
|
535
|
+
],
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def handle_projects_status(ctx: Context, args: argparse.Namespace) -> int:
|
|
540
|
+
project_id = _resolve_project_id(ctx, args.project)
|
|
541
|
+
payload = ctx.client.get_project_status(project_id)
|
|
542
|
+
output = _project_status_output(project_id, payload)
|
|
543
|
+
return _emit(
|
|
544
|
+
ctx,
|
|
545
|
+
output,
|
|
546
|
+
[
|
|
547
|
+
("Project", project_id),
|
|
548
|
+
("Project status", output["project"].get("status", "")),
|
|
549
|
+
("Runtime status", _summary_value(output["runtime"])),
|
|
550
|
+
("Resource pressure", _resource_pressure(output["resources"])),
|
|
551
|
+
("Readiness", _summary_value(output["readiness"])),
|
|
552
|
+
],
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def handle_env(ctx: Context, args: argparse.Namespace) -> int:
|
|
557
|
+
project_id = _resolve_project_id(ctx, None)
|
|
558
|
+
conn = ctx.client.connection_info(project_id)
|
|
559
|
+
keys = ctx.client.list_api_keys(project_id)
|
|
560
|
+
output = {
|
|
561
|
+
"env": {
|
|
562
|
+
"DATABASE_URL": _remove_pgbouncer_query(
|
|
563
|
+
conn.get("pooled", {}).get("connection_string_without_password")
|
|
564
|
+
),
|
|
565
|
+
"DIRECT_URL": _passwordless_url(
|
|
566
|
+
conn.get("direct", {}).get("connection_string_without_password")
|
|
567
|
+
),
|
|
568
|
+
"POLYGRES_RUNTIME_URL": conn.get("runtime_api_url")
|
|
569
|
+
or conn.get("runtime", {}).get("url"),
|
|
570
|
+
},
|
|
571
|
+
"api_keys": [_sanitize_key(key) for key in _items(keys, "api_keys", "keys")],
|
|
572
|
+
"request_id": conn.get("request_id") or keys.get("request_id"),
|
|
573
|
+
}
|
|
574
|
+
if ctx.json:
|
|
575
|
+
write_json(redact(output))
|
|
576
|
+
elif not ctx.quiet:
|
|
577
|
+
for key, value in output["env"].items():
|
|
578
|
+
if value:
|
|
579
|
+
sys.stdout.write(f"export {key}={shlex.quote(str(value))}\n")
|
|
580
|
+
sys.stdout.write("# POLYGRES_API_KEY is not shown by default. Create one with:\n")
|
|
581
|
+
sys.stdout.write("# polygres keys create <name>\n")
|
|
582
|
+
return SUCCESS
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def handle_db_info(ctx: Context, args: argparse.Namespace) -> int:
|
|
586
|
+
project_id = _resolve_project_id(ctx, None)
|
|
587
|
+
payload = ctx.client.connection_info(project_id)
|
|
588
|
+
database = _database_output(payload)
|
|
589
|
+
output = {"database": database, "request_id": payload.get("request_id")}
|
|
590
|
+
return _emit(
|
|
591
|
+
ctx,
|
|
592
|
+
output,
|
|
593
|
+
[
|
|
594
|
+
("Project", database.get("project_id")),
|
|
595
|
+
("Database", database.get("database")),
|
|
596
|
+
("Username", database.get("username")),
|
|
597
|
+
("Port", database.get("port")),
|
|
598
|
+
("Direct host", database.get("direct_host")),
|
|
599
|
+
("Pooled host", database.get("pooled_host")),
|
|
600
|
+
("Ready", database.get("ready")),
|
|
601
|
+
],
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def handle_db_psql(ctx: Context, args: argparse.Namespace) -> int:
|
|
606
|
+
project_id = _resolve_project_id(ctx, None)
|
|
607
|
+
payload = ctx.client.connection_info(project_id)
|
|
608
|
+
database = _database_output(payload)
|
|
609
|
+
command = [
|
|
610
|
+
"psql",
|
|
611
|
+
"--host",
|
|
612
|
+
str(database["direct_host"]),
|
|
613
|
+
"--port",
|
|
614
|
+
str(database["port"]),
|
|
615
|
+
"--username",
|
|
616
|
+
str(database["username"]),
|
|
617
|
+
"--dbname",
|
|
618
|
+
str(database["database"]),
|
|
619
|
+
]
|
|
620
|
+
env = {"PGSSLMODE": "require"}
|
|
621
|
+
if shutil.which("psql") is None:
|
|
622
|
+
if ctx.json:
|
|
623
|
+
write_json(
|
|
624
|
+
{
|
|
625
|
+
"command": command,
|
|
626
|
+
"env": env,
|
|
627
|
+
"executed": False,
|
|
628
|
+
"request_id": payload.get("request_id"),
|
|
629
|
+
}
|
|
630
|
+
)
|
|
631
|
+
elif not ctx.quiet:
|
|
632
|
+
sys.stdout.write("psql is not installed or not on PATH.\n\n")
|
|
633
|
+
sys.stdout.write("Run after installing psql:\n")
|
|
634
|
+
sys.stdout.write("PGSSLMODE=require " + " ".join(command) + "\n")
|
|
635
|
+
return LOCAL_DEPENDENCY
|
|
636
|
+
if ctx.json:
|
|
637
|
+
write_json(
|
|
638
|
+
{
|
|
639
|
+
"command": command,
|
|
640
|
+
"env": env,
|
|
641
|
+
"executed": True,
|
|
642
|
+
"request_id": payload.get("request_id"),
|
|
643
|
+
}
|
|
644
|
+
)
|
|
645
|
+
return SUCCESS
|
|
646
|
+
child_env = dict(os.environ)
|
|
647
|
+
child_env.pop("PGPASSWORD", None)
|
|
648
|
+
child_env.update(env)
|
|
649
|
+
return subprocess.run(command, env=child_env, check=False).returncode
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def handle_keys_list(ctx: Context, args: argparse.Namespace) -> int:
|
|
653
|
+
project_id = _resolve_project_id(ctx, None)
|
|
654
|
+
payload = ctx.client.list_api_keys(project_id)
|
|
655
|
+
output = {
|
|
656
|
+
"keys": [_sanitize_key(key) for key in _items(payload, "api_keys", "keys")],
|
|
657
|
+
"request_id": payload.get("request_id"),
|
|
658
|
+
}
|
|
659
|
+
if ctx.json:
|
|
660
|
+
write_json(redact(output))
|
|
661
|
+
elif not ctx.quiet:
|
|
662
|
+
print_table(output["keys"], ["id", "name", "prefix", "status"])
|
|
663
|
+
return SUCCESS
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def handle_keys_create(ctx: Context, args: argparse.Namespace) -> int:
|
|
667
|
+
project_id = _resolve_project_id(ctx, None)
|
|
668
|
+
payload = ctx.client.create_api_key(project_id, args.name)
|
|
669
|
+
key = _normalize_created_key(payload)
|
|
670
|
+
secret = key.get("secret")
|
|
671
|
+
if not isinstance(secret, str) or not secret:
|
|
672
|
+
raise CliError(
|
|
673
|
+
"API_KEY_RESPONSE_INVALID",
|
|
674
|
+
"API key creation response did not include the one-time secret.",
|
|
675
|
+
request_id=payload.get("request_id"),
|
|
676
|
+
)
|
|
677
|
+
output = {"key": key, "request_id": payload.get("request_id")}
|
|
678
|
+
if ctx.json:
|
|
679
|
+
write_json(output)
|
|
680
|
+
elif not ctx.quiet:
|
|
681
|
+
if sys.stdout.isatty():
|
|
682
|
+
sys.stdout.write("This key is shown once. Store it now.\n\n")
|
|
683
|
+
elif sys.stderr.isatty():
|
|
684
|
+
sys.stderr.write("This key is shown once. Store it now.\n")
|
|
685
|
+
sys.stdout.write(str(key.get("secret", "")) + "\n")
|
|
686
|
+
return SUCCESS
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def handle_keys_revoke(ctx: Context, args: argparse.Namespace) -> int:
|
|
690
|
+
_validate_uuid(args.key_id, "key ID")
|
|
691
|
+
project_hint = ctx.args.project or ctx.selected_project_id or "the selected project"
|
|
692
|
+
_require_confirmation(ctx, args.yes, f"Revoke key {args.key_id} for project {project_hint}?")
|
|
693
|
+
project_id = _resolve_project_id(ctx, None)
|
|
694
|
+
payload = ctx.client.revoke_api_key(project_id, args.key_id)
|
|
695
|
+
key = payload.get("key") or payload.get("api_key") or {"id": args.key_id, "status": "revoked"}
|
|
696
|
+
output = {"key": _sanitize_key(key), "revoked": True, "request_id": payload.get("request_id")}
|
|
697
|
+
return _emit(ctx, output, [("Revoked", args.key_id)])
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def handle_migrations_list(ctx: Context, args: argparse.Namespace) -> int:
|
|
701
|
+
project_id = _resolve_project_id(ctx, None)
|
|
702
|
+
payload = ctx.client.migrations_list(project_id)
|
|
703
|
+
output = {"migrations": _items(payload, "migrations"), "request_id": payload.get("request_id")}
|
|
704
|
+
if ctx.json:
|
|
705
|
+
write_json(output)
|
|
706
|
+
elif not ctx.quiet:
|
|
707
|
+
print_table(output["migrations"], ["id", "name", "version", "status"])
|
|
708
|
+
return SUCCESS
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def handle_migrations_apply(ctx: Context, args: argparse.Namespace) -> int:
|
|
712
|
+
sql_path = _readable_file(args.file)
|
|
713
|
+
if args.name:
|
|
714
|
+
_validate_migration_name(args.name)
|
|
715
|
+
name = args.name
|
|
716
|
+
else:
|
|
717
|
+
name = _migration_name(sql_path.stem)
|
|
718
|
+
sql_body = _read_text_file(sql_path)
|
|
719
|
+
project_id = _resolve_project_id(ctx, None)
|
|
720
|
+
created = ctx.client.migrations_create(project_id, name, sql_body)
|
|
721
|
+
migration = _object(created, "migration")
|
|
722
|
+
migration_id = migration.get("id")
|
|
723
|
+
_validate_response_uuid(migration_id, "migration")
|
|
724
|
+
applied = ctx.client.migrations_apply(project_id, migration_id)
|
|
725
|
+
applied_migration = applied.get("migration", migration)
|
|
726
|
+
create_operation = (
|
|
727
|
+
created.get("operation") if isinstance(created.get("operation"), dict) else {}
|
|
728
|
+
)
|
|
729
|
+
apply_operation = (
|
|
730
|
+
applied.get("operation") if isinstance(applied.get("operation"), dict) else {}
|
|
731
|
+
)
|
|
732
|
+
status = applied_migration.get("status")
|
|
733
|
+
output = {
|
|
734
|
+
"migration": applied_migration,
|
|
735
|
+
"operation": {
|
|
736
|
+
"created": bool(create_operation.get("created", True)),
|
|
737
|
+
"applied": bool(apply_operation.get("applied", status == "applied")),
|
|
738
|
+
"noop": bool(create_operation.get("noop") or apply_operation.get("noop")),
|
|
739
|
+
},
|
|
740
|
+
"request_id": applied.get("request_id") or created.get("request_id"),
|
|
741
|
+
}
|
|
742
|
+
_emit(
|
|
743
|
+
ctx,
|
|
744
|
+
output,
|
|
745
|
+
[
|
|
746
|
+
("Migration", output["migration"].get("name", name)),
|
|
747
|
+
("Status", output["migration"].get("status", "")),
|
|
748
|
+
],
|
|
749
|
+
)
|
|
750
|
+
return GENERAL_FAILURE if status == "failed" else SUCCESS
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def handle_graph_discover(ctx: Context, args: argparse.Namespace) -> int:
|
|
754
|
+
project_id = _resolve_project_id(ctx, None)
|
|
755
|
+
response = ctx.client.graph_discover(project_id)
|
|
756
|
+
return _emit_configuration(
|
|
757
|
+
ctx,
|
|
758
|
+
{
|
|
759
|
+
"configuration": _graph_discovery_configuration(response),
|
|
760
|
+
"request_id": response.get("request_id"),
|
|
761
|
+
},
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def handle_graph_config_export(ctx: Context, args: argparse.Namespace) -> int:
|
|
766
|
+
project_id = _resolve_project_id(ctx, None)
|
|
767
|
+
return _emit_configuration(ctx, ctx.client.get_graph_configuration(project_id))
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def handle_graph_config_apply(ctx: Context, args: argparse.Namespace) -> int:
|
|
771
|
+
payload = _graph_configuration_file(args.file)
|
|
772
|
+
project_id = _resolve_project_id(ctx, None)
|
|
773
|
+
response = ctx.client.put_graph_configuration(project_id, payload)
|
|
774
|
+
return _emit_configuration(
|
|
775
|
+
ctx,
|
|
776
|
+
response,
|
|
777
|
+
operation=response.get("operation", {"applied": True}),
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def handle_graph_build(ctx: Context, args: argparse.Namespace) -> int:
|
|
782
|
+
project_id = _resolve_project_id(ctx, None)
|
|
783
|
+
payload = ctx.client.graph_build(project_id)
|
|
784
|
+
operation = payload.get("operation")
|
|
785
|
+
if not isinstance(operation, dict):
|
|
786
|
+
operation = {
|
|
787
|
+
"build_started": True,
|
|
788
|
+
"build_completed": operation == "completed"
|
|
789
|
+
or payload.get("build_status") == "ready"
|
|
790
|
+
or payload.get("configuration", {}).get("build_status") == "ready",
|
|
791
|
+
}
|
|
792
|
+
output = {
|
|
793
|
+
"graph": payload.get("graph", payload.get("configuration", {})),
|
|
794
|
+
"operation": operation,
|
|
795
|
+
"request_id": payload.get("request_id"),
|
|
796
|
+
}
|
|
797
|
+
return _emit(ctx, output, [("Graph", output["graph"].get("build_status", ""))])
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def handle_graph_status(ctx: Context, args: argparse.Namespace) -> int:
|
|
801
|
+
project_id = _resolve_project_id(ctx, None)
|
|
802
|
+
payload = ctx.client.graph_status(project_id)
|
|
803
|
+
graph = payload.get("graph") or payload.get("status") or {}
|
|
804
|
+
return _emit(
|
|
805
|
+
ctx,
|
|
806
|
+
{"graph": graph, "request_id": payload.get("request_id")},
|
|
807
|
+
[("Graph", graph.get("build_status", ""))],
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def handle_vector_configs_list(ctx: Context, args: argparse.Namespace) -> int:
|
|
812
|
+
project_id = _resolve_project_id(ctx, None)
|
|
813
|
+
payload = ctx.client.list_vector_configurations(project_id)
|
|
814
|
+
output = {
|
|
815
|
+
"configurations": _items(payload, "configurations", "vector_configurations"),
|
|
816
|
+
"request_id": payload.get("request_id"),
|
|
817
|
+
}
|
|
818
|
+
if ctx.json:
|
|
819
|
+
write_json(output)
|
|
820
|
+
elif not ctx.quiet:
|
|
821
|
+
print_table(
|
|
822
|
+
output["configurations"],
|
|
823
|
+
["id", "name", "index_status", "dimensions", "metric"],
|
|
824
|
+
)
|
|
825
|
+
return SUCCESS
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def handle_vector_configs_create(ctx: Context, args: argparse.Namespace) -> int:
|
|
829
|
+
_validate_identifiers(args.schema, args.table, args.row_id_column, args.embedding_column)
|
|
830
|
+
_validate_identifiers(*args.metadata_column, *args.filter_column)
|
|
831
|
+
payload = {
|
|
832
|
+
"name": args.name,
|
|
833
|
+
"schema_name": args.schema,
|
|
834
|
+
"table_name": args.table,
|
|
835
|
+
"row_id_column": args.row_id_column,
|
|
836
|
+
"embedding_column": args.embedding_column,
|
|
837
|
+
"dimensions": args.dimensions,
|
|
838
|
+
"metric": args.metric,
|
|
839
|
+
"metadata_columns": args.metadata_column,
|
|
840
|
+
"filter_columns": args.filter_column,
|
|
841
|
+
"index_kind": args.index_kind,
|
|
842
|
+
}
|
|
843
|
+
project_id = _resolve_project_id(ctx, None)
|
|
844
|
+
response = ctx.client.create_vector_configuration(project_id, payload)
|
|
845
|
+
return _emit_config_response(ctx, response)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def handle_vector_configs_delete(ctx: Context, args: argparse.Namespace) -> int:
|
|
849
|
+
_validate_uuid(args.config_id, "configuration ID")
|
|
850
|
+
_require_confirmation(ctx, args.yes, f"Delete vector configuration {args.config_id}?")
|
|
851
|
+
project_id = _resolve_project_id(ctx, None)
|
|
852
|
+
response = ctx.client.delete_vector_configuration(project_id, args.config_id)
|
|
853
|
+
return _emit_config_response(ctx, response, default_operation={"deleted": True})
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def handle_vector_reindex(ctx: Context, args: argparse.Namespace) -> int:
|
|
857
|
+
_validate_uuid(args.config_id, "configuration ID")
|
|
858
|
+
project_id = _resolve_project_id(ctx, None)
|
|
859
|
+
response = ctx.client.reindex_vector_configuration(project_id, args.config_id)
|
|
860
|
+
return _emit_config_response(ctx, response, default_operation={"reindexed": True})
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def handle_text_configs_list(ctx: Context, args: argparse.Namespace) -> int:
|
|
864
|
+
project_id = _resolve_project_id(ctx, None)
|
|
865
|
+
payload = ctx.client.list_text_configurations(project_id)
|
|
866
|
+
output = {
|
|
867
|
+
"configurations": _items(payload, "configurations", "text_configurations"),
|
|
868
|
+
"request_id": payload.get("request_id"),
|
|
869
|
+
}
|
|
870
|
+
if ctx.json:
|
|
871
|
+
write_json(output)
|
|
872
|
+
elif not ctx.quiet:
|
|
873
|
+
print_table(output["configurations"], ["id", "name", "search_kind", "index_status"])
|
|
874
|
+
return SUCCESS
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def handle_text_create_tsvector(ctx: Context, args: argparse.Namespace) -> int:
|
|
878
|
+
generated_mode = bool(args.text_column or args.generated_column)
|
|
879
|
+
existing_mode = bool(args.tsvector_column)
|
|
880
|
+
if existing_mode == generated_mode:
|
|
881
|
+
raise CliError(
|
|
882
|
+
"VALIDATION_ERROR",
|
|
883
|
+
"Provide exactly one of --tsvector-column or --text-column with --generated-column.",
|
|
884
|
+
exit_code=USAGE,
|
|
885
|
+
)
|
|
886
|
+
_validate_identifiers(args.schema, args.table, args.row_id_column, args.language)
|
|
887
|
+
_validate_identifiers(*args.metadata_column, *args.filter_column)
|
|
888
|
+
migration: dict[str, Any] | None = None
|
|
889
|
+
if existing_mode:
|
|
890
|
+
_validate_identifiers(args.tsvector_column)
|
|
891
|
+
tsvector_column = args.tsvector_column
|
|
892
|
+
generated_column_created = False
|
|
893
|
+
else:
|
|
894
|
+
if not args.text_column or not args.generated_column:
|
|
895
|
+
raise CliError(
|
|
896
|
+
"VALIDATION_ERROR",
|
|
897
|
+
"Generated-column mode requires --text-column and --generated-column.",
|
|
898
|
+
exit_code=USAGE,
|
|
899
|
+
)
|
|
900
|
+
_require_confirmation(ctx, args.yes, "Create a generated tsvector column?")
|
|
901
|
+
_validate_identifiers(args.text_column, args.generated_column)
|
|
902
|
+
tsvector_column = args.generated_column
|
|
903
|
+
migration_name = _migration_name(
|
|
904
|
+
f"m_{args.name}_{args.generated_column}_generated_tsvector"
|
|
905
|
+
)
|
|
906
|
+
sql_body = _generated_tsvector_sql(
|
|
907
|
+
args.schema, args.table, args.text_column, args.generated_column, args.language
|
|
908
|
+
)
|
|
909
|
+
project_id = _resolve_project_id(ctx, None)
|
|
910
|
+
created = ctx.client.migrations_create(project_id, migration_name, sql_body)
|
|
911
|
+
migration_id = _object(created, "migration").get("id")
|
|
912
|
+
_validate_response_uuid(migration_id, "migration")
|
|
913
|
+
applied = ctx.client.migrations_apply(project_id, migration_id)
|
|
914
|
+
migration = applied.get("migration") or created.get("migration")
|
|
915
|
+
if not isinstance(migration, dict) or migration.get("status") == "failed":
|
|
916
|
+
raise CliError(
|
|
917
|
+
"MIGRATION_APPLY_FAILED",
|
|
918
|
+
str(
|
|
919
|
+
migration.get("error_message")
|
|
920
|
+
if isinstance(migration, dict)
|
|
921
|
+
else "Generated-column migration failed."
|
|
922
|
+
),
|
|
923
|
+
request_id=applied.get("request_id"),
|
|
924
|
+
)
|
|
925
|
+
generated_column_created = True
|
|
926
|
+
if existing_mode:
|
|
927
|
+
project_id = _resolve_project_id(ctx, None)
|
|
928
|
+
payload = {
|
|
929
|
+
"name": args.name,
|
|
930
|
+
"search_kind": "tsvector",
|
|
931
|
+
"schema_name": args.schema,
|
|
932
|
+
"table_name": args.table,
|
|
933
|
+
"row_id_column": args.row_id_column,
|
|
934
|
+
"row_id_columns": [args.row_id_column],
|
|
935
|
+
"tsvector_column": tsvector_column,
|
|
936
|
+
"language": args.language,
|
|
937
|
+
"metadata_columns": args.metadata_column,
|
|
938
|
+
"filter_columns": args.filter_column,
|
|
939
|
+
}
|
|
940
|
+
try:
|
|
941
|
+
response = ctx.client.create_text_configuration(project_id, payload)
|
|
942
|
+
except CliError as exc:
|
|
943
|
+
if migration is not None:
|
|
944
|
+
exc.message = (
|
|
945
|
+
f"{exc.message} The generated column migration was applied; "
|
|
946
|
+
"the generated column may already exist."
|
|
947
|
+
)
|
|
948
|
+
raise
|
|
949
|
+
output = {
|
|
950
|
+
"configuration": response.get("configuration", {}),
|
|
951
|
+
"operation": {
|
|
952
|
+
**(response.get("operation") if isinstance(response.get("operation"), dict) else {}),
|
|
953
|
+
"generated_column_created": generated_column_created,
|
|
954
|
+
},
|
|
955
|
+
"request_id": response.get("request_id"),
|
|
956
|
+
}
|
|
957
|
+
if migration is not None:
|
|
958
|
+
output["migration"] = migration
|
|
959
|
+
return _emit(ctx, output, [("Configuration", output["configuration"].get("id", ""))])
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def handle_text_create_fuzzy(ctx: Context, args: argparse.Namespace) -> int:
|
|
963
|
+
_validate_identifiers(
|
|
964
|
+
args.schema, args.table, args.row_id_column, args.text_column, args.language
|
|
965
|
+
)
|
|
966
|
+
_validate_identifiers(*args.metadata_column, *args.filter_column)
|
|
967
|
+
payload = {
|
|
968
|
+
"name": args.name,
|
|
969
|
+
"search_kind": "fuzzy",
|
|
970
|
+
"schema_name": args.schema,
|
|
971
|
+
"table_name": args.table,
|
|
972
|
+
"row_id_column": args.row_id_column,
|
|
973
|
+
"row_id_columns": [args.row_id_column],
|
|
974
|
+
"text_column": args.text_column,
|
|
975
|
+
"language": args.language,
|
|
976
|
+
"metadata_columns": args.metadata_column,
|
|
977
|
+
"filter_columns": args.filter_column,
|
|
978
|
+
"similarity_threshold": args.similarity_threshold,
|
|
979
|
+
}
|
|
980
|
+
project_id = _resolve_project_id(ctx, None)
|
|
981
|
+
response = ctx.client.create_text_configuration(project_id, payload)
|
|
982
|
+
return _emit_config_response(ctx, response)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def handle_text_configs_delete(ctx: Context, args: argparse.Namespace) -> int:
|
|
986
|
+
_validate_uuid(args.config_id, "configuration ID")
|
|
987
|
+
_require_confirmation(ctx, args.yes, f"Delete text configuration {args.config_id}?")
|
|
988
|
+
project_id = _resolve_project_id(ctx, None)
|
|
989
|
+
response = ctx.client.delete_text_configuration(project_id, args.config_id)
|
|
990
|
+
return _emit_config_response(ctx, response, default_operation={"deleted": True})
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def handle_import_csv(ctx: Context, args: argparse.Namespace) -> int:
|
|
994
|
+
file_path = _readable_file(args.file)
|
|
995
|
+
try:
|
|
996
|
+
file_size = file_path.stat().st_size
|
|
997
|
+
except OSError as exc:
|
|
998
|
+
raise CliError(
|
|
999
|
+
"VALIDATION_ERROR", f"Unable to read file: {file_path}", exit_code=USAGE
|
|
1000
|
+
) from exc
|
|
1001
|
+
if file_size > MAX_CSV_UPLOAD_BYTES:
|
|
1002
|
+
raise CliError(
|
|
1003
|
+
"IMPORT_LIMIT_EXCEEDED",
|
|
1004
|
+
"CSV file exceeds the 1 GiB local upload limit.",
|
|
1005
|
+
exit_code=USAGE,
|
|
1006
|
+
details={"limit_bytes": MAX_CSV_UPLOAD_BYTES, "file_size_bytes": file_size},
|
|
1007
|
+
)
|
|
1008
|
+
_validate_identifiers(args.schema, args.table)
|
|
1009
|
+
preview_fields = {
|
|
1010
|
+
"target_schema": args.schema,
|
|
1011
|
+
"target_table": args.table,
|
|
1012
|
+
"mode": args.mode,
|
|
1013
|
+
"encoding": args.encoding,
|
|
1014
|
+
"has_header": "false" if args.no_header else "true",
|
|
1015
|
+
"sample_row_count": "50",
|
|
1016
|
+
}
|
|
1017
|
+
for cli_name, field_name in [
|
|
1018
|
+
("delimiter", "delimiter"),
|
|
1019
|
+
("quote_char", "quote_char"),
|
|
1020
|
+
("escape_char", "escape_char"),
|
|
1021
|
+
]:
|
|
1022
|
+
value = getattr(args, cli_name)
|
|
1023
|
+
if value is not None:
|
|
1024
|
+
preview_fields[field_name] = value
|
|
1025
|
+
project_id = _resolve_project_id(ctx, None)
|
|
1026
|
+
preview = ctx.client.csv_preview(project_id, file_path, preview_fields)
|
|
1027
|
+
preview_payload = preview.get("preview") if isinstance(preview.get("preview"), dict) else {}
|
|
1028
|
+
job_id = preview_payload.get("job_id")
|
|
1029
|
+
if not isinstance(job_id, str):
|
|
1030
|
+
raise CliError("IMPORT_INVALID", "CSV preview response did not include a job ID.")
|
|
1031
|
+
_validate_response_uuid(job_id, "import preview job")
|
|
1032
|
+
columns = preview_payload.get("columns")
|
|
1033
|
+
if not isinstance(columns, list):
|
|
1034
|
+
raise CliError("IMPORT_INVALID", "CSV preview response did not include columns.")
|
|
1035
|
+
import_fields = {
|
|
1036
|
+
"target_schema": args.schema,
|
|
1037
|
+
"target_table": args.table,
|
|
1038
|
+
"mode": args.mode,
|
|
1039
|
+
"encoding": str(preview_payload.get("encoding", args.encoding)),
|
|
1040
|
+
"delimiter": str(preview_payload.get("delimiter", args.delimiter or "")),
|
|
1041
|
+
"quote_char": str(preview_payload.get("quote_char", args.quote_char or '"')),
|
|
1042
|
+
"has_header": (
|
|
1043
|
+
"true" if preview_payload.get("has_header", not args.no_header) else "false"
|
|
1044
|
+
),
|
|
1045
|
+
}
|
|
1046
|
+
effective_escape = preview_payload.get("escape_char", args.escape_char)
|
|
1047
|
+
if effective_escape is not None:
|
|
1048
|
+
import_fields["escape_char"] = str(effective_escape)
|
|
1049
|
+
import_fields.update({"job_id": job_id, "columns": json.dumps(columns)})
|
|
1050
|
+
started = ctx.client.start_csv_import(project_id, file_path, import_fields)
|
|
1051
|
+
output = _import_output(started)
|
|
1052
|
+
if args.wait and output["import"].get("status") not in {"succeeded", "failed", "cancelled"}:
|
|
1053
|
+
import_id = output["import"].get("id")
|
|
1054
|
+
_validate_response_uuid(import_id, "import job")
|
|
1055
|
+
output = _poll_import(ctx, project_id, import_id, args.timeout, started)
|
|
1056
|
+
if ctx.json:
|
|
1057
|
+
write_json(output)
|
|
1058
|
+
elif not ctx.quiet:
|
|
1059
|
+
_write_import_human(output)
|
|
1060
|
+
return _import_exit_code(output["import"])
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def handle_import_status(ctx: Context, args: argparse.Namespace) -> int:
|
|
1064
|
+
if args.job_id:
|
|
1065
|
+
_validate_uuid(args.job_id, "import job ID")
|
|
1066
|
+
project_id = _resolve_project_id(ctx, None)
|
|
1067
|
+
if args.job_id:
|
|
1068
|
+
job_id = args.job_id
|
|
1069
|
+
else:
|
|
1070
|
+
imports_payload = ctx.client.list_imports(project_id)
|
|
1071
|
+
imports = _items(imports_payload, "imports")
|
|
1072
|
+
job_id = _latest_import_id(imports)
|
|
1073
|
+
if job_id is None:
|
|
1074
|
+
output = {
|
|
1075
|
+
"imports": imports,
|
|
1076
|
+
"latest_import": None,
|
|
1077
|
+
"request_id": imports_payload.get("request_id"),
|
|
1078
|
+
}
|
|
1079
|
+
if ctx.json:
|
|
1080
|
+
write_json(output)
|
|
1081
|
+
elif not ctx.quiet:
|
|
1082
|
+
sys.stdout.write("No imports found.\n")
|
|
1083
|
+
if output.get("request_id"):
|
|
1084
|
+
sys.stdout.write(f"Request ID: {output['request_id']}\n")
|
|
1085
|
+
return SUCCESS
|
|
1086
|
+
_validate_uuid(job_id, "import job ID")
|
|
1087
|
+
output = _import_output(ctx.client.get_import(project_id, job_id))
|
|
1088
|
+
if ctx.json:
|
|
1089
|
+
write_json(output)
|
|
1090
|
+
elif not ctx.quiet:
|
|
1091
|
+
_write_import_human(output)
|
|
1092
|
+
return _import_exit_code(output["import"])
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def handle_ready(ctx: Context, args: argparse.Namespace) -> int:
|
|
1096
|
+
project_id = _resolve_project_id(ctx, None)
|
|
1097
|
+
payload = ctx.client.retrieval_readiness(project_id)
|
|
1098
|
+
output = {
|
|
1099
|
+
"project_id": payload.get("project_id", project_id),
|
|
1100
|
+
"graph": payload.get("graph", {}),
|
|
1101
|
+
"vector": payload.get("vector", {}),
|
|
1102
|
+
"hybrid": payload.get("hybrid", {}),
|
|
1103
|
+
"request_id": payload.get("request_id"),
|
|
1104
|
+
}
|
|
1105
|
+
return _emit(
|
|
1106
|
+
ctx,
|
|
1107
|
+
output,
|
|
1108
|
+
[
|
|
1109
|
+
("Project", output["project_id"]),
|
|
1110
|
+
("Graph ready", output["graph"].get("ready", "")),
|
|
1111
|
+
("Vector ready", output["vector"].get("ready", "")),
|
|
1112
|
+
("Hybrid ready", output["hybrid"].get("ready", "")),
|
|
1113
|
+
],
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def handle_config_path(ctx: Context, args: argparse.Namespace) -> int:
|
|
1118
|
+
payload = {"path": str(ctx.store.path)}
|
|
1119
|
+
if ctx.json:
|
|
1120
|
+
write_json(payload)
|
|
1121
|
+
elif not ctx.quiet:
|
|
1122
|
+
sys.stdout.write(payload["path"] + "\n")
|
|
1123
|
+
return SUCCESS
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _resolve_project_id(ctx: Context, positional: str | None) -> str:
|
|
1127
|
+
candidate = positional or ctx.args.project or ctx.selected_project_id
|
|
1128
|
+
if not candidate:
|
|
1129
|
+
raise CliError(
|
|
1130
|
+
"PROJECT_REQUIRED",
|
|
1131
|
+
"Select a project with `polygres projects use <project>` or pass --project.",
|
|
1132
|
+
exit_code=USAGE,
|
|
1133
|
+
)
|
|
1134
|
+
if PROJECT_ID_RE.match(candidate):
|
|
1135
|
+
return candidate
|
|
1136
|
+
project = _resolve_project(ctx, candidate)
|
|
1137
|
+
return _project_api_id(project)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _resolve_project(ctx: Context, candidate: str) -> dict[str, Any]:
|
|
1141
|
+
if PROJECT_ID_RE.match(candidate):
|
|
1142
|
+
payload = ctx.client.get_project(candidate)
|
|
1143
|
+
project = _object(payload, "project")
|
|
1144
|
+
project.setdefault("external_id", candidate)
|
|
1145
|
+
project.setdefault("id", candidate)
|
|
1146
|
+
project["request_id"] = payload.get("request_id")
|
|
1147
|
+
return project
|
|
1148
|
+
projects_payload = ctx.client.list_projects()
|
|
1149
|
+
matches = [
|
|
1150
|
+
project
|
|
1151
|
+
for project in _items(projects_payload, "projects")
|
|
1152
|
+
if project.get("name") == candidate
|
|
1153
|
+
]
|
|
1154
|
+
if not matches:
|
|
1155
|
+
raise CliError(
|
|
1156
|
+
"PROJECT_NOT_FOUND",
|
|
1157
|
+
"Project not found.",
|
|
1158
|
+
exit_code=NOT_FOUND,
|
|
1159
|
+
details={"project": candidate},
|
|
1160
|
+
request_id=projects_payload.get("request_id"),
|
|
1161
|
+
)
|
|
1162
|
+
if len(matches) > 1:
|
|
1163
|
+
raise CliError(
|
|
1164
|
+
"PROJECT_AMBIGUOUS",
|
|
1165
|
+
"Project name matches more than one project.",
|
|
1166
|
+
exit_code=CONFLICT,
|
|
1167
|
+
details={
|
|
1168
|
+
"project": candidate,
|
|
1169
|
+
"matches": [_project_api_id(project) for project in matches],
|
|
1170
|
+
},
|
|
1171
|
+
)
|
|
1172
|
+
project = dict(matches[0])
|
|
1173
|
+
project["request_id"] = projects_payload.get("request_id")
|
|
1174
|
+
return project
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _project_api_id(project: dict[str, Any]) -> str:
|
|
1178
|
+
value = project.get("external_id") or project.get("id")
|
|
1179
|
+
if isinstance(value, str) and value:
|
|
1180
|
+
return value
|
|
1181
|
+
raise CliError("PROJECT_INVALID", "Project response did not include an ID.")
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _has_external_ids(projects: list[dict[str, Any]]) -> bool:
|
|
1185
|
+
return any(isinstance(project.get("external_id"), str) for project in projects)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _items(payload: dict[str, Any], *keys: str) -> list[dict[str, Any]]:
|
|
1189
|
+
for key in keys:
|
|
1190
|
+
value = payload.get(key)
|
|
1191
|
+
if isinstance(value, list):
|
|
1192
|
+
return [item for item in value if isinstance(item, dict)]
|
|
1193
|
+
return []
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _object(payload: dict[str, Any], key: str) -> dict[str, Any]:
|
|
1197
|
+
value = payload.get(key)
|
|
1198
|
+
if isinstance(value, dict):
|
|
1199
|
+
return dict(value)
|
|
1200
|
+
return {k: v for k, v in payload.items() if k != "request_id"}
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _sanitize_key(key: dict[str, Any]) -> dict[str, Any]:
|
|
1204
|
+
return {k: v for k, v in key.items() if k not in {"raw_key", "secret", "api_key"}}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _normalize_created_key(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1208
|
+
key = dict(payload.get("key") or payload.get("api_key") or {})
|
|
1209
|
+
if "raw_key" in key:
|
|
1210
|
+
key["secret"] = key.pop("raw_key")
|
|
1211
|
+
return key
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _database_output(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1215
|
+
return {
|
|
1216
|
+
"project_id": payload.get("project_id"),
|
|
1217
|
+
"database": payload.get("database"),
|
|
1218
|
+
"username": payload.get("username"),
|
|
1219
|
+
"port": payload.get("port"),
|
|
1220
|
+
"direct_host": payload.get("direct", {}).get("host"),
|
|
1221
|
+
"pooled_host": payload.get("pooled", {}).get("host"),
|
|
1222
|
+
"ready": payload.get("ready", payload.get("readiness")),
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _emit(ctx: Context, payload: dict[str, Any], human_items: list[tuple[str, Any]]) -> int:
|
|
1227
|
+
if ctx.json:
|
|
1228
|
+
write_json(redact(payload, allow_key_secret="key" in payload))
|
|
1229
|
+
elif not ctx.quiet:
|
|
1230
|
+
print_kv(human_items)
|
|
1231
|
+
return SUCCESS
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _emit_configuration(
|
|
1235
|
+
ctx: Context, payload: dict[str, Any], *, operation: dict[str, Any] | None = None
|
|
1236
|
+
) -> int:
|
|
1237
|
+
output = {
|
|
1238
|
+
"configuration": payload.get("configuration", payload.get("graph_configuration", {})),
|
|
1239
|
+
"request_id": payload.get("request_id"),
|
|
1240
|
+
}
|
|
1241
|
+
if operation is not None:
|
|
1242
|
+
output["operation"] = operation
|
|
1243
|
+
if ctx.json:
|
|
1244
|
+
write_json(redact(output))
|
|
1245
|
+
elif not ctx.quiet:
|
|
1246
|
+
sys.stdout.write(json.dumps(output["configuration"], indent=2, sort_keys=True) + "\n")
|
|
1247
|
+
return SUCCESS
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _emit_config_response(
|
|
1251
|
+
ctx: Context, payload: dict[str, Any], *, default_operation: dict[str, Any] | None = None
|
|
1252
|
+
) -> int:
|
|
1253
|
+
output = {
|
|
1254
|
+
"configuration": payload.get("configuration", {}),
|
|
1255
|
+
"request_id": payload.get("request_id"),
|
|
1256
|
+
}
|
|
1257
|
+
operation = (
|
|
1258
|
+
payload.get("operation")
|
|
1259
|
+
if isinstance(payload.get("operation"), dict)
|
|
1260
|
+
else default_operation
|
|
1261
|
+
)
|
|
1262
|
+
if operation is not None:
|
|
1263
|
+
output["operation"] = operation
|
|
1264
|
+
return _emit(ctx, output, [("Configuration", output["configuration"].get("id", ""))])
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _project_status_output(project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
1268
|
+
status = payload.get("status") if isinstance(payload.get("status"), dict) else {}
|
|
1269
|
+
project = payload.get("project") if isinstance(payload.get("project"), dict) else {}
|
|
1270
|
+
runtime = payload.get("runtime") if isinstance(payload.get("runtime"), dict) else {}
|
|
1271
|
+
resources = payload.get("resources") if isinstance(payload.get("resources"), dict) else {}
|
|
1272
|
+
readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
|
|
1273
|
+
|
|
1274
|
+
if status:
|
|
1275
|
+
if not project:
|
|
1276
|
+
project = {
|
|
1277
|
+
"id": project_id,
|
|
1278
|
+
"status": status.get("project") or status.get("status"),
|
|
1279
|
+
}
|
|
1280
|
+
if not runtime:
|
|
1281
|
+
runtime = {
|
|
1282
|
+
key: status[key]
|
|
1283
|
+
for key in (
|
|
1284
|
+
"database",
|
|
1285
|
+
"direct_host",
|
|
1286
|
+
"effective_tier_id",
|
|
1287
|
+
"namespace",
|
|
1288
|
+
"pooled_host",
|
|
1289
|
+
"pooler",
|
|
1290
|
+
"runtime_api",
|
|
1291
|
+
"runtime_api_host",
|
|
1292
|
+
"runtime_api_url",
|
|
1293
|
+
"runtime_sync",
|
|
1294
|
+
"traefik",
|
|
1295
|
+
)
|
|
1296
|
+
if key in status
|
|
1297
|
+
}
|
|
1298
|
+
if not resources:
|
|
1299
|
+
resources = {
|
|
1300
|
+
key: status[key]
|
|
1301
|
+
for key in ("last_storage_measurement", "memory")
|
|
1302
|
+
if key in status
|
|
1303
|
+
}
|
|
1304
|
+
if not readiness:
|
|
1305
|
+
readiness = {
|
|
1306
|
+
key: status[key]
|
|
1307
|
+
for key in ("graph", "hybrid", "text", "vector")
|
|
1308
|
+
if key in status
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
output = {
|
|
1312
|
+
"project": project,
|
|
1313
|
+
"runtime": runtime,
|
|
1314
|
+
"resources": resources,
|
|
1315
|
+
"readiness": readiness,
|
|
1316
|
+
"request_id": payload.get("request_id"),
|
|
1317
|
+
}
|
|
1318
|
+
return output
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _poll_project_status(
|
|
1322
|
+
ctx: Context, project_id: str, *, deadline: float
|
|
1323
|
+
) -> dict[str, Any]:
|
|
1324
|
+
last_status: dict[str, Any] = {}
|
|
1325
|
+
while time.monotonic() <= deadline:
|
|
1326
|
+
payload = ctx.client.get_project_status(project_id, deadline=deadline)
|
|
1327
|
+
status = (
|
|
1328
|
+
payload.get("status")
|
|
1329
|
+
if isinstance(payload.get("status"), dict)
|
|
1330
|
+
else payload.get("project", {})
|
|
1331
|
+
)
|
|
1332
|
+
last_status = status if isinstance(status, dict) else {}
|
|
1333
|
+
project_status = last_status.get("project") or last_status.get("status")
|
|
1334
|
+
if project_status in {"ready", "read_only"}:
|
|
1335
|
+
return last_status
|
|
1336
|
+
if project_status == "failed":
|
|
1337
|
+
raise CliError("PROJECT_PROVISIONING_FAILED", "Project provisioning failed.")
|
|
1338
|
+
if project_status in {"suspended", "deleting"}:
|
|
1339
|
+
raise CliError(
|
|
1340
|
+
"PROJECT_UNAVAILABLE", f"Project is {project_status}.", exit_code=CONFLICT
|
|
1341
|
+
)
|
|
1342
|
+
if project_status == "deleted":
|
|
1343
|
+
raise CliError("PROJECT_NOT_FOUND", "Project was deleted.", exit_code=NOT_FOUND)
|
|
1344
|
+
_write_poll_progress(ctx, "Project", project_id, last_status)
|
|
1345
|
+
_sleep_until_deadline(_poll_interval(payload), deadline)
|
|
1346
|
+
raise CliError(
|
|
1347
|
+
"TIMEOUT",
|
|
1348
|
+
f"Timed out waiting for project {project_id}; last status is still in progress.",
|
|
1349
|
+
exit_code=UNAVAILABLE,
|
|
1350
|
+
details={"status": last_status},
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _project_create_wait_error(
|
|
1355
|
+
*,
|
|
1356
|
+
project: dict[str, Any],
|
|
1357
|
+
project_id: str,
|
|
1358
|
+
create_request_id: object,
|
|
1359
|
+
cause: CliError,
|
|
1360
|
+
) -> CliError:
|
|
1361
|
+
if cause.code not in {"SERVICE_UNAVAILABLE", "TIMEOUT"} and cause.exit_code != UNAVAILABLE:
|
|
1362
|
+
return cause
|
|
1363
|
+
project_name = project.get("name")
|
|
1364
|
+
project_status = project.get("status")
|
|
1365
|
+
details: dict[str, Any] = {
|
|
1366
|
+
"project": {
|
|
1367
|
+
"id": project.get("id"),
|
|
1368
|
+
"external_id": project.get("external_id") or project_id,
|
|
1369
|
+
"name": project_name,
|
|
1370
|
+
"status": project_status,
|
|
1371
|
+
},
|
|
1372
|
+
"create_request_id": create_request_id,
|
|
1373
|
+
"wait_error": {
|
|
1374
|
+
"code": cause.code,
|
|
1375
|
+
"message": cause.message,
|
|
1376
|
+
"details": cause.details,
|
|
1377
|
+
"request_id": cause.request_id,
|
|
1378
|
+
},
|
|
1379
|
+
}
|
|
1380
|
+
details["project"] = {
|
|
1381
|
+
key: value for key, value in details["project"].items() if value is not None
|
|
1382
|
+
}
|
|
1383
|
+
code = (
|
|
1384
|
+
"PROJECT_READINESS_TIMEOUT"
|
|
1385
|
+
if cause.code == "TIMEOUT"
|
|
1386
|
+
else "PROJECT_READINESS_UNAVAILABLE"
|
|
1387
|
+
)
|
|
1388
|
+
message = (
|
|
1389
|
+
f"Project {project_id} was created"
|
|
1390
|
+
" but readiness polling timed out."
|
|
1391
|
+
if cause.code == "TIMEOUT"
|
|
1392
|
+
else f"Project {project_id} was created but readiness polling failed."
|
|
1393
|
+
)
|
|
1394
|
+
message += f" Run `polygres projects status {project_id}` to resume validation or cleanup."
|
|
1395
|
+
return CliError(
|
|
1396
|
+
code,
|
|
1397
|
+
message,
|
|
1398
|
+
exit_code=UNAVAILABLE,
|
|
1399
|
+
details=details,
|
|
1400
|
+
request_id=cause.request_id or str(create_request_id or ""),
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def _poll_import(
|
|
1405
|
+
ctx: Context, project_id: str, job_id: str, timeout_seconds: int, previous: dict[str, Any]
|
|
1406
|
+
) -> dict[str, Any]:
|
|
1407
|
+
deadline = time.monotonic() + timeout_seconds
|
|
1408
|
+
interval_payload = previous
|
|
1409
|
+
last_output = _import_output(previous)
|
|
1410
|
+
last_progress: str | None = None
|
|
1411
|
+
while time.monotonic() < deadline:
|
|
1412
|
+
progress = json.dumps(last_output["import"], sort_keys=True, default=str)
|
|
1413
|
+
if progress != last_progress:
|
|
1414
|
+
_write_poll_progress(ctx, "Import", job_id, last_output["import"])
|
|
1415
|
+
last_progress = progress
|
|
1416
|
+
_sleep_until_deadline(_poll_interval(interval_payload), deadline)
|
|
1417
|
+
if time.monotonic() >= deadline:
|
|
1418
|
+
break
|
|
1419
|
+
payload = ctx.client.get_import(project_id, job_id, deadline=deadline)
|
|
1420
|
+
output = _import_output(payload)
|
|
1421
|
+
if output["import"].get("status") in {"succeeded", "failed", "cancelled"}:
|
|
1422
|
+
return output
|
|
1423
|
+
interval_payload = payload
|
|
1424
|
+
last_output = output
|
|
1425
|
+
raise CliError(
|
|
1426
|
+
"TIMEOUT",
|
|
1427
|
+
f"Timed out waiting for import {job_id}; it is still in progress.",
|
|
1428
|
+
exit_code=UNAVAILABLE,
|
|
1429
|
+
details={"import": last_output["import"], "request_id": last_output.get("request_id")},
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _poll_interval(payload: dict[str, Any]) -> int:
|
|
1434
|
+
value = payload.get("poll_interval_seconds", 2)
|
|
1435
|
+
try:
|
|
1436
|
+
seconds = int(value)
|
|
1437
|
+
except (TypeError, ValueError):
|
|
1438
|
+
seconds = 2
|
|
1439
|
+
return min(max(seconds, 1), 30)
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _import_output(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1443
|
+
item = payload.get("import") or payload.get("job") or {}
|
|
1444
|
+
return {"import": item, "request_id": payload.get("request_id")}
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _write_import_human(output: dict[str, Any]) -> None:
|
|
1448
|
+
item = output["import"]
|
|
1449
|
+
sys.stdout.write(f"Import {item.get('id')} {item.get('status')}\n")
|
|
1450
|
+
request_id = output.get("request_id")
|
|
1451
|
+
if request_id:
|
|
1452
|
+
sys.stdout.write(f"Request ID: {request_id}\n")
|
|
1453
|
+
if item.get("status") != "failed":
|
|
1454
|
+
return
|
|
1455
|
+
error = item.get("error") if isinstance(item.get("error"), dict) else {}
|
|
1456
|
+
error_code = item.get("error_code") or error.get("code")
|
|
1457
|
+
error_message = item.get("error_message") or error.get("message")
|
|
1458
|
+
if error_code:
|
|
1459
|
+
sys.stdout.write(f"Error code: {error_code}\n")
|
|
1460
|
+
if error_message:
|
|
1461
|
+
sys.stdout.write(f"Error message: {error_message}\n")
|
|
1462
|
+
for label, key in [
|
|
1463
|
+
("Row errors", "row_errors"),
|
|
1464
|
+
("Row details", "row_details"),
|
|
1465
|
+
("Details", "details"),
|
|
1466
|
+
("Errors", "errors"),
|
|
1467
|
+
]:
|
|
1468
|
+
value = item.get(key)
|
|
1469
|
+
if value:
|
|
1470
|
+
sys.stdout.write(f"{label}: {json.dumps(value, sort_keys=True)}\n")
|
|
1471
|
+
progress = item.get("progress") if isinstance(item.get("progress"), dict) else {}
|
|
1472
|
+
for label, key in [
|
|
1473
|
+
("SQL state", "sqlstate"),
|
|
1474
|
+
("Detail", "detail"),
|
|
1475
|
+
("Progress row errors", "row_errors"),
|
|
1476
|
+
("Progress details", "details"),
|
|
1477
|
+
]:
|
|
1478
|
+
value = progress.get(key)
|
|
1479
|
+
if value:
|
|
1480
|
+
if isinstance(value, (dict, list)):
|
|
1481
|
+
rendered = json.dumps(value, sort_keys=True)
|
|
1482
|
+
else:
|
|
1483
|
+
rendered = str(value)
|
|
1484
|
+
sys.stdout.write(f"{label}: {rendered}\n")
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _import_exit_code(item: dict[str, Any]) -> int:
|
|
1488
|
+
status = item.get("status")
|
|
1489
|
+
if status == "succeeded":
|
|
1490
|
+
return SUCCESS
|
|
1491
|
+
if status == "failed":
|
|
1492
|
+
return GENERAL_FAILURE
|
|
1493
|
+
if status == "cancelled":
|
|
1494
|
+
return CONFLICT
|
|
1495
|
+
return SUCCESS
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _latest_import_id(imports: list[dict[str, Any]]) -> str | None:
|
|
1499
|
+
if not imports:
|
|
1500
|
+
return None
|
|
1501
|
+
imports.sort(
|
|
1502
|
+
key=lambda item: (
|
|
1503
|
+
str(item.get("created_at") or ""),
|
|
1504
|
+
str(item.get("updated_at") or ""),
|
|
1505
|
+
str(item.get("id") or ""),
|
|
1506
|
+
),
|
|
1507
|
+
reverse=True,
|
|
1508
|
+
)
|
|
1509
|
+
job_id = imports[0].get("id")
|
|
1510
|
+
if not isinstance(job_id, str):
|
|
1511
|
+
raise CliError("IMPORT_INVALID", "Latest import did not include an ID.")
|
|
1512
|
+
return job_id
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _require_confirmation(ctx: Context, yes: bool, prompt: str) -> None:
|
|
1516
|
+
if yes:
|
|
1517
|
+
return
|
|
1518
|
+
if sys.stdin.isatty():
|
|
1519
|
+
sys.stderr.write(prompt + " Type 'yes' to continue: ")
|
|
1520
|
+
if sys.stdin.readline().strip() == "yes":
|
|
1521
|
+
return
|
|
1522
|
+
raise CliError(
|
|
1523
|
+
"CONFIRMATION_REQUIRED",
|
|
1524
|
+
"Re-run with --yes to confirm.",
|
|
1525
|
+
exit_code=USAGE,
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _readable_file(value: str) -> Path:
|
|
1530
|
+
path = Path(value)
|
|
1531
|
+
if not path.exists() or not path.is_file():
|
|
1532
|
+
raise CliError(
|
|
1533
|
+
"VALIDATION_ERROR",
|
|
1534
|
+
f"File does not exist or is not a regular file: {path}",
|
|
1535
|
+
exit_code=USAGE,
|
|
1536
|
+
)
|
|
1537
|
+
try:
|
|
1538
|
+
with path.open("rb"):
|
|
1539
|
+
pass
|
|
1540
|
+
except OSError as exc:
|
|
1541
|
+
raise CliError(
|
|
1542
|
+
"VALIDATION_ERROR", f"File is not readable: {path}", exit_code=USAGE
|
|
1543
|
+
) from exc
|
|
1544
|
+
return path
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _read_text_file(path: Path) -> str:
|
|
1548
|
+
try:
|
|
1549
|
+
return path.read_text(encoding="utf-8")
|
|
1550
|
+
except (OSError, UnicodeError) as exc:
|
|
1551
|
+
raise CliError(
|
|
1552
|
+
"VALIDATION_ERROR",
|
|
1553
|
+
f"File is not readable UTF-8 text: {path}",
|
|
1554
|
+
exit_code=USAGE,
|
|
1555
|
+
) from exc
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def _json_object_file(value: str) -> dict[str, Any]:
|
|
1559
|
+
path = _readable_file(value)
|
|
1560
|
+
try:
|
|
1561
|
+
payload = json.loads(_read_text_file(path))
|
|
1562
|
+
except json.JSONDecodeError as exc:
|
|
1563
|
+
raise CliError("VALIDATION_ERROR", f"Invalid JSON file: {path}", exit_code=USAGE) from exc
|
|
1564
|
+
if not isinstance(payload, dict):
|
|
1565
|
+
raise CliError("VALIDATION_ERROR", "JSON file must contain an object.", exit_code=USAGE)
|
|
1566
|
+
return payload
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def _graph_configuration_file(value: str) -> dict[str, Any]:
|
|
1570
|
+
payload = _json_object_file(value)
|
|
1571
|
+
if "configuration" not in payload:
|
|
1572
|
+
configuration = payload
|
|
1573
|
+
else:
|
|
1574
|
+
allowed_wrapper_keys = {"configuration", "request_id"}
|
|
1575
|
+
extra_keys = sorted(set(payload) - allowed_wrapper_keys)
|
|
1576
|
+
if extra_keys:
|
|
1577
|
+
raise CliError(
|
|
1578
|
+
"VALIDATION_ERROR",
|
|
1579
|
+
"Graph configuration export contains unsupported wrapper fields.",
|
|
1580
|
+
exit_code=USAGE,
|
|
1581
|
+
details={"fields": extra_keys},
|
|
1582
|
+
)
|
|
1583
|
+
configuration = payload["configuration"]
|
|
1584
|
+
if configuration is None:
|
|
1585
|
+
raise CliError(
|
|
1586
|
+
"GRAPH_CONFIGURATION_EMPTY",
|
|
1587
|
+
"Graph configuration export does not contain an applyable configuration.",
|
|
1588
|
+
exit_code=USAGE,
|
|
1589
|
+
)
|
|
1590
|
+
if not isinstance(configuration, dict):
|
|
1591
|
+
raise CliError(
|
|
1592
|
+
"VALIDATION_ERROR",
|
|
1593
|
+
"Graph configuration must contain an object.",
|
|
1594
|
+
exit_code=USAGE,
|
|
1595
|
+
)
|
|
1596
|
+
_reject_unknown_fields(
|
|
1597
|
+
configuration,
|
|
1598
|
+
GRAPH_CONFIGURATION_KEYS | GRAPH_CONFIGURATION_READ_ONLY_KEYS,
|
|
1599
|
+
"graph configuration",
|
|
1600
|
+
)
|
|
1601
|
+
request_configuration = {
|
|
1602
|
+
key: configuration[key]
|
|
1603
|
+
for key in GRAPH_CONFIGURATION_KEYS
|
|
1604
|
+
if key in configuration
|
|
1605
|
+
}
|
|
1606
|
+
_validate_graph_configuration(request_configuration)
|
|
1607
|
+
return request_configuration
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _graph_discovery_configuration(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1611
|
+
node_tables = payload.get("node_tables", [])
|
|
1612
|
+
relationships = payload.get("relationships", [])
|
|
1613
|
+
filter_columns = payload.get("filter_columns", [])
|
|
1614
|
+
if not all(isinstance(value, list) for value in (node_tables, relationships, filter_columns)):
|
|
1615
|
+
raise CliError(
|
|
1616
|
+
"GRAPH_DISCOVERY_INVALID",
|
|
1617
|
+
"Graph discovery response contains invalid candidate arrays.",
|
|
1618
|
+
)
|
|
1619
|
+
configuration = {
|
|
1620
|
+
"registered_tables": [
|
|
1621
|
+
{
|
|
1622
|
+
key: item[key]
|
|
1623
|
+
for key in (
|
|
1624
|
+
"schema",
|
|
1625
|
+
"table",
|
|
1626
|
+
"id_column",
|
|
1627
|
+
"id_columns",
|
|
1628
|
+
"columns",
|
|
1629
|
+
"tenant_column",
|
|
1630
|
+
)
|
|
1631
|
+
if key in item
|
|
1632
|
+
}
|
|
1633
|
+
if isinstance(item, dict)
|
|
1634
|
+
else item
|
|
1635
|
+
for item in node_tables
|
|
1636
|
+
],
|
|
1637
|
+
"registered_relationships": [
|
|
1638
|
+
{
|
|
1639
|
+
key: item[key]
|
|
1640
|
+
for key in (
|
|
1641
|
+
"from_schema",
|
|
1642
|
+
"from_table",
|
|
1643
|
+
"from_column",
|
|
1644
|
+
"to_schema",
|
|
1645
|
+
"to_table",
|
|
1646
|
+
"to_column",
|
|
1647
|
+
"label",
|
|
1648
|
+
"bidirectional",
|
|
1649
|
+
)
|
|
1650
|
+
if key in item
|
|
1651
|
+
}
|
|
1652
|
+
if isinstance(item, dict)
|
|
1653
|
+
else item
|
|
1654
|
+
for item in relationships
|
|
1655
|
+
],
|
|
1656
|
+
"filter_columns": [
|
|
1657
|
+
{key: item[key] for key in ("schema", "table", "column", "type") if key in item}
|
|
1658
|
+
if isinstance(item, dict)
|
|
1659
|
+
else item
|
|
1660
|
+
for item in filter_columns
|
|
1661
|
+
],
|
|
1662
|
+
"runtime_settings": {},
|
|
1663
|
+
}
|
|
1664
|
+
try:
|
|
1665
|
+
_validate_graph_configuration(configuration)
|
|
1666
|
+
except CliError as exc:
|
|
1667
|
+
raise CliError(
|
|
1668
|
+
"GRAPH_DISCOVERY_INVALID",
|
|
1669
|
+
f"Graph discovery response is not applyable: {exc.message}",
|
|
1670
|
+
details=exc.details,
|
|
1671
|
+
) from exc
|
|
1672
|
+
return configuration
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _validate_graph_configuration(configuration: dict[str, Any]) -> None:
|
|
1676
|
+
_reject_unknown_fields(configuration, GRAPH_CONFIGURATION_KEYS, "graph configuration")
|
|
1677
|
+
for key in ("registered_tables", "registered_relationships", "filter_columns"):
|
|
1678
|
+
value = configuration.get(key, [])
|
|
1679
|
+
if not isinstance(value, list):
|
|
1680
|
+
_graph_invalid(f"{key} must be an array.")
|
|
1681
|
+
runtime_settings = configuration.get("runtime_settings", {})
|
|
1682
|
+
if not isinstance(runtime_settings, dict):
|
|
1683
|
+
_graph_invalid("runtime_settings must be an object.")
|
|
1684
|
+
|
|
1685
|
+
table_keys = {"schema", "table", "id_column", "id_columns", "columns", "tenant_column"}
|
|
1686
|
+
for index, item in enumerate(configuration.get("registered_tables", [])):
|
|
1687
|
+
if not isinstance(item, dict):
|
|
1688
|
+
_graph_invalid(f"registered_tables[{index}] must be an object.")
|
|
1689
|
+
_reject_unknown_fields(item, table_keys, f"registered_tables[{index}]")
|
|
1690
|
+
_required_string(item, "table", f"registered_tables[{index}]")
|
|
1691
|
+
for key in ("schema", "id_column", "tenant_column"):
|
|
1692
|
+
if key in item and item[key] is not None:
|
|
1693
|
+
_graph_identifier(item[key], f"registered_tables[{index}].{key}")
|
|
1694
|
+
for key in ("id_columns", "columns"):
|
|
1695
|
+
values = item.get(key, [])
|
|
1696
|
+
if not isinstance(values, list):
|
|
1697
|
+
_graph_invalid(f"registered_tables[{index}].{key} must be an array.")
|
|
1698
|
+
for value in values:
|
|
1699
|
+
_graph_identifier(value, f"registered_tables[{index}].{key}")
|
|
1700
|
+
has_single = isinstance(item.get("id_column"), str) and bool(item["id_column"])
|
|
1701
|
+
has_multiple = bool(item.get("id_columns"))
|
|
1702
|
+
if has_single == has_multiple:
|
|
1703
|
+
_graph_invalid(
|
|
1704
|
+
f"registered_tables[{index}] must use exactly one of id_column or id_columns."
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
relationship_keys = {
|
|
1708
|
+
"from_schema",
|
|
1709
|
+
"from_table",
|
|
1710
|
+
"from_column",
|
|
1711
|
+
"to_schema",
|
|
1712
|
+
"to_table",
|
|
1713
|
+
"to_column",
|
|
1714
|
+
"label",
|
|
1715
|
+
"bidirectional",
|
|
1716
|
+
}
|
|
1717
|
+
for index, item in enumerate(configuration.get("registered_relationships", [])):
|
|
1718
|
+
if not isinstance(item, dict):
|
|
1719
|
+
_graph_invalid(f"registered_relationships[{index}] must be an object.")
|
|
1720
|
+
_reject_unknown_fields(item, relationship_keys, f"registered_relationships[{index}]")
|
|
1721
|
+
for key in ("from_table", "from_column", "to_table", "to_column", "label"):
|
|
1722
|
+
_required_string(item, key, f"registered_relationships[{index}]")
|
|
1723
|
+
for key in ("from_schema", "to_schema"):
|
|
1724
|
+
if key in item:
|
|
1725
|
+
_graph_identifier(item[key], f"registered_relationships[{index}].{key}")
|
|
1726
|
+
if "bidirectional" in item and not isinstance(item["bidirectional"], bool):
|
|
1727
|
+
_graph_invalid(f"registered_relationships[{index}].bidirectional must be boolean.")
|
|
1728
|
+
|
|
1729
|
+
filter_keys = {"schema", "table", "column", "type"}
|
|
1730
|
+
filter_types = {"numeric", "boolean", "text", "date", "timestamptz", "uuid"}
|
|
1731
|
+
for index, item in enumerate(configuration.get("filter_columns", [])):
|
|
1732
|
+
if not isinstance(item, dict):
|
|
1733
|
+
_graph_invalid(f"filter_columns[{index}] must be an object.")
|
|
1734
|
+
_reject_unknown_fields(item, filter_keys, f"filter_columns[{index}]")
|
|
1735
|
+
for key in ("table", "column"):
|
|
1736
|
+
_required_string(item, key, f"filter_columns[{index}]")
|
|
1737
|
+
if "schema" in item:
|
|
1738
|
+
_graph_identifier(item["schema"], f"filter_columns[{index}].schema")
|
|
1739
|
+
if item.get("type") not in filter_types:
|
|
1740
|
+
_graph_invalid(f"filter_columns[{index}].type is invalid.")
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def _reject_unknown_fields(item: dict[str, Any], allowed: set[str], label: str) -> None:
|
|
1744
|
+
unknown = sorted(set(item) - allowed)
|
|
1745
|
+
if unknown:
|
|
1746
|
+
_graph_invalid(f"{label} contains unknown fields.", {"fields": unknown})
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _required_string(item: dict[str, Any], key: str, label: str) -> None:
|
|
1750
|
+
if key not in item:
|
|
1751
|
+
_graph_invalid(f"{label}.{key} is required.")
|
|
1752
|
+
_graph_identifier(item[key], f"{label}.{key}")
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def _graph_identifier(value: object, label: str) -> None:
|
|
1756
|
+
if not isinstance(value, str) or not SQL_IDENTIFIER_RE.fullmatch(value):
|
|
1757
|
+
_graph_invalid(f"{label} must be a valid SQL identifier.")
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _graph_invalid(message: str, details: dict[str, Any] | None = None) -> None:
|
|
1761
|
+
raise CliError(
|
|
1762
|
+
"GRAPH_CONFIGURATION_INVALID",
|
|
1763
|
+
message,
|
|
1764
|
+
exit_code=USAGE,
|
|
1765
|
+
details=details or {},
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def _validate_uuid(value: str, label: str) -> None:
|
|
1770
|
+
if not UUID_LIKE_RE.match(value):
|
|
1771
|
+
raise CliError("VALIDATION_ERROR", f"Invalid {label}.", exit_code=USAGE)
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _validate_response_uuid(value: object, label: str) -> None:
|
|
1775
|
+
if not isinstance(value, str) or not UUID_LIKE_RE.fullmatch(value):
|
|
1776
|
+
raise CliError(
|
|
1777
|
+
"MIGRATION_INVALID" if "migration" in label else "IMPORT_INVALID",
|
|
1778
|
+
f"{label.capitalize()} response did not include a valid ID.",
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def _validate_migration_name(value: str) -> None:
|
|
1783
|
+
if not MIGRATION_NAME_RE.match(value):
|
|
1784
|
+
raise CliError("VALIDATION_ERROR", "Invalid migration name.", exit_code=USAGE)
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _validate_identifiers(*values: str | None) -> None:
|
|
1788
|
+
for value in values:
|
|
1789
|
+
if value is not None and not SQL_IDENTIFIER_RE.match(value):
|
|
1790
|
+
raise CliError("VALIDATION_ERROR", f"Invalid SQL identifier: {value}", exit_code=USAGE)
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _generated_tsvector_sql(
|
|
1794
|
+
schema: str, table: str, source_column: str, generated_column: str, language: str
|
|
1795
|
+
) -> str:
|
|
1796
|
+
return (
|
|
1797
|
+
f'ALTER TABLE "{schema}"."{table}" ADD COLUMN IF NOT EXISTS "{generated_column}" '
|
|
1798
|
+
"tsvector GENERATED ALWAYS AS "
|
|
1799
|
+
f"(to_tsvector('{language}'::regconfig, coalesce(\"{source_column}\"::text, ''))) STORED;"
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _migration_name(value: str) -> str:
|
|
1804
|
+
normalized = re.sub(r"[^A-Za-z0-9_]", "_", value)
|
|
1805
|
+
normalized = re.sub(r"_+", "_", normalized).strip("_")
|
|
1806
|
+
if not normalized:
|
|
1807
|
+
normalized = "migration"
|
|
1808
|
+
if not re.match(r"^[A-Za-z_]", normalized):
|
|
1809
|
+
normalized = f"m_{normalized}"
|
|
1810
|
+
_validate_migration_name(normalized)
|
|
1811
|
+
return normalized
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def _timeout_seconds(value: str) -> int:
|
|
1815
|
+
try:
|
|
1816
|
+
seconds = int(value)
|
|
1817
|
+
except ValueError as exc:
|
|
1818
|
+
raise argparse.ArgumentTypeError("timeout must be an integer") from exc
|
|
1819
|
+
if seconds < 1 or seconds > 86400:
|
|
1820
|
+
raise argparse.ArgumentTypeError("timeout must be between 1 and 86400")
|
|
1821
|
+
return seconds
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
def _dimensions(value: str) -> int:
|
|
1825
|
+
try:
|
|
1826
|
+
dimensions = int(value)
|
|
1827
|
+
except ValueError as exc:
|
|
1828
|
+
raise argparse.ArgumentTypeError("dimensions must be an integer") from exc
|
|
1829
|
+
if dimensions < 1 or dimensions > 2000:
|
|
1830
|
+
raise argparse.ArgumentTypeError("dimensions must be between 1 and 2000")
|
|
1831
|
+
return dimensions
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _similarity_threshold(value: str) -> float:
|
|
1835
|
+
try:
|
|
1836
|
+
threshold = float(value)
|
|
1837
|
+
except ValueError as exc:
|
|
1838
|
+
raise argparse.ArgumentTypeError("similarity threshold must be a number") from exc
|
|
1839
|
+
if threshold < 0 or threshold > 1:
|
|
1840
|
+
raise argparse.ArgumentTypeError("similarity threshold must be between 0 and 1")
|
|
1841
|
+
return threshold
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _one_char(value: str) -> str:
|
|
1845
|
+
if len(value) != 1:
|
|
1846
|
+
raise argparse.ArgumentTypeError("value must be one character")
|
|
1847
|
+
return value
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _delimiter(value: str) -> str:
|
|
1851
|
+
value = _one_char(value)
|
|
1852
|
+
if value not in {",", "\t", ";", "|"}:
|
|
1853
|
+
raise argparse.ArgumentTypeError("delimiter must be comma, tab, semicolon, or pipe")
|
|
1854
|
+
return value
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
def _remove_pgbouncer_query(value: object) -> object:
|
|
1858
|
+
value = _passwordless_url(value)
|
|
1859
|
+
if not isinstance(value, str):
|
|
1860
|
+
return value
|
|
1861
|
+
parsed = urlsplit(value)
|
|
1862
|
+
query = [
|
|
1863
|
+
(key, item)
|
|
1864
|
+
for key, item in parse_qsl(parsed.query, keep_blank_values=True)
|
|
1865
|
+
if not (key.lower() == "pgbouncer" and item.lower() == "true")
|
|
1866
|
+
]
|
|
1867
|
+
return urlunsplit(
|
|
1868
|
+
(parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _passwordless_url(value: object) -> object:
|
|
1873
|
+
if not isinstance(value, str):
|
|
1874
|
+
return value
|
|
1875
|
+
parsed = urlsplit(value)
|
|
1876
|
+
if parsed.username is None:
|
|
1877
|
+
return value
|
|
1878
|
+
host = parsed.hostname or ""
|
|
1879
|
+
if parsed.port is not None:
|
|
1880
|
+
host = f"{host}:{parsed.port}"
|
|
1881
|
+
netloc = f"{parsed.username}@{host}"
|
|
1882
|
+
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
def _summary_value(value: object) -> str:
|
|
1886
|
+
if not isinstance(value, dict):
|
|
1887
|
+
return str(value or "")
|
|
1888
|
+
for key in ("status", "ready", "runtime_status", "project"):
|
|
1889
|
+
if key in value:
|
|
1890
|
+
return str(value[key])
|
|
1891
|
+
return json.dumps(value, sort_keys=True) if value else ""
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def _resource_pressure(resources: dict[str, Any]) -> str:
|
|
1895
|
+
for key in ("pressure", "resource_pressure", "memory_pressure", "status"):
|
|
1896
|
+
if key in resources:
|
|
1897
|
+
return str(resources[key])
|
|
1898
|
+
memory = resources.get("memory")
|
|
1899
|
+
if isinstance(memory, dict):
|
|
1900
|
+
return _resource_pressure(memory)
|
|
1901
|
+
return _summary_value(resources)
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _sleep_until_deadline(seconds: float, deadline: float) -> None:
|
|
1905
|
+
remaining = max(deadline - time.monotonic(), 0.0)
|
|
1906
|
+
delay = min(float(seconds), remaining)
|
|
1907
|
+
if delay > 0:
|
|
1908
|
+
time.sleep(delay)
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def _write_poll_progress(
|
|
1912
|
+
ctx: Context, operation: str, identifier: str, status: dict[str, Any]
|
|
1913
|
+
) -> None:
|
|
1914
|
+
if ctx.json or ctx.quiet:
|
|
1915
|
+
return
|
|
1916
|
+
state = status.get("status") or status.get("project") or "in progress"
|
|
1917
|
+
progress = status.get("progress")
|
|
1918
|
+
suffix = f" {json.dumps(progress, sort_keys=True)}" if isinstance(progress, dict) else ""
|
|
1919
|
+
sys.stderr.write(f"{operation} {identifier}: {state}{suffix}\n")
|