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/config.py ADDED
@@ -0,0 +1,406 @@
1
+
2
+ from dataclasses import dataclass, field
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from .exceptions import ValidationError
10
+ from .utils import deep_merge, resolve_path
11
+
12
+
13
+ @dataclass
14
+ class TableColumn:
15
+ name: 'str'
16
+ type: 'str'
17
+ comment: 'str' = ""
18
+
19
+ @classmethod
20
+ def from_mapping(cls, payload: 'dict[str, Any]') -> "TableColumn":
21
+ return cls(
22
+ name=str(payload["name"]),
23
+ type=str(payload.get("type", "string")),
24
+ comment=str(payload.get("comment", "")),
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class TableDefinition:
30
+ name: 'str'
31
+ description: 'str'
32
+ columns: 'list[TableColumn]' = field(default_factory=list)
33
+ sample_rows: 'list[dict[str, Any]]' = field(default_factory=list)
34
+ partitions: 'list[str]' = field(default_factory=list)
35
+ upstream_tables: 'list[str]' = field(default_factory=list)
36
+ downstream_tables: 'list[str]' = field(default_factory=list)
37
+ partition_columns: 'list[TableColumn]' = field(default_factory=list)
38
+ owner: 'str | None' = None
39
+ created_at: 'str | None' = None
40
+ updated_at: 'str | None' = None
41
+ table_type: 'str | None' = None
42
+ size_bytes: 'int | None' = None
43
+ extra_metadata: 'dict[str, Any]' = field(default_factory=dict)
44
+
45
+ @classmethod
46
+ def from_mapping(cls, payload: 'dict[str, Any]') -> "TableDefinition":
47
+ return cls(
48
+ name=str(payload["name"]),
49
+ description=str(payload.get("description", "")),
50
+ columns=[TableColumn.from_mapping(item) for item in payload.get("columns", [])],
51
+ sample_rows=list(payload.get("sample_rows", [])),
52
+ partitions=[str(item) for item in payload.get("partitions", [])],
53
+ upstream_tables=[str(item) for item in payload.get("upstream_tables", [])],
54
+ downstream_tables=[str(item) for item in payload.get("downstream_tables", [])],
55
+ partition_columns=[
56
+ TableColumn.from_mapping(item)
57
+ for item in payload.get("partition_columns", [])
58
+ ],
59
+ owner=str(payload["owner"]) if payload.get("owner") is not None else None,
60
+ created_at=(
61
+ str(payload["created_at"]) if payload.get("created_at") is not None else None
62
+ ),
63
+ updated_at=(
64
+ str(payload["updated_at"]) if payload.get("updated_at") is not None else None
65
+ ),
66
+ table_type=(
67
+ str(payload["table_type"]) if payload.get("table_type") is not None else None
68
+ ),
69
+ size_bytes=(
70
+ int(payload["size_bytes"]) if payload.get("size_bytes") is not None else None
71
+ ),
72
+ extra_metadata=dict(payload.get("extra_metadata", {})),
73
+ )
74
+
75
+
76
+ @dataclass
77
+ class AgentConfig:
78
+ auto_approve_cost_cu: 'float' = 10
79
+ safety_mode: 'str' = "strict"
80
+ audit_log: 'Path | None' = None
81
+
82
+
83
+ # BackendConfig removed - only ODPS backend is supported
84
+
85
+
86
+ @dataclass
87
+ class NcsAuthConfig:
88
+ account_type: 'str | None' = None
89
+ employee_id: 'str | None' = None
90
+ account_name: 'str | None' = None
91
+ app_name: 'str | None' = None
92
+ process_command: 'str | None' = None
93
+ process_timeout: 'int' = 20
94
+
95
+ @classmethod
96
+ def from_mapping(cls, payload: 'dict[str, Any] | None') -> "NcsAuthConfig":
97
+ payload = payload or {}
98
+ return cls(
99
+ account_type=_optional_string(payload.get("account_type")),
100
+ employee_id=_optional_string(payload.get("employee_id")),
101
+ account_name=_optional_string(payload.get("account_name")),
102
+ app_name=_optional_string(payload.get("app_name")),
103
+ process_command=_optional_string(
104
+ payload.get("process_command") or payload.get("command")
105
+ ),
106
+ process_timeout=int(payload.get("process_timeout", 20)),
107
+ )
108
+
109
+ def to_mapping(self) -> 'dict[str, Any]':
110
+ payload: 'dict[str, Any]' = {}
111
+ if self.account_type:
112
+ payload["account_type"] = self.account_type
113
+ if self.employee_id:
114
+ payload["employee_id"] = self.employee_id
115
+ if self.account_name:
116
+ payload["account_name"] = self.account_name
117
+ if self.app_name:
118
+ payload["app_name"] = self.app_name
119
+ if self.process_command:
120
+ payload["process_command"] = self.process_command
121
+ if payload:
122
+ payload["process_timeout"] = self.process_timeout
123
+ return payload
124
+
125
+ def is_configured(self) -> 'bool':
126
+ return bool(
127
+ self.process_command
128
+ or self.employee_id
129
+ or self.account_name
130
+ or self.app_name
131
+ )
132
+
133
+
134
+ @dataclass
135
+ class AuthConfig:
136
+ provider: 'str | None' = None
137
+ access_id: 'str | None' = None
138
+ secret_access_key: 'str | None' = None
139
+ security_token: 'str | None' = None
140
+ token_expires_at: 'str | None' = None
141
+ project: 'str | None' = None
142
+ endpoint: 'str | None' = None
143
+ region_name: 'str | None' = None
144
+ tunnel_endpoint: 'str | None' = None
145
+ ncs: 'NcsAuthConfig' = field(default_factory=NcsAuthConfig)
146
+
147
+ @classmethod
148
+ def from_mapping(cls, payload: 'dict[str, Any]') -> "AuthConfig":
149
+ return cls(
150
+ provider=_optional_string(payload.get("provider")),
151
+ access_id=_optional_string(
152
+ payload.get("access_id") or payload.get("access_key_id")
153
+ ),
154
+ secret_access_key=_optional_string(
155
+ payload.get("secret_access_key") or payload.get("access_key_secret")
156
+ ),
157
+ security_token=_optional_string(
158
+ payload.get("security_token") or payload.get("sts_token")
159
+ ),
160
+ token_expires_at=_optional_string(payload.get("token_expires_at")),
161
+ project=_optional_string(payload.get("project")),
162
+ endpoint=_optional_string(payload.get("endpoint")),
163
+ region_name=_optional_string(
164
+ payload.get("region_name") or payload.get("region")
165
+ ),
166
+ tunnel_endpoint=_optional_string(payload.get("tunnel_endpoint")),
167
+ ncs=NcsAuthConfig.from_mapping(payload.get("ncs") if isinstance(payload.get("ncs"), dict) else None),
168
+ )
169
+
170
+ def to_mapping(self) -> 'dict[str, Any]':
171
+ payload: 'dict[str, Any]' = {}
172
+ if self.provider:
173
+ payload["provider"] = self.provider
174
+ if self.access_id:
175
+ payload["access_id"] = self.access_id
176
+ if self.secret_access_key:
177
+ payload["secret_access_key"] = self.secret_access_key
178
+ if self.security_token:
179
+ payload["security_token"] = self.security_token
180
+ if self.token_expires_at:
181
+ payload["token_expires_at"] = self.token_expires_at
182
+ if self.project:
183
+ payload["project"] = self.project
184
+ if self.endpoint:
185
+ payload["endpoint"] = self.endpoint
186
+ if self.region_name:
187
+ payload["region_name"] = self.region_name
188
+ if self.tunnel_endpoint:
189
+ payload["tunnel_endpoint"] = self.tunnel_endpoint
190
+ if self.ncs.is_configured():
191
+ payload["ncs"] = self.ncs.to_mapping()
192
+ return payload
193
+
194
+
195
+ @dataclass
196
+ class MaxCConfig:
197
+ default_project: 'str'
198
+ default_schema: 'str | None'
199
+ default_format: 'str'
200
+ default_region: 'str'
201
+ project_context: 'str'
202
+ allowed_operations: 'list[str]'
203
+ cost_threshold_cu: 'float'
204
+ sensitive_columns: 'list[str]'
205
+ agent: 'AgentConfig'
206
+ auth: 'AuthConfig'
207
+ state_dir: 'Path'
208
+ cache_dir: 'Path'
209
+ catalog: 'dict[str, TableDefinition]'
210
+ sources: 'list[Path]'
211
+
212
+
213
+ def _optional_string(value: 'Any') -> 'str | None':
214
+ if value is None:
215
+ return None
216
+ text = str(value).strip()
217
+ return text or None
218
+
219
+
220
+ def _load_yaml_file(path: 'Path') -> 'dict[str, Any]':
221
+ if not path.exists() or path.is_dir():
222
+ return {}
223
+ try:
224
+ payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
225
+ except yaml.YAMLError as exc:
226
+ raise ValidationError(
227
+ f"Configuration file contains invalid YAML: {path}",
228
+ suggestion=f"Fix the syntax in {path}, or delete it and re-run `maxc auth login` to create a fresh config.",
229
+ ) from exc
230
+ if not isinstance(payload, dict):
231
+ raise ValidationError(f"Invalid configuration file format: {path}")
232
+ return payload
233
+
234
+
235
+ def default_global_config_path() -> 'Path':
236
+ return Path.home() / ".maxc" / "config.yaml"
237
+
238
+
239
+ def session_override_path() -> 'Path':
240
+ """Path to session override file (highest priority for project/schema)."""
241
+ return Path.home() / ".maxc" / "session_override.yaml"
242
+
243
+
244
+ def load_config_mapping(path: 'Path') -> 'dict[str, Any]':
245
+ return _load_yaml_file(path)
246
+
247
+
248
+ def save_config_mapping(path: 'Path', payload: 'dict[str, Any]') -> 'None':
249
+ path.parent.mkdir(parents=True, exist_ok=True)
250
+ path.write_text(
251
+ yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
252
+ encoding="utf-8",
253
+ )
254
+ try:
255
+ path.chmod(0o600)
256
+ except OSError:
257
+ pass
258
+
259
+
260
+ def persist_login_config(
261
+ target_path: 'Path',
262
+ *,
263
+ auth: 'AuthConfig',
264
+ ) -> 'dict[str, Any]':
265
+ payload = load_config_mapping(target_path) if target_path.exists() else {}
266
+
267
+ payload["auth"] = auth.to_mapping()
268
+
269
+ if auth.project:
270
+ payload["default_project"] = auth.project
271
+ if auth.region_name:
272
+ payload["default_region"] = auth.region_name
273
+
274
+ save_config_mapping(target_path, payload)
275
+ return payload
276
+
277
+
278
+ def discover_config_files(cwd: 'Path', explicit_path: 'Path | None' = None) -> 'list[Path]':
279
+ if explicit_path is not None:
280
+ if not explicit_path.exists():
281
+ raise ValidationError(f"Configuration file does not exist: {explicit_path}")
282
+ return [explicit_path.resolve()]
283
+
284
+ candidates = [
285
+ Path.home() / ".maxc" / "config.yaml",
286
+ cwd / ".maxc" / "config.yaml",
287
+ cwd / ".maxc.yaml",
288
+ cwd / ".maxc",
289
+ ]
290
+ paths: 'list[Path]' = []
291
+ for candidate in candidates:
292
+ if candidate.exists() and not candidate.is_dir():
293
+ paths.append(candidate.resolve())
294
+ return paths
295
+
296
+
297
+ def load_config(cwd: 'Path', explicit_path: 'Path | None' = None) -> 'MaxCConfig':
298
+ sources = discover_config_files(cwd, explicit_path)
299
+ merged: 'dict[str, Any]' = {}
300
+ for source in sources:
301
+ merged = deep_merge(merged, _load_yaml_file(source))
302
+
303
+ # Load session override (highest priority for project/schema)
304
+ override = _load_yaml_file(session_override_path())
305
+
306
+ env_project = (
307
+ os.environ.get("MAXCOMPUTE_PROJECT")
308
+ or os.environ.get("ODPS_PROJECT")
309
+ or None
310
+ )
311
+ env_region = (
312
+ os.environ.get("MAXCOMPUTE_REGION")
313
+ or os.environ.get("ALIBABA_CLOUD_REGION")
314
+ or None
315
+ )
316
+
317
+ auth_payload = merged.get("auth", {}) or {}
318
+ if not isinstance(auth_payload, dict):
319
+ raise ValidationError("The `auth` configuration must be a mapping.")
320
+ auth = AuthConfig.from_mapping(auth_payload)
321
+
322
+ # Priority: session override > env var > config file > auth
323
+ # Exception: when auth.provider is explicitly configured, auth.project takes
324
+ # priority over env vars — env vars must not silently reroute to a different
325
+ # project when the user has committed to a specific auth configuration.
326
+ has_explicit_auth_provider = bool(auth.provider)
327
+ default_project_value = merged.get("default_project")
328
+ if override.get("project"):
329
+ default_project = str(override["project"])
330
+ elif env_project and not has_explicit_auth_provider:
331
+ default_project = env_project
332
+ elif default_project_value is not None:
333
+ default_project = str(default_project_value)
334
+ elif auth.project:
335
+ default_project = auth.project
336
+ elif env_project:
337
+ # Fallback: env var still used when no auth.project is available
338
+ default_project = env_project
339
+ else:
340
+ default_project = "demo_project"
341
+
342
+ # Priority: session override > config file
343
+ default_schema = _optional_string(override.get("schema")) or _optional_string(merged.get("default_schema"))
344
+
345
+ default_format = str(merged.get("default_format", "json"))
346
+ default_region_value = merged.get("default_region")
347
+ if env_region:
348
+ default_region = str(env_region)
349
+ elif auth.region_name:
350
+ default_region = auth.region_name
351
+ elif default_region_value is not None:
352
+ default_region = str(default_region_value)
353
+ else:
354
+ default_region = "local"
355
+ project_context = str(merged.get("project_context", "")).strip()
356
+ allowed_operations = [
357
+ str(item).upper() for item in merged.get("allowed_operations", ["SELECT"])
358
+ ]
359
+ cost_threshold_cu = float(merged.get("cost_threshold_cu", 50))
360
+ sensitive_columns = [str(item) for item in merged.get("sensitive_columns", [])]
361
+
362
+ agent_payload = merged.get("agent", {}) or {}
363
+ if not isinstance(agent_payload, dict):
364
+ raise ValidationError("The `agent` configuration must be a mapping.")
365
+ state_dir = resolve_path(
366
+ merged.get("state_dir", "~/.maxc/state"),
367
+ base_dir=cwd,
368
+ )
369
+ cache_dir = resolve_path(
370
+ merged.get("cache_dir", "~/.maxc/cache"),
371
+ base_dir=cwd,
372
+ )
373
+ audit_log = resolve_path(
374
+ agent_payload.get("audit_log", str(state_dir / "audit.log")),
375
+ base_dir=cwd,
376
+ )
377
+ agent = AgentConfig(
378
+ auto_approve_cost_cu=float(agent_payload.get("auto_approve_cost_cu", 10)),
379
+ safety_mode=str(agent_payload.get("safety_mode", "strict")),
380
+ audit_log=audit_log,
381
+ )
382
+
383
+ tables = {}
384
+ catalog_payload = merged.get("catalog", {}) or {}
385
+ table_items = catalog_payload.get("tables", []) if isinstance(catalog_payload, dict) else []
386
+ for item in table_items:
387
+ table = TableDefinition.from_mapping(item)
388
+ tables[table.name] = table
389
+
390
+
391
+ return MaxCConfig(
392
+ default_project=default_project,
393
+ default_schema=default_schema,
394
+ default_format=default_format,
395
+ default_region=default_region,
396
+ project_context=project_context,
397
+ allowed_operations=allowed_operations,
398
+ cost_threshold_cu=cost_threshold_cu,
399
+ sensitive_columns=sensitive_columns,
400
+ agent=agent,
401
+ auth=auth,
402
+ state_dir=state_dir,
403
+ cache_dir=cache_dir,
404
+ catalog=tables,
405
+ sources=sources,
406
+ )
maxc_cli/exceptions.py ADDED
@@ -0,0 +1,99 @@
1
+
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class ErrorPayload:
8
+ code: 'str'
9
+ message: 'str'
10
+ suggestion: 'str | None'
11
+ recoverable: 'bool'
12
+
13
+ def to_dict(self) -> 'dict[str, Any]':
14
+ payload = {
15
+ "code": self.code,
16
+ "message": self.message,
17
+ "recoverable": self.recoverable,
18
+ }
19
+ if self.suggestion:
20
+ payload["suggestion"] = self.suggestion
21
+ return payload
22
+
23
+
24
+ class MaxCError(Exception):
25
+ exit_code = 1
26
+ error_code = "EXECUTION_FAILED"
27
+ recoverable = True
28
+
29
+ def __init__(
30
+ self,
31
+ message: 'str',
32
+ *,
33
+ suggestion: 'str | None' = None,
34
+ recoverable: 'bool | None' = None,
35
+ ) -> 'None':
36
+ super().__init__(message)
37
+ self.message = message
38
+ self.suggestion = suggestion
39
+ if recoverable is None:
40
+ self.recoverable = self.__class__.recoverable
41
+ else:
42
+ self.recoverable = recoverable
43
+
44
+ def to_payload(self) -> 'ErrorPayload':
45
+ return ErrorPayload(
46
+ code=self.error_code,
47
+ message=self.message,
48
+ suggestion=self.suggestion,
49
+ recoverable=self.recoverable,
50
+ )
51
+
52
+
53
+ class PermissionDeniedError(MaxCError):
54
+ exit_code = 2
55
+ error_code = "PERMISSION_DENIED"
56
+ recoverable = False
57
+
58
+
59
+ class QuotaExceededError(MaxCError):
60
+ exit_code = 3
61
+ error_code = "QUOTA_EXCEEDED"
62
+ recoverable = True
63
+
64
+
65
+ class SqlError(MaxCError):
66
+ exit_code = 4
67
+ error_code = "SQL_ERROR"
68
+ recoverable = False
69
+
70
+
71
+ class CostLimitExceededError(MaxCError):
72
+ exit_code = 5
73
+ error_code = "COST_LIMIT_EXCEEDED"
74
+ recoverable = False
75
+
76
+
77
+ class NotFoundError(MaxCError):
78
+ error_code = "NOT_FOUND"
79
+ recoverable = False
80
+
81
+
82
+ class ValidationError(MaxCError):
83
+ error_code = "VALIDATION_ERROR"
84
+ recoverable = False
85
+
86
+
87
+ class FeatureUnavailableError(MaxCError):
88
+ error_code = "FEATURE_UNAVAILABLE"
89
+ recoverable = False
90
+
91
+
92
+ class BackendConnectionError(MaxCError):
93
+ error_code = "BACKEND_CONNECTION_ERROR"
94
+ recoverable = True
95
+
96
+
97
+ class JobTimeoutError(MaxCError):
98
+ error_code = "JOB_TIMEOUT"
99
+ recoverable = True