maxc-cli 0.3.1__tar.gz → 0.3.3__tar.gz
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-0.3.1 → maxc_cli-0.3.3}/PKG-INFO +1 -1
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/setup.py +1 -1
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/__init__.py +1 -1
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/app.py +30 -8
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/query.py +51 -13
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/cli.py +13 -7
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/PKG-INFO +1 -1
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/SOURCES.txt +1 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cli_mock.py +37 -8
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_e2e_smoke.py +7 -7
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_external_auth.py +54 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_flag_hoist.py +7 -2
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_help_format.py +8 -8
- maxc_cli-0.3.3/tests/test_help_version_e2e.py +144 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_phase1_improvements.py +32 -54
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_setting_parser.py +40 -4
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/MANIFEST.in +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/README.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/pyproject.toml +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/scripts/pyinstaller_entry.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/scripts/regression_test.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/setup.cfg +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/__main__.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/_samples.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/agent_platforms.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/audit.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/auth_providers.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/__init__.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/auth.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/catalog.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/data.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/job.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/meta.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/odps.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/cache.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/catalog_bootstrap.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/config.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/exceptions.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/help_format.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/helpers.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/masking.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/models.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/output.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/setting_parser.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/SKILL.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/agents/openai.yaml +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/command-patterns.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/json-output-format.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/migrate-from-odpscmd.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/partition-guide.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/red-lines.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/setup-install.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/store.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/utils.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/entry_points.txt +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/requires.txt +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/top_level.txt +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_hints_and_cli.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_platforms.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_skill_commands.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_skill_commands_context.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_backend_data.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_backend_data_serialization.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_backend_meta.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_build_release_script.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cache.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_catalog.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_catalog_bootstrap.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cli_arg_validation.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cli_query_parse_and_sanitize.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_compat.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_envelope_shape.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_error_self_correction.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_error_translation.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_exit_codes.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_helpers.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_helpers_csv.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_integration.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_integration_real.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_job_improvements.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_masking.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_meta_schema_and_partition_cols.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_pyinstaller_bundle.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_query_auto_promote.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_query_result_csv_fallback.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_skill_cli_consistency.py +0 -0
- {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_skill_renderer.py +0 -0
|
@@ -9,7 +9,7 @@ README = ROOT / "README.md"
|
|
|
9
9
|
|
|
10
10
|
setup(
|
|
11
11
|
name="maxc-cli",
|
|
12
|
-
version="0.3.
|
|
12
|
+
version="0.3.3",
|
|
13
13
|
description="Agent-native MaxCompute CLI for external coding agents",
|
|
14
14
|
long_description=README.read_text(encoding="utf-8"),
|
|
15
15
|
long_description_content_type="text/markdown",
|
|
@@ -21,6 +21,7 @@ from .backend import OdpsBackend
|
|
|
21
21
|
from .cache import LocalCache
|
|
22
22
|
from .config import (
|
|
23
23
|
AuthConfig,
|
|
24
|
+
ExternalAuthConfig,
|
|
24
25
|
TableDefinition,
|
|
25
26
|
default_global_config_path,
|
|
26
27
|
load_config,
|
|
@@ -2160,7 +2161,13 @@ class MaxCApp:
|
|
|
2160
2161
|
self.log("meta.list-schemas", envelope.status, envelope.metadata)
|
|
2161
2162
|
return envelope
|
|
2162
2163
|
|
|
2163
|
-
def session_set(
|
|
2164
|
+
def session_set(
|
|
2165
|
+
self,
|
|
2166
|
+
project: 'str | None' = None,
|
|
2167
|
+
schema: 'str | None' = None,
|
|
2168
|
+
*,
|
|
2169
|
+
target_config_path: 'Path | None' = None,
|
|
2170
|
+
) -> 'Envelope':
|
|
2164
2171
|
"""Set default project and/or schema by writing to ~/.maxc/config.yaml.
|
|
2165
2172
|
|
|
2166
2173
|
Mirrors `gcloud config set project` / `kubectl config use-context`: the
|
|
@@ -2168,8 +2175,12 @@ class MaxCApp:
|
|
|
2168
2175
|
(e.g., ./.maxc/config.yaml) shadows the value, a warning is emitted but
|
|
2169
2176
|
the write still happens — the in-memory value is updated for the current
|
|
2170
2177
|
invocation.
|
|
2178
|
+
|
|
2179
|
+
When ``target_config_path`` is given (i.e. the user passed ``--config``),
|
|
2180
|
+
the write goes to that file instead, so a subsequent ``session show
|
|
2181
|
+
--config <same>`` round-trips correctly.
|
|
2171
2182
|
"""
|
|
2172
|
-
target_path = default_global_config_path()
|
|
2183
|
+
target_path = target_config_path or default_global_config_path()
|
|
2173
2184
|
config_payload = load_config_mapping(target_path) if target_path.exists() else {}
|
|
2174
2185
|
|
|
2175
2186
|
changes: list[str] = []
|
|
@@ -2245,13 +2256,18 @@ class MaxCApp:
|
|
|
2245
2256
|
self.log("session.set", envelope.status, {"changes": changes})
|
|
2246
2257
|
return envelope
|
|
2247
2258
|
|
|
2248
|
-
def session_unset(
|
|
2259
|
+
def session_unset(
|
|
2260
|
+
self, *, target_config_path: 'Path | None' = None
|
|
2261
|
+
) -> 'Envelope':
|
|
2249
2262
|
"""Remove default_project / default_schema from ~/.maxc/config.yaml.
|
|
2250
2263
|
|
|
2251
2264
|
Project-level config files in the working directory are NOT modified, since
|
|
2252
2265
|
they may be checked into version control. Edit those by hand if needed.
|
|
2266
|
+
|
|
2267
|
+
When ``target_config_path`` is given (``--config``), unset operates on
|
|
2268
|
+
that file instead of the global one.
|
|
2253
2269
|
"""
|
|
2254
|
-
target_path = default_global_config_path()
|
|
2270
|
+
target_path = target_config_path or default_global_config_path()
|
|
2255
2271
|
cleared: list[str] = []
|
|
2256
2272
|
|
|
2257
2273
|
if target_path.exists():
|
|
@@ -2283,9 +2299,16 @@ class MaxCApp:
|
|
|
2283
2299
|
self.log("session.unset", envelope.status, {})
|
|
2284
2300
|
return envelope
|
|
2285
2301
|
|
|
2286
|
-
def session_show(
|
|
2287
|
-
|
|
2288
|
-
|
|
2302
|
+
def session_show(
|
|
2303
|
+
self, *, target_config_path: 'Path | None' = None
|
|
2304
|
+
) -> 'Envelope':
|
|
2305
|
+
"""Show current session settings with source information.
|
|
2306
|
+
|
|
2307
|
+
When ``target_config_path`` is given (``--config``), the reported
|
|
2308
|
+
``config_path`` reflects that file, matching what ``session set
|
|
2309
|
+
--config <same>`` would write to.
|
|
2310
|
+
"""
|
|
2311
|
+
config_path = target_config_path or default_global_config_path()
|
|
2289
2312
|
|
|
2290
2313
|
env_project = os.environ.get("MAXCOMPUTE_PROJECT") or os.environ.get("ODPS_PROJECT")
|
|
2291
2314
|
has_explicit_auth_provider = bool(self.config.auth.provider)
|
|
@@ -2740,7 +2763,6 @@ class MaxCApp:
|
|
|
2740
2763
|
JSON to stdout. See :class:`ExternalCredentialProvider` for the
|
|
2741
2764
|
expected JSON format.
|
|
2742
2765
|
"""
|
|
2743
|
-
from .auth_providers import ExternalAuthConfig
|
|
2744
2766
|
target_path = target_config_path or default_global_config_path()
|
|
2745
2767
|
existing_payload = load_config_mapping(target_path) if target_path.exists() else {}
|
|
2746
2768
|
existing_auth = AuthConfig.from_mapping(existing_payload.get("auth", {}) or {})
|
|
@@ -46,15 +46,20 @@ def _count_statements(sql: 'str') -> 'int':
|
|
|
46
46
|
return sum(1 for part in cleaned.split(";") if part.strip())
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def _parse_sql_with_hints(
|
|
49
|
+
def _parse_sql_with_hints(
|
|
50
|
+
sql: 'str', *, force: 'bool' = False,
|
|
51
|
+
) -> 'tuple[str, dict[str, str], int | None]':
|
|
50
52
|
"""Extract SET statements from *sql* and enforce client-side read-only mode.
|
|
51
53
|
|
|
52
|
-
Returns ``(remaining_sql, merged_hints)
|
|
53
|
-
contains
|
|
54
|
-
|
|
54
|
+
Returns ``(remaining_sql, merged_hints, priority)``. ``merged_hints``
|
|
55
|
+
contains user-supplied SET values minus ``odps.instance.priority``,
|
|
56
|
+
which is lifted out into ``priority`` so callers can pass it as the
|
|
57
|
+
``priority=`` kwarg of ``run_sql`` / ``execute_sql``.
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
Write operations (INSERT, CREATE, DROP, etc.) are blocked unless
|
|
60
|
+
*force* is ``True``. Empty SQL raises ``ValidationError``.
|
|
61
|
+
Multi-statement SQL automatically receives
|
|
62
|
+
``odps.sql.submit.mode=script`` unless the user already set it.
|
|
58
63
|
"""
|
|
59
64
|
parsed = SettingParser.parse(sql)
|
|
60
65
|
if parsed.errors:
|
|
@@ -87,7 +92,34 @@ def _parse_sql_with_hints(sql: 'str', *, force: 'bool' = False) -> 'tuple[str, d
|
|
|
87
92
|
if _count_statements(remaining) >= 2:
|
|
88
93
|
hints.setdefault("odps.sql.submit.mode", "script")
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
# odps.instance.priority is not a SQL hint — it's a top-level kwarg on
|
|
96
|
+
# run_sql/execute_sql. Lift it out so the caller can thread it through.
|
|
97
|
+
priority = _pop_priority(hints)
|
|
98
|
+
|
|
99
|
+
return remaining, hints, priority
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pop_priority(hints: 'dict[str, str]') -> 'int | None':
|
|
103
|
+
"""Pop ``odps.instance.priority`` from *hints* and parse as int.
|
|
104
|
+
|
|
105
|
+
Match is case-insensitive on the key. Returns ``None`` if absent.
|
|
106
|
+
Raises ``ValidationError`` if the value isn't an integer.
|
|
107
|
+
"""
|
|
108
|
+
matched_key: str | None = None
|
|
109
|
+
for k in hints:
|
|
110
|
+
if k.lower() == "odps.instance.priority":
|
|
111
|
+
matched_key = k
|
|
112
|
+
break
|
|
113
|
+
if matched_key is None:
|
|
114
|
+
return None
|
|
115
|
+
raw = hints.pop(matched_key)
|
|
116
|
+
try:
|
|
117
|
+
return int(raw)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
raise ValidationError(
|
|
120
|
+
f"Invalid odps.instance.priority value {raw!r}: must be an integer.",
|
|
121
|
+
suggestion="Use SET odps.instance.priority=N; where N is an integer.",
|
|
122
|
+
) from None
|
|
91
123
|
|
|
92
124
|
|
|
93
125
|
class QueryMixin:
|
|
@@ -125,7 +157,8 @@ class QueryMixin:
|
|
|
125
157
|
ValidationError: If SET syntax is invalid.
|
|
126
158
|
BackendConnectionError: If ODPS connection fails.
|
|
127
159
|
"""
|
|
128
|
-
actual_sql, hints = _parse_sql_with_hints(sql, force=force)
|
|
160
|
+
actual_sql, hints, priority = _parse_sql_with_hints(sql, force=force)
|
|
161
|
+
priority_kwargs = {"priority": priority} if priority is not None else {}
|
|
129
162
|
|
|
130
163
|
started_at = now_utc_iso()
|
|
131
164
|
started_monotonic = monotonic()
|
|
@@ -162,7 +195,7 @@ class QueryMixin:
|
|
|
162
195
|
|
|
163
196
|
try:
|
|
164
197
|
instance = self.client.run_sql(
|
|
165
|
-
actual_sql, project=project, hints=hints,
|
|
198
|
+
actual_sql, project=project, hints=hints, **priority_kwargs,
|
|
166
199
|
)
|
|
167
200
|
except Exception as exc:
|
|
168
201
|
raise translate_odps_error(exc) from exc
|
|
@@ -204,7 +237,7 @@ class QueryMixin:
|
|
|
204
237
|
Returns:
|
|
205
238
|
Dict with estimated_input_size_bytes, sql_complexity, sql_udf_num, etc.
|
|
206
239
|
"""
|
|
207
|
-
actual_sql, hints = _parse_sql_with_hints(sql, force=force)
|
|
240
|
+
actual_sql, hints, _priority = _parse_sql_with_hints(sql, force=force)
|
|
208
241
|
started_monotonic = monotonic()
|
|
209
242
|
try:
|
|
210
243
|
sql_cost = self.client.execute_sql_cost(
|
|
@@ -240,7 +273,8 @@ class QueryMixin:
|
|
|
240
273
|
Returns:
|
|
241
274
|
Dict with query outline, cost metadata, and ``execution_plan`` text.
|
|
242
275
|
"""
|
|
243
|
-
actual_sql, hints = _parse_sql_with_hints(sql, force=force)
|
|
276
|
+
actual_sql, hints, priority = _parse_sql_with_hints(sql, force=force)
|
|
277
|
+
priority_kwargs = {"priority": priority} if priority is not None else {}
|
|
244
278
|
# script-mode auto-hint doesn't apply to EXPLAIN itself; remove if present.
|
|
245
279
|
explain_hints = {k: v for k, v in hints.items() if k != "odps.sql.submit.mode"}
|
|
246
280
|
started_monotonic = monotonic()
|
|
@@ -252,6 +286,7 @@ class QueryMixin:
|
|
|
252
286
|
f"EXPLAIN {actual_sql}",
|
|
253
287
|
project=project,
|
|
254
288
|
hints=explain_hints,
|
|
289
|
+
**priority_kwargs,
|
|
255
290
|
)
|
|
256
291
|
try:
|
|
257
292
|
results = instance.get_task_results()
|
|
@@ -319,14 +354,17 @@ class QueryMixin:
|
|
|
319
354
|
"""
|
|
320
355
|
from ..models import JobInfo
|
|
321
356
|
|
|
322
|
-
actual_sql, hints = _parse_sql_with_hints(sql, force=force)
|
|
357
|
+
actual_sql, hints, priority = _parse_sql_with_hints(sql, force=force)
|
|
358
|
+
priority_kwargs = {"priority": priority} if priority is not None else {}
|
|
359
|
+
idem_kwargs = {"unique_identifier_id": idempotency_key} if idempotency_key is not None else {}
|
|
323
360
|
|
|
324
361
|
try:
|
|
325
362
|
instance = self.client.run_sql(
|
|
326
363
|
actual_sql,
|
|
327
364
|
project=project,
|
|
328
365
|
hints=hints,
|
|
329
|
-
|
|
366
|
+
**idem_kwargs,
|
|
367
|
+
**priority_kwargs,
|
|
330
368
|
)
|
|
331
369
|
except Exception as exc:
|
|
332
370
|
raise translate_odps_error(exc) from exc
|
|
@@ -82,6 +82,10 @@ def _epilog_for(command_path: str) -> str | None:
|
|
|
82
82
|
# (e.g., --project, --limit) are NOT hoisted — they belong to specific
|
|
83
83
|
# subparsers. arity = number of subsequent argv tokens consumed as a value
|
|
84
84
|
# when given as `--flag value`; `--flag=value` is always a single token.
|
|
85
|
+
#
|
|
86
|
+
# -h/--help is deliberately NOT hoisted: argparse auto-adds it to every
|
|
87
|
+
# subparser, and hoisting would turn `maxc query -h` into top-level help
|
|
88
|
+
# instead of the query subcommand's help.
|
|
85
89
|
_GLOBAL_FLAG_ARITY: dict[str, int] = {
|
|
86
90
|
"--format": 1,
|
|
87
91
|
"-f": 1,
|
|
@@ -92,8 +96,6 @@ _GLOBAL_FLAG_ARITY: dict[str, int] = {
|
|
|
92
96
|
"--debug": 0,
|
|
93
97
|
"-v": 0,
|
|
94
98
|
"--version": 0,
|
|
95
|
-
"--help": 0,
|
|
96
|
-
"-h": 0,
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
|
|
@@ -1297,23 +1299,27 @@ def _handle_session_set(app: MaxCApp, args: argparse.Namespace, stdout: TextIO)
|
|
|
1297
1299
|
"""Set current project and/or schema for the session."""
|
|
1298
1300
|
project = args.project
|
|
1299
1301
|
schema = args.schema
|
|
1300
|
-
|
|
1302
|
+
|
|
1301
1303
|
if not project and not schema:
|
|
1302
1304
|
raise ValidationError("At least one of `--project` or `--schema` must be specified.")
|
|
1303
|
-
|
|
1304
|
-
envelope = app.session_set(
|
|
1305
|
+
|
|
1306
|
+
envelope = app.session_set(
|
|
1307
|
+
project=project,
|
|
1308
|
+
schema=schema,
|
|
1309
|
+
target_config_path=args.requested_config_path,
|
|
1310
|
+
)
|
|
1305
1311
|
_emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
|
|
1306
1312
|
|
|
1307
1313
|
|
|
1308
1314
|
def _handle_session_show(app: MaxCApp, args: argparse.Namespace, stdout: TextIO) -> None:
|
|
1309
1315
|
"""Show current session settings."""
|
|
1310
|
-
envelope = app.session_show()
|
|
1316
|
+
envelope = app.session_show(target_config_path=args.requested_config_path)
|
|
1311
1317
|
_emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
|
|
1312
1318
|
|
|
1313
1319
|
|
|
1314
1320
|
def _handle_session_unset(app: MaxCApp, args: argparse.Namespace, stdout: TextIO) -> None:
|
|
1315
1321
|
"""Clear session override."""
|
|
1316
|
-
envelope = app.session_unset()
|
|
1322
|
+
envelope = app.session_unset(target_config_path=args.requested_config_path)
|
|
1317
1323
|
_emit_envelope(envelope, args=args, stdout=stdout, default_format="json")
|
|
1318
1324
|
|
|
1319
1325
|
|
|
@@ -1007,11 +1007,36 @@ def test_legacy_session_override_is_migrated_into_global_config(
|
|
|
1007
1007
|
|
|
1008
1008
|
|
|
1009
1009
|
def test_session_set_writes_to_global_config(tmp_path: 'Path', monkeypatch) -> None:
|
|
1010
|
-
"""session set
|
|
1010
|
+
"""When --config is NOT passed, session set persists to ~/.maxc/config.yaml."""
|
|
1011
1011
|
clear_odps_env(monkeypatch)
|
|
1012
1012
|
isolate_home(monkeypatch, tmp_path)
|
|
1013
1013
|
|
|
1014
|
-
|
|
1014
|
+
# No --config: rely on normal discovery so session set hits the global path.
|
|
1015
|
+
code, payload, _ = run_json_command(
|
|
1016
|
+
tmp_path, None,
|
|
1017
|
+
["session", "set", "--project", "new_proj", "--schema", "new_schema", "--json"],
|
|
1018
|
+
)
|
|
1019
|
+
assert code == 0
|
|
1020
|
+
assert payload["status"] == "success"
|
|
1021
|
+
assert payload["data"]["project"] == "new_proj"
|
|
1022
|
+
assert payload["data"]["schema"] == "new_schema"
|
|
1023
|
+
|
|
1024
|
+
global_config_path = tmp_path / ".maxc" / "config.yaml"
|
|
1025
|
+
assert global_config_path.exists()
|
|
1026
|
+
persisted = yaml.safe_load(global_config_path.read_text(encoding="utf-8"))
|
|
1027
|
+
assert persisted["default_project"] == "new_proj"
|
|
1028
|
+
assert persisted["default_schema"] == "new_schema"
|
|
1029
|
+
|
|
1030
|
+
assert not (tmp_path / ".maxc" / "session_override.yaml").exists()
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def test_session_set_writes_to_explicit_config_when_passed(tmp_path: 'Path', monkeypatch) -> None:
|
|
1034
|
+
"""When --config is passed, session set writes to THAT file (not global), so
|
|
1035
|
+
a subsequent `session show --config <same>` round-trips."""
|
|
1036
|
+
clear_odps_env(monkeypatch)
|
|
1037
|
+
isolate_home(monkeypatch, tmp_path)
|
|
1038
|
+
|
|
1039
|
+
config_path = tmp_path / "explicit.yaml"
|
|
1015
1040
|
config_path.write_text(
|
|
1016
1041
|
"default_project: demo\n"
|
|
1017
1042
|
"default_format: json\n"
|
|
@@ -1026,16 +1051,20 @@ def test_session_set_writes_to_global_config(tmp_path: 'Path', monkeypatch) -> N
|
|
|
1026
1051
|
)
|
|
1027
1052
|
assert code == 0
|
|
1028
1053
|
assert payload["status"] == "success"
|
|
1029
|
-
assert payload["data"]["project"] == "new_proj"
|
|
1030
|
-
assert payload["data"]["schema"] == "new_schema"
|
|
1031
1054
|
|
|
1032
|
-
|
|
1033
|
-
assert global_config_path.exists()
|
|
1034
|
-
persisted = yaml.safe_load(global_config_path.read_text(encoding="utf-8"))
|
|
1055
|
+
persisted = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
1035
1056
|
assert persisted["default_project"] == "new_proj"
|
|
1036
1057
|
assert persisted["default_schema"] == "new_schema"
|
|
1058
|
+
# Global file must NOT be touched when --config is explicit.
|
|
1059
|
+
assert not (tmp_path / ".maxc" / "config.yaml").exists()
|
|
1037
1060
|
|
|
1038
|
-
|
|
1061
|
+
# Round-trip: session show via same --config sees the new value.
|
|
1062
|
+
code, show_payload, _ = run_json_command(
|
|
1063
|
+
tmp_path, config_path, ["session", "show", "--json"]
|
|
1064
|
+
)
|
|
1065
|
+
assert code == 0
|
|
1066
|
+
assert show_payload["data"]["project"]["value"] == "new_proj"
|
|
1067
|
+
assert show_payload["data"]["schema"]["value"] == "new_schema"
|
|
1039
1068
|
|
|
1040
1069
|
|
|
1041
1070
|
def test_session_set_warns_when_project_config_shadows(tmp_path: 'Path', monkeypatch) -> None:
|
|
@@ -85,11 +85,11 @@ def test_e2e_bootstrap_session_query(tmp_path: Path, monkeypatch) -> None:
|
|
|
85
85
|
assert code == 0, f"session show failed: {payload}"
|
|
86
86
|
project_info = payload["data"]["project"]
|
|
87
87
|
schema_info = payload["data"]["schema"]
|
|
88
|
-
# session show
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
assert
|
|
92
|
-
assert
|
|
88
|
+
# session show always returns {"value": ..., "source": ...} dicts; assert
|
|
89
|
+
# the shape directly so a future flattening regression fails this test.
|
|
90
|
+
assert isinstance(project_info, dict) and isinstance(schema_info, dict), payload
|
|
91
|
+
assert project_info["value"] == "other_project"
|
|
92
|
+
assert schema_info["value"] == "my_schema"
|
|
93
93
|
|
|
94
94
|
# 5. session unset — revert
|
|
95
95
|
code, payload, _ = run_cmd(tmp_path, config_path, ["session", "unset", "--json"])
|
|
@@ -99,8 +99,8 @@ def test_e2e_bootstrap_session_query(tmp_path: Path, monkeypatch) -> None:
|
|
|
99
99
|
code, payload, _ = run_cmd(tmp_path, config_path, ["session", "show", "--json"])
|
|
100
100
|
assert code == 0
|
|
101
101
|
project_info = payload["data"]["project"]
|
|
102
|
-
|
|
103
|
-
assert
|
|
102
|
+
assert isinstance(project_info, dict), payload
|
|
103
|
+
assert project_info["value"] == "smoke_project"
|
|
104
104
|
|
|
105
105
|
# 7. agent context — quick config summary
|
|
106
106
|
code, payload, _ = run_cmd(tmp_path, config_path, ["agent", "context", "--json"])
|
|
@@ -790,3 +790,57 @@ class TestAuthSeemsConfiguredExternal:
|
|
|
790
790
|
self._clear_odps_env(monkeypatch)
|
|
791
791
|
config = _minimal_config()
|
|
792
792
|
assert _auth_seems_configured(self._stub_app(config)) is False
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ============================================================
|
|
796
|
+
# MaxCApp.auth_login_external — end-to-end regression
|
|
797
|
+
# ============================================================
|
|
798
|
+
|
|
799
|
+
class TestAuthLoginExternalAppMethod:
|
|
800
|
+
"""Regression: a stale lazy import (``from .auth_providers import
|
|
801
|
+
ExternalAuthConfig``) in ``MaxCApp.auth_login_external`` raised
|
|
802
|
+
``ImportError`` at runtime after a ruff F401 sweep removed the
|
|
803
|
+
transitive re-export from ``auth_providers``. The dataclass lives in
|
|
804
|
+
``config``; the import must come from there. This test exercises the
|
|
805
|
+
method end-to-end so any future re-routing of ``ExternalAuthConfig``
|
|
806
|
+
that breaks the call site is caught immediately.
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
@staticmethod
|
|
810
|
+
def _clear_odps_env(monkeypatch):
|
|
811
|
+
import maxc_cli.backend as backend_module
|
|
812
|
+
for aliases in backend_module.ODPS_ENV_ALIASES.values():
|
|
813
|
+
for alias in aliases:
|
|
814
|
+
monkeypatch.delenv(alias, raising=False)
|
|
815
|
+
|
|
816
|
+
def test_writes_external_provider_config_without_validation(self, tmp_path, monkeypatch):
|
|
817
|
+
import yaml
|
|
818
|
+
|
|
819
|
+
from maxc_cli.app import MaxCApp
|
|
820
|
+
self._clear_odps_env(monkeypatch)
|
|
821
|
+
monkeypatch.chdir(tmp_path)
|
|
822
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
823
|
+
|
|
824
|
+
config_path = tmp_path / "config.yaml"
|
|
825
|
+
app = MaxCApp(cwd=tmp_path, load_backend=False)
|
|
826
|
+
|
|
827
|
+
envelope = app.auth_login_external(
|
|
828
|
+
process_command="/usr/bin/echo '{}'",
|
|
829
|
+
process_timeout=30,
|
|
830
|
+
project="proj_x",
|
|
831
|
+
endpoint="http://service.cn-test.maxcompute.aliyun.com/api",
|
|
832
|
+
region_name="cn-test",
|
|
833
|
+
no_validate=True,
|
|
834
|
+
target_config_path=config_path,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
assert envelope.status == "success"
|
|
838
|
+
assert envelope.command == "auth.login-external"
|
|
839
|
+
assert envelope.data["auth_type"] == "external"
|
|
840
|
+
assert envelope.data["process_command"] == "/usr/bin/echo '{}'"
|
|
841
|
+
|
|
842
|
+
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
843
|
+
assert saved["auth"]["provider"] == "external"
|
|
844
|
+
assert saved["auth"]["external"]["process_command"] == "/usr/bin/echo '{}'"
|
|
845
|
+
assert saved["auth"]["external"]["process_timeout"] == 30
|
|
846
|
+
assert saved["auth"]["project"] == "proj_x"
|
|
@@ -33,9 +33,14 @@ from maxc_cli.cli import _hoist_global_flags
|
|
|
33
33
|
(["meta", "describe", "t", "--json", "--quiet"], ["--json", "--quiet", "meta", "describe", "t"]),
|
|
34
34
|
# No arguments
|
|
35
35
|
([], []),
|
|
36
|
-
#
|
|
37
|
-
(["query", "x", "--help"], ["--help", "query", "x"]),
|
|
36
|
+
# --version is hoisted (only the top-level parser defines it)
|
|
38
37
|
(["--version"], ["--version"]),
|
|
38
|
+
(["query", "x", "-v"], ["-v", "query", "x"]),
|
|
39
|
+
# -h/--help is NOT hoisted — every subparser auto-registers its own,
|
|
40
|
+
# so `maxc query -h` must reach the query subparser, not the root.
|
|
41
|
+
(["query", "x", "--help"], ["query", "x", "--help"]),
|
|
42
|
+
(["query", "x", "-h"], ["query", "x", "-h"]),
|
|
43
|
+
(["agent", "skill", "install", "-h"], ["agent", "skill", "install", "-h"]),
|
|
39
44
|
],
|
|
40
45
|
)
|
|
41
46
|
def test_hoist_matrix(raw, expected):
|
|
@@ -130,14 +130,14 @@ def test_multi_line_description_preserved():
|
|
|
130
130
|
description="line one\nline two\nline three",
|
|
131
131
|
)
|
|
132
132
|
text = strip_ansi(p.format_help())
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
# Each of the three input lines must appear as its OWN stripped line —
|
|
134
|
+
# not collapsed onto a shared line. (AliyunStyleFormatter renders the
|
|
135
|
+
# description block between "Usage:" and "Flags:".)
|
|
136
|
+
stripped_lines = [ln.strip() for ln in text.splitlines()]
|
|
137
|
+
for expected in ("line one", "line two", "line three"):
|
|
138
|
+
assert expected in stripped_lines, (
|
|
139
|
+
f"{expected!r} not on its own line in:\n{text}"
|
|
140
|
+
)
|
|
141
141
|
|
|
142
142
|
|
|
143
143
|
def test_top_level_maxc_help_uses_aliyun_style(monkeypatch):
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Black-box guard: argparse-eager flags reach the parser the user typed at.
|
|
2
|
+
|
|
3
|
+
Spawned as actual subprocesses (`python -m maxc_cli ...`) so these tests
|
|
4
|
+
also catch regressions that in-process tests miss: broken console_scripts
|
|
5
|
+
entry, import errors at startup, module-level side effects.
|
|
6
|
+
|
|
7
|
+
The motivating regression: a `_hoist_global_flags` change pushed `-h` to the
|
|
8
|
+
front of argv, which made `maxc query -h` print the TOP-LEVEL help instead
|
|
9
|
+
of the `query` subparser's help. The bug shipped because the only test was
|
|
10
|
+
a unit test that asserted the wrong post-hoist argv shape.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from maxc_cli import __version__
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.e2e
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _maxc_argv() -> list[str]:
|
|
25
|
+
# `python -m maxc_cli` avoids depending on PATH resolution of the
|
|
26
|
+
# `maxc` console script, so the test works in any environment where
|
|
27
|
+
# maxc_cli is importable (which is already a prerequisite for the
|
|
28
|
+
# rest of the test suite).
|
|
29
|
+
return [sys.executable, "-m", "maxc_cli"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(argv: list[str], timeout: float = 15.0) -> subprocess.CompletedProcess:
|
|
33
|
+
return subprocess.run(
|
|
34
|
+
[*_maxc_argv(), *argv],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Each row: (argv, must_appear_in_stdout, must_NOT_appear_in_stdout).
|
|
42
|
+
# `must_not` is the critical half — it catches the case where every help
|
|
43
|
+
# request silently degrades to top-level help.
|
|
44
|
+
_TOPLEVEL_COMMAND_LIST = "{query,job,meta,session,data,auth,agent,cache}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize(
|
|
48
|
+
"argv, must_contain, must_not_contain",
|
|
49
|
+
[
|
|
50
|
+
# Top-level help (sanity — must show the command list).
|
|
51
|
+
(["-h"], [_TOPLEVEL_COMMAND_LIST], []),
|
|
52
|
+
(["--help"], [_TOPLEVEL_COMMAND_LIST], []),
|
|
53
|
+
# 1-deep subparser help: must show the subcommand's own usage line,
|
|
54
|
+
# must NOT degrade to the top-level command list.
|
|
55
|
+
(["query", "-h"], ["maxc query"], [_TOPLEVEL_COMMAND_LIST]),
|
|
56
|
+
(["query", "--help"], ["maxc query"], [_TOPLEVEL_COMMAND_LIST]),
|
|
57
|
+
# 2-deep nested (agent subcommands).
|
|
58
|
+
(["agent", "skill", "-h"],
|
|
59
|
+
["maxc agent skill", "{install,update,uninstall,list,diff,path}"],
|
|
60
|
+
[_TOPLEVEL_COMMAND_LIST]),
|
|
61
|
+
# 3-deep nested (agent skill install — the worst-case hoist regression).
|
|
62
|
+
(["agent", "skill", "install", "-h"],
|
|
63
|
+
["maxc agent skill install", "{claude-code,cursor"],
|
|
64
|
+
[_TOPLEVEL_COMMAND_LIST]),
|
|
65
|
+
# Leaf with distinctive flags — proves it's really the leaf's help.
|
|
66
|
+
(["auth", "login", "--help"],
|
|
67
|
+
["maxc auth login", "--access-id"],
|
|
68
|
+
[_TOPLEVEL_COMMAND_LIST]),
|
|
69
|
+
(["meta", "describe", "-h"],
|
|
70
|
+
["maxc meta describe", "--schema"],
|
|
71
|
+
[_TOPLEVEL_COMMAND_LIST]),
|
|
72
|
+
# Other hoistable globals MUST NOT redirect --help. If --debug is
|
|
73
|
+
# hoisted to the front, --help must still reach the query subparser.
|
|
74
|
+
(["--debug", "query", "--help"],
|
|
75
|
+
["maxc query"],
|
|
76
|
+
[_TOPLEVEL_COMMAND_LIST]),
|
|
77
|
+
# --help after a positional value (post-SQL) must still reach the
|
|
78
|
+
# subparser — regression check for any future "hoist --help after
|
|
79
|
+
# positional" attempt.
|
|
80
|
+
(["query", "SELECT 1", "-h"],
|
|
81
|
+
["maxc query"],
|
|
82
|
+
[_TOPLEVEL_COMMAND_LIST]),
|
|
83
|
+
],
|
|
84
|
+
ids=[
|
|
85
|
+
"top -h",
|
|
86
|
+
"top --help",
|
|
87
|
+
"query -h",
|
|
88
|
+
"query --help",
|
|
89
|
+
"agent skill -h (nested)",
|
|
90
|
+
"agent skill install -h (deep nested)",
|
|
91
|
+
"auth login --help (leaf with --access-id)",
|
|
92
|
+
"meta describe -h (leaf with --schema)",
|
|
93
|
+
"--debug query --help (hoist coexists)",
|
|
94
|
+
"query SQL -h (help after positional)",
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
def test_help_reaches_correct_parser(
|
|
98
|
+
argv: list[str],
|
|
99
|
+
must_contain: list[str],
|
|
100
|
+
must_not_contain: list[str],
|
|
101
|
+
) -> None:
|
|
102
|
+
result = _run(argv)
|
|
103
|
+
assert result.returncode == 0, (
|
|
104
|
+
f"argv={argv!r} exited {result.returncode}\nstderr:\n{result.stderr}"
|
|
105
|
+
)
|
|
106
|
+
out = result.stdout
|
|
107
|
+
for needle in must_contain:
|
|
108
|
+
assert needle in out, (
|
|
109
|
+
f"argv={argv!r}: expected {needle!r} in help output, got:\n{out}"
|
|
110
|
+
)
|
|
111
|
+
for needle in must_not_contain:
|
|
112
|
+
assert needle not in out, (
|
|
113
|
+
f"argv={argv!r}: help silently degraded — found top-level marker "
|
|
114
|
+
f"{needle!r} in:\n{out}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize(
|
|
119
|
+
"argv",
|
|
120
|
+
[
|
|
121
|
+
["-v"],
|
|
122
|
+
["--version"],
|
|
123
|
+
# --version is hoisted on purpose (only the top parser defines it).
|
|
124
|
+
# These prove the hoist for version still works after the -h/--help
|
|
125
|
+
# hoist was removed.
|
|
126
|
+
["query", "-v"],
|
|
127
|
+
["query", "--version"],
|
|
128
|
+
["agent", "skill", "install", "--version"],
|
|
129
|
+
],
|
|
130
|
+
ids=["top -v", "top --version", "query -v (hoisted)",
|
|
131
|
+
"query --version (hoisted)", "deep --version (hoisted)"],
|
|
132
|
+
)
|
|
133
|
+
def test_version_reaches_top_parser(argv: list[str]) -> None:
|
|
134
|
+
result = _run(argv)
|
|
135
|
+
assert result.returncode == 0, (
|
|
136
|
+
f"argv={argv!r} exited {result.returncode}\nstderr:\n{result.stderr}"
|
|
137
|
+
)
|
|
138
|
+
# argparse writes --version output to stdout in Python 3.4+.
|
|
139
|
+
out = (result.stdout or "") + (result.stderr or "")
|
|
140
|
+
expected = f"maxc {__version__}"
|
|
141
|
+
assert expected in out, (
|
|
142
|
+
f"argv={argv!r}: expected {expected!r}, got stdout={result.stdout!r} "
|
|
143
|
+
f"stderr={result.stderr!r}"
|
|
144
|
+
)
|
|
@@ -572,19 +572,19 @@ class TestParseSqlWithHints:
|
|
|
572
572
|
def test_multi_statement_injects_script_mode(self):
|
|
573
573
|
from maxc_cli.backend.query import _parse_sql_with_hints
|
|
574
574
|
|
|
575
|
-
_, hints = _parse_sql_with_hints("SELECT 1; SELECT 2")
|
|
575
|
+
_, hints, _ = _parse_sql_with_hints("SELECT 1; SELECT 2")
|
|
576
576
|
assert hints.get("odps.sql.submit.mode") == "script"
|
|
577
577
|
|
|
578
578
|
def test_single_statement_does_not_inject_script_mode(self):
|
|
579
579
|
from maxc_cli.backend.query import _parse_sql_with_hints
|
|
580
580
|
|
|
581
|
-
_, hints = _parse_sql_with_hints("SELECT 1")
|
|
581
|
+
_, hints, _ = _parse_sql_with_hints("SELECT 1")
|
|
582
582
|
assert "odps.sql.submit.mode" not in hints
|
|
583
583
|
|
|
584
584
|
def test_user_provided_script_mode_is_preserved(self):
|
|
585
585
|
from maxc_cli.backend.query import _parse_sql_with_hints
|
|
586
586
|
|
|
587
|
-
_, hints = _parse_sql_with_hints(
|
|
587
|
+
_, hints, _ = _parse_sql_with_hints(
|
|
588
588
|
"SET odps.sql.submit.mode=non_script; SELECT 1; SELECT 2"
|
|
589
589
|
)
|
|
590
590
|
# User's value wins
|
|
@@ -593,13 +593,13 @@ class TestParseSqlWithHints:
|
|
|
593
593
|
def test_trailing_semicolon_is_not_treated_as_multistatement(self):
|
|
594
594
|
from maxc_cli.backend.query import _parse_sql_with_hints
|
|
595
595
|
|
|
596
|
-
_, hints = _parse_sql_with_hints("SELECT 1;")
|
|
596
|
+
_, hints, _ = _parse_sql_with_hints("SELECT 1;")
|
|
597
597
|
assert "odps.sql.submit.mode" not in hints
|
|
598
598
|
|
|
599
599
|
def test_comments_are_not_counted_as_statements(self):
|
|
600
600
|
from maxc_cli.backend.query import _parse_sql_with_hints
|
|
601
601
|
|
|
602
|
-
_, hints = _parse_sql_with_hints(
|
|
602
|
+
_, hints, _ = _parse_sql_with_hints(
|
|
603
603
|
"-- header;\nSELECT 1; -- trailing comment;"
|
|
604
604
|
)
|
|
605
605
|
assert "odps.sql.submit.mode" not in hints
|
|
@@ -640,11 +640,9 @@ class TestInstallSkillExclusion:
|
|
|
640
640
|
"""B3 — skill_install skips .git/ and similar junk."""
|
|
641
641
|
|
|
642
642
|
def test_excluded_names_are_skipped(self, tmp_path, monkeypatch):
|
|
643
|
-
from pathlib import Path
|
|
644
|
-
|
|
645
643
|
from maxc_cli.app import MaxCApp
|
|
646
644
|
|
|
647
|
-
# Build a fake skills
|
|
645
|
+
# Build a fake skills source with both real content and junk to be excluded.
|
|
648
646
|
fake_skills = tmp_path / "fake_skills"
|
|
649
647
|
fake_skills.mkdir()
|
|
650
648
|
(fake_skills / "SKILL.md").write_text("# skill")
|
|
@@ -654,66 +652,46 @@ class TestInstallSkillExclusion:
|
|
|
654
652
|
(fake_skills / "stale.pyc").write_text("junk")
|
|
655
653
|
(fake_skills / "references").mkdir()
|
|
656
654
|
(fake_skills / "references" / "doc.md").write_text("real doc")
|
|
655
|
+
(fake_skills / "references" / "__pycache__").mkdir()
|
|
656
|
+
(fake_skills / "references" / "__pycache__" / "x.cpython-312.pyc").write_text("junk")
|
|
657
657
|
|
|
658
|
-
# Monkeypatch importlib.resources.files to return our fake dir
|
|
659
|
-
class _Files:
|
|
660
|
-
def __init__(self, p):
|
|
661
|
-
self._p = Path(p)
|
|
662
|
-
def __truediv__(self, other):
|
|
663
|
-
return _Files(self._p / other)
|
|
664
|
-
def is_dir(self):
|
|
665
|
-
return self._p.is_dir()
|
|
666
|
-
def is_file(self):
|
|
667
|
-
return self._p.is_file()
|
|
668
|
-
def __str__(self):
|
|
669
|
-
return str(self._p)
|
|
670
|
-
def iterdir(self):
|
|
671
|
-
return self._p.iterdir()
|
|
672
|
-
|
|
673
|
-
def fake_files(pkg):
|
|
674
|
-
return _Files(fake_skills.parent)
|
|
675
|
-
|
|
676
|
-
# Set up minimal config so MaxCApp loads
|
|
677
658
|
config_path = tmp_path / "config.yaml"
|
|
678
659
|
config_path.write_text(
|
|
679
660
|
"auth:\n provider: access_key\n access_id: x\n secret_access_key: y\n"
|
|
680
661
|
"default_project: p\nbackend:\n type: odps\n"
|
|
681
662
|
)
|
|
682
|
-
MaxCApp(cwd=tmp_path, config_path=config_path, load_backend=False)
|
|
663
|
+
app = MaxCApp(cwd=tmp_path, config_path=config_path, load_backend=False)
|
|
664
|
+
|
|
665
|
+
# Route production _locate_skills_source at the fake tree so we exercise
|
|
666
|
+
# the real _render_skill_into exclusion logic end-to-end.
|
|
667
|
+
monkeypatch.setattr(app, "_locate_skills_source", lambda: fake_skills)
|
|
683
668
|
|
|
684
|
-
# Install dir
|
|
685
669
|
install_root = tmp_path / "install"
|
|
686
|
-
#
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return name in EXCLUDED_NAMES or any(name.endswith(s) for s in EXCLUDED_SUFFIXES)
|
|
693
|
-
|
|
694
|
-
copied = []
|
|
695
|
-
install_root.mkdir()
|
|
696
|
-
for item in fake_skills.iterdir():
|
|
697
|
-
if _is_excluded(item.name):
|
|
698
|
-
continue
|
|
699
|
-
if item.is_file():
|
|
700
|
-
shutil.copy2(str(item), install_root / item.name)
|
|
701
|
-
copied.append(item.name)
|
|
702
|
-
elif item.is_dir():
|
|
703
|
-
shutil.copytree(
|
|
704
|
-
str(item),
|
|
705
|
-
str(install_root / item.name),
|
|
706
|
-
ignore=shutil.ignore_patterns(*EXCLUDED_NAMES, "*.pyc"),
|
|
707
|
-
)
|
|
708
|
-
copied.append(item.name + "/")
|
|
670
|
+
# `cursor` has no extra_files → no render_fn dependencies, keeps this
|
|
671
|
+
# test focused on the exclusion behavior under audit.
|
|
672
|
+
envelope = app.skill_install(
|
|
673
|
+
platform="cursor",
|
|
674
|
+
dir_override=install_root,
|
|
675
|
+
)
|
|
709
676
|
|
|
677
|
+
assert envelope.status == "success", envelope.error
|
|
678
|
+
|
|
679
|
+
# Real assertions: the installed tree must reflect production exclusion logic.
|
|
680
|
+
assert (install_root / "SKILL.md").exists()
|
|
681
|
+
assert (install_root / "references" / "doc.md").exists()
|
|
682
|
+
assert not (install_root / ".git").exists()
|
|
683
|
+
assert not (install_root / "nohup.out").exists()
|
|
684
|
+
assert not (install_root / "stale.pyc").exists()
|
|
685
|
+
assert not (install_root / "references" / "__pycache__").exists()
|
|
686
|
+
|
|
687
|
+
# And the envelope's files_copied list must agree.
|
|
688
|
+
copied = envelope.data["files_copied"]
|
|
710
689
|
assert "SKILL.md" in copied
|
|
690
|
+
assert "references/" in copied
|
|
711
691
|
assert ".git" not in copied
|
|
712
692
|
assert ".git/" not in copied
|
|
713
693
|
assert "nohup.out" not in copied
|
|
714
694
|
assert "stale.pyc" not in copied
|
|
715
|
-
assert "references/" in copied
|
|
716
|
-
assert (install_root / "references" / "doc.md").exists()
|
|
717
695
|
|
|
718
696
|
|
|
719
697
|
class TestRenderBriefPreview:
|
|
@@ -91,19 +91,21 @@ def test_set_with_escaped_semicolon():
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
def test_parse_sql_with_hints_default_no_extra_hints():
|
|
94
|
-
actual_sql, hints = _parse_sql_with_hints("SELECT 1")
|
|
94
|
+
actual_sql, hints, priority = _parse_sql_with_hints("SELECT 1")
|
|
95
95
|
assert actual_sql == "SELECT 1"
|
|
96
96
|
assert hints == {}
|
|
97
|
+
assert priority is None
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
def test_parse_sql_with_hints_merges_user_set():
|
|
100
|
-
actual_sql, hints = _parse_sql_with_hints(
|
|
101
|
+
actual_sql, hints, priority = _parse_sql_with_hints(
|
|
101
102
|
"SET odps.sql.type.system.odps2=true; SELECT 1"
|
|
102
103
|
)
|
|
103
104
|
assert actual_sql == "SELECT 1"
|
|
104
105
|
assert hints == {
|
|
105
106
|
"odps.sql.type.system.odps2": "true",
|
|
106
107
|
}
|
|
108
|
+
assert priority is None
|
|
107
109
|
|
|
108
110
|
|
|
109
111
|
def test_parse_sql_with_hints_blocks_write_without_force():
|
|
@@ -117,13 +119,14 @@ def test_parse_sql_with_hints_blocks_write_without_force():
|
|
|
117
119
|
|
|
118
120
|
|
|
119
121
|
def test_parse_sql_with_hints_force_allows_write():
|
|
120
|
-
actual_sql, hints = _parse_sql_with_hints("CREATE TABLE t (id BIGINT)", force=True)
|
|
122
|
+
actual_sql, hints, priority = _parse_sql_with_hints("CREATE TABLE t (id BIGINT)", force=True)
|
|
121
123
|
assert actual_sql == "CREATE TABLE t (id BIGINT)"
|
|
122
124
|
assert hints == {}
|
|
125
|
+
assert priority is None
|
|
123
126
|
|
|
124
127
|
|
|
125
128
|
def test_parse_sql_with_hints_force_preserves_user_sets():
|
|
126
|
-
actual_sql, hints = _parse_sql_with_hints(
|
|
129
|
+
actual_sql, hints, _priority = _parse_sql_with_hints(
|
|
127
130
|
"SET odps.sql.type.system.odps2=true; CREATE TABLE t (id BIGINT)",
|
|
128
131
|
force=True,
|
|
129
132
|
)
|
|
@@ -136,6 +139,39 @@ def test_parse_sql_with_hints_invalid_set_raises():
|
|
|
136
139
|
_parse_sql_with_hints("SET no_semicolon SELECT 1")
|
|
137
140
|
|
|
138
141
|
|
|
142
|
+
def test_parse_sql_with_hints_extracts_priority():
|
|
143
|
+
actual_sql, hints, priority = _parse_sql_with_hints(
|
|
144
|
+
"SET odps.instance.priority=3; SELECT 1"
|
|
145
|
+
)
|
|
146
|
+
assert actual_sql == "SELECT 1"
|
|
147
|
+
assert priority == 3
|
|
148
|
+
# priority must be stripped from the hints dict — it's a run_sql kwarg, not a SQL hint.
|
|
149
|
+
assert "odps.instance.priority" not in hints
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_parse_sql_with_hints_priority_case_insensitive_key():
|
|
153
|
+
_, hints, priority = _parse_sql_with_hints(
|
|
154
|
+
"SET ODPS.Instance.Priority=5; SELECT 1"
|
|
155
|
+
)
|
|
156
|
+
assert priority == 5
|
|
157
|
+
assert hints == {}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_parse_sql_with_hints_priority_invalid_raises():
|
|
161
|
+
with pytest.raises(ValidationError, match="odps.instance.priority"):
|
|
162
|
+
_parse_sql_with_hints("SET odps.instance.priority=high; SELECT 1")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_parse_sql_with_hints_priority_coexists_with_other_hints():
|
|
166
|
+
_, hints, priority = _parse_sql_with_hints(
|
|
167
|
+
"SET odps.instance.priority=1; "
|
|
168
|
+
"SET odps.sql.type.system.odps2=true; "
|
|
169
|
+
"SELECT 1"
|
|
170
|
+
)
|
|
171
|
+
assert priority == 1
|
|
172
|
+
assert hints == {"odps.sql.type.system.odps2": "true"}
|
|
173
|
+
|
|
174
|
+
|
|
139
175
|
# --- translate_odps_error readonly detection tests ---
|
|
140
176
|
|
|
141
177
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|