maxc-cli 0.6.0__tar.gz → 0.6.1__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 (127) hide show
  1. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/PKG-INFO +1 -1
  2. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/__init__.py +1 -1
  3. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/odps.py +83 -36
  4. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli.egg-info/PKG-INFO +1 -1
  5. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_query_result_csv_fallback.py +120 -2
  6. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/MANIFEST.in +0 -0
  7. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/README.md +0 -0
  8. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/pyproject.toml +0 -0
  9. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/scripts/pyinstaller_entry.py +0 -0
  10. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/scripts/regression_test.py +0 -0
  11. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/setup.cfg +0 -0
  12. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/setup.py +0 -0
  13. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/__main__.py +0 -0
  14. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/_samples.py +0 -0
  15. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/agent_platforms.py +0 -0
  16. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/app.py +0 -0
  17. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/audit.py +0 -0
  18. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/auth_continuation.py +0 -0
  19. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/auth_providers.py +0 -0
  20. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/__init__.py +0 -0
  21. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/auth.py +0 -0
  22. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/catalog.py +0 -0
  23. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/data.py +0 -0
  24. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/job.py +0 -0
  25. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/meta.py +0 -0
  26. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/query.py +0 -0
  27. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/backend/semantic.py +0 -0
  28. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/cache.py +0 -0
  29. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/catalog_bootstrap.py +0 -0
  30. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/cli.py +0 -0
  31. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/config.py +0 -0
  32. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/exceptions.py +0 -0
  33. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/help_format.py +0 -0
  34. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/helpers.py +0 -0
  35. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/job_ids.py +0 -0
  36. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/masking.py +0 -0
  37. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/models.py +0 -0
  38. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/oauth.py +0 -0
  39. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/odps_runtime.py +0 -0
  40. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/output.py +0 -0
  41. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/proxy_auth.py +0 -0
  42. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/semantic.py +0 -0
  43. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/semantic_management.py +0 -0
  44. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/setting_parser.py +0 -0
  45. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/SKILL.md +0 -0
  46. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/agents/openai.yaml +0 -0
  47. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/bootstrap-auth.md +0 -0
  48. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/bootstrap-flow.md +0 -0
  49. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/command-patterns.md +0 -0
  50. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/json-output-format.md +0 -0
  51. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/maxcompute-select-guide.md +0 -0
  52. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/maxcompute-sql-notes.md +0 -0
  53. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/partition-guide.md +0 -0
  54. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/red-lines.md +0 -0
  55. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/semantic-packages.md +0 -0
  56. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/setup-install.md +0 -0
  57. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/sql-common-errors.md +0 -0
  58. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/sql-query-patterns.md +0 -0
  59. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/skills/references/text2sql-principles.md +0 -0
  60. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/state_permissions.py +0 -0
  61. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/store.py +0 -0
  62. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli/utils.py +0 -0
  63. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli.egg-info/SOURCES.txt +0 -0
  64. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli.egg-info/dependency_links.txt +0 -0
  65. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli.egg-info/entry_points.txt +0 -0
  66. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli.egg-info/requires.txt +0 -0
  67. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/src/maxc_cli.egg-info/top_level.txt +0 -0
  68. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_agent_hints_and_cli.py +0 -0
  69. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_agent_platforms.py +0 -0
  70. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_agent_skill_commands.py +0 -0
  71. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_agent_skill_commands_context.py +0 -0
  72. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_ai_native_contract_regressions.py +0 -0
  73. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_auth_logout.py +0 -0
  74. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_backend_auth.py +0 -0
  75. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_backend_data.py +0 -0
  76. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_backend_data_serialization.py +0 -0
  77. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_backend_meta.py +0 -0
  78. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_build_release_archive_compat.py +0 -0
  79. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_build_release_script.py +0 -0
  80. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_cache.py +0 -0
  81. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_catalog.py +0 -0
  82. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_catalog_bootstrap.py +0 -0
  83. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_cli_arg_validation.py +0 -0
  84. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_cli_mock.py +0 -0
  85. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_cli_query_parse_and_sanitize.py +0 -0
  86. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_compat.py +0 -0
  87. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_config_atomic_write.py +0 -0
  88. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_e2e_smoke.py +0 -0
  89. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_effective_hints_contract.py +0 -0
  90. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_envelope_shape.py +0 -0
  91. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_error_self_correction.py +0 -0
  92. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_error_translation.py +0 -0
  93. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_exit_codes.py +0 -0
  94. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_external_auth.py +0 -0
  95. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_flag_hoist.py +0 -0
  96. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_help_format.py +0 -0
  97. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_help_version_e2e.py +0 -0
  98. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_helpers.py +0 -0
  99. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_helpers_csv.py +0 -0
  100. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_installer_contracts.py +0 -0
  101. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_integration.py +0 -0
  102. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_integration_real.py +0 -0
  103. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_job_improvements.py +0 -0
  104. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_job_store_durability.py +0 -0
  105. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_manifest_runtime_contract.py +0 -0
  106. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_masking.py +0 -0
  107. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_meta_schema_and_partition_cols.py +0 -0
  108. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_oauth.py +0 -0
  109. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_odps_runtime.py +0 -0
  110. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_output_action_safety.py +0 -0
  111. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_output_format_contract.py +0 -0
  112. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_packaging_metadata.py +0 -0
  113. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_phase1_improvements.py +0 -0
  114. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_proxy_auth.py +0 -0
  115. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_pyinstaller_bundle.py +0 -0
  116. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_python39_compat.py +0 -0
  117. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_query_auto_promote.py +0 -0
  118. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_semantic_management.py +0 -0
  119. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_semantic_scope.py +0 -0
  120. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_semantic_transport.py +0 -0
  121. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_setting_parser.py +0 -0
  122. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_skill_cli_consistency.py +0 -0
  123. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_skill_eval.py +0 -0
  124. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_skill_renderer.py +0 -0
  125. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_startup_imports.py +0 -0
  126. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_state_permissions.py +0 -0
  127. {maxc_cli-0.6.0 → maxc_cli-0.6.1}/tests/test_state_portability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
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
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.6.0"
5
+ __version__ = "0.6.1"
@@ -56,6 +56,25 @@ def _summarize_fallback_warning(message: 'str') -> 'str | None':
56
56
  return None
