maxc-cli 0.8.1__tar.gz → 0.8.2__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.8.1 → maxc_cli-0.8.2}/PKG-INFO +1 -1
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/__init__.py +1 -1
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/app.py +91 -35
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/job.py +4 -2
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/odps.py +18 -4
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/query.py +0 -1
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/cli.py +11 -2
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/exceptions.py +1 -1
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/models.py +6 -1
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/output.py +21 -4
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/utils.py +42 -7
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/PKG-INFO +1 -1
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_job_improvements.py +52 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_output_action_safety.py +25 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_output_format_contract.py +15 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_query_auto_promote.py +96 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_query_result_csv_fallback.py +26 -2
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/MANIFEST.in +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/README.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/pyproject.toml +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/scripts/pyinstaller_entry.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/scripts/regression_test.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/setup.cfg +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/setup.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/__main__.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/_samples.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/agent_platforms.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/audit.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/auth_continuation.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/auth_providers.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/__init__.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/auth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/catalog.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/data.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/mcp.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/meta.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/backend/semantic.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/cache.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/catalog_bootstrap.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/config.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/enterprise_tls.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/help_format.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/helpers.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/job_ids.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/masking.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/mcp_serve.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/oauth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/odps_runtime.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/proxy_auth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/semantic.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/semantic_management.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/setting_parser.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/SKILL.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/agents/openai.yaml +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/command-patterns.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/json-output-format.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/partition-guide.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/red-lines.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/semantic-packages.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/setup-install.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/state_permissions.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli/store.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/SOURCES.txt +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/entry_points.txt +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/requires.txt +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/src/maxc_cli.egg-info/top_level.txt +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_hints_and_cli.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_platforms.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_skill_commands.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_agent_skill_commands_context.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_ai_native_contract_regressions.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_auth_logout.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_auth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_data.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_data_serialization.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_mcp.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_backend_meta.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_build_release_archive_compat.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_build_release_script.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cache.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_catalog.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_catalog_bootstrap.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cli_arg_validation.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cli_mock.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_cli_query_parse_and_sanitize.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_compat.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_config_atomic_write.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_connection_bootstrap.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_e2e_smoke.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_effective_hints_contract.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_enterprise_tls.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_envelope_shape.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_error_self_correction.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_error_translation.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_exit_codes.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_external_auth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_flag_hoist.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_help_format.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_help_version_e2e.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_helpers.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_helpers_csv.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_installer_contracts.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_integration.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_integration_real.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_job_inspection.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_job_store_durability.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_kb_commands.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_manifest_runtime_contract.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_masking.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_mcp_serve.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_meta_schema_and_partition_cols.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_oauth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_odps_runtime.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_packaging_metadata.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_phase1_improvements.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_proxy_auth.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_pyinstaller_bundle.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_python39_compat.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_semantic_management.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_semantic_scope.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_semantic_transport.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_setting_parser.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_skill_cli_consistency.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_skill_eval.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_skill_renderer.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_startup_imports.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_state_permissions.py +0 -0
- {maxc_cli-0.8.1 → maxc_cli-0.8.2}/tests/test_state_portability.py +0 -0
|
@@ -866,13 +866,22 @@ class MaxCApp:
|
|
|
866
866
|
session["job_id"],
|
|
867
867
|
project=session.get("project") or None,
|
|
868
868
|
)
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
869
|
+
try:
|
|
870
|
+
result = self.backend.fetch_job_result(
|
|
871
|
+
resolved.instance_id,
|
|
872
|
+
project=resolved.project,
|
|
873
|
+
max_rows=max_rows,
|
|
874
|
+
offset=offset,
|
|
875
|
+
session_context=resolved.session_context,
|
|
876
|
+
)
|
|
877
|
+
except Exception as exc:
|
|
878
|
+
error = exc if isinstance(exc, MaxCError) else translate_odps_error(exc)
|
|
879
|
+
envelope = self._job_result_fetch_failure(
|
|
880
|
+
command, resolved, None, error.to_payload(),
|
|
881
|
+
max_rows=max_rows, cursor=cursor,
|
|
882
|
+
)
|
|
883
|
+
self.log(command, envelope.status, envelope.metadata)
|
|
884
|
+
return envelope
|
|
876
885
|
envelope = self._build_query_envelope(
|
|
877
886
|
command=command,
|
|
878
887
|
result=result,
|
|
@@ -1098,25 +1107,13 @@ class MaxCApp:
|
|
|
1098
1107
|
except Exception as exc:
|
|
1099
1108
|
fetch_error = translate_odps_error(exc).to_payload()
|
|
1100
1109
|
if fetch_error is not None:
|
|
1101
|
-
envelope =
|
|
1102
|
-
command=
|
|
1103
|
-
status="failure",
|
|
1104
|
-
data=None,
|
|
1105
|
-
error=fetch_error,
|
|
1110
|
+
envelope = self._job_result_fetch_failure(
|
|
1111
|
+
command, resolved, job_info, fetch_error, max_rows=max_rows,
|
|
1106
1112
|
metadata={
|
|
1107
|
-
"job_id": resolved.external_job_id,
|
|
1108
|
-
"project": resolved.project,
|
|
1109
|
-
"logview": job_info.logview,
|
|
1110
1113
|
"sql_executed": sql,
|
|
1111
1114
|
**execution_metadata,
|
|
1112
1115
|
},
|
|
1113
|
-
|
|
1114
|
-
actions=[
|
|
1115
|
-
action("job.result", data={"job_id": resolved.external_job_id}, metadata={"job_id": resolved.external_job_id, "project": resolved.project, "sql_executed": sql}),
|
|
1116
|
-
action("job.status", data={"job_id": resolved.external_job_id}, metadata={"job_id": resolved.external_job_id, "project": resolved.project}),
|
|
1117
|
-
],
|
|
1118
|
-
warnings=common_warnings,
|
|
1119
|
-
),
|
|
1116
|
+
warnings=common_warnings,
|
|
1120
1117
|
)
|
|
1121
1118
|
if idempotency_key:
|
|
1122
1119
|
envelope.metadata["idempotency_key"] = idempotency_key
|
|
@@ -1458,12 +1455,20 @@ class MaxCApp:
|
|
|
1458
1455
|
]
|
|
1459
1456
|
self.log("job.wait", envelope.status, envelope.metadata)
|
|
1460
1457
|
return envelope, events
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1458
|
+
try:
|
|
1459
|
+
result = self.backend.fetch_job_result(
|
|
1460
|
+
resolved.instance_id,
|
|
1461
|
+
project=resolved.project,
|
|
1462
|
+
max_rows=100,
|
|
1463
|
+
session_context=resolved.session_context,
|
|
1464
|
+
)
|
|
1465
|
+
except Exception as exc:
|
|
1466
|
+
error = exc if isinstance(exc, MaxCError) else translate_odps_error(exc)
|
|
1467
|
+
envelope = self._job_result_fetch_failure(
|
|
1468
|
+
"job.wait", resolved, after, error.to_payload(), max_rows=100,
|
|
1469
|
+
)
|
|
1470
|
+
self.log("job.wait", envelope.status, envelope.metadata)
|
|
1471
|
+
return envelope, []
|
|
1467
1472
|
envelope = self._build_query_envelope(
|
|
1468
1473
|
command="job.wait",
|
|
1469
1474
|
result=result,
|
|
@@ -1561,13 +1566,22 @@ class MaxCApp:
|
|
|
1561
1566
|
)
|
|
1562
1567
|
self.log("job.result", envelope.status, envelope.metadata)
|
|
1563
1568
|
return envelope
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1569
|
+
try:
|
|
1570
|
+
result = self.backend.fetch_job_result(
|
|
1571
|
+
resolved.instance_id,
|
|
1572
|
+
project=resolved.project,
|
|
1573
|
+
max_rows=max_rows,
|
|
1574
|
+
offset=offset,
|
|
1575
|
+
session_context=resolved.session_context,
|
|
1576
|
+
)
|
|
1577
|
+
except Exception as exc:
|
|
1578
|
+
error = exc if isinstance(exc, MaxCError) else translate_odps_error(exc)
|
|
1579
|
+
envelope = self._job_result_fetch_failure(
|
|
1580
|
+
"job.result", resolved, info, error.to_payload(),
|
|
1581
|
+
max_rows=max_rows, cursor=cursor,
|
|
1582
|
+
)
|
|
1583
|
+
self.log("job.result", envelope.status, envelope.metadata)
|
|
1584
|
+
return envelope
|
|
1571
1585
|
envelope = self._build_query_envelope(
|
|
1572
1586
|
command="job.result",
|
|
1573
1587
|
result=result,
|
|
@@ -1669,6 +1683,48 @@ class MaxCApp:
|
|
|
1669
1683
|
self.log("job.result", envelope.status, envelope.metadata)
|
|
1670
1684
|
return envelope
|
|
1671
1685
|
|
|
1686
|
+
def _job_result_fetch_failure(
|
|
1687
|
+
self,
|
|
1688
|
+
command: 'str',
|
|
1689
|
+
resolved: '_ResolvedExternalJobId',
|
|
1690
|
+
info: 'JobInfo | None',
|
|
1691
|
+
error: 'ErrorPayload',
|
|
1692
|
+
*,
|
|
1693
|
+
max_rows: 'int',
|
|
1694
|
+
cursor: 'str | None' = None,
|
|
1695
|
+
metadata: 'dict[str, Any] | None' = None,
|
|
1696
|
+
warnings: 'list[str] | None' = None,
|
|
1697
|
+
) -> 'Envelope':
|
|
1698
|
+
"""Keep the existing job and page observable when reading fails."""
|
|
1699
|
+
scope = {
|
|
1700
|
+
"job_id": resolved.external_job_id,
|
|
1701
|
+
"project": resolved.project,
|
|
1702
|
+
"logview": info.logview if info else error.logview,
|
|
1703
|
+
**({
|
|
1704
|
+
"job_status": info.status,
|
|
1705
|
+
"submitted_at": info.submitted_at,
|
|
1706
|
+
"completed_at": info.completed_at,
|
|
1707
|
+
} if info else {}),
|
|
1708
|
+
**(metadata or {}),
|
|
1709
|
+
}
|
|
1710
|
+
retry = action("job.result", data={"max_rows": max_rows, "cursor": cursor}, metadata=scope)
|
|
1711
|
+
error.instance_id = resolved.external_job_id
|
|
1712
|
+
error.logview = scope["logview"]
|
|
1713
|
+
error.recovery_steps = [
|
|
1714
|
+
"Retry reading the existing job result: " + retry.command,
|
|
1715
|
+
"Do not resubmit the SQL to recover a result-reader failure.",
|
|
1716
|
+
]
|
|
1717
|
+
return Envelope(
|
|
1718
|
+
command=command,
|
|
1719
|
+
status="failure",
|
|
1720
|
+
error=error,
|
|
1721
|
+
metadata=scope,
|
|
1722
|
+
agent_hints=AgentHints(
|
|
1723
|
+
actions=[retry, action("job.diagnose", metadata=scope)],
|
|
1724
|
+
warnings=warnings or [],
|
|
1725
|
+
),
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1672
1728
|
def _bound_job_result_cursor(
|
|
1673
1729
|
self,
|
|
1674
1730
|
cursor: 'str | None',
|
|
@@ -846,10 +846,12 @@ class JobMixin(QueryMixin):
|
|
|
846
846
|
return sql.rstrip(";") if sql else None
|
|
847
847
|
|
|
848
848
|
def _safe_logview(self, instance) -> 'str | None':
|
|
849
|
-
"""Get a LogView URL
|
|
849
|
+
"""Get a usable SDK LogView URL for the caller, without URL userinfo."""
|
|
850
850
|
from ..utils import sanitize_logview_url
|
|
851
851
|
|
|
852
852
|
try:
|
|
853
|
-
return sanitize_logview_url(
|
|
853
|
+
return sanitize_logview_url(
|
|
854
|
+
instance.get_logview_address(), include_access_token=True,
|
|
855
|
+
)
|
|
854
856
|
except Exception:
|
|
855
857
|
return None
|
|
@@ -152,7 +152,17 @@ class OdpsBackend(
|
|
|
152
152
|
# timeout), the column metadata is parsed lazily from the
|
|
153
153
|
# CSV header during the first __next__ call — so we can
|
|
154
154
|
# only inspect it after iteration begins.
|
|
155
|
-
|
|
155
|
+
iterator = iter(reader)
|
|
156
|
+
skipped = sum(1 for _ in islice(iterator, offset))
|
|
157
|
+
records = list(islice(iterator, max_rows))
|
|
158
|
+
count = getattr(reader, "count", None)
|
|
159
|
+
# CSV fallback readers expose no count. Count the
|
|
160
|
+
# remaining accessible records without retaining
|
|
161
|
+
# them so a page is not mistaken for the full result.
|
|
162
|
+
accessible_count = (
|
|
163
|
+
skipped + len(records) + sum(1 for _ in iterator)
|
|
164
|
+
if count is None else int(count)
|
|
165
|
+
)
|
|
156
166
|
|
|
157
167
|
reader_schema = getattr(reader, "schema", None)
|
|
158
168
|
if reader_schema is not None and hasattr(reader_schema, "columns"):
|
|
@@ -181,7 +191,7 @@ class OdpsBackend(
|
|
|
181
191
|
record_to_dict(column_names, record.values)
|
|
182
192
|
for record in records
|
|
183
193
|
]
|
|
184
|
-
total_rows =
|
|
194
|
+
total_rows = accessible_count
|
|
185
195
|
except Exception as reader_exc:
|
|
186
196
|
# Some SHOW results (e.g. multi-column SHOW INDEXES
|
|
187
197
|
# col_names) contain unquoted commas that make
|
|
@@ -197,7 +207,7 @@ class OdpsBackend(
|
|
|
197
207
|
rows = all_raw_rows[offset : offset + max_rows]
|
|
198
208
|
schema = [{"name": name, "type": "string", "comment": ""}
|
|
199
209
|
for name in _RAW_RESULT_COLUMNS]
|
|
200
|
-
total_rows = len(
|
|
210
|
+
total_rows = len(all_raw_rows)
|
|
201
211
|
|
|
202
212
|
for w in captured:
|
|
203
213
|
msg_text = str(w.message)
|
|
@@ -210,7 +220,10 @@ class OdpsBackend(
|
|
|
210
220
|
warnings.formatwarning(w.message, w.category, w.filename, w.lineno)
|
|
211
221
|
)
|
|
212
222
|
except Exception as exc:
|
|
213
|
-
|
|
223
|
+
error = translate_odps_error(exc)
|
|
224
|
+
error.instance_id = instance.id
|
|
225
|
+
error.logview = self._safe_logview(instance)
|
|
226
|
+
raise error from exc
|
|
214
227
|
|
|
215
228
|
if degraded_reason is not None:
|
|
216
229
|
fallback_warnings.append(
|
|
@@ -223,6 +236,7 @@ class OdpsBackend(
|
|
|
223
236
|
returned_rows = len(rows)
|
|
224
237
|
has_more = total_rows > (offset + returned_rows)
|
|
225
238
|
extra_metadata["current_offset"] = offset
|
|
239
|
+
extra_metadata["logview"] = self._safe_logview(instance)
|
|
226
240
|
if degraded_reason is not None:
|
|
227
241
|
extra_metadata["result_kind"] = "raw_task_result"
|
|
228
242
|
if resultless_statement:
|
|
@@ -506,7 +506,6 @@ class QueryMixin:
|
|
|
506
506
|
submitted_at=now_utc_iso(),
|
|
507
507
|
updated_at=now_utc_iso(),
|
|
508
508
|
logview=self._safe_logview(instance),
|
|
509
|
-
warnings=["The MaxCompute instance has been submitted; use job.status or job.wait to track it."],
|
|
510
509
|
session_task_name=session_task_name,
|
|
511
510
|
session_subquery_id=session_subquery_id,
|
|
512
511
|
session_project_name=getattr(getattr(instance, "project", None), "name", None),
|
|
@@ -1786,7 +1786,7 @@ def run(
|
|
|
1786
1786
|
if getattr(exc, "logview", None):
|
|
1787
1787
|
from .utils import sanitize_logview_url
|
|
1788
1788
|
|
|
1789
|
-
stderr.write(f" LogView: {sanitize_logview_url(exc.logview)}\n")
|
|
1789
|
+
stderr.write(f" LogView: {sanitize_logview_url(exc.logview, include_access_token=True)}\n")
|
|
1790
1790
|
return exc.exit_code
|
|
1791
1791
|
except Exception as exc:
|
|
1792
1792
|
error_payload = ErrorPayload(
|
|
@@ -4446,7 +4446,7 @@ def _render_human(envelope: Envelope) -> str:
|
|
|
4446
4446
|
if logview:
|
|
4447
4447
|
from .utils import sanitize_logview_url
|
|
4448
4448
|
|
|
4449
|
-
details["logview"] = sanitize_logview_url(logview)
|
|
4449
|
+
details["logview"] = sanitize_logview_url(logview, include_access_token=True)
|
|
4450
4450
|
if error.context:
|
|
4451
4451
|
details["context"] = error.context
|
|
4452
4452
|
sections.append(render_key_values(details))
|
|
@@ -4491,11 +4491,20 @@ def _render_human(envelope: Envelope) -> str:
|
|
|
4491
4491
|
)
|
|
4492
4492
|
return "\n\n".join(section for section in sections if section)
|
|
4493
4493
|
|
|
4494
|
+
if envelope.status == "pending" and (command == "query" or command.startswith("job.")):
|
|
4495
|
+
from .output import render_markdown
|
|
4496
|
+
|
|
4497
|
+
return render_markdown(envelope)
|
|
4498
|
+
|
|
4494
4499
|
if command == "query":
|
|
4500
|
+
from .utils import sanitize_logview_url
|
|
4501
|
+
|
|
4495
4502
|
rows = data.get("rows", [])
|
|
4496
4503
|
summary = render_key_values(
|
|
4497
4504
|
{
|
|
4498
4505
|
"status": envelope.status,
|
|
4506
|
+
"job_id": metadata.get("job_id"),
|
|
4507
|
+
"logview": sanitize_logview_url(metadata.get("logview"), include_access_token=True),
|
|
4499
4508
|
"project": metadata.get("project"),
|
|
4500
4509
|
"elapsed_ms": metadata.get("elapsed_ms"),
|
|
4501
4510
|
"returned_rows": data.get("returned_rows"),
|
|
@@ -38,7 +38,7 @@ class ErrorPayload:
|
|
|
38
38
|
if self.logview:
|
|
39
39
|
from .utils import sanitize_logview_url
|
|
40
40
|
|
|
41
|
-
payload["logview"] = sanitize_logview_url(self.logview)
|
|
41
|
+
payload["logview"] = sanitize_logview_url(self.logview, include_access_token=True)
|
|
42
42
|
if self.context:
|
|
43
43
|
payload["context"] = self.context
|
|
44
44
|
return payload
|
|
@@ -186,7 +186,9 @@ class Envelope:
|
|
|
186
186
|
data = _normalize_data(self.command, self.data) if normalize else self.data
|
|
187
187
|
metadata = dict(self.metadata)
|
|
188
188
|
if metadata.get("logview"):
|
|
189
|
-
metadata["logview"] = sanitize_logview_url(
|
|
189
|
+
metadata["logview"] = sanitize_logview_url(
|
|
190
|
+
metadata["logview"], include_access_token=True,
|
|
191
|
+
)
|
|
190
192
|
payload = {
|
|
191
193
|
"version": self.version,
|
|
192
194
|
"command": command,
|
|
@@ -685,6 +687,9 @@ def _format_next_action(
|
|
|
685
687
|
max_rows = data.get("max_rows")
|
|
686
688
|
if isinstance(max_rows, int) and not isinstance(max_rows, bool) and max_rows > 0:
|
|
687
689
|
parts.extend(["--max-rows", str(max_rows)])
|
|
690
|
+
cursor = _string_value(data.get("cursor"))
|
|
691
|
+
if cursor:
|
|
692
|
+
parts.extend(["--cursor", _shell_arg(cursor, "<cursor>")])
|
|
688
693
|
output_path = _string_value(metadata.get("output_path"))
|
|
689
694
|
output_format = _string_value(metadata.get("output_format"))
|
|
690
695
|
if output_path:
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
from typing import TYPE_CHECKING, Any, TextIO
|
|
5
5
|
|
|
6
6
|
from .models import suggested_action_is_safe
|
|
7
|
-
from .utils import distribution_cli_text
|
|
7
|
+
from .utils import distribution_cli_text, sanitize_logview_url
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from .models import Envelope, SuggestedAction
|
|
@@ -166,9 +166,7 @@ def _render_pending_md(envelope: Envelope) -> str:
|
|
|
166
166
|
if metadata.get("wait_seconds") is not None:
|
|
167
167
|
kv["Waited"] = f"{metadata['wait_seconds']}s"
|
|
168
168
|
if metadata.get("logview"):
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
kv["Logview"] = sanitize_logview_url(metadata["logview"])
|
|
169
|
+
kv["Logview"] = sanitize_logview_url(metadata["logview"], include_access_token=True)
|
|
172
170
|
if kv:
|
|
173
171
|
parts.append(render_key_values(kv))
|
|
174
172
|
parts.append("")
|
|
@@ -253,6 +251,18 @@ def render_markdown(envelope: Envelope) -> str:
|
|
|
253
251
|
parts.append(
|
|
254
252
|
f"> **Suggestion**: {distribution_cli_text(err.suggestion)}"
|
|
255
253
|
)
|
|
254
|
+
details = {}
|
|
255
|
+
metadata = envelope.metadata or {}
|
|
256
|
+
job_id = metadata.get("job_id") or err.instance_id
|
|
257
|
+
if job_id:
|
|
258
|
+
details["Job ID"] = job_id
|
|
259
|
+
if metadata.get("project"):
|
|
260
|
+
details["Project"] = metadata["project"]
|
|
261
|
+
logview = metadata.get("logview") or err.logview
|
|
262
|
+
if logview:
|
|
263
|
+
details["Logview"] = sanitize_logview_url(logview, include_access_token=True)
|
|
264
|
+
if details:
|
|
265
|
+
parts.extend(["", render_key_values(details)])
|
|
256
266
|
parts.append("")
|
|
257
267
|
return _append_agent_hints_md(parts, envelope)
|
|
258
268
|
|
|
@@ -283,6 +293,13 @@ def render_markdown(envelope: Envelope) -> str:
|
|
|
283
293
|
if meta_items:
|
|
284
294
|
parts.append(" | ".join(meta_items))
|
|
285
295
|
parts.append("")
|
|
296
|
+
details = {}
|
|
297
|
+
if metadata.get("job_id"):
|
|
298
|
+
details["Job ID"] = metadata["job_id"]
|
|
299
|
+
if metadata.get("logview"):
|
|
300
|
+
details["Logview"] = sanitize_logview_url(metadata["logview"], include_access_token=True)
|
|
301
|
+
if details:
|
|
302
|
+
parts.extend([render_key_values(details), ""])
|
|
286
303
|
rows = data.get("rows")
|
|
287
304
|
if rows:
|
|
288
305
|
parts.append(render_table(rows))
|
|
@@ -316,8 +316,15 @@ def distribution_cli_text(text: 'str') -> 'str':
|
|
|
316
316
|
return "".join(rendered)
|
|
317
317
|
|
|
318
318
|
|
|
319
|
-
def sanitize_logview_url(
|
|
320
|
-
|
|
319
|
+
def sanitize_logview_url(
|
|
320
|
+
value: 'str | None', *, include_access_token: 'bool' = False,
|
|
321
|
+
) -> 'str | None':
|
|
322
|
+
"""Validate LogView URLs, keeping SDK routing and optional instance access.
|
|
323
|
+
|
|
324
|
+
CLI output needs the legacy instance-scoped token to open the link. Audit
|
|
325
|
+
records redact the whole LogView field independently. Other query secrets,
|
|
326
|
+
URL userinfo and fragments are never retained.
|
|
327
|
+
"""
|
|
321
328
|
if not value:
|
|
322
329
|
return value
|
|
323
330
|
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
@@ -363,13 +370,41 @@ def sanitize_logview_url(value: 'str | None') -> 'str | None':
|
|
|
363
370
|
return "[redacted invalid LogView URL]"
|
|
364
371
|
safe_host = hostname
|
|
365
372
|
safe_netloc = safe_host if port is None else f"{safe_host}:{port}"
|
|
366
|
-
|
|
373
|
+
query_items = parse_qsl(parsed.query, keep_blank_values=True)
|
|
374
|
+
routing = dict(query_items)
|
|
375
|
+
allowed_keys = set()
|
|
376
|
+
sdk_path = parsed.path.rstrip("/")
|
|
377
|
+
if (
|
|
378
|
+
(sdk_path == "/logview" or sdk_path.endswith("/job-insights"))
|
|
379
|
+
and len(routing) == len(query_items)
|
|
380
|
+
and all(routing.get(key) for key in ("h", "p", "i"))
|
|
381
|
+
):
|
|
382
|
+
try:
|
|
383
|
+
endpoint = urlsplit(routing["h"])
|
|
384
|
+
valid_endpoint = (
|
|
385
|
+
endpoint.scheme in {"http", "https"}
|
|
386
|
+
and bool(endpoint.hostname)
|
|
387
|
+
and endpoint.username is None
|
|
388
|
+
and endpoint.password is None
|
|
389
|
+
and not endpoint.query
|
|
390
|
+
and not endpoint.fragment
|
|
391
|
+
and sanitize_logview_url(routing["h"]) == routing["h"]
|
|
392
|
+
)
|
|
393
|
+
except ValueError:
|
|
394
|
+
valid_endpoint = False
|
|
395
|
+
if valid_endpoint:
|
|
396
|
+
allowed_keys.update({"h", "p", "i"})
|
|
397
|
+
if sdk_path == "/logview" and include_access_token:
|
|
398
|
+
allowed_keys.add("token")
|
|
399
|
+
safe_items = [
|
|
367
400
|
(key, item)
|
|
368
|
-
for key, item in
|
|
369
|
-
if key
|
|
370
|
-
|
|
401
|
+
for key, item in query_items
|
|
402
|
+
if key in allowed_keys
|
|
403
|
+
or (key.lower() == "subquery" and re.fullmatch(r"[0-9]+", item))
|
|
404
|
+
]
|
|
405
|
+
safe_query = urlencode(safe_items)
|
|
371
406
|
if (
|
|
372
|
-
|
|
407
|
+
safe_items == query_items
|
|
373
408
|
and not parsed.fragment
|
|
374
409
|
and safe_netloc == parsed.netloc
|
|
375
410
|
):
|
|
@@ -189,6 +189,48 @@ def test_safe_logview_preserves_valid_explicit_port():
|
|
|
189
189
|
)
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def test_logview_output_keeps_legacy_access_and_job_insights_routing():
|
|
193
|
+
from maxc_cli.backend.job import JobMixin
|
|
194
|
+
from maxc_cli.models import Envelope
|
|
195
|
+
|
|
196
|
+
for url in (
|
|
197
|
+
"http://logview.odps.aliyun.com/logview/"
|
|
198
|
+
"?h=https://service.example.test/api&p=test_proj&i=job-1&token=fixture-access&subQuery=7",
|
|
199
|
+
"https://maxcompute.console.aliyun.com/cn-hangzhou/job-insights"
|
|
200
|
+
"?h=https://service.example.test/api&p=test_proj&i=job-1&subQuery=7",
|
|
201
|
+
):
|
|
202
|
+
instance = MagicMock()
|
|
203
|
+
instance.get_logview_address.return_value = url
|
|
204
|
+
address = JobMixin._safe_logview(object(), instance)
|
|
205
|
+
assert address == url
|
|
206
|
+
assert Envelope(command="job.status", status="success", metadata={"logview": address}).to_dict()["metadata"]["logview"] == url
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_logview_output_drops_unrelated_secrets_and_default_still_redacts_token():
|
|
210
|
+
from maxc_cli.models import Envelope
|
|
211
|
+
from maxc_cli.utils import sanitize_logview_url
|
|
212
|
+
|
|
213
|
+
routing = "h=https://service.example.test/api&p=test_proj&i=job-1"
|
|
214
|
+
url = f"https://logview.example.test/logview/?{routing}&token=fixture-access&secret=other-secret#fragment"
|
|
215
|
+
payload = Envelope(command="query", status="success", metadata={"logview": url}).to_dict()
|
|
216
|
+
assert payload["metadata"]["logview"].endswith("&token=fixture-access")
|
|
217
|
+
assert "other-secret" not in payload["metadata"]["logview"]
|
|
218
|
+
assert "fragment" not in payload["metadata"]["logview"]
|
|
219
|
+
assert "fixture-access" not in sanitize_logview_url(url)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.parametrize("routing", [
|
|
223
|
+
"h=https://service.example.test/api&p=test_proj",
|
|
224
|
+
"h=https://user:password@service.example.test/api&p=test_proj&i=job-1",
|
|
225
|
+
"h=https://service.example.test/api?secret=hidden&p=test_proj&i=job-1",
|
|
226
|
+
])
|
|
227
|
+
def test_logview_output_never_preserves_token_without_safe_complete_routing(routing):
|
|
228
|
+
from maxc_cli.utils import sanitize_logview_url
|
|
229
|
+
|
|
230
|
+
url = f"https://logview.example.test/logview/?{routing}&token=fixture-access"
|
|
231
|
+
assert "fixture-access" not in sanitize_logview_url(url, include_access_token=True)
|
|
232
|
+
|
|
233
|
+
|
|
192
234
|
def test_polling_continues_after_fewer_than_5_consecutive_errors() -> None:
|
|
193
235
|
"""4 consecutive reload failures should not raise — polling continues."""
|
|
194
236
|
instance = FakeInstance(reload_errors=4)
|
|
@@ -463,3 +505,13 @@ def test_job_result_rejects_cursor_from_another_local_job(tmp_path: Path) -> Non
|
|
|
463
505
|
|
|
464
506
|
with pytest.raises(ValidationError, match="different job"):
|
|
465
507
|
app.job_result(second_job, max_rows=2, cursor=cursor)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@pytest.mark.parametrize("endpoint", ["https://service.example.test:bad/api", "https://service.example.test:99999/api", "https://service.example.test:0/api", "https://-invalid.example.test/api"])
|
|
511
|
+
def test_logview_rejects_invalid_nested_endpoint_authority(endpoint):
|
|
512
|
+
from urllib.parse import urlencode
|
|
513
|
+
|
|
514
|
+
from maxc_cli.utils import sanitize_logview_url
|
|
515
|
+
|
|
516
|
+
url = "https://logview.example.test/logview/?" + urlencode({"h": endpoint, "p": "proj", "i": "job-1", "token": "fixture-access"})
|
|
517
|
+
assert "fixture-access" not in sanitize_logview_url(url, include_access_token=True)
|
|
@@ -207,3 +207,28 @@ def test_default_human_failure_does_not_render_gated_next_actions(
|
|
|
207
207
|
|
|
208
208
|
assert "Next actions" not in rendered
|
|
209
209
|
assert action.command not in rendered
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.parametrize("status", ["pending", "success", "failure"])
|
|
213
|
+
@pytest.mark.parametrize("renderer", [_render_human, render_markdown])
|
|
214
|
+
def test_query_renderers_keep_job_scope_and_usable_logview(status, renderer):
|
|
215
|
+
from maxc_cli.exceptions import ErrorPayload
|
|
216
|
+
|
|
217
|
+
url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=proj&i=job-42&token=fixture-access"
|
|
218
|
+
error = ErrorPayload(code="SQL_ERROR", message="failed", suggestion=None, recoverable=False, instance_id="job-42", logview=url) if status == "failure" else None
|
|
219
|
+
envelope = Envelope(command="query", status=status, data={"job_id": "job-42", "rows": [], "total_rows": 0}, metadata={"job_id": "job-42", "project": "proj", "logview": url}, error=error)
|
|
220
|
+
rendered = renderer(envelope)
|
|
221
|
+
assert "job-42" in rendered
|
|
222
|
+
assert url in rendered
|
|
223
|
+
if status == "failure":
|
|
224
|
+
assert envelope.to_dict()["error"]["logview"] == url
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_error_logview_retains_access_but_audit_redacts_it():
|
|
228
|
+
from maxc_cli.audit import sanitize_audit_payload
|
|
229
|
+
from maxc_cli.exceptions import SqlError
|
|
230
|
+
|
|
231
|
+
url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=proj&i=job-42&token=fixture-access"
|
|
232
|
+
payload = SqlError("failed", logview=url).to_payload().to_dict()
|
|
233
|
+
assert payload["logview"] == url
|
|
234
|
+
assert "fixture-access" not in str(sanitize_audit_payload(payload))
|
|
@@ -862,3 +862,18 @@ def test_job_result_output_format_requires_output_before_remote_fetch(
|
|
|
862
862
|
assert code == 2
|
|
863
863
|
assert payload["error"]["code"] == "OUTPUT_FORMAT_ERROR"
|
|
864
864
|
assert app.job_result_calls == 0
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_raised_query_failure_keeps_usable_logview_on_stderr(tmp_path, monkeypatch):
|
|
869
|
+
url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=test_project&i=job-42&token=fixture-access"
|
|
870
|
+
|
|
871
|
+
class Raising(_FakeCliApp):
|
|
872
|
+
def query(self, **_kwargs):
|
|
873
|
+
raise SqlError("invalid SQL", instance_id="job-42", logview=url)
|
|
874
|
+
|
|
875
|
+
code, stdout, stderr = _run_with_app(tmp_path, monkeypatch, Raising(), ["query", "SELECT 1"])
|
|
876
|
+
assert code != 0
|
|
877
|
+
assert stdout == ""
|
|
878
|
+
assert "job-42" in stderr
|
|
879
|
+
assert url in stderr
|
|
@@ -17,6 +17,7 @@ def _make_app(tmp_path: Path) -> MaxCApp:
|
|
|
17
17
|
config_path.write_text("backend:\n type: auto\n")
|
|
18
18
|
app = MaxCApp(cwd=tmp_path, config_path=config_path, load_backend=False)
|
|
19
19
|
app.config.state_dir = tmp_path / "state"
|
|
20
|
+
app.config.cache_dir = tmp_path / "cache"
|
|
20
21
|
# Simulate remote_jobs=True
|
|
21
22
|
app.remote_jobs = True # remote_jobs is a plain instance attribute set in __init__
|
|
22
23
|
return app
|
|
@@ -98,6 +99,48 @@ def test_query_returns_success_when_job_finishes_within_wait(tmp_path):
|
|
|
98
99
|
assert kwargs["poll_interval"] == 1
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
@pytest.mark.parametrize("wait", [0, 10])
|
|
103
|
+
def test_query_response_uses_current_lifecycle_and_complete_logview(tmp_path, wait):
|
|
104
|
+
from types import SimpleNamespace
|
|
105
|
+
from urllib.parse import parse_qs, urlsplit
|
|
106
|
+
|
|
107
|
+
from maxc_cli.audit import sanitize_audit_payload
|
|
108
|
+
from maxc_cli.backend.job import JobMixin
|
|
109
|
+
from maxc_cli.backend.query import QueryMixin
|
|
110
|
+
|
|
111
|
+
url = (
|
|
112
|
+
"http://logview.odps.aliyun.com/logview/"
|
|
113
|
+
"?h=https%3A%2F%2Fservice.cn-hangzhou.maxcompute.aliyun.com%2Fapi"
|
|
114
|
+
"&p=test_proj&i=job-1&token=fixture-logview-access"
|
|
115
|
+
)
|
|
116
|
+
instance = SimpleNamespace(id="job-1", get_logview_address=lambda: url)
|
|
117
|
+
app = _make_app(tmp_path)
|
|
118
|
+
app.backend = MagicMock()
|
|
119
|
+
app.backend.client.run_sql.return_value = instance
|
|
120
|
+
app.backend._safe_logview.side_effect = lambda inst: JobMixin._safe_logview(object(), inst)
|
|
121
|
+
app.backend.submit_query.side_effect = lambda *args, **kwargs: QueryMixin.submit_query(
|
|
122
|
+
app.backend, *args, **kwargs,
|
|
123
|
+
)
|
|
124
|
+
done = _fake_job_info(status="success")
|
|
125
|
+
done.logview = JobMixin._safe_logview(object(), instance)
|
|
126
|
+
app.backend.wait_job.return_value = done
|
|
127
|
+
app.backend.fetch_job_result.return_value = _fake_query_result()
|
|
128
|
+
|
|
129
|
+
payload = app.query(command="query", sql="SELECT 1", project="test_proj", wait=wait).to_dict()
|
|
130
|
+
|
|
131
|
+
assert payload["status"] == ("pending" if wait == 0 else "success")
|
|
132
|
+
hints = payload["agent_hints"] or {}
|
|
133
|
+
assert not any("job.status or job.wait" in item for item in hints.get("warnings", []))
|
|
134
|
+
action_ids = hints.get("action_ids", [])
|
|
135
|
+
if wait == 0:
|
|
136
|
+
assert "job.wait" in action_ids
|
|
137
|
+
else:
|
|
138
|
+
assert "job.wait" not in action_ids and "job.status" not in action_ids
|
|
139
|
+
params = parse_qs(urlsplit(payload["metadata"]["logview"]).query)
|
|
140
|
+
assert params == parse_qs(urlsplit(url).query)
|
|
141
|
+
assert "fixture-logview-access" not in str(sanitize_audit_payload(payload))
|
|
142
|
+
|
|
143
|
+
|
|
101
144
|
def test_query_returns_successful_statement_envelope_for_ddl(tmp_path):
|
|
102
145
|
app = _make_app(tmp_path)
|
|
103
146
|
job_done = _fake_job_info(status="success")
|
|
@@ -445,3 +488,56 @@ def test_error_payload_keeps_only_safe_numeric_logview_selector() -> None:
|
|
|
445
488
|
assert payload["logview"] == (
|
|
446
489
|
"https://logview.example.test/job-1?subQuery=9"
|
|
447
490
|
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@pytest.mark.parametrize("command", ["query", "job.wait", "job.result"])
|
|
494
|
+
def test_completed_result_fetch_failure_recovers_by_reading_same_job(tmp_path, command):
|
|
495
|
+
app = _make_app(tmp_path)
|
|
496
|
+
done = _fake_job_info(status="success")
|
|
497
|
+
done.logview = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=test_proj&i=job-1&token=fixture-access"
|
|
498
|
+
app.backend = MagicMock()
|
|
499
|
+
app.backend.submit_query.return_value = _fake_job_info()
|
|
500
|
+
app.backend.get_job.return_value = done
|
|
501
|
+
app.backend.wait_job.return_value = done
|
|
502
|
+
app.backend.fetch_job_result.side_effect = BackendConnectionError("result reader unavailable")
|
|
503
|
+
if command == "query":
|
|
504
|
+
envelope = app.query(command=command, sql="SELECT 1", project="test_proj", max_rows=2)
|
|
505
|
+
elif command == "job.wait":
|
|
506
|
+
envelope, events = app.job_wait("job-1", project="test_proj")
|
|
507
|
+
assert events == []
|
|
508
|
+
else:
|
|
509
|
+
envelope = app.job_result("job-1", project="test_proj", max_rows=2)
|
|
510
|
+
payload = envelope.to_dict()
|
|
511
|
+
assert payload["status"] == "failure"
|
|
512
|
+
assert payload["error"]["code"] == "BACKEND_CONNECTION_ERROR"
|
|
513
|
+
assert payload["metadata"]["logview"] == done.logview
|
|
514
|
+
assert payload["metadata"]["job_status"] == "success"
|
|
515
|
+
actions = {item.id: item.command for item in envelope.agent_hints.actions}
|
|
516
|
+
assert "job.result" in actions
|
|
517
|
+
assert "job.wait" not in actions and "job.status" not in actions
|
|
518
|
+
if command != "job.wait":
|
|
519
|
+
assert "--max-rows 2" in actions["job.result"]
|
|
520
|
+
app.backend.submit_query.assert_called_once() if command == "query" else app.backend.submit_query.assert_not_called()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@pytest.mark.parametrize("command", ["query", "job.result"])
|
|
524
|
+
def test_result_fetch_failure_preserves_failed_cursor(tmp_path, command):
|
|
525
|
+
from maxc_cli.utils import encode_cursor
|
|
526
|
+
|
|
527
|
+
app = _make_app(tmp_path)
|
|
528
|
+
session = app.cache.create_session(job_id="job-1", project="test_proj", sql="SELECT 1")
|
|
529
|
+
cursor = encode_cursor(2, session_id=session)
|
|
530
|
+
done = _fake_job_info(status="success")
|
|
531
|
+
app.backend = MagicMock()
|
|
532
|
+
app.backend.get_job.return_value = done
|
|
533
|
+
app.backend.fetch_job_result.side_effect = BackendConnectionError("unavailable", logview=done.logview)
|
|
534
|
+
if command == "query":
|
|
535
|
+
envelope = app.query(command=command, sql="SELECT 1", cursor=cursor, max_rows=2, project="test_proj")
|
|
536
|
+
else:
|
|
537
|
+
envelope = app.job_result("job-1", cursor=cursor, max_rows=2, project="test_proj")
|
|
538
|
+
assert envelope.status == "failure"
|
|
539
|
+
assert envelope.metadata["job_id"] == "job-1"
|
|
540
|
+
retry = next(item for item in envelope.agent_hints.actions if item.id == "job.result")
|
|
541
|
+
assert "--cursor " + cursor in retry.command
|
|
542
|
+
assert "--max-rows 2" in retry.command
|
|
543
|
+
app.backend.submit_query.assert_not_called()
|
|
@@ -62,6 +62,7 @@ class _StubBackend:
|
|
|
62
62
|
project = "test_project"
|
|
63
63
|
_instance_to_query_result = OdpsBackend._instance_to_query_result
|
|
64
64
|
_safe_task_results = JobMixin._safe_task_results
|
|
65
|
+
_safe_logview = JobMixin._safe_logview
|
|
65
66
|
_task_cost = OdpsBackend._task_cost
|
|
66
67
|
|
|
67
68
|
|
|
@@ -517,8 +518,7 @@ def test_degraded_offset_and_max_rows_apply_to_task_rows() -> None:
|
|
|
517
518
|
offset=1,
|
|
518
519
|
)
|
|
519
520
|
assert [row["task_name"] for row in result.rows] == ["taskB"]
|
|
520
|
-
|
|
521
|
-
assert result.total_rows == 1
|
|
521
|
+
assert result.total_rows == 2
|
|
522
522
|
assert result.has_more is False
|
|
523
523
|
|
|
524
524
|
|
|
@@ -603,3 +603,27 @@ def test_fallback_warning_is_still_emitted_to_stderr(capsys) -> None:
|
|
|
603
603
|
|
|
604
604
|
captured = capsys.readouterr()
|
|
605
605
|
assert "Instance tunnel timed out" in captured.err
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@pytest.mark.parametrize("offset, expected, has_more", [(0, ["1", "2"], True), (2, ["3", "4"], False), (8, [], False)])
|
|
609
|
+
def test_csv_fallback_reports_all_accessible_rows(offset, expected, has_more):
|
|
610
|
+
result = _run(CsvRecordReader(schema=None, stream="a\n1\n2\n3\n4\n"), max_rows=2, offset=offset)
|
|
611
|
+
assert [row["a"] for row in result.rows] == expected
|
|
612
|
+
assert result.total_rows == 4
|
|
613
|
+
assert result.has_more is has_more
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def test_raw_task_fallback_first_page_preserves_remaining_tasks():
|
|
617
|
+
raw = "a,b\n1,2,3\n"
|
|
618
|
+
result = _run(CsvRecordReader(schema=None, stream=raw), task_results={"taskA": raw, "taskB": raw}, max_rows=1)
|
|
619
|
+
assert result.total_rows == 2
|
|
620
|
+
assert result.has_more is True
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def test_result_conversion_keeps_logview_for_cursor_responses():
|
|
625
|
+
url = "https://logview.example.test/logview/?h=https%3A%2F%2Fservice.example.test%2Fapi&p=test_project&i=fake_instance_id&token=fixture-access"
|
|
626
|
+
instance = _FakeInstance(CsvRecordReader(schema=None, stream="a\n1\n2\n"))
|
|
627
|
+
instance.get_logview_address = lambda: url
|
|
628
|
+
result = _StubBackend()._instance_to_query_result(instance, project="test_project", max_rows=1, offset=1, sql="SELECT a FROM t", elapsed_ms=0)
|
|
629
|
+
assert result.extra_metadata["logview"] == url
|
|
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
|
|
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
|