maxc-cli 0.3.2__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.2 → maxc_cli-0.3.3}/PKG-INFO +1 -1
  2. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/setup.py +1 -1
  3. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/__init__.py +1 -1
  4. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/app.py +29 -7
  5. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/query.py +2 -1
  6. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/cli.py +13 -7
  7. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/PKG-INFO +1 -1
  8. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/SOURCES.txt +1 -0
  9. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cli_mock.py +37 -8
  10. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_e2e_smoke.py +7 -7
  11. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_flag_hoist.py +7 -2
  12. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_help_format.py +8 -8
  13. maxc_cli-0.3.3/tests/test_help_version_e2e.py +144 -0
  14. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_phase1_improvements.py +27 -49
  15. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/MANIFEST.in +0 -0
  16. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/README.md +0 -0
  17. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/pyproject.toml +0 -0
  18. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/scripts/pyinstaller_entry.py +0 -0
  19. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/scripts/regression_test.py +0 -0
  20. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/setup.cfg +0 -0
  21. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/__main__.py +0 -0
  22. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/_samples.py +0 -0
  23. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/agent_platforms.py +0 -0
  24. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/audit.py +0 -0
  25. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/auth_providers.py +0 -0
  26. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/__init__.py +0 -0
  27. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/auth.py +0 -0
  28. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/catalog.py +0 -0
  29. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/data.py +0 -0
  30. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/job.py +0 -0
  31. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/meta.py +0 -0
  32. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/odps.py +0 -0
  33. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/cache.py +0 -0
  34. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/catalog_bootstrap.py +0 -0
  35. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/config.py +0 -0
  36. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/exceptions.py +0 -0
  37. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/help_format.py +0 -0
  38. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/helpers.py +0 -0
  39. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/masking.py +0 -0
  40. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/models.py +0 -0
  41. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/output.py +0 -0
  42. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/setting_parser.py +0 -0
  43. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/SKILL.md +0 -0
  44. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/agents/openai.yaml +0 -0
  45. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
  46. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
  47. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/command-patterns.md +0 -0
  48. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/json-output-format.md +0 -0
  49. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
  50. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
  51. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/migrate-from-odpscmd.md +0 -0
  52. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/partition-guide.md +0 -0
  53. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/red-lines.md +0 -0
  54. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/setup-install.md +0 -0
  55. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
  56. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
  57. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
  58. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/store.py +0 -0
  59. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/utils.py +0 -0
  60. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
  61. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/entry_points.txt +0 -0
  62. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/requires.txt +0 -0
  63. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/top_level.txt +0 -0
  64. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_hints_and_cli.py +0 -0
  65. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_platforms.py +0 -0
  66. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_skill_commands.py +0 -0
  67. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_skill_commands_context.py +0 -0
  68. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_backend_data.py +0 -0
  69. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_backend_data_serialization.py +0 -0
  70. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_backend_meta.py +0 -0
  71. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_build_release_script.py +0 -0
  72. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cache.py +0 -0
  73. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_catalog.py +0 -0
  74. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_catalog_bootstrap.py +0 -0
  75. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cli_arg_validation.py +0 -0
  76. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cli_query_parse_and_sanitize.py +0 -0
  77. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_compat.py +0 -0
  78. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_envelope_shape.py +0 -0
  79. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_error_self_correction.py +0 -0
  80. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_error_translation.py +0 -0
  81. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_exit_codes.py +0 -0
  82. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_external_auth.py +0 -0
  83. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_helpers.py +0 -0
  84. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_helpers_csv.py +0 -0
  85. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_integration.py +0 -0
  86. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_integration_real.py +0 -0
  87. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_job_improvements.py +0 -0
  88. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_masking.py +0 -0
  89. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_meta_schema_and_partition_cols.py +0 -0
  90. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_pyinstaller_bundle.py +0 -0
  91. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_query_auto_promote.py +0 -0
  92. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_query_result_csv_fallback.py +0 -0
  93. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_setting_parser.py +0 -0
  94. {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_skill_cli_consistency.py +0 -0
  95. {maxc_cli-0.3.2 → 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.2
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.2",
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.2"
5
+ __version__ = "0.3.3"
@@ -2161,7 +2161,13 @@ class MaxCApp:
2161
2161
  self.log("meta.list-schemas", envelope.status, envelope.metadata)
2162
2162
  return envelope
2163
2163
 
2164
- 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':
2165
2171
  """Set default project and/or schema by writing to ~/.maxc/config.yaml.
2166
2172
 
2167
2173
  Mirrors `gcloud config set project` / `kubectl config use-context`: the
@@ -2169,8 +2175,12 @@ class MaxCApp:
2169
2175
  (e.g., ./.maxc/config.yaml) shadows the value, a warning is emitted but
2170
2176
  the write still happens — the in-memory value is updated for the current
2171
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.
2172
2182
  """
2173
- target_path = default_global_config_path()
2183
+ target_path = target_config_path or default_global_config_path()
2174
2184
  config_payload = load_config_mapping(target_path) if target_path.exists() else {}
2175
2185
 
2176
2186
  changes: list[str] = []
@@ -2246,13 +2256,18 @@ class MaxCApp:
2246
2256
  self.log("session.set", envelope.status, {"changes": changes})
2247
2257
  return envelope
2248
2258
 
2249
- def session_unset(self) -> 'Envelope':
2259
+ def session_unset(
2260
+ self, *, target_config_path: 'Path | None' = None
2261
+ ) -> 'Envelope':
2250
2262
  """Remove default_project / default_schema from ~/.maxc/config.yaml.
2251
2263
 
2252
2264
  Project-level config files in the working directory are NOT modified, since
2253
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.
2254
2269
  """
2255
- target_path = default_global_config_path()
2270
+ target_path = target_config_path or default_global_config_path()
2256
2271
  cleared: list[str] = []
2257
2272
 
2258
2273
  if target_path.exists():
@@ -2284,9 +2299,16 @@ class MaxCApp:
2284
2299
  self.log("session.unset", envelope.status, {})
2285
2300
  return envelope
2286
2301
 
2287
- def session_show(self) -> 'Envelope':
2288
- """Show current session settings with source information."""
2289
- 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()
2290
2312
 
2291
2313
  env_project = os.environ.get("MAXCOMPUTE_PROJECT") or os.environ.get("ODPS_PROJECT")
2292
2314
  has_explicit_auth_provider = bool(self.config.auth.provider)
@@ -356,13 +356,14 @@ class QueryMixin:
356
356
 
357
357
  actual_sql, hints, priority = _parse_sql_with_hints(sql, force=force)
358
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 {}
359
360
 
360
361
  try:
361
362
  instance = self.client.run_sql(
362
363
  actual_sql,
363
364
  project=project,
364
365
  hints=hints,
365
- unique_identifier_id=idempotency_key,
366
+ **idem_kwargs,
366
367
  **priority_kwargs,
367
368
  )
368
369
  except Exception as 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.2
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"])
@@ -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
+ )
@@ -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:
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