maxc-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.
maxc_cli/cli.py ADDED
@@ -0,0 +1,1274 @@
1
+
2
+ import argparse
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Sequence, TextIO
6
+
7
+ from .app import MaxCApp, read_stdin
8
+ from .exceptions import ErrorPayload, MaxCError, ValidationError
9
+ from .models import Envelope
10
+ from .output import emit_json, emit_ndjson, render_error, render_key_values, render_table
11
+ from .utils import read_sql_input
12
+
13
+
14
+ def _add_required_subparsers(
15
+ parser: 'argparse.ArgumentParser',
16
+ *,
17
+ dest: 'str',
18
+ ):
19
+ """Backport-required subparsers for Python 3.6."""
20
+ subparsers = parser.add_subparsers(dest=dest)
21
+ subparsers.required = True
22
+ return subparsers
23
+
24
+
25
+ def build_parser() -> 'argparse.ArgumentParser':
26
+ parser = argparse.ArgumentParser(prog="maxc", description="Agent-first MaxCompute CLI MVP")
27
+ parser.add_argument("--config", help="Explicit path to a config file")
28
+
29
+ subparsers = _add_required_subparsers(parser, dest="command_group")
30
+
31
+ query_parser = subparsers.add_parser(
32
+ "query",
33
+ help="Run a SQL query (supports run/cost/explain aliases)",
34
+ description=(
35
+ "Run a SQL query.\n"
36
+ "Recommended usage:\n"
37
+ " maxc query \"SELECT 1\"\n"
38
+ " maxc query cost \"SELECT 1\"\n"
39
+ " maxc query explain \"SELECT 1\"\n"
40
+ "Legacy-compatible usage:\n"
41
+ " maxc query \"SELECT 1\" --mode cost"
42
+ ),
43
+ formatter_class=argparse.RawTextHelpFormatter,
44
+ )
45
+ query_parser.add_argument("sql_parts", nargs="*", help="SQL text; `@natural` placeholders are reserved for future support")
46
+ query_parser.add_argument("--file", help="Read SQL from file")
47
+ query_parser.add_argument("--stdin", action="store_true", help="Read SQL from stdin")
48
+ query_parser.add_argument("--project", help="Target MaxCompute project")
49
+ query_parser.add_argument(
50
+ "--mode",
51
+ choices=["run", "cost", "explain"],
52
+ default="run",
53
+ help="Query mode. Legacy-compatible, but `query run/cost/explain <SQL>` is preferred.",
54
+ )
55
+ query_parser.add_argument("--format", choices=["table", "json", "csv", "ndjson"], help="Output format")
56
+ query_parser.add_argument("--json", action="store_true", help="Output as JSON envelope")
57
+ query_parser.add_argument("--max-rows", type=int, default=100, help="Maximum rows to return (default: 100)")
58
+ query_parser.add_argument("--page-size", type=int, help="Rows per page for pagination")
59
+ query_parser.add_argument("--cursor", help="Pagination cursor from previous response")
60
+ query_parser.add_argument("--output", help="Write output to file")
61
+ query_parser.add_argument("--output-format", choices=["table", "json", "csv", "ndjson"], help="Output file format")
62
+ query_parser.add_argument("--wait", type=int, default=10,
63
+ help="Seconds to poll before promoting to async (default: 10). --wait 0 returns job_id immediately.")
64
+ query_parser.add_argument("--dry-run", action="store_true", help="Show query plan without executing")
65
+ query_parser.add_argument("--cost-check", type=float, help="Abort if estimated cost exceeds threshold (CU)")
66
+ query_parser.add_argument("--idempotency-key", help="Deduplication key for retries")
67
+ query_parser.add_argument("--retry-on", default="", help="Comma-separated error codes to retry on")
68
+ query_parser.add_argument("--max-retries", type=int, default=0, help="Maximum retry attempts (default: 0)")
69
+ query_parser.add_argument("--retry-backoff", choices=["fixed", "exponential"], default="fixed", help="Retry backoff strategy")
70
+ query_parser.set_defaults(handler=_handle_query)
71
+
72
+ job_parser = subparsers.add_parser("job", help="Manage async jobs")
73
+ job_subparsers = _add_required_subparsers(job_parser, dest="job_command")
74
+
75
+ job_submit = job_subparsers.add_parser("submit", help="Submit an async job")
76
+ job_submit.add_argument("sql_parts", nargs="*", help="SQL text")
77
+ job_submit.add_argument("--file", help="Read SQL from file")
78
+ job_submit.add_argument("--stdin", action="store_true", help="Read SQL from stdin")
79
+ job_submit.add_argument("--project", help="Target MaxCompute project")
80
+ job_submit.add_argument("--json", action="store_true", help="Output as JSON envelope")
81
+ job_submit.add_argument("--max-rows", type=int, default=100, help="Maximum rows to return (default: 100)")
82
+ job_submit.add_argument("--cost-check", type=float, help="Abort if estimated cost exceeds threshold (CU)")
83
+ job_submit.add_argument("--idempotency-key", help="Deduplication key for retries")
84
+ job_submit.set_defaults(handler=_handle_job_submit)
85
+
86
+ job_status = job_subparsers.add_parser("status", help="Show job status")
87
+ job_status.add_argument("job_id", help="Job ID returned by submit")
88
+ job_status.add_argument("--json", action="store_true", help="Output as JSON envelope")
89
+ job_status.set_defaults(handler=_handle_job_status)
90
+
91
+ job_wait = job_subparsers.add_parser("wait", help="Wait for a job to finish")
92
+ job_wait.add_argument("job_id", help="Job ID returned by submit")
93
+ job_wait.add_argument("--json", action="store_true", help="Output as JSON envelope")
94
+ job_wait.add_argument("--stream", action="store_true", help="Stream job progress as NDJSON")
95
+ job_wait.add_argument("--timeout", type=int, default=None, help="Timeout in seconds (default: 300)")
96
+ job_wait.set_defaults(handler=_handle_job_wait)
97
+
98
+ job_diagnose = job_subparsers.add_parser("diagnose", help="Diagnose job status and failure reasons")
99
+ job_diagnose.add_argument("job_id", help="Job ID returned by submit")
100
+ job_diagnose.add_argument("--json", action="store_true", help="Output as JSON envelope")
101
+ job_diagnose.set_defaults(handler=_handle_job_diagnose)
102
+
103
+ job_result = job_subparsers.add_parser("result", help="Fetch job results")
104
+ job_result.add_argument("job_id", help="Job ID returned by submit")
105
+ job_result.add_argument("--json", action="store_true", help="Output as JSON envelope")
106
+ job_result.add_argument("--max-rows", type=int, default=100, dest="max_rows", help="Maximum rows to return (default: 100)")
107
+ job_result.add_argument("--cursor", default=None, help="Pagination cursor from previous response")
108
+ job_result.set_defaults(handler=_handle_job_result)
109
+
110
+ job_cancel = job_subparsers.add_parser("cancel", help="Cancel a job")
111
+ job_cancel.add_argument("job_id", help="Job ID returned by submit")
112
+ job_cancel.add_argument("--json", action="store_true", help="Output as JSON envelope")
113
+ job_cancel.set_defaults(handler=_handle_job_cancel)
114
+
115
+ job_list = job_subparsers.add_parser("list", help="List jobs")
116
+ job_list.add_argument("--json", action="store_true", help="Output as JSON envelope")
117
+ job_list.add_argument("--limit", type=int, default=20, help="Maximum number of jobs to return (default: 20)")
118
+ job_list.set_defaults(handler=_handle_job_list)
119
+
120
+ meta_parser = subparsers.add_parser("meta", help="Metadata commands")
121
+ meta_subparsers = _add_required_subparsers(meta_parser, dest="meta_command")
122
+
123
+ meta_list = meta_subparsers.add_parser("list-tables", help="List tables")
124
+ meta_list.add_argument("--json", action="store_true", help="Output as JSON envelope")
125
+ meta_list.set_defaults(handler=_handle_meta_list_tables)
126
+
127
+ meta_describe = meta_subparsers.add_parser("describe", help="Describe a table")
128
+ meta_describe.add_argument("table_name", help="Table name (schema.table or table)")
129
+ meta_describe.add_argument("--json", action="store_true", help="Output as JSON envelope")
130
+ meta_describe.add_argument("--full", action="store_true", help="Show full column list (default is summary mode)")
131
+ meta_describe.set_defaults(handler=_handle_meta_describe)
132
+
133
+ meta_search = meta_subparsers.add_parser("search", help="Search tables")
134
+ meta_search.add_argument("keyword", help="Search keyword")
135
+ meta_search.add_argument("--json", action="store_true", help="Output as JSON envelope")
136
+ meta_search.set_defaults(handler=_handle_meta_search)
137
+
138
+ meta_search_columns = meta_subparsers.add_parser("search-columns", help="Search columns")
139
+ meta_search_columns.add_argument("keyword", help="Search keyword")
140
+ meta_search_columns.add_argument("--json", action="store_true", help="Output as JSON envelope")
141
+ meta_search_columns.set_defaults(handler=_handle_meta_search_columns)
142
+
143
+ meta_latest_partition = meta_subparsers.add_parser("latest-partition", help="Show the latest partition")
144
+ meta_latest_partition.add_argument("table_name", help="Table name (schema.table or table)")
145
+ meta_latest_partition.add_argument("--json", action="store_true", help="Output as JSON envelope")
146
+ meta_latest_partition.set_defaults(handler=_handle_meta_latest_partition)
147
+
148
+ meta_freshness = meta_subparsers.add_parser("freshness", help="Show table freshness")
149
+ meta_freshness.add_argument("table_name", help="Table name (schema.table or table)")
150
+ meta_freshness.add_argument("--json", action="store_true", help="Output as JSON envelope")
151
+ meta_freshness.set_defaults(handler=_handle_meta_freshness)
152
+
153
+ meta_lineage = meta_subparsers.add_parser("lineage", help="Show lineage")
154
+ meta_lineage.add_argument("table_name", help="Table name (schema.table or table)")
155
+ meta_lineage.add_argument("--json", action="store_true", help="Output as JSON envelope")
156
+ meta_lineage.set_defaults(handler=_handle_meta_lineage)
157
+
158
+ meta_partitions = meta_subparsers.add_parser("partitions", help="List partitions")
159
+ meta_partitions.add_argument("table_name", help="Table name (schema.table or table)")
160
+ meta_partitions.add_argument("--json", action="store_true", help="Output as JSON envelope")
161
+ meta_partitions.set_defaults(handler=_handle_meta_partitions)
162
+
163
+ meta_list_projects = meta_subparsers.add_parser("list-projects", help="List accessible projects")
164
+ meta_list_projects.add_argument("--json", action="store_true", help="Output as JSON envelope")
165
+ meta_list_projects.set_defaults(handler=_handle_meta_list_projects)
166
+
167
+ meta_list_schemas = meta_subparsers.add_parser("list-schemas", help="List schemas in a project")
168
+ meta_list_schemas.add_argument("--project", help="Target MaxCompute project")
169
+ meta_list_schemas.add_argument("--json", action="store_true", help="Output as JSON envelope")
170
+ meta_list_schemas.set_defaults(handler=_handle_meta_list_schemas)
171
+
172
+ # Semantic metadata subcommands
173
+ meta_semantic = meta_subparsers.add_parser("semantic", help="Semantic metadata management")
174
+ meta_semantic_subparsers = _add_required_subparsers(
175
+ meta_semantic,
176
+ dest="semantic_command",
177
+ )
178
+
179
+ # semantic set
180
+ semantic_set = meta_semantic_subparsers.add_parser("set", help="Set semantic metadata for a table")
181
+ semantic_set.add_argument("table_name", help="Table name")
182
+ semantic_set.add_argument("--desc", "--description", dest="semantic_desc", help="Table description")
183
+ semantic_set.add_argument("--use-cases", nargs="*", help="Use cases (space-separated)")
184
+ semantic_set.add_argument("--sample-questions", nargs="*", help="Sample questions (space-separated)")
185
+ semantic_set.add_argument("--column-semantics", type=str, help="Column semantics as JSON string")
186
+ semantic_set.add_argument("--relations", type=str, help="Relations as JSON string")
187
+ semantic_set.add_argument("--stats", type=str, help="Stats as JSON string")
188
+ semantic_set.add_argument("--json", action="store_true", help="Output as JSON envelope")
189
+ semantic_set.set_defaults(handler=_handle_meta_semantic_set)
190
+
191
+ # semantic get
192
+ semantic_get = meta_semantic_subparsers.add_parser("get", help="Get semantic metadata for a table")
193
+ semantic_get.add_argument("table_name", help="Table name")
194
+ semantic_get.add_argument("--json", action="store_true", help="Output as JSON envelope")
195
+ semantic_get.set_defaults(handler=_handle_meta_semantic_get)
196
+
197
+ # semantic list-missing
198
+ semantic_list_missing = meta_semantic_subparsers.add_parser("list-missing", help="List tables without semantic metadata")
199
+ semantic_list_missing.add_argument("--json", action="store_true", help="Output as JSON envelope")
200
+ semantic_list_missing.set_defaults(handler=_handle_meta_semantic_list_missing)
201
+
202
+ session_parser = subparsers.add_parser("session", help="Session management - switch project/schema")
203
+ session_subparsers = _add_required_subparsers(
204
+ session_parser,
205
+ dest="session_command",
206
+ )
207
+
208
+ session_set = session_subparsers.add_parser("set", help="Set current project and/or schema for this session")
209
+ session_set.add_argument("--project", help="Project name")
210
+ session_set.add_argument("--schema", help="Schema name")
211
+ session_set.add_argument("--json", action="store_true", help="Output as JSON envelope")
212
+ session_set.set_defaults(handler=_handle_session_set)
213
+
214
+ session_show = session_subparsers.add_parser("show", help="Show current session settings")
215
+ session_show.add_argument("--json", action="store_true", help="Output as JSON envelope")
216
+ session_show.set_defaults(handler=_handle_session_show)
217
+
218
+ session_unset = session_subparsers.add_parser("unset", help="Clear session override, revert to env/config")
219
+ session_unset.add_argument("--json", action="store_true", help="Output as JSON envelope")
220
+ session_unset.set_defaults(handler=_handle_session_unset)
221
+
222
+ data_parser = subparsers.add_parser("data", help="Data exploration commands")
223
+ data_subparsers = _add_required_subparsers(data_parser, dest="data_command")
224
+
225
+ data_sample = data_subparsers.add_parser("sample", help="Sample rows")
226
+ data_sample.add_argument("table_name", help="Table name (schema.table or table)")
227
+ data_sample.add_argument("--rows", type=int, default=5, help="Number of sample rows (default: 5)")
228
+ data_sample.add_argument("--partition", help="Partition specification")
229
+ data_sample.add_argument("--columns", help="Comma-separated column names")
230
+ data_sample.add_argument("--json", action="store_true", help="Output as JSON envelope")
231
+ data_sample.set_defaults(handler=_handle_data_sample)
232
+
233
+ data_profile = data_subparsers.add_parser("profile", help="Profile table data")
234
+ data_profile.add_argument("table_name", help="Table name (schema.table or table)")
235
+ data_profile.add_argument("--partition", help="Partition specification")
236
+ data_profile.add_argument("--json", action="store_true", help="Output as JSON envelope")
237
+ data_profile.set_defaults(handler=_handle_data_profile)
238
+
239
+ auth_parser = subparsers.add_parser("auth", help="Authentication and permission checks")
240
+ auth_subparsers = _add_required_subparsers(auth_parser, dest="auth_command")
241
+
242
+ auth_login = auth_subparsers.add_parser("login", help="Save MaxCompute login configuration")
243
+ auth_login.add_argument("--access-id", "--access-key-id", dest="access_id", help="AccessKey ID")
244
+ auth_login.add_argument(
245
+ "--secret-access-key",
246
+ "--access-key-secret",
247
+ dest="secret_access_key",
248
+ help="AccessKey Secret",
249
+ )
250
+ auth_login.add_argument("--security-token", help="STS security token")
251
+ auth_login.add_argument("--project", help="Target MaxCompute project")
252
+ auth_login.add_argument("--endpoint", help="MaxCompute endpoint URL")
253
+ auth_login.add_argument("--region", dest="region_name", help="MaxCompute region name")
254
+ auth_login.add_argument("--tunnel-endpoint", help="Tunnel endpoint URL for data transfer")
255
+ auth_login.add_argument("--from-env", action="store_true", help="Import credentials from environment variables")
256
+ auth_login.add_argument("--no-validate", action="store_true", help="Skip credential validation")
257
+ auth_login.add_argument("--json", action="store_true", help="Output as JSON envelope")
258
+ auth_login.set_defaults(handler=_handle_auth_login)
259
+
260
+ auth_login_ncs = auth_subparsers.add_parser("login-ncs", help="Save ncs-based MaxCompute login configuration")
261
+ auth_login_ncs.add_argument("--account-type", choices=["user", "account", "app"], help="NCS account type")
262
+ auth_login_ncs.add_argument("--employee-id", help="Employee ID for NCS authentication")
263
+ auth_login_ncs.add_argument("--account-name", help="Account name for NCS authentication")
264
+ auth_login_ncs.add_argument("--app-name", help="App name for NCS authentication")
265
+ auth_login_ncs.add_argument("--project", help="Target MaxCompute project")
266
+ auth_login_ncs.add_argument("--endpoint", help="MaxCompute endpoint URL")
267
+ auth_login_ncs.add_argument("--region", dest="region_name", help="MaxCompute region name")
268
+ auth_login_ncs.add_argument("--tunnel-endpoint", help="Tunnel endpoint URL for data transfer")
269
+ auth_login_ncs.add_argument("--interactive", action="store_true", help="Interactive NCS login")
270
+ auth_login_ncs.add_argument("--list-accounts", action="store_true", help="List available NCS accounts")
271
+ auth_login_ncs.add_argument("--no-validate", action="store_true", help="Skip credential validation")
272
+ auth_login_ncs.add_argument("--json", action="store_true", help="Output as JSON envelope")
273
+ auth_login_ncs.set_defaults(handler=_handle_auth_login_ncs)
274
+
275
+ auth_whoami = auth_subparsers.add_parser("whoami", help="Show the current identity")
276
+ auth_whoami.add_argument("--json", action="store_true", help="Output as JSON envelope")
277
+ auth_whoami.set_defaults(handler=_handle_auth_whoami)
278
+
279
+ auth_can_i = auth_subparsers.add_parser("can-i", help="Check whether an operation is allowed")
280
+ auth_can_i.add_argument("--table", required=True, help="Table name to check")
281
+ auth_can_i.add_argument("--operation", required=True, help="Operation to check (e.g. SELECT, INSERT)")
282
+ auth_can_i.add_argument("--project", help="Target MaxCompute project")
283
+ auth_can_i.add_argument("--brief", action="store_true", help="Show brief result")
284
+ auth_can_i.add_argument("--json", action="store_true", help="Output as JSON envelope")
285
+ auth_can_i.set_defaults(handler=_handle_auth_can_i)
286
+
287
+ diff_parser = subparsers.add_parser("diff", help="Diff commands")
288
+ diff_subparsers = _add_required_subparsers(diff_parser, dest="diff_command")
289
+
290
+ diff_schema = diff_subparsers.add_parser("schema", help="Compare two table schemas")
291
+ diff_schema.add_argument("left_table", help="Left table for comparison")
292
+ diff_schema.add_argument("right_table", help="Right table for comparison")
293
+ diff_schema.add_argument("--json", action="store_true", help="Output as JSON envelope")
294
+ diff_schema.set_defaults(handler=_handle_diff_schema)
295
+
296
+ diff_partition = diff_subparsers.add_parser("partition", help="Compare partition lists")
297
+ diff_partition.add_argument("left_table", help="Left table for comparison")
298
+ diff_partition.add_argument("right_table", help="Right table for comparison")
299
+ diff_partition.add_argument("--json", action="store_true", help="Output as JSON envelope")
300
+ diff_partition.set_defaults(handler=_handle_diff_partition)
301
+
302
+ diff_data = diff_subparsers.add_parser("data", help="Compare read-only table snapshots by key")
303
+ diff_data.add_argument("left_table", help="Left table for comparison")
304
+ diff_data.add_argument("right_table", help="Right table for comparison")
305
+ diff_data.add_argument("--keys", required=True, help="Comma-separated alignment key columns")
306
+ diff_data.add_argument("--columns", help="Comma-separated non-key comparison columns; defaults to shared columns")
307
+ diff_data.add_argument("--rows", type=int, default=100, help="Maximum rows to sample from each side")
308
+ diff_data.add_argument("--partition", help="Partition applied to both tables")
309
+ diff_data.add_argument("--left-partition", help="Partition for left table")
310
+ diff_data.add_argument("--right-partition", help="Partition for right table")
311
+ diff_data.add_argument("--json", action="store_true", help="Output as JSON envelope")
312
+ diff_data.set_defaults(handler=_handle_diff_data)
313
+
314
+ agent_parser = subparsers.add_parser("agent", help="Agent helper commands")
315
+ agent_subparsers = _add_required_subparsers(agent_parser, dest="agent_command")
316
+
317
+ agent_context = agent_subparsers.add_parser("context", help="Show agent context")
318
+ agent_context.add_argument("--json", action="store_true", help="Output as JSON envelope")
319
+ agent_context.set_defaults(handler=_handle_agent_context)
320
+
321
+ cache_parser = subparsers.add_parser("cache", help="Metadata cache management")
322
+ cache_subparsers = _add_required_subparsers(cache_parser, dest="cache_command")
323
+
324
+ cache_build = cache_subparsers.add_parser("build", help="Build the metadata cache")
325
+ cache_build.add_argument("--project", help="Target MaxCompute project")
326
+ cache_build.add_argument("--schema", help="Target schema name")
327
+ cache_build.add_argument("--async", dest="async_mode", action="store_true", help="Run the cache build asynchronously")
328
+ cache_build.add_argument("--json", action="store_true", help="Output as JSON envelope")
329
+ cache_build.set_defaults(handler=_handle_cache_build)
330
+
331
+ cache_build_status = cache_subparsers.add_parser("build-status", help="Show cache build status")
332
+ cache_build_status.add_argument("--project", help="Target MaxCompute project")
333
+ cache_build_status.add_argument("--build-id", help="Build ID")
334
+ cache_build_status.add_argument("--json", action="store_true", help="Output as JSON envelope")
335
+ cache_build_status.set_defaults(handler=_handle_cache_build_status)
336
+
337
+ cache_status = cache_subparsers.add_parser("status", help="Show cache status")
338
+ cache_status.add_argument("--project", help="Target MaxCompute project")
339
+ cache_status.add_argument("--schema", help="Target schema name")
340
+ cache_status.add_argument("--json", action="store_true", help="Output as JSON envelope")
341
+ cache_status.set_defaults(handler=_handle_cache_status)
342
+
343
+ cache_clear = cache_subparsers.add_parser("clear", help="Clear cached metadata")
344
+ cache_clear.add_argument("--project", help="Target MaxCompute project")
345
+ cache_clear.add_argument("--schema", help="Target schema name")
346
+ cache_clear.add_argument("--json", action="store_true", help="Output as JSON envelope")
347
+ cache_clear.set_defaults(handler=_handle_cache_clear)
348
+
349
+ cache_save_semantic = cache_subparsers.add_parser("save-semantic", help="Save semantic metadata")
350
+ cache_save_semantic.add_argument("--table", required=True, help="Table name")
351
+ cache_save_semantic.add_argument("--schema", default="default", help="Schema name (default: default)")
352
+ cache_save_semantic.add_argument("--semantic-desc", required=True, help="One-sentence business description for the table")
353
+ cache_save_semantic.add_argument("--use-cases", default="[]", help="Use cases as a JSON array")
354
+ cache_save_semantic.add_argument("--sample-questions", default="[]", help="Sample questions as a JSON array")
355
+ cache_save_semantic.add_argument("--column-semantics", default="[]", help="Column semantics as a JSON array")
356
+ cache_save_semantic.add_argument("--project", help="Target MaxCompute project")
357
+ cache_save_semantic.add_argument("--json", action="store_true", help="Output as JSON envelope")
358
+ cache_save_semantic.set_defaults(handler=_handle_cache_save_semantic)
359
+
360
+ cache_get_semantic = cache_subparsers.add_parser("get-semantic", help="Get semantic metadata")
361
+ cache_get_semantic.add_argument("--table", required=True, help="Table name")
362
+ cache_get_semantic.add_argument("--schema", default="default", help="Schema name (default: default)")
363
+ cache_get_semantic.add_argument("--project", help="Target MaxCompute project")
364
+ cache_get_semantic.add_argument("--json", action="store_true", help="Output as JSON envelope")
365
+ cache_get_semantic.set_defaults(handler=_handle_cache_get_semantic)
366
+
367
+ return parser
368
+
369
+
370
+ def main(argv: 'Sequence[str] | None' = None) -> 'int':
371
+ return run(argv=argv)
372
+
373
+
374
+ def run(
375
+ argv: 'Sequence[str] | None' = None,
376
+ *,
377
+ cwd: 'Path | None' = None,
378
+ stdout: 'TextIO | None' = None,
379
+ stderr: 'TextIO | None' = None,
380
+ ) -> 'int':
381
+ stdout = stdout or sys.stdout
382
+ stderr = stderr or sys.stderr
383
+ parser = build_parser()
384
+ args = parser.parse_args(argv)
385
+ working_dir = cwd or Path.cwd()
386
+ requested_config_path = Path(args.config).resolve() if args.config else None
387
+ args.requested_config_path = requested_config_path
388
+ args.stderr = stderr
389
+ config_path = requested_config_path
390
+ if (
391
+ requested_config_path is not None
392
+ and not requested_config_path.exists()
393
+ and _command_name(args) in {"auth.login", "auth.login-ncs"}
394
+ ):
395
+ config_path = None
396
+
397
+ app: 'MaxCApp | None' = None
398
+ try:
399
+ command_name = _command_name(args)
400
+ app = MaxCApp(
401
+ cwd=working_dir,
402
+ config_path=config_path,
403
+ load_backend=_should_load_backend(command_name),
404
+ )
405
+ args.handler(app, args, stdout)
406
+ return 0
407
+ except MaxCError as exc:
408
+ if app is not None:
409
+ app.log(
410
+ _command_name(args),
411
+ "failure",
412
+ {},
413
+ error=exc.to_payload().to_dict(),
414
+ )
415
+ if getattr(args, "json", False):
416
+ payload = Envelope(
417
+ command=_command_name(args),
418
+ status="failure",
419
+ error=exc.to_payload(),
420
+ )
421
+ emit_json(payload.to_dict(), stdout)
422
+ else:
423
+ stderr.write(render_error(exc.error_code, exc.message, exc.suggestion) + "\n")
424
+ return exc.exit_code
425
+ except Exception as exc:
426
+ error_payload = ErrorPayload(
427
+ code="INTERNAL_ERROR",
428
+ message=str(exc) or type(exc).__name__,
429
+ suggestion="This is an unexpected error. Please report it with the full message.",
430
+ recoverable=False,
431
+ )
432
+ cmd = _command_name(args) if hasattr(args, "handler") else "unknown"
433
+ if app is not None:
434
+ app.log(cmd, "failure", {}, error=error_payload.to_dict())
435
+ if getattr(args, "json", False):
436
+ envelope = Envelope(
437
+ command=cmd,
438
+ status="failure",
439
+ error=error_payload,
440
+ )
441
+ emit_json(envelope.to_dict(), stdout)
442
+ else:
443
+ stderr.write(render_error(
444
+ error_payload.code,
445
+ error_payload.message,
446
+ error_payload.suggestion,
447
+ ) + "\n")
448
+ return 1
449
+
450
+
451
+ def _handle_query(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
452
+ mode, sql_parts = _resolve_query_mode(args)
453
+ args.resolved_command = "query" if mode == "run" else f"query.{mode}"
454
+ sql = read_sql_input(
455
+ sql_parts,
456
+ file_path=args.file,
457
+ use_stdin=args.stdin,
458
+ stdin_text=read_stdin() if args.stdin else None,
459
+ )
460
+ if mode == "cost":
461
+ _validate_query_analysis_args(args, mode)
462
+ envelope = app.query_cost(sql=sql, project=args.project)
463
+ elif mode == "explain":
464
+ _validate_query_analysis_args(args, mode)
465
+ envelope = app.query_explain(sql=sql, project=args.project)
466
+ else:
467
+ retry_on = [item.strip() for item in args.retry_on.split(",") if item.strip()]
468
+ envelope = app.query(
469
+ command="query",
470
+ sql=sql,
471
+ project=args.project,
472
+ max_rows=_query_page_size(args),
473
+ cursor=args.cursor,
474
+ dry_run=args.dry_run,
475
+ wait=args.wait,
476
+ cost_check=args.cost_check,
477
+ idempotency_key=args.idempotency_key,
478
+ retry_on=retry_on,
479
+ max_retries=args.max_retries,
480
+ )
481
+ if args.output:
482
+ output_format = _query_output_format(args)
483
+ output_path = _write_output_file(envelope, args.output, output_format)
484
+ envelope.metadata["output_path"] = str(output_path)
485
+ envelope.metadata["output_format"] = output_format
486
+ _emit_envelope(
487
+ envelope,
488
+ args=args,
489
+ stdout=stdout,
490
+ default_format=args.format or _query_default_format(app, mode),
491
+ )
492
+
493
+
494
+ def _handle_job_submit(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
495
+ sql = read_sql_input(
496
+ args.sql_parts,
497
+ file_path=args.file,
498
+ use_stdin=args.stdin,
499
+ stdin_text=read_stdin() if args.stdin else None,
500
+ )
501
+ envelope = app.submit_job(
502
+ sql=sql,
503
+ project=args.project,
504
+ max_rows=args.max_rows,
505
+ cost_check=args.cost_check,
506
+ idempotency_key=args.idempotency_key,
507
+ )
508
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
509
+
510
+
511
+ def _handle_job_status(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
512
+ envelope = app.job_status(args.job_id)
513
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
514
+
515
+
516
+ def _handle_job_wait(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
517
+ envelope, events = app.job_wait(args.job_id, timeout=args.timeout)
518
+ if args.stream:
519
+ emit_ndjson(events, stdout)
520
+ return
521
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
522
+
523
+
524
+ def _handle_job_diagnose(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
525
+ envelope = app.job_diagnose(args.job_id)
526
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
527
+
528
+
529
+ def _handle_job_result(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
530
+ envelope = app.job_result(args.job_id, max_rows=args.max_rows, cursor=args.cursor)
531
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
532
+
533
+
534
+ def _handle_job_cancel(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
535
+ envelope = app.cancel_job(args.job_id)
536
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
537
+
538
+
539
+ def _handle_job_list(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
540
+ envelope = app.list_jobs(limit=args.limit)
541
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
542
+
543
+
544
+ def _handle_meta_list_tables(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
545
+ envelope = app.meta_list_tables()
546
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="table")
547
+
548
+
549
+ def _handle_meta_describe(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
550
+ envelope = app.meta_describe(args.table_name, full=args.full)
551
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
552
+
553
+
554
+ def _handle_meta_search(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
555
+ envelope = app.meta_search(args.keyword)
556
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="table")
557
+
558
+
559
+ def _handle_meta_search_columns(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
560
+ envelope = app.meta_search_columns(args.keyword)
561
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="table")
562
+
563
+
564
+ def _handle_meta_latest_partition(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
565
+ envelope = app.meta_latest_partition(args.table_name)
566
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
567
+
568
+
569
+ def _handle_meta_freshness(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
570
+ envelope = app.meta_freshness(args.table_name)
571
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
572
+
573
+
574
+ def _handle_meta_lineage(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
575
+ envelope = app.meta_lineage(args.table_name)
576
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
577
+
578
+
579
+ def _handle_meta_partitions(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
580
+ envelope = app.meta_partitions(args.table_name)
581
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
582
+
583
+
584
+ def _handle_meta_list_projects(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
585
+ envelope = app.meta_list_projects()
586
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="table")
587
+
588
+
589
+ def _handle_meta_list_schemas(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
590
+ envelope = app.meta_list_schemas(project=args.project)
591
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="table")
592
+
593
+
594
+ def _handle_meta_semantic_set(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
595
+ """Handle semantic set command."""
596
+ import json
597
+
598
+ # Parse JSON arguments if provided
599
+ column_semantics = None
600
+ if args.column_semantics:
601
+ try:
602
+ column_semantics = json.loads(args.column_semantics)
603
+ except json.JSONDecodeError as e:
604
+ envelope = Envelope(
605
+ command="meta.semantic.set",
606
+ status="failure",
607
+ data=None,
608
+ metadata={},
609
+ error=ErrorPayload(
610
+ code="INVALID_JSON",
611
+ message=f"Invalid JSON for --column-semantics: {e}",
612
+ recoverable=True,
613
+ suggestion="Provide valid JSON for the --column-semantics argument.",
614
+ ),
615
+ )
616
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
617
+ return
618
+
619
+ relations = None
620
+ if args.relations:
621
+ try:
622
+ relations = json.loads(args.relations)
623
+ except json.JSONDecodeError as e:
624
+ envelope = Envelope(
625
+ command="meta.semantic.set",
626
+ status="failure",
627
+ data=None,
628
+ metadata={},
629
+ error=ErrorPayload(
630
+ code="INVALID_JSON",
631
+ message=f"Invalid JSON for --relations: {e}",
632
+ recoverable=True,
633
+ suggestion="Provide valid JSON for the --relations argument.",
634
+ ),
635
+ )
636
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
637
+ return
638
+
639
+ stats = None
640
+ if args.stats:
641
+ try:
642
+ stats = json.loads(args.stats)
643
+ except json.JSONDecodeError as e:
644
+ envelope = Envelope(
645
+ command="meta.semantic.set",
646
+ status="failure",
647
+ data=None,
648
+ metadata={},
649
+ error=ErrorPayload(
650
+ code="INVALID_JSON",
651
+ message=f"Invalid JSON for --stats: {e}",
652
+ recoverable=True,
653
+ suggestion="Provide valid JSON for the --stats argument.",
654
+ ),
655
+ )
656
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
657
+ return
658
+
659
+ envelope = app.semantic_set(
660
+ table_name=args.table_name,
661
+ semantic_desc=args.semantic_desc,
662
+ use_cases=args.use_cases,
663
+ sample_questions=args.sample_questions,
664
+ column_semantics=column_semantics,
665
+ relations=relations,
666
+ stats=stats,
667
+ )
668
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
669
+
670
+
671
+ def _handle_meta_semantic_get(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
672
+ """Handle semantic get command."""
673
+ envelope = app.semantic_get(table_name=args.table_name)
674
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
675
+
676
+
677
+ def _handle_meta_semantic_list_missing(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
678
+ """Handle semantic list-missing command."""
679
+ envelope = app.semantic_list_missing()
680
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
681
+
682
+
683
+ def _handle_session_set(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
684
+ """Set current project and/or schema for the session."""
685
+ project = args.project
686
+ schema = args.schema
687
+
688
+ if not project and not schema:
689
+ raise ValidationError("At least one of `--project` or `--schema` must be specified.")
690
+
691
+ envelope = app.session_set(project=project, schema=schema)
692
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
693
+
694
+
695
+ def _handle_session_show(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
696
+ """Show current session settings."""
697
+ envelope = app.session_show()
698
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
699
+
700
+
701
+ def _handle_session_unset(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
702
+ """Clear session override."""
703
+ envelope = app.session_unset()
704
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
705
+
706
+
707
+ def _handle_data_sample(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
708
+ columns = _csv_arg_list(args.columns)
709
+ envelope = app.data_sample(
710
+ args.table_name,
711
+ rows=args.rows,
712
+ partition=args.partition,
713
+ columns=columns or None,
714
+ )
715
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="table")
716
+
717
+
718
+ def _handle_data_profile(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
719
+ envelope = app.data_profile(args.table_name, partition=args.partition)
720
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
721
+
722
+
723
+ def _handle_auth_login(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
724
+ envelope = app.auth_login(
725
+ access_id=args.access_id,
726
+ secret_access_key=args.secret_access_key,
727
+ security_token=args.security_token,
728
+ project=args.project,
729
+ endpoint=args.endpoint,
730
+ region_name=args.region_name,
731
+ tunnel_endpoint=args.tunnel_endpoint,
732
+ from_env=args.from_env,
733
+ no_validate=args.no_validate,
734
+ target_config_path=args.requested_config_path,
735
+ )
736
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
737
+
738
+
739
+ def _handle_auth_login_ncs(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
740
+ envelope = app.auth_login_ncs(
741
+ account_type=args.account_type,
742
+ employee_id=args.employee_id,
743
+ account_name=args.account_name,
744
+ app_name=args.app_name,
745
+ project=args.project,
746
+ endpoint=args.endpoint,
747
+ region_name=args.region_name,
748
+ tunnel_endpoint=args.tunnel_endpoint,
749
+ interactive=args.interactive,
750
+ list_accounts_mode=args.list_accounts,
751
+ no_validate=args.no_validate,
752
+ target_config_path=args.requested_config_path,
753
+ )
754
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
755
+
756
+
757
+ def _handle_auth_whoami(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
758
+ envelope = app.auth_whoami()
759
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
760
+
761
+
762
+ def _handle_auth_can_i(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
763
+ envelope = app.auth_can_i(
764
+ table_name=args.table,
765
+ operation=args.operation,
766
+ project=args.project,
767
+ )
768
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
769
+
770
+
771
+ def _handle_diff_schema(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
772
+ envelope = app.schema_diff(args.left_table, args.right_table)
773
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
774
+
775
+
776
+ def _handle_diff_partition(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
777
+ envelope = app.partition_diff(args.left_table, args.right_table)
778
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
779
+
780
+
781
+ def _handle_diff_data(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
782
+ envelope = app.data_diff(
783
+ args.left_table,
784
+ args.right_table,
785
+ keys=_csv_arg_list(args.keys),
786
+ columns=_csv_arg_list(args.columns) or None,
787
+ rows=args.rows,
788
+ partition=args.partition,
789
+ left_partition=args.left_partition,
790
+ right_partition=args.right_partition,
791
+ )
792
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
793
+
794
+
795
+ def _handle_agent_context(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
796
+ envelope = app.agent_context()
797
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
798
+
799
+
800
+ def _handle_cache_build(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
801
+ """Build the metadata cache.
802
+
803
+ JSON or async invocations return the standard envelope contract.
804
+ Human-mode synchronous builds keep the incremental terminal progress output.
805
+ """
806
+ from concurrent.futures import ThreadPoolExecutor, as_completed
807
+ import threading
808
+ import time
809
+ import uuid
810
+
811
+ is_json_mode = getattr(args, "json", False)
812
+ is_async_mode = getattr(args, "async_mode", False)
813
+ target_project = args.project or app.config.default_project
814
+ schema_name = getattr(args, "schema", None)
815
+ max_workers = 8
816
+
817
+ if is_async_mode:
818
+ envelope = app.cache_build(
819
+ project=args.project,
820
+ schema_name=schema_name,
821
+ async_mode=is_async_mode,
822
+ )
823
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
824
+ return
825
+
826
+ if is_json_mode:
827
+ progress_stream = getattr(args, "stderr", None) or sys.stderr
828
+ last_progress_emit = 0.0
829
+
830
+ def emit_progress(event: 'dict[str, Any]') -> 'None':
831
+ nonlocal last_progress_emit
832
+ event_type = str(event.get("type", ""))
833
+ now = time.monotonic()
834
+ if event_type == "listing_start":
835
+ progress_stream.write("Fetching table list...\n")
836
+ progress_stream.flush()
837
+ return
838
+ if event_type == "listing_complete":
839
+ total = int(event.get("total_tables", 0))
840
+ progress_stream.write(f"Discovered {total} table(s), starting cache build...\n")
841
+ progress_stream.flush()
842
+ return
843
+ if event_type == "progress":
844
+ if now - last_progress_emit < 0.5:
845
+ return
846
+ last_progress_emit = now
847
+ progress_stream.write(
848
+ "\rProgress: {cached}/{total} tables cached (failed: {failed})".format(
849
+ cached=event.get("cached_tables", 0),
850
+ total=event.get("total_tables", 0),
851
+ failed=event.get("failed_tables", 0),
852
+ )
853
+ )
854
+ progress_stream.flush()
855
+ return
856
+ if event_type == "completed":
857
+ progress_stream.write(
858
+ "\rProgress: {cached}/{total} tables cached (failed: {failed})\n".format(
859
+ cached=event.get("cached_tables", 0),
860
+ total=event.get("total_tables", 0),
861
+ failed=event.get("failed_tables", 0),
862
+ )
863
+ )
864
+ progress_stream.flush()
865
+
866
+ envelope = app.cache_build(
867
+ project=args.project,
868
+ schema_name=schema_name,
869
+ async_mode=False,
870
+ progress_callback=emit_progress,
871
+ )
872
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
873
+ return
874
+
875
+ # Phase 1: Single-threaded list all tables
876
+ stdout.write("Fetching table list...\n")
877
+ stdout.flush()
878
+
879
+ tables = app.backend.list_tables()
880
+ total = len(tables)
881
+
882
+ if total == 0:
883
+ stdout.write("No tables found, cache build completed.\n")
884
+ return
885
+
886
+ build_id = str(uuid.uuid4())[:8]
887
+ app.cache.start_build(target_project, build_id, total)
888
+
889
+ # Output initial state
890
+ stdout.write(f"Target project: {target_project}\n")
891
+ if schema_name:
892
+ stdout.write(f"Target schema: {schema_name}\n")
893
+ stdout.write(f"Discovered {total} table(s), starting cache build...\n\n")
894
+ stdout.flush()
895
+
896
+ # Phase 2: Multi-threaded fetch schema for each table
897
+ cached_count = 0
898
+ errors: 'list[str]' = []
899
+ lock = threading.Lock()
900
+ last_progress_time = time.monotonic()
901
+ progress_interval = 3.0 # seconds
902
+
903
+ def fetch_and_cache(table_name: 'str') -> 'tuple[str, str | None]':
904
+ """Fetch and cache a table. Returns (table_name, error_or_none)."""
905
+ import concurrent.futures
906
+
907
+ def _do_fetch():
908
+ # Use a simpler approach: only get table metadata without sample rows
909
+ # to avoid potential hangs on table.head() or iterate_partitions()
910
+ table = app.backend._get_table(table_name)
911
+ # Force reload to get full schema info
912
+ if hasattr(table, 'reload'):
913
+ table.reload()
914
+
915
+ columns = [
916
+ {"name": c.name, "type": str(c.type), "comment": getattr(c, 'comment', '') or ''}
917
+ for c in getattr(table.table_schema, 'columns', [])
918
+ ]
919
+
920
+ # Get partition columns but don't fetch actual partitions (can be slow)
921
+ partitions = [
922
+ c.name for c in getattr(table.table_schema, 'partitions', [])
923
+ ]
924
+
925
+ row_count = int(getattr(table, 'record_num', -1) or -1)
926
+ size_bytes = int(getattr(table, 'size', 0)) if getattr(table, 'size', None) else None
927
+
928
+ app.cache.cache_table(
929
+ project=target_project,
930
+ table_name=table.name,
931
+ description=getattr(table, 'comment', '') or '',
932
+ columns=columns,
933
+ partitions=partitions,
934
+ row_count=row_count if row_count >= 0 else None,
935
+ size_bytes=size_bytes,
936
+ owner=getattr(table, 'owner', None),
937
+ schema_name=schema_name or "default",
938
+ )
939
+ return table_name, None
940
+
941
+ # Run with timeout to prevent hanging
942
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
943
+ future = executor.submit(_do_fetch)
944
+ try:
945
+ return future.result(timeout=30) # 30 second timeout per table
946
+ except concurrent.futures.TimeoutError:
947
+ return table_name, f"{table_name}: timeout after 30s"
948
+ except Exception as exc:
949
+ return table_name, f"{table_name}: {exc}"
950
+
951
+ def emit_progress(force: 'bool' = False) -> 'None':
952
+ nonlocal last_progress_time
953
+ current_time = time.monotonic()
954
+
955
+ _ = force
956
+ if current_time - last_progress_time >= progress_interval:
957
+ last_progress_time = current_time
958
+ stdout.write(f"\rProgress: {cached_count}/{total} tables cached (failed: {len(errors)})")
959
+ stdout.flush()
960
+
961
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
962
+ futures = {executor.submit(fetch_and_cache, t.name): t.name for t in tables}
963
+
964
+ for future in as_completed(futures):
965
+ table_name, error = future.result()
966
+
967
+ with lock:
968
+ if error:
969
+ errors.append(error)
970
+ else:
971
+ cached_count += 1
972
+
973
+ # Move emit_progress outside the lock to avoid holding lock during IO
974
+ emit_progress()
975
+
976
+ # Update build progress in DB (can be done outside lock)
977
+ app.cache.update_build_progress(
978
+ target_project, build_id, cached_count, len(errors)
979
+ )
980
+
981
+ # Complete build
982
+ if errors:
983
+ app.cache.complete_build(target_project, build_id, error_message=f"{len(errors)} errors")
984
+ else:
985
+ app.cache.complete_build(target_project, build_id)
986
+
987
+ # Final output
988
+ stdout.write("\n\n")
989
+
990
+ if not errors:
991
+ stdout.write("Cache build completed successfully!\n")
992
+ stdout.write(f" Tables cached: {cached_count}/{total}\n")
993
+ else:
994
+ stdout.write("Cache build completed with errors.\n")
995
+ stdout.write(f" Succeeded: {cached_count}\n")
996
+ stdout.write(f" Failed: {len(errors)}\n")
997
+ error_list = errors[:5]
998
+ if error_list:
999
+ stdout.write("\nError details:\n")
1000
+ for error in error_list:
1001
+ stdout.write(f" - {error}\n")
1002
+ if len(errors) > 5:
1003
+ stdout.write(f" ... and {len(errors) - 5} more error(s)\n")
1004
+
1005
+ stats = app.cache.get_cache_stats(target_project)
1006
+ if stats:
1007
+ stdout.write("\nCache stats:\n")
1008
+ stdout.write(f" Total tables: {stats.get('table_count', 0)}\n")
1009
+ if stats.get("oldest"):
1010
+ stdout.write(f" Oldest update: {stats.get('oldest')}\n")
1011
+ if stats.get("newest"):
1012
+ stdout.write(f" Newest update: {stats.get('newest')}\n")
1013
+
1014
+ schemas = app.cache.get_schemas(target_project)
1015
+ if schemas:
1016
+ stdout.write("\nCached schemas:\n")
1017
+ for schema in schemas:
1018
+ stdout.write(f" - {schema}\n")
1019
+
1020
+ stdout.write("\n")
1021
+ stdout.flush()
1022
+
1023
+ app.log("cache.build", "success" if not errors else "partial", {"project": target_project})
1024
+
1025
+
1026
+
1027
+ def _handle_cache_build_status(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
1028
+ envelope = app.cache_build_status(
1029
+ project=args.project,
1030
+ build_id=getattr(args, 'build_id', None),
1031
+ )
1032
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1033
+
1034
+
1035
+ def _handle_cache_status(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
1036
+ envelope = app.cache_status(
1037
+ project=args.project,
1038
+ schema_name=getattr(args, 'schema', None),
1039
+ )
1040
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1041
+
1042
+
1043
+ def _handle_cache_clear(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
1044
+ envelope = app.cache_clear(
1045
+ project=args.project,
1046
+ schema_name=getattr(args, 'schema', None),
1047
+ )
1048
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1049
+
1050
+
1051
+ def _handle_cache_save_semantic(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
1052
+ import json as json_module
1053
+ envelope = app.cache_save_semantic(
1054
+ table_name=args.table,
1055
+ semantic_desc=args.semantic_desc,
1056
+ use_cases=json_module.loads(args.use_cases),
1057
+ sample_questions=json_module.loads(args.sample_questions),
1058
+ column_semantics=json_module.loads(args.column_semantics),
1059
+ project=args.project,
1060
+ schema_name=getattr(args, 'schema', 'default'),
1061
+ )
1062
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1063
+
1064
+
1065
+ def _handle_cache_get_semantic(app: 'MaxCApp', args: 'argparse.Namespace', stdout: 'TextIO') -> 'None':
1066
+ envelope = app.cache_get_semantic(
1067
+ table_name=args.table,
1068
+ project=args.project,
1069
+ schema_name=getattr(args, 'schema', 'default'),
1070
+ )
1071
+ _emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
1072
+
1073
+
1074
+ def _emit_envelope(
1075
+ envelope: 'Envelope',
1076
+ *,
1077
+ args: 'argparse.Namespace',
1078
+ stdout: 'TextIO',
1079
+ default_format: 'str',
1080
+ ) -> 'None':
1081
+ if getattr(args, "brief", False):
1082
+ stdout.write(_render_brief(envelope) + "\n")
1083
+ return
1084
+ if getattr(args, "json", False):
1085
+ emit_json(envelope.to_dict(), stdout)
1086
+ return
1087
+
1088
+ if default_format == "ndjson":
1089
+ rows = envelope.data.get("rows", [])
1090
+ emit_ndjson(rows, stdout)
1091
+ return
1092
+ if default_format == "csv":
1093
+ _emit_csv(envelope.data.get("rows", []), stdout)
1094
+ return
1095
+ if default_format == "json":
1096
+ emit_json(envelope.data, stdout)
1097
+ return
1098
+
1099
+ stdout.write(_render_human(envelope) + "\n")
1100
+
1101
+
1102
+ def _render_brief(envelope: 'Envelope') -> 'str':
1103
+ if envelope.command == "auth.can-i":
1104
+ return "ALLOWED" if envelope.data.get("allowed") else "DENIED"
1105
+ return envelope.status.upper()
1106
+
1107
+
1108
+ def _render_human(envelope: 'Envelope') -> 'str':
1109
+ command = envelope.command
1110
+ data = envelope.data
1111
+ metadata = envelope.metadata
1112
+
1113
+ if command == "query":
1114
+ rows = data.get("rows", [])
1115
+ summary = render_key_values(
1116
+ {
1117
+ "status": envelope.status,
1118
+ "project": metadata.get("project"),
1119
+ "elapsed_ms": metadata.get("elapsed_ms"),
1120
+ "returned_rows": data.get("returned_rows"),
1121
+ "total_rows": data.get("total_rows"),
1122
+ "has_more": data.get("has_more"),
1123
+ "next_cursor": data.get("next_cursor"),
1124
+ "current_offset": metadata.get("current_offset"),
1125
+ "bytes_scanned": metadata.get("bytes_scanned"),
1126
+ "task_cost_cpu": metadata.get("task_cost_cpu"),
1127
+ "task_cost_memory": metadata.get("task_cost_memory"),
1128
+ "tables": metadata.get("tables_used", []),
1129
+ }
1130
+ )
1131
+ body = render_table(rows)
1132
+ return f"{summary}\n\n{body}"
1133
+
1134
+ if command in {"query.cost", "query.explain"}:
1135
+ return render_key_values(data)
1136
+
1137
+ if command == "meta.list-tables":
1138
+ return render_table(data.get("tables", []))
1139
+
1140
+ if command in {"meta.search", "meta.search-columns"}:
1141
+ return render_table(data.get("matches", []))
1142
+
1143
+ if command == "data.sample":
1144
+ return render_table(data.get("rows", []))
1145
+
1146
+ if command == "skill.list":
1147
+ return render_table(data.get("skills", []))
1148
+
1149
+ return render_key_values(data if isinstance(data, dict) else {"value": data})
1150
+
1151
+
1152
+ def _emit_csv(rows: 'list[dict[str, Any]]', stdout: 'TextIO') -> 'None':
1153
+ if not rows:
1154
+ stdout.write("\n")
1155
+ return
1156
+ columns = list(rows[0])
1157
+ stdout.write(",".join(columns) + "\n")
1158
+ for row in rows:
1159
+ stdout.write(",".join(str(row.get(column, "")) for column in columns) + "\n")
1160
+
1161
+
1162
+ def _write_output_file(envelope: 'Envelope', raw_path: 'str', output_format: 'str') -> 'Path':
1163
+ path = Path(raw_path).expanduser().resolve()
1164
+ path.parent.mkdir(parents=True, exist_ok=True)
1165
+ with path.open("w", encoding="utf-8") as handle:
1166
+ if output_format == "ndjson":
1167
+ emit_ndjson(envelope.data.get("rows", []), handle)
1168
+ elif output_format == "csv":
1169
+ _emit_csv(envelope.data.get("rows", []), handle)
1170
+ elif output_format == "table":
1171
+ handle.write(render_table(envelope.data.get("rows", [])) + "\n")
1172
+ else:
1173
+ emit_json(envelope.data, handle)
1174
+ return path
1175
+
1176
+
1177
+ def _command_name(args: 'argparse.Namespace') -> 'str':
1178
+ resolved = getattr(args, "resolved_command", None)
1179
+ if resolved:
1180
+ return resolved
1181
+ parts = [args.command_group]
1182
+ for attr in (
1183
+ "job_command",
1184
+ "meta_command",
1185
+ "semantic_command",
1186
+ "session_command",
1187
+ "data_command",
1188
+ "auth_command",
1189
+ "diff_command",
1190
+ "agent_command",
1191
+ "cache_command",
1192
+ "skill_command",
1193
+ ):
1194
+ value = getattr(args, attr, None)
1195
+ if value:
1196
+ parts.append(value)
1197
+ return ".".join(parts)
1198
+
1199
+
1200
+ def _should_load_backend(command_name: 'str') -> 'bool':
1201
+ return command_name not in {
1202
+ "auth.login",
1203
+ "auth.login-ncs",
1204
+ "auth.whoami",
1205
+ "session.set",
1206
+ "session.show",
1207
+ "session.unset",
1208
+ "agent.context",
1209
+ }
1210
+
1211
+
1212
+ def _resolve_query_mode(args: 'argparse.Namespace') -> 'tuple[str, list[str]]':
1213
+ mode = args.mode
1214
+ sql_parts = list(args.sql_parts)
1215
+ alias = sql_parts[0].lower() if sql_parts else ""
1216
+ if mode != "run":
1217
+ if alias in {"run", "cost", "explain"} and (len(sql_parts) > 1 or args.file or args.stdin):
1218
+ raise ValidationError("Do not combine query mode aliases with `--mode`; pick one style.")
1219
+ return mode, sql_parts
1220
+
1221
+ if alias in {"run", "cost", "explain"} and (len(sql_parts) > 1 or args.file or args.stdin):
1222
+ return alias, sql_parts[1:]
1223
+ return mode, sql_parts
1224
+
1225
+
1226
+ def _validate_query_analysis_args(args: 'argparse.Namespace', mode: 'str') -> 'None':
1227
+ _ = mode
1228
+ unsupported = []
1229
+ if args.dry_run:
1230
+ unsupported.append("--dry-run")
1231
+ if args.cursor:
1232
+ unsupported.append("--cursor")
1233
+ if args.output:
1234
+ unsupported.append("--output")
1235
+ if args.output_format:
1236
+ unsupported.append("--output-format")
1237
+ if getattr(args, "wait", 10) != 10:
1238
+ unsupported.append("--wait")
1239
+ if unsupported:
1240
+ raise ValidationError(
1241
+ f"{', '.join(unsupported)} cannot be combined with `query cost` or `query explain`."
1242
+ )
1243
+ if args.format in {"csv", "ndjson"}:
1244
+ raise ValidationError("`query cost` and `query explain` support only `table` or `json` output.")
1245
+
1246
+
1247
+ def _query_page_size(args: 'argparse.Namespace') -> 'int':
1248
+ return args.page_size if args.page_size is not None else args.max_rows
1249
+
1250
+
1251
+ def _csv_arg_list(value: 'str | None') -> 'list[str]':
1252
+ return [item.strip() for item in (value or "").split(",") if item.strip()]
1253
+
1254
+
1255
+ def _query_output_format(args: 'argparse.Namespace') -> 'str':
1256
+ if args.output_format:
1257
+ return args.output_format
1258
+ if args.output:
1259
+ suffix = Path(args.output).suffix.lower()
1260
+ if suffix == ".csv":
1261
+ return "csv"
1262
+ if suffix == ".ndjson":
1263
+ return "ndjson"
1264
+ if suffix == ".table":
1265
+ return "table"
1266
+ if args.format in {"json", "csv", "ndjson", "table"}:
1267
+ return args.format
1268
+ return "json"
1269
+
1270
+
1271
+ def _query_default_format(app: 'MaxCApp', mode: 'str') -> 'str':
1272
+ if mode == "run":
1273
+ return app.config.default_format
1274
+ return "table"