57
57
 
58
58
 
59
+ _RAW_RESULT_COLUMNS = ("task_name", "result")
60
+
61
+
62
+ def _raw_task_result_rows(task_results: 'dict[str, Any]') -> 'list[dict[str, Any]]':
63
+ """Materialize raw task results as single-column rows for degraded reads.
64
+
65
+ Used when the tabular result reader cannot parse the server's CSV text
66
+ (e.g. unquoted commas inside SHOW INDEXES column values). The original
67
+ result strings are returned verbatim without type casting.
68
+ """
69
+ return [
70
+ {
71
+ "task_name": str(name),
72
+ "result": "" if value is None else str(value),
73
+ }
74
+ for name, value in task_results.items()
75
+ ]
76
+
77
+
59
78
  class OdpsBackend(
60
79
  JobMixin, # JobMixin extends QueryMixin
61
80
  CatalogMixin,
@@ -116,7 +135,9 @@ class OdpsBackend(
116
135
  schema: list[dict[str, Any]] = []
117
136
  rows: list[dict[str, Any]] = []
118
137
  total_rows = 0
138
+ degraded_reason: str | None = None
119
139
  else:
140
+ degraded_reason = None
120
141
  try:
121
142
  # Capture pyodps's tunnel-fallback UserWarnings so we can surface
122
143
  # them via the envelope. catch_warnings(record=True) suppresses
@@ -124,42 +145,59 @@ class OdpsBackend(
124
145
  # running without --json still see the original message.
125
146
  with warnings.catch_warnings(record=True) as captured:
126
147
  warnings.simplefilter("always")
127
- with instance.open_reader() as reader:
128
- # Materialize records first. When pyodps falls back from
129
- # the instance tunnel to CsvRecordReader (e.g. on tunnel
130
- # timeout), the column metadata is parsed lazily from the
131
- # CSV header during the first __next__ call — so we can
132
- # only inspect it after iteration begins.
133
- records = list(islice(reader, offset, offset + max_rows))
134
-
135
- reader_schema = getattr(reader, "schema", None)
136
- if reader_schema is not None and hasattr(reader_schema, "columns"):
137
- columns = list(reader_schema.columns)
138
- else:
139
- # Fallback path: CsvRecordReader exposes header columns
140
- # via `_csv_columns` after iteration starts.
141
- columns = list(getattr(reader, "_csv_columns", None) or [])
142
-
143
- if not columns:
144
- # Truly schema-less query-like result.
145
- schema = []
146
- rows = []
147
- total_rows = 0
148
- else:
149
- schema = [
150
- {
151
- "name": column.name,
152
- "type": str(column.type),
153
- "comment": "",
154
- }
155
- for column in columns
156
- ]
157
- column_names = [column["name"] for column in schema]
158
- rows = [
159
- record_to_dict(column_names, record.values)
160
- for record in records
161
- ]
162
- total_rows = int(getattr(reader, "count", len(rows)) or len(rows))
148
+ try:
149
+ with instance.open_reader() as reader:
150
+ # Materialize records first. When pyodps falls back from
151
+ # the instance tunnel to CsvRecordReader (e.g. on tunnel
152
+ # timeout), the column metadata is parsed lazily from the
153
+ # CSV header during the first __next__ call — so we can
154
+ # only inspect it after iteration begins.
155
+ records = list(islice(reader, offset, offset + max_rows))
156
+
157
+ reader_schema = getattr(reader, "schema", None)
158
+ if reader_schema is not None and hasattr(reader_schema, "columns"):
159
+ columns = list(reader_schema.columns)
160
+ else:
161
+ # Fallback path: CsvRecordReader exposes header columns
162
+ # via `_csv_columns` after iteration starts.
163
+ columns = list(getattr(reader, "_csv_columns", None) or [])
164
+
165
+ if not columns:
166
+ # Truly schema-less query-like result.
167
+ schema = []
168
+ rows = []
169
+ total_rows = 0
170
+ else:
171
+ schema = [
172
+ {
173
+ "name": column.name,
174
+ "type": str(column.type),
175
+ "comment": "",
176
+ }
177
+ for column in columns
178
+ ]
179
+ column_names = [column["name"] for column in schema]
180
+ rows = [
181
+ record_to_dict(column_names, record.values)
182
+ for record in records
183
+ ]
184
+ total_rows = int(getattr(reader, "count", len(rows)) or len(rows))
185
+ except Exception as reader_exc:
186
+ # Some SHOW results (e.g. multi-column SHOW INDEXES
187
+ # col_names) contain unquoted commas that make
188
+ # PyODPS's CSV reader raise IndexError while casting
189
+ # fields. The job itself succeeded, so degrade to the
190
+ # raw task-result text instead of failing the query.
191
+ task_results = self._safe_task_results(instance)
192
+ if not any(str(value).strip() for value in task_results.values()):
193
+ # No raw text to degrade to — surface the real error.
194
+ raise
195
+ degraded_reason = str(reader_exc) or type(reader_exc).__name__
196
+ all_raw_rows = _raw_task_result_rows(task_results)
197
+ rows = all_raw_rows[offset : offset + max_rows]
198
+ schema = [{"name": name, "type": "string", "comment": ""}
199
+ for name in _RAW_RESULT_COLUMNS]
200
+ total_rows = len(rows)
163
201
 
164
202
  for w in captured:
165
203
  msg_text = str(w.message)
@@ -174,10 +212,19 @@ class OdpsBackend(
174
212
  except Exception as exc:
175
213
  raise translate_odps_error(exc) from exc
176
214
 
215
+ if degraded_reason is not None:
216
+ fallback_warnings.append(
217
+ "Result reader failed to parse the task result "
218
+ f"({degraded_reason}); returning the raw task result text "
219
+ "verbatim in the `result` column without type casting."
220
+ )
221
+
177
222
  bytes_scanned, extra_metadata = self._task_cost(instance)
178
223
  returned_rows = len(rows)
179
224
  has_more = total_rows > (offset + returned_rows)
180
225
  extra_metadata["current_offset"] = offset
226
+ if degraded_reason is not None:
227
+ extra_metadata["result_kind"] = "raw_task_result"
181
228
  if resultless_statement:
182
229
  extra_metadata["result_kind"] = "statement"
183
230
  if statement_operation is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maxc-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
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
@@ -17,6 +17,7 @@ pytestmark = pytest.mark.unit
17
17
 
18
18
  from odps.readers import CsvRecordReader
19
19
 
20
+ from maxc_cli.backend.job import JobMixin
20
21
  from maxc_cli.backend.odps import OdpsBackend
21
22
 
22
23
 
@@ -27,9 +28,18 @@ class _FakeInstance:
27
28
  start_time = None
28
29
  end_time = None
29
30
 
30
- def __init__(self, reader, *, warn_on_open: str | None = None) -> None:
31
+ def __init__(
32
+ self,
33
+ reader,
34
+ *,
35
+ warn_on_open: str | None = None,
36
+ task_results: dict[str, str] | None = None,
37
+ raise_on_task_results: bool = False,
38
+ ) -> None:
31
39
  self._reader = reader
32
40
  self._warn_on_open = warn_on_open
41
+ self._task_results = task_results
42
+ self._raise_on_task_results = raise_on_task_results
33
43
 
34
44
  def open_reader(self):
35
45
  if self._warn_on_open:
@@ -40,12 +50,18 @@ class _FakeInstance:
40
50
  def get_task_cost(self):
41
51
  return None
42
52
 
53
+ def get_task_results(self):
54
+ if self._raise_on_task_results or self._task_results is None:
55
+ raise RuntimeError("get_task_results not stubbed")
56
+ return dict(self._task_results)
57
+
43
58
 
44
59
  class _StubBackend:
45
60
  """Bind just the methods under test without invoking OdpsBackend.__init__."""
46
61
 
47
62
  project = "test_project"
48
63
  _instance_to_query_result = OdpsBackend._instance_to_query_result
64
+ _safe_task_results = JobMixin._safe_task_results
49
65
  _task_cost = OdpsBackend._task_cost
50
66
 
51
67
 
@@ -56,8 +72,15 @@ def _run(
56
72
  max_rows: int = 100,
57
73
  offset: int = 0,
58
74
  warn_on_open: str | None = None,
75
+ task_results: dict[str, str] | None = None,
76
+ raise_on_task_results: bool = False,
59
77
  ):
60
- instance = _FakeInstance(reader, warn_on_open=warn_on_open)
78
+ instance = _FakeInstance(
79
+ reader,
80
+ warn_on_open=warn_on_open,
81
+ task_results=task_results,
82
+ raise_on_task_results=raise_on_task_results,
83
+ )
61
84
  return _StubBackend()._instance_to_query_result(
62
85
  instance,
63
86
  project="test_project",
@@ -404,6 +427,101 @@ def test_query_like_statement_still_opens_result_reader() -> None:
404
427
  )
405
428
 
406
429
 
430
+ # ---------------------------------------------------------------------------
431
+ # Raw task-result degradation: when the CSV reader cannot parse a successful
432
+ # job's result text (e.g. SHOW INDEXES multi-column col_names contain unquoted
433
+ # commas and PyODPS raises IndexError while casting), fall back to returning
434
+ # the raw task result verbatim instead of failing the query.
435
+ # ---------------------------------------------------------------------------
436
+
437
+ SHOW_INDEXES_MULTI_COLUMN_HEADER = (
438
+ "idx_name,tab_name,col_names,idx_tab_name,idx_type,comment\n"
439
+ )
440
+ SHOW_INDEXES_MULTI_COLUMN_ROW = (
441
+ "vidx,tbl,c1,c2,vidx_idx,VECTOR,\n"
442
+ )
443
+
444
+
445
+ def test_csv_index_error_degrades_to_raw_task_result() -> None:
446
+ """The SHOW INDEXES crash: data rows have more CSV fields than the header,
447
+ so CsvRecordReader raises IndexError mid-iteration. The job succeeded, so
448
+ the envelope must carry the raw result text with an explanatory warning.
449
+ """
450
+ raw = SHOW_INDEXES_MULTI_COLUMN_HEADER + SHOW_INDEXES_MULTI_COLUMN_ROW
451
+ reader = CsvRecordReader(schema=None, stream=raw)
452
+ result = _run(
453
+ reader,
454
+ sql="SHOW INDEXES ON proj.default.tbl",
455
+ task_results={"AnonymousSQLTask": raw},
456
+ )
457
+
458
+ assert [col["name"] for col in result.schema] == ["task_name", "result"]
459
+ assert result.rows == [{"task_name": "AnonymousSQLTask", "result": raw}]
460
+ assert result.total_rows == 1
461
+ assert result.extra_metadata["result_kind"] == "raw_task_result"
462
+ assert any("raw task result" in w for w in result.warnings), result.warnings
463
+ assert any("IndexError" in w or "index out of range" in w for w in result.warnings), (
464
+ result.warnings
465
+ )
466
+
467
+
468
+ def test_real_pyodps_indexerror_on_unquoted_comma_row_degrades() -> None:
469
+ """End-to-end through the real PyODPS reader: a row whose values contain
470
+ unquoted commas must degrade rather than propagate IndexError."""
471
+ raw = (
472
+ "a,b\n"
473
+ '1,"x"\n'
474
+ "2,x,y,z\n" # extra unquoted fields -> IndexError inside _cast_value
475
+ )
476
+ reader = CsvRecordReader(schema=None, stream=raw)
477
+ with pytest.raises(IndexError):
478
+ list(reader) # sanity: the bare reader genuinely fails
479
+
480
+ result = _run(
481
+ CsvRecordReader(schema=None, stream=raw),
482
+ sql="SHOW INDEXES ON t",
483
+ task_results={"SQLTask_1": raw},
484
+ )
485
+ assert result.rows[0]["result"] == raw
486
+ assert result.extra_metadata["result_kind"] == "raw_task_result"
487
+
488
+
489
+ def test_reader_failure_without_task_result_text_still_raises() -> None:
490
+ """Degradation requires actual result text; empty or unreadable task
491
+ results keep the original error translation so real failures are not
492
+ masked as success."""
493
+ reader = CsvRecordReader(schema=None, stream="a,b\n1,2,3\n")
494
+ with pytest.raises(Exception, match="list index out of range"):
495
+ _run(reader, sql="SHOW INDEXES ON t", raise_on_task_results=True)
496
+
497
+ # Whitespace-only task results are treated as absent: the header row is
498
+ # consumed by the lazy column load, so iteration yields no records and the
499
+ # bare reader never raises. A genuinely unparseable body must still fail.
500
+ with pytest.raises(Exception, match="list index out of range"):
501
+ _run(
502
+ CsvRecordReader(schema=None, stream="hdr\nx\na,b\n1,2,3\n"),
503
+ sql="SHOW INDEXES ON t",
504
+ task_results={"AnonymousSQLTask": " \n"},
505
+ )
506
+
507
+
508
+ def test_degraded_offset_and_max_rows_apply_to_task_rows() -> None:
509
+ raw_a = "a,b\n1,2,3\n"
510
+ raw_b = "a,b\n4,5,6\n"
511
+ reader = CsvRecordReader(schema=None, stream=raw_a)
512
+ result = _run(
513
+ reader,
514
+ sql="SHOW INDEXES ON t",
515
+ task_results={"taskA": raw_a, "taskB": raw_b},
516
+ max_rows=1,
517
+ offset=1,
518
+ )
519
+ assert [row["task_name"] for row in result.rows] == ["taskB"]
520
+ # total_rows counts the materialized window, so pagination terminates.
521
+ assert result.total_rows == 1
522
+ assert result.has_more is False
523
+
524
+
407
525
  # ---------------------------------------------------------------------------
408
526
  # Fallback-warning capture: surface pyodps's "tunnel fallback" UserWarning
409
527
  # into the envelope so agents can see that the result was truncated.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes