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/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")