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/models.py ADDED
@@ -0,0 +1,533 @@
1
+ """Data models for MaxCompute CLI."""
2
+
3
+
4
+ from dataclasses import dataclass, field
5
+ import shlex
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class AgentHints:
11
+ next_actions: 'list[str]' = field(default_factory=list)
12
+ warnings: 'list[str]' = field(default_factory=list)
13
+ insights: 'list[str]' = field(default_factory=list)
14
+
15
+ def to_dict(self) -> 'dict[str, Any]':
16
+ payload: 'dict[str, Any]' = {}
17
+ if self.next_actions:
18
+ payload["next_actions"] = self.next_actions
19
+ if self.warnings:
20
+ payload["warnings"] = self.warnings
21
+ if self.insights:
22
+ payload["insights"] = self.insights
23
+ return payload
24
+
25
+
26
+ @dataclass
27
+ class Envelope:
28
+ command: 'str'
29
+ status: 'str'
30
+ data: 'dict[str, Any]' = field(default_factory=dict)
31
+ metadata: 'dict[str, Any]' = field(default_factory=dict)
32
+ error: 'Any | None' = None
33
+ agent_hints: 'AgentHints | None' = None
34
+ version: 'str' = "2.0"
35
+
36
+ def to_dict(self, *, normalize: 'bool' = True) -> 'dict[str, Any]':
37
+ command = _format_command_path(self.command) if normalize else self.command
38
+ data = _normalize_data(self.command, self.data) if normalize else self.data
39
+ payload = {
40
+ "version": self.version,
41
+ "command": command,
42
+ "command_id": self.command,
43
+ "status": self.status,
44
+ "data": data,
45
+ "metadata": self.metadata,
46
+ }
47
+ payload["error"] = self.error.to_dict() if self.error else None
48
+ payload["agent_hints"] = _render_agent_hints(self)
49
+ return payload
50
+
51
+
52
+ @dataclass
53
+ class QueryResult:
54
+ """Result of a query execution."""
55
+
56
+ rows: 'list[dict[str, Any]]'
57
+ schema: 'list[dict[str, Any]]'
58
+ total_rows: 'int'
59
+ returned_rows: 'int'
60
+ has_more: 'bool'
61
+ next_cursor: 'str | None'
62
+ elapsed_ms: 'int'
63
+ bytes_scanned: 'int | None'
64
+ project: 'str'
65
+ sql_executed: 'str'
66
+ tables_used: 'list[str]'
67
+ warnings: 'list[str]' = field(default_factory=list)
68
+ job_id: 'str | None' = None
69
+ submitted_at: 'str | None' = None
70
+ completed_at: 'str | None' = None
71
+ extra_metadata: 'dict[str, Any]' = field(default_factory=dict)
72
+
73
+
74
+ @dataclass
75
+ class JobInfo:
76
+ """Information about a job."""
77
+
78
+ job_id: 'str'
79
+ status: 'str'
80
+ project: 'str'
81
+ progress: 'int'
82
+ stage: 'str | None' = None
83
+ retryable: 'bool | None' = None
84
+ failure_reason: 'str | None' = None
85
+ task_summary: 'dict[str, Any]' = field(default_factory=dict)
86
+ sql: 'str | None' = None
87
+ submitted_at: 'str | None' = None
88
+ updated_at: 'str | None' = None
89
+ completed_at: 'str | None' = None
90
+ logview: 'str | None' = None
91
+ error_message: 'str | None' = None
92
+ warnings: 'list[str]' = field(default_factory=list)
93
+
94
+
95
+ def _format_command_path(command: 'str') -> 'str':
96
+ if "." not in command:
97
+ return command
98
+ return command.replace(".", " ")
99
+
100
+
101
+ def _normalize_data(command: 'str', data: 'dict[str, Any]') -> 'dict[str, Any]':
102
+ if not isinstance(data, dict):
103
+ return {"value": data}
104
+
105
+ if _already_normalized(command, data):
106
+ return data
107
+
108
+ if command in {"query", "job.wait", "job.result"}:
109
+ if set(data) == {"job_id"}:
110
+ return {"job": {"job_id": data["job_id"]}}
111
+ return {
112
+ "result": {
113
+ "rows": data.get("rows", []),
114
+ "schema": data.get("schema", []),
115
+ "row_count": data.get("total_rows"),
116
+ "returned_rows": data.get("returned_rows"),
117
+ },
118
+ "pagination": {
119
+ "has_more": data.get("has_more", False),
120
+ "next_cursor": data.get("next_cursor"),
121
+ },
122
+ }
123
+ if command in {"query.cost", "query.explain"}:
124
+ return {"analysis": data}
125
+ if command == "auth.whoami":
126
+ options = data.get("auth_options")
127
+ identity = {key: value for key, value in data.items() if key != "auth_options"}
128
+ payload: 'dict[str, Any]' = {"identity": identity}
129
+ if options is not None:
130
+ payload["auth_options"] = options
131
+ return payload
132
+ if command == "auth.login":
133
+ identity = {
134
+ key: value
135
+ for key, value in data.items()
136
+ if key not in {"saved", "validated"}
137
+ }
138
+ return {
139
+ "identity": identity,
140
+ "persistence": {
141
+ "saved": data.get("saved"),
142
+ "validated": data.get("validated"),
143
+ },
144
+ }
145
+ if command == "auth.login-ncs":
146
+ if "raw_lines" in data or "raw_output" in data:
147
+ return {"accounts": data}
148
+ identity = {
149
+ key: value
150
+ for key, value in data.items()
151
+ if key not in {"saved", "validated"}
152
+ }
153
+ return {
154
+ "identity": identity,
155
+ "persistence": {
156
+ "saved": data.get("saved"),
157
+ "validated": data.get("validated"),
158
+ },
159
+ }
160
+ if command == "auth.can-i":
161
+ return {"authorization": data}
162
+ if command == "meta.list-tables":
163
+ return {
164
+ "tables": data.get("tables", []),
165
+ "pagination": {
166
+ "total": data.get("total"),
167
+ "has_more": False,
168
+ },
169
+ }
170
+ if command in {"meta.list-projects", "meta.list-schemas"}:
171
+ collection_key = "projects" if command == "meta.list-projects" else "schemas"
172
+ return {
173
+ collection_key: data.get(collection_key, []),
174
+ "pagination": {
175
+ "total": data.get("total"),
176
+ "has_more": False,
177
+ },
178
+ }
179
+ if command in {"meta.search", "meta.search-columns"}:
180
+ return {
181
+ "search": {
182
+ "keyword": data.get("keyword"),
183
+ "matches": data.get("matches", []),
184
+ },
185
+ "pagination": {
186
+ "total": data.get("total"),
187
+ "has_more": False,
188
+ },
189
+ }
190
+ if command == "meta.describe":
191
+ return {"table": data}
192
+ if command == "meta.partitions":
193
+ return {
194
+ "table": {"table_name": data.get("table_name")},
195
+ "partitions": data.get("partitions", []),
196
+ }
197
+ if command in {"meta.latest-partition", "meta.freshness", "meta.lineage"}:
198
+ key = {
199
+ "meta.latest-partition": "partition",
200
+ "meta.freshness": "freshness",
201
+ "meta.lineage": "lineage",
202
+ }[command]
203
+ return {key: data}
204
+ if command == "data.sample":
205
+ return {
206
+ "sample": {
207
+ "table_name": data.get("table_name"),
208
+ "applied_partition": data.get("applied_partition"),
209
+ "selected_columns": data.get("selected_columns"),
210
+ "rows": data.get("rows", []),
211
+ "schema": data.get("schema", []),
212
+ "returned_rows": data.get("returned_rows"),
213
+ }
214
+ }
215
+ if command == "data.profile":
216
+ return {"profile": data}
217
+ if command.startswith("project."):
218
+ return {"project": data}
219
+ if command.startswith("diff."):
220
+ return {"diff": data}
221
+ if command == "job.list":
222
+ return {
223
+ "jobs": data.get("jobs", []),
224
+ "pagination": {
225
+ "total": data.get("total"),
226
+ "has_more": False,
227
+ },
228
+ }
229
+ if command in {"job.status", "job.cancel"}:
230
+ return {"job": data}
231
+ if command == "job.diagnose":
232
+ return {"diagnosis": data}
233
+ if command in {"cache.get-semantic", "cache.save-semantic"}:
234
+ return {"semantic": data}
235
+ if command == "agent.context":
236
+ return {"context": data}
237
+
238
+ return data
239
+
240
+
241
+ def _already_normalized(command: 'str', data: 'dict[str, Any]') -> 'bool':
242
+ if command in {"query", "job.wait", "job.result"}:
243
+ return _has_mapping(data, "job") or _has_mapping(data, "result", "pagination")
244
+ if command in {"query.cost", "query.explain"}:
245
+ return _has_mapping(data, "analysis")
246
+ if command == "auth.whoami":
247
+ return _has_mapping(data, "identity")
248
+ if command == "auth.login":
249
+ return _has_mapping(data, "identity", "persistence")
250
+ if command == "auth.login-ncs":
251
+ return _has_mapping(data, "identity", "persistence") or _has_mapping(data, "accounts")
252
+ if command == "auth.can-i":
253
+ return _has_mapping(data, "authorization")
254
+ if command == "meta.list-tables":
255
+ return _has_sequence(data, "tables") and _has_mapping(data, "pagination")
256
+ if command == "meta.list-projects":
257
+ return _has_sequence(data, "projects") and _has_mapping(data, "pagination")
258
+ if command == "meta.list-schemas":
259
+ return _has_sequence(data, "schemas") and _has_mapping(data, "pagination")
260
+ if command in {"meta.search", "meta.search-columns"}:
261
+ return _has_mapping(data, "search", "pagination")
262
+ if command == "meta.describe":
263
+ return _has_mapping(data, "table")
264
+ if command == "meta.partitions":
265
+ return _has_mapping(data, "table") and _has_sequence(data, "partitions")
266
+ if command == "meta.latest-partition":
267
+ return _has_mapping(data, "partition")
268
+ if command == "meta.freshness":
269
+ return _has_mapping(data, "freshness")
270
+ if command == "meta.lineage":
271
+ return _has_mapping(data, "lineage")
272
+ if command == "data.sample":
273
+ return _has_mapping(data, "sample")
274
+ if command == "data.profile":
275
+ return _has_mapping(data, "profile")
276
+ if command == "job.list":
277
+ return _has_sequence(data, "jobs") and _has_mapping(data, "pagination")
278
+ if command in {"job.status", "job.cancel"}:
279
+ return _has_mapping(data, "job")
280
+ if command == "job.diagnose":
281
+ return _has_mapping(data, "diagnosis")
282
+ if command in {"cache.get-semantic", "cache.save-semantic"}:
283
+ return _has_mapping(data, "semantic")
284
+ if command == "agent.context":
285
+ return _has_mapping(data, "context")
286
+ return False
287
+
288
+
289
+ def _has_mapping(data: 'dict[str, Any]', *keys: 'str') -> 'bool':
290
+ return all(isinstance(data.get(key), dict) for key in keys)
291
+
292
+
293
+ def _has_sequence(data: 'dict[str, Any]', key: 'str') -> 'bool':
294
+ return isinstance(data.get(key), list)
295
+
296
+
297
+ def _render_agent_hints(envelope: 'Envelope') -> 'dict[str, Any] | None':
298
+ if envelope.agent_hints is None:
299
+ return None
300
+
301
+ payload = envelope.agent_hints.to_dict()
302
+ if envelope.agent_hints.next_actions:
303
+ payload["action_ids"] = list(envelope.agent_hints.next_actions)
304
+ payload["next_actions"] = [
305
+ _format_next_action(
306
+ action,
307
+ data=envelope.data,
308
+ metadata=envelope.metadata,
309
+ )
310
+ for action in envelope.agent_hints.next_actions
311
+ ]
312
+ return payload
313
+
314
+
315
+ def _format_next_action(
316
+ action: 'str',
317
+ *,
318
+ data: 'dict[str, Any]',
319
+ metadata: 'dict[str, Any]',
320
+ ) -> 'str':
321
+ if " " in action:
322
+ return action
323
+
324
+ sql = _suggested_sql(data, metadata)
325
+ next_cursor = _string_value(data.get("next_cursor"))
326
+ job_id = _string_value(data.get("job_id")) or _string_value(metadata.get("job_id"))
327
+ build_id = _string_value(data.get("build_id")) or _string_value(metadata.get("build_id"))
328
+ table_name = _string_value(data.get("table_name")) or _single_list_value(metadata.get("tables_used"))
329
+ keyword = _string_value(data.get("keyword"))
330
+ operation = _string_value(data.get("operation")) or "SELECT"
331
+ project = _string_value(metadata.get("project")) or _string_value(data.get("project"))
332
+ project_name = _string_value(data.get("name")) or project
333
+ schema_name = _string_value(data.get("schema_name"))
334
+ left_table = _string_value(data.get("left_table"))
335
+ right_table = _string_value(data.get("right_table"))
336
+
337
+ if action in {"query.paginate", "query.next_page"}:
338
+ return _cli_command(
339
+ "query",
340
+ _shell_arg(sql, "<sql>"),
341
+ "--cursor",
342
+ _shell_arg(next_cursor, "<next_cursor>"),
343
+ "--json",
344
+ )
345
+ if action in {"query", "query.cost", "query.explain"}:
346
+ parts = ["query"]
347
+ if action != "query":
348
+ parts.append(action.split(".", 1)[1])
349
+ parts.extend([_shell_arg(sql, "<sql>"), "--json"])
350
+ return _cli_command(*parts)
351
+ if action == "job.submit":
352
+ return _cli_command("job", "submit", _shell_arg(sql, "<sql>"), "--json")
353
+ if action in {"job.status", "job.wait", "job.result", "job.cancel", "job.diagnose"}:
354
+ return _cli_command(
355
+ "job",
356
+ action.split(".", 1)[1],
357
+ _shell_arg(job_id, "<job_id>"),
358
+ "--json",
359
+ )
360
+ if action == "job.list":
361
+ return _cli_command("job", "list", "--json")
362
+ if action == "auth.can-i":
363
+ parts = [
364
+ "auth",
365
+ "can-i",
366
+ "--table",
367
+ _shell_arg(table_name, "<table_name>"),
368
+ "--operation",
369
+ _shell_arg(operation, "SELECT"),
370
+ ]
371
+ if project:
372
+ parts.extend(["--project", _shell_arg(project, "<project>")])
373
+ parts.append("--json")
374
+ return _cli_command(*parts)
375
+ if action == "auth.whoami":
376
+ return _cli_command("auth", "whoami", "--json")
377
+ if action == "meta.list-tables":
378
+ return _cli_command("meta", "list-tables", "--json")
379
+ if action in {
380
+ "meta.describe",
381
+ "meta.partitions",
382
+ "meta.latest-partition",
383
+ "meta.freshness",
384
+ "meta.lineage",
385
+ "data.sample",
386
+ "data.profile",
387
+ }:
388
+ return _cli_command(
389
+ action.split(".", 1)[0],
390
+ action.split(".", 1)[1],
391
+ _shell_arg(table_name, "<table_name>"),
392
+ "--json",
393
+ )
394
+ if action in {"meta.search", "meta.search-columns"}:
395
+ return _cli_command(
396
+ action.split(".", 1)[0],
397
+ action.split(".", 1)[1],
398
+ _shell_arg(keyword, "<keyword>"),
399
+ "--json",
400
+ )
401
+ if action == "meta.list-projects":
402
+ return _cli_command("meta", "list-projects", "--json")
403
+ if action == "meta.list-schemas":
404
+ parts = ["meta", "list-schemas"]
405
+ if project:
406
+ parts.extend(["--project", _shell_arg(project, "<project>")])
407
+ parts.append("--json")
408
+ return _cli_command(*parts)
409
+ if action == "project.use":
410
+ parts = ["project", "use", _shell_arg(project_name, "<project_name>")]
411
+ if schema_name:
412
+ parts.extend(["--schema", _shell_arg(schema_name, "<schema_name>")])
413
+ parts.append("--json")
414
+ return _cli_command(*parts)
415
+ if action == "project.info":
416
+ parts = ["project", "info"]
417
+ if project_name:
418
+ parts.append(_shell_arg(project_name, "<project_name>"))
419
+ parts.append("--json")
420
+ return _cli_command(*parts)
421
+ if action in {"diff.schema", "diff.partition"}:
422
+ return _cli_command(
423
+ action.split(".", 1)[0],
424
+ action.split(".", 1)[1],
425
+ _shell_arg(left_table, "<left_table>"),
426
+ _shell_arg(right_table, "<right_table>"),
427
+ "--json",
428
+ )
429
+ if action == "diff.data":
430
+ return _cli_command(
431
+ "diff",
432
+ "data",
433
+ _shell_arg(left_table, "<left_table>"),
434
+ _shell_arg(right_table, "<right_table>"),
435
+ "--keys",
436
+ "<key_columns>",
437
+ "--json",
438
+ )
439
+ if action == "cache.build":
440
+ parts = ["cache", "build"]
441
+ if project:
442
+ parts.extend(["--project", _shell_arg(project, "<project>")])
443
+ parts.append("--json")
444
+ return _cli_command(*parts)
445
+ if action == "cache.build-status":
446
+ parts = ["cache", "build-status"]
447
+ if build_id:
448
+ parts.extend(["--build-id", _shell_arg(build_id, "<build_id>")])
449
+ if project:
450
+ parts.extend(["--project", _shell_arg(project, "<project>")])
451
+ parts.append("--json")
452
+ return _cli_command(*parts)
453
+ if action == "cache.status":
454
+ parts = ["cache", "status"]
455
+ if project:
456
+ parts.extend(["--project", _shell_arg(project, "<project>")])
457
+ parts.append("--json")
458
+ return _cli_command(*parts)
459
+ if action == "cache.clear":
460
+ parts = ["cache", "clear"]
461
+ if project:
462
+ parts.extend(["--project", _shell_arg(project, "<project>")])
463
+ parts.append("--json")
464
+ return _cli_command(*parts)
465
+ if action == "cache.get-semantic":
466
+ parts = [
467
+ "cache",
468
+ "get-semantic",
469
+ "--table",
470
+ _shell_arg(table_name, "<table_name>"),
471
+ ]
472
+ if schema_name:
473
+ parts.extend(["--schema", _shell_arg(schema_name, "<schema_name>")])
474
+ if project:
475
+ parts.extend(["--project", _shell_arg(project, "<project>")])
476
+ parts.append("--json")
477
+ return _cli_command(*parts)
478
+ if action == "cache.save-semantic":
479
+ parts = [
480
+ "cache",
481
+ "save-semantic",
482
+ "--table",
483
+ _shell_arg(table_name, "<table_name>"),
484
+ "--semantic-desc",
485
+ "<semantic_desc>",
486
+ ]
487
+ if schema_name:
488
+ parts.extend(["--schema", _shell_arg(schema_name, "<schema_name>")])
489
+ if project:
490
+ parts.extend(["--project", _shell_arg(project, "<project>")])
491
+ parts.append("--json")
492
+ return _cli_command(*parts)
493
+ if action == "agent.context":
494
+ return _cli_command("agent", "context", "--json")
495
+
496
+ if "." not in action:
497
+ return _cli_command(action, "--json")
498
+ group, subcommand = action.split(".", 1)
499
+ return _cli_command(group, subcommand, "--json")
500
+
501
+
502
+ def _suggested_sql(data: 'dict[str, Any]', metadata: 'dict[str, Any]') -> 'str | None':
503
+ sql = _string_value(metadata.get("sql_executed"))
504
+ if sql:
505
+ return sql
506
+
507
+ table_name = _string_value(data.get("table_name"))
508
+ if table_name:
509
+ return f"SELECT * FROM {table_name} LIMIT 20"
510
+ return None
511
+
512
+
513
+ def _string_value(value: 'Any') -> 'str | None':
514
+ if value is None:
515
+ return None
516
+ text = str(value).strip()
517
+ return text or None
518
+
519
+
520
+ def _single_list_value(value: 'Any') -> 'str | None':
521
+ if not isinstance(value, list) or len(value) != 1:
522
+ return None
523
+ return _string_value(value[0])
524
+
525
+
526
+ def _shell_arg(value: 'str | None', placeholder: 'str') -> 'str':
527
+ if value is None:
528
+ return placeholder
529
+ return shlex.quote(value)
530
+
531
+
532
+ def _cli_command(*parts: 'str') -> 'str':
533
+ return " ".join(part for part in parts if part)
maxc_cli/output.py ADDED
@@ -0,0 +1,75 @@
1
+
2
+ import json
3
+ from typing import Any, TextIO
4
+
5
+
6
+ def emit_json(payload: 'dict[str, Any]', stdout: 'TextIO') -> 'None':
7
+ stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
8
+
9
+
10
+ def emit_ndjson(events: 'list[dict[str, Any]]', stdout: 'TextIO') -> 'None':
11
+ for event in events:
12
+ stdout.write(json.dumps(event, ensure_ascii=False) + "\n")
13
+
14
+
15
+ def render_table(rows: 'list[dict[str, Any]]') -> 'str':
16
+ if not rows:
17
+ return "(no rows)"
18
+
19
+ columns: 'list[str]' = []
20
+ for row in rows:
21
+ for key in row:
22
+ if key not in columns:
23
+ columns.append(key)
24
+
25
+ widths = {
26
+ column: max(
27
+ len(str(column)),
28
+ max(len(_stringify(row.get(column, ""))) for row in rows),
29
+ )
30
+ for column in columns
31
+ }
32
+ header = "| " + " | ".join(str(column).ljust(widths[column]) for column in columns) + " |"
33
+ separator = "|" + "|".join("-" * (widths[column] + 2) for column in columns) + "|"
34
+ lines = [header, separator]
35
+ for row in rows:
36
+ line = "| " + " | ".join(
37
+ _escape_md_cell(_stringify(row.get(column, ""))).ljust(widths[column]) for column in columns
38
+ ) + " |"
39
+ lines.append(line)
40
+ return "\n".join(lines)
41
+
42
+
43
+ def render_key_values(mapping: 'dict[str, Any]') -> 'str':
44
+ if not mapping:
45
+ return ""
46
+ key_width = max(max(len(str(k)) for k in mapping), 3)
47
+ val_width = max(max(len(_stringify(v)) for v in mapping.values()), 5)
48
+ header = f"| {'Key'.ljust(key_width)} | {'Value'.ljust(val_width)} |"
49
+ separator = f"|{'-' * (key_width + 2)}|{'-' * (val_width + 2)}|"
50
+ lines = [header, separator]
51
+ for key, value in mapping.items():
52
+ lines.append(
53
+ f"| {str(key).ljust(key_width)} | {_escape_md_cell(_stringify(value)).ljust(val_width)} |"
54
+ )
55
+ return "\n".join(lines)
56
+
57
+
58
+ def render_error(code: 'str', message: 'str', suggestion: 'str | None' = None) -> 'str':
59
+ parts = [f"**Error** [`{code}`]: {message}"]
60
+ if suggestion:
61
+ parts.append("")
62
+ parts.append(f"> **Suggestion**: {suggestion}")
63
+ return "\n".join(parts)
64
+
65
+
66
+ def _escape_md_cell(text: 'str') -> 'str':
67
+ return text.replace("|", "\\|")
68
+
69
+
70
+ def _stringify(value: 'Any') -> 'str':
71
+ if isinstance(value, float):
72
+ return f"{value:.2f}"
73
+ if isinstance(value, (list, dict)):
74
+ return json.dumps(value, ensure_ascii=False)
75
+ return str(value)