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.
Files changed (95) hide show
  1. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/PKG-INFO +1 -1
  2. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/setup.py +1 -1
  3. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/__init__.py +1 -1
  4. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/app.py +30 -8
  5. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/query.py +51 -13
  6. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/cli.py +13 -7
  7. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/PKG-INFO +1 -1
  8. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/SOURCES.txt +1 -0
  9. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cli_mock.py +37 -8
  10. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_e2e_smoke.py +7 -7
  11. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_external_auth.py +54 -0
  12. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_flag_hoist.py +7 -2
  13. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_help_format.py +8 -8
  14. maxc_cli-0.3.3/tests/test_help_version_e2e.py +144 -0
  15. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_phase1_improvements.py +32 -54
  16. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_setting_parser.py +40 -4
  17. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/MANIFEST.in +0 -0
  18. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/README.md +0 -0
  19. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/pyproject.toml +0 -0
  20. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/scripts/pyinstaller_entry.py +0 -0
  21. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/scripts/regression_test.py +0 -0
  22. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/setup.cfg +0 -0
  23. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/__main__.py +0 -0
  24. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/_samples.py +0 -0
  25. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/agent_platforms.py +0 -0
  26. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/audit.py +0 -0
  27. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/auth_providers.py +0 -0
  28. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/__init__.py +0 -0
  29. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/auth.py +0 -0
  30. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/catalog.py +0 -0
  31. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/data.py +0 -0
  32. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/job.py +0 -0
  33. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/meta.py +0 -0
  34. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/backend/odps.py +0 -0
  35. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/cache.py +0 -0
  36. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/catalog_bootstrap.py +0 -0
  37. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/config.py +0 -0
  38. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/exceptions.py +0 -0
  39. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/help_format.py +0 -0
  40. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/helpers.py +0 -0
  41. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/masking.py +0 -0
  42. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/models.py +0 -0
  43. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/output.py +0 -0
  44. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/setting_parser.py +0 -0
  45. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/SKILL.md +0 -0
  46. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/agents/openai.yaml +0 -0
  47. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
  48. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
  49. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/command-patterns.md +0 -0
  50. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/json-output-format.md +0 -0
  51. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
  52. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
  53. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/migrate-from-odpscmd.md +0 -0
  54. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/partition-guide.md +0 -0
  55. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/red-lines.md +0 -0
  56. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/setup-install.md +0 -0
  57. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
  58. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
  59. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
  60. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/store.py +0 -0
  61. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli/utils.py +0 -0
  62. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
  63. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/entry_points.txt +0 -0
  64. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/requires.txt +0 -0
  65. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/top_level.txt +0 -0
  66. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_hints_and_cli.py +0 -0
  67. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_platforms.py +0 -0
  68. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_skill_commands.py +0 -0
  69. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_agent_skill_commands_context.py +0 -0
  70. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_backend_data.py +0 -0
  71. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_backend_data_serialization.py +0 -0
  72. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_backend_meta.py +0 -0
  73. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_build_release_script.py +0 -0
  74. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cache.py +0 -0
  75. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_catalog.py +0 -0
  76. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_catalog_bootstrap.py +0 -0
  77. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cli_arg_validation.py +0 -0
  78. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_cli_query_parse_and_sanitize.py +0 -0
  79. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_compat.py +0 -0
  80. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_envelope_shape.py +0 -0
  81. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_error_self_correction.py +0 -0
  82. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_error_translation.py +0 -0
  83. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_exit_codes.py +0 -0
  84. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_helpers.py +0 -0
  85. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_helpers_csv.py +0 -0
  86. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_integration.py +0 -0
  87. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_integration_real.py +0 -0
  88. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_job_improvements.py +0 -0
  89. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_masking.py +0 -0
  90. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_meta_schema_and_partition_cols.py +0 -0
  91. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_pyinstaller_bundle.py +0 -0
  92. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_query_auto_promote.py +0 -0
  93. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_query_result_csv_fallback.py +0 -0
  94. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_skill_cli_consistency.py +0 -0
  95. {maxc_cli-0.3.1 → maxc_cli-0.3.3}/tests/test_skill_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -9,7 +9,7 @@ README = ROOT / "README.md"
9
9
 
10
10
  setup(
11
11
  name="maxc-cli",
12
- version="0.3.1",
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",
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.3.1"
5
+ __version__ = "0.3.3"
@@ -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(self, project: 'str | None' = None, schema: 'str | None' = None) -> 'Envelope':
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(self) -> 'Envelope':
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(self) -> 'Envelope':
2287
- """Show current session settings with source information."""
2288
- config_path = default_global_config_path()
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(sql: 'str', *, force: 'bool' = False) -> 'tuple[str, dict[str, str]]':
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)`` where ``merged_hints``
53
- contains only user-supplied SET values. Write operations
54
- (INSERT, CREATE, DROP, etc.) are blocked unless *force* is ``True``.
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
- Empty SQL raises ``ValidationError``. Multi-statement SQL automatically
57
- receives ``odps.sql.submit.mode=script`` unless the user already set it.
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
- return remaining, hints
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
- unique_identifier_id=idempotency_key,
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(project=project, schema=schema)
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: Agent-native MaxCompute CLI for external coding agents
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Programming Language :: Python :: 3.9
@@ -76,6 +76,7 @@ tests/test_exit_codes.py
76
76
  tests/test_external_auth.py
77
77
  tests/test_flag_hoist.py
78
78
  tests/test_help_format.py
79
+ tests/test_help_version_e2e.py
79
80
  tests/test_helpers.py
80
81
  tests/test_helpers_csv.py
81
82
  tests/test_integration.py
@@ -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 should persist project/schema to ~/.maxc/config.yaml, no override file created."""
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
- config_path = tmp_path / "config.yaml"
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
- global_config_path = tmp_path / ".maxc" / "config.yaml"
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
- assert not (tmp_path / ".maxc" / "session_override.yaml").exists()
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 may return {"source": ..., "value": ...} dicts or plain strings
89
- project_val = project_info["value"] if isinstance(project_info, dict) else project_info
90
- schema_val = schema_info["value"] if isinstance(schema_info, dict) else schema_info
91
- assert project_val == "other_project"
92
- assert schema_val == "my_schema"
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
- project_val = project_info["value"] if isinstance(project_info, dict) else project_info
103
- assert project_val == "smoke_project"
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
- # Help / version flags
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
- assert "line one" in text
134
- assert "line two" in text
135
- assert "line three" in text
136
- # All three appear on separate lines.
137
- desc_block = text.split("Usage:")[0] # description renders before Usage
138
- assert "line one" in desc_block.split("\n")[0:8] or any(
139
- ln.strip() == "line one" for ln in text.splitlines()
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 dir
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
- # Bypass the platform map by directly testing the iteration logic
687
- import shutil
688
- EXCLUDED_NAMES = {".git", "__pycache__", ".DS_Store", "nohup.out", ".gitignore", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
689
- EXCLUDED_SUFFIXES = (".pyc", ".pyo", ".log")
690
-
691
- def _is_excluded(name):
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