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.
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/PKG-INFO +1 -1
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/setup.py +1 -1
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/__init__.py +1 -1
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/app.py +29 -7
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/query.py +2 -1
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/cli.py +13 -7
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/PKG-INFO +1 -1
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/SOURCES.txt +1 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cli_mock.py +37 -8
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_e2e_smoke.py +7 -7
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_flag_hoist.py +7 -2
- {maxc_cli-0.3.2 → 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.2 → maxc_cli-0.3.3}/tests/test_phase1_improvements.py +27 -49
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/MANIFEST.in +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/README.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/pyproject.toml +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/scripts/pyinstaller_entry.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/scripts/regression_test.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/setup.cfg +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/__main__.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/_samples.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/agent_platforms.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/audit.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/auth_providers.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/__init__.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/auth.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/catalog.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/data.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/job.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/meta.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/backend/odps.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/cache.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/catalog_bootstrap.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/config.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/exceptions.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/help_format.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/helpers.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/masking.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/models.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/output.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/setting_parser.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/SKILL.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/agents/openai.yaml +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/command-patterns.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/json-output-format.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/migrate-from-odpscmd.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/partition-guide.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/red-lines.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/setup-install.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/store.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli/utils.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/entry_points.txt +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/requires.txt +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/src/maxc_cli.egg-info/top_level.txt +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_hints_and_cli.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_platforms.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_skill_commands.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_agent_skill_commands_context.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_backend_data.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_backend_data_serialization.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_backend_meta.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_build_release_script.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cache.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_catalog.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_catalog_bootstrap.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cli_arg_validation.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_cli_query_parse_and_sanitize.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_compat.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_envelope_shape.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_error_self_correction.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_error_translation.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_exit_codes.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_external_auth.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_helpers.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_helpers_csv.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_integration.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_integration_real.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_job_improvements.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_masking.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_meta_schema_and_partition_cols.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_pyinstaller_bundle.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_query_auto_promote.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_query_result_csv_fallback.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_setting_parser.py +0 -0
- {maxc_cli-0.3.2 → maxc_cli-0.3.3}/tests/test_skill_cli_consistency.py +0 -0
- {maxc_cli-0.3.2 → 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",
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
2288
|
-
|
|
2289
|
-
|
|
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
|
-
|
|
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(
|
|
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"])
|
|
@@ -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
|
+
)
|
|
@@ -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:
|
|
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
|
|
File without changes
|
|
File without changes
|