scalim-cli 0.8.3__py3-none-any.whl

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.
scalim_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Scalim CLI package (dev tooling)."""
2
+
3
+ __all__ = ()
scalim_cli/log.py ADDED
@@ -0,0 +1,199 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Iterable, Iterator, List, Tuple
6
+
7
+ from scalim.ob.structured_logging import normalize_keys_to_full
8
+
9
+ _LOG_LEVEL_ERROR = 40
10
+ _MAX_JOINED_CHARS = 1024 * 1024
11
+ _MAX_BUFFER_LINES = 200
12
+
13
+
14
+ def register(subparsers: Any) -> None:
15
+ parser = subparsers.add_parser("log", help="Structured log utilities (JSONL)")
16
+ _set_help_default(parser)
17
+ log_subparsers = parser.add_subparsers(dest="log_command")
18
+
19
+ fmt_parser = log_subparsers.add_parser("fmt", help="Render structured logs (human-friendly)")
20
+ _add_input_arg(fmt_parser)
21
+ _ = fmt_parser.add_argument("--max-fields", type=int, default=12, help="Max number of fields to show per record")
22
+ _ = fmt_parser.add_argument("--max-value-chars", type=int, default=120, help="Max characters per value when rendering")
23
+ fmt_parser.set_defaults(func=_run_fmt)
24
+
25
+ summarize_parser = log_subparsers.add_parser("summarize", help="Summarize structured logs (llm-friendly)")
26
+ _add_input_arg(summarize_parser)
27
+ _ = summarize_parser.add_argument("--budget-chars", type=int, default=8000, help="Max characters in output")
28
+ summarize_parser.set_defaults(func=_run_summarize)
29
+
30
+
31
+ def _set_help_default(parser: argparse.ArgumentParser) -> None:
32
+ def _show_help(_args: argparse.Namespace) -> int:
33
+ parser.print_help()
34
+ return 2
35
+
36
+ parser.set_defaults(func=_show_help)
37
+
38
+
39
+ def _add_input_arg(parser: argparse.ArgumentParser) -> None:
40
+ _ = parser.add_argument(
41
+ "input",
42
+ nargs="?",
43
+ default="-",
44
+ type=str,
45
+ help="Input JSONL or mixed log file. Use '-' for stdin.",
46
+ )
47
+
48
+
49
+ def _iter_input_lines(path: str) -> Iterator[str]:
50
+ if path == "-" or not path:
51
+ for line in sys.stdin:
52
+ yield line
53
+ return
54
+ with Path(path).open("r", encoding="utf-8", errors="replace") as f:
55
+ for line in f:
56
+ yield line
57
+
58
+
59
+ def _iter_json_objects(lines: Iterable[str]) -> Iterator[Dict[str, Any]]:
60
+ """Best-effort JSON object scanner with multiline recovery.
61
+
62
+ Strategy:
63
+ - Ignore non-JSON lines.
64
+ - When a line looks like a JSON object start, try parse; if fails, keep buffering until parse succeeds.
65
+ """
66
+ buf: List[str] = []
67
+ for raw in lines:
68
+ line = raw.rstrip("\n")
69
+ if not buf:
70
+ stripped = line.lstrip()
71
+ if not stripped.startswith("{"):
72
+ continue
73
+ try:
74
+ obj = json.loads(stripped)
75
+ except json.JSONDecodeError:
76
+ buf.append(stripped)
77
+ continue
78
+ if isinstance(obj, dict):
79
+ yield normalize_keys_to_full(obj) # type: ignore[arg-type]
80
+ continue
81
+
82
+ buf.append(line)
83
+ joined = "\n".join(buf)
84
+ try:
85
+ obj = json.loads(joined)
86
+ except json.JSONDecodeError:
87
+ if len(joined) > _MAX_JOINED_CHARS or len(buf) > _MAX_BUFFER_LINES:
88
+ buf = []
89
+ continue
90
+ buf = []
91
+ if isinstance(obj, dict):
92
+ yield normalize_keys_to_full(obj) # type: ignore[arg-type]
93
+
94
+
95
+ def _shorten(value: object, *, max_chars: int) -> str:
96
+ text = str(value)
97
+ if max_chars <= 0:
98
+ return text
99
+ if len(text) <= max_chars:
100
+ return text
101
+ return text[: max_chars - 1] + "…"
102
+
103
+
104
+ def _format_kv(items: Dict[str, Any], *, max_fields: int, max_value_chars: int) -> str:
105
+ parts: List[str] = []
106
+ for key in sorted(items.keys()):
107
+ if max_fields > 0 and len(parts) >= max_fields:
108
+ parts.append("…")
109
+ break
110
+ val = items[key]
111
+ if val is None:
112
+ continue
113
+ parts.append("{}={}".format(key, _shorten(val, max_chars=max_value_chars)))
114
+ return ", ".join(parts)
115
+
116
+
117
+ def _format_human(record: Dict[str, Any], *, max_fields: int, max_value_chars: int) -> str:
118
+ level = int(record.get("level") or 0)
119
+ logger = str(record.get("logger") or "")
120
+ kind = str(record.get("kind") or "")
121
+ message = str(record.get("message") or "")
122
+
123
+ ctx = record.get("context") or {}
124
+ fields = record.get("fields") or {}
125
+ err = record.get("error") or {}
126
+
127
+ ctx_text = ""
128
+ if isinstance(ctx, dict) and ctx:
129
+ picked: Dict[str, Any] = {}
130
+ for k in ("demand", "workflow_node_id", "run_id", "demand_path"):
131
+ if k in ctx and ctx[k] not in (None, ""):
132
+ picked[k] = ctx[k]
133
+ if picked:
134
+ ctx_text = " ctx({})".format(_format_kv(picked, max_fields=6, max_value_chars=max_value_chars))
135
+
136
+ fields_text = ""
137
+ if isinstance(fields, dict) and fields:
138
+ fields_text = " {}".format(_format_kv(fields, max_fields=max_fields, max_value_chars=max_value_chars))
139
+
140
+ err_text = ""
141
+ if isinstance(err, dict) and err:
142
+ err_text = " err({})".format(_format_kv(err, max_fields=6, max_value_chars=max_value_chars))
143
+
144
+ head = kind or message or "<log>"
145
+ if logger:
146
+ head = "{} {}".format(logger, head)
147
+
148
+ prefix = "ERROR " if level >= _LOG_LEVEL_ERROR else ""
149
+ return "{}{}{}{}{}".format(prefix, head, ctx_text, fields_text, err_text).strip()
150
+
151
+
152
+ def _run_fmt(args: argparse.Namespace) -> int:
153
+ path = str(getattr(args, "input", "-") or "-")
154
+ max_fields = int(getattr(args, "max_fields", 12) or 12)
155
+ max_value_chars = int(getattr(args, "max_value_chars", 120) or 120)
156
+ for record in _iter_json_objects(_iter_input_lines(path)):
157
+ sys.stdout.write(_format_human(record, max_fields=max_fields, max_value_chars=max_value_chars) + "\n")
158
+ return 0
159
+
160
+
161
+ def _summarize_kinds(records: List[Dict[str, Any]]) -> List[Tuple[str, int]]:
162
+ counts: Dict[str, int] = {}
163
+ for r in records:
164
+ kind = str(r.get("kind") or "")
165
+ if not kind:
166
+ continue
167
+ counts[kind] = int(counts.get(kind, 0)) + 1
168
+ return sorted(counts.items(), key=lambda kv: (-int(kv[1]), kv[0]))
169
+
170
+
171
+ def _run_summarize(args: argparse.Namespace) -> int:
172
+ path = str(getattr(args, "input", "-") or "-")
173
+ budget = int(getattr(args, "budget_chars", 8000) or 8000)
174
+ records = list(_iter_json_objects(_iter_input_lines(path)))
175
+
176
+ lines: List[str] = []
177
+ lines.append("records={}".format(len(records)))
178
+
179
+ for kind, count in _summarize_kinds(records)[:20]:
180
+ lines.append("{}={}".format(kind, count))
181
+
182
+ # best-effort: show last performance/relations summaries if present
183
+ for want_kind in ("performance.summary", "performance.loader_breakdown", "relations.summary"):
184
+ picked = None
185
+ for r in reversed(records):
186
+ if str(r.get("kind") or "") == want_kind:
187
+ picked = r
188
+ break
189
+ if picked and isinstance(picked.get("fields"), dict):
190
+ lines.append("{} {}".format(want_kind, _format_kv(picked["fields"], max_fields=12, max_value_chars=120))) # type: ignore[index]
191
+
192
+ text = "\n".join(lines)
193
+ if budget > 0 and len(text) > budget:
194
+ text = text[: max(0, budget - 1)] + "…"
195
+ sys.stdout.write(text + "\n")
196
+ return 0
197
+
198
+
199
+ __all__ = ("register",)
scalim_cli/main.py ADDED
@@ -0,0 +1,31 @@
1
+ import argparse
2
+ from typing import List, Optional
3
+
4
+ from . import log as log_cmd
5
+ from . import yaml_dsl
6
+
7
+
8
+ def _build_parser() -> argparse.ArgumentParser:
9
+ parser = argparse.ArgumentParser(prog="scalim-cli", description="Scalim CLI")
10
+
11
+ def _show_help(_args: argparse.Namespace) -> int:
12
+ parser.print_help()
13
+ return 2
14
+
15
+ parser.set_defaults(func=_show_help)
16
+ subparsers = parser.add_subparsers(dest="command")
17
+ yaml_dsl.register(subparsers)
18
+ log_cmd.register(subparsers)
19
+ return parser
20
+
21
+
22
+ def main(argv: Optional[List[str]] = None) -> int:
23
+ parser = _build_parser()
24
+ args = parser.parse_args(argv)
25
+ return args.func(args) # type: ignore[attr-defined]
26
+
27
+
28
+ if __name__ == "__main__":
29
+ raise SystemExit(main())
30
+
31
+ __all__ = ()
scalim_cli/yaml_dsl.py ADDED
@@ -0,0 +1,832 @@
1
+ import argparse
2
+ import json
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
7
+
8
+ from scalim.dsl.yaml_dsl._internal.config_parsing.error_envelope import ErrorEnvelope, ScalimYamlValidationError
9
+ from scalim.dsl.yaml_dsl._internal.config_parsing.imports import (
10
+ ScalimYamlImportExpansionError,
11
+ contains_import_syntax,
12
+ expand_imports_inplace,
13
+ )
14
+ from scalim.dsl.yaml_dsl._internal.config_parsing.jsonschema_issues import (
15
+ ScalimJsonSchemaCollectorError,
16
+ collect_jsonschema_validation_issues,
17
+ )
18
+ from scalim.dsl.yaml_dsl._internal.config_parsing.unknown_fields import find_unknown_fields
19
+ from scalim.dsl.yaml_dsl._internal.config_parsing.yaml_load import (
20
+ YamlLocationIndex,
21
+ error_loc_for_yaml_path,
22
+ load_yaml_mapping_text,
23
+ )
24
+ from scalim.dsl.yaml_dsl.validation_service import (
25
+ DemandValidationResult,
26
+ ValidationPayload,
27
+ WorkflowValidationResult,
28
+ extract_demand_book_ids,
29
+ find_demand_book_binding_errors,
30
+ find_legacy_field_errors,
31
+ find_removed_outputs_defaults_errors,
32
+ find_retry_enabled_missing_should_retry_errors,
33
+ issues_to_rows,
34
+ validate_demand_text,
35
+ validate_workflow_text,
36
+ )
37
+ from scalim.vendor.compact.importlibx import import_module
38
+
39
+ from . import yaml_dsl_lsp
40
+
41
+ try:
42
+ jsonschema = import_module("jsonschema")
43
+ except ImportError:
44
+ jsonschema = None # type: ignore[assignment]
45
+ _HAS_JSONSCHEMA = False # pyright: ignore[reportConstantRedefinition]
46
+ else:
47
+ _HAS_JSONSCHEMA = True
48
+
49
+
50
+ def register(subparsers: Any) -> None:
51
+ parser = subparsers.add_parser("yaml-dsl", help="YAML DSL utilities")
52
+ _set_help_default(parser)
53
+ yaml_subparsers = parser.add_subparsers(dest="yaml_dsl_command")
54
+
55
+ validate_parser = yaml_subparsers.add_parser("validate", help="Validate YAML DSL via internal validator")
56
+ _add_validate_args(validate_parser)
57
+ validate_parser.set_defaults(func=_run_validate)
58
+
59
+ schema_parser = yaml_subparsers.add_parser("schema", help="Schema helpers")
60
+ _set_help_default(schema_parser)
61
+ schema_subparsers = schema_parser.add_subparsers(dest="yaml_dsl_schema_command")
62
+
63
+ schema_validate_parser = schema_subparsers.add_parser("validate", help="Validate YAML DSL via JSON Schema")
64
+ _add_schema_validate_args(schema_validate_parser)
65
+ schema_validate_parser.set_defaults(func=_run_schema_validate)
66
+
67
+ schema_show_parser = schema_subparsers.add_parser("show", help="Print JSON Schema")
68
+ _ = schema_show_parser.add_argument(
69
+ "--type",
70
+ dest="schema_type",
71
+ type=str,
72
+ default=yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE,
73
+ help="Schema 类型(例如 demand/workflow/scalim_yaml)",
74
+ )
75
+ schema_show_parser.set_defaults(func=_run_schema_show)
76
+
77
+ schema_path_parser = schema_subparsers.add_parser("path", help="Print JSON Schema path")
78
+ _ = schema_path_parser.add_argument(
79
+ "--type",
80
+ dest="schema_type",
81
+ type=str,
82
+ default=yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE,
83
+ help="Schema 类型(例如 demand/workflow/scalim_yaml)",
84
+ )
85
+ schema_path_parser.set_defaults(func=_run_schema_path)
86
+
87
+ upsert_parser = yaml_subparsers.add_parser(
88
+ "upsert-lsp-comment",
89
+ help="Upsert YAML $schema modeline comment (JetBrains/RedHat)",
90
+ )
91
+ _add_upsert_lsp_comment_args(upsert_parser)
92
+ upsert_parser.set_defaults(func=_run_upsert_lsp_comment)
93
+
94
+
95
+ def _set_help_default(parser: argparse.ArgumentParser) -> None:
96
+ def _show_help(_args: argparse.Namespace) -> int:
97
+ parser.print_help()
98
+ return 2
99
+
100
+ parser.set_defaults(func=_show_help)
101
+
102
+
103
+ def _add_validate_args(parser: argparse.ArgumentParser) -> None:
104
+ _ = parser.add_argument("yaml_file", type=Path, help="YAML 文件路径")
105
+ _ = parser.add_argument("--schema", "-s", type=Path, default=None, help="JSON Schema 文件路径")
106
+ _ = parser.add_argument(
107
+ "--type",
108
+ dest="yaml_type",
109
+ type=str,
110
+ choices=["auto", "demand", "workflow"],
111
+ default="auto",
112
+ help="校验类型: auto/demand/workflow",
113
+ )
114
+ _ = parser.add_argument(
115
+ "--path-alias",
116
+ dest="path_aliases",
117
+ type=str,
118
+ action="append",
119
+ default=[],
120
+ help="仅 workflow validate: 需求路径别名,格式 <alias>=<path> (可重复)",
121
+ )
122
+ _ = parser.add_argument(
123
+ "--allowed-yaml-root",
124
+ dest="allowed_yaml_roots",
125
+ type=Path,
126
+ action="append",
127
+ default=None,
128
+ help="允许读取 YAML 的根目录(可重复);默认仅允许入口 YAML 所在目录",
129
+ )
130
+ _ = parser.add_argument("--json", action="store_true", help="输出 JSON 结果")
131
+ _ = parser.add_argument("--verbose", "-v", action="store_true", help="显示详细错误信息")
132
+
133
+
134
+ def _add_schema_validate_args(parser: argparse.ArgumentParser) -> None:
135
+ _ = parser.add_argument("yaml_file", type=Path, help="YAML 文件路径")
136
+ _ = parser.add_argument("--schema", "-s", type=Path, default=None, help="JSON Schema 文件路径")
137
+ _ = parser.add_argument("--json", action="store_true", help="输出 JSON 结果")
138
+ _ = parser.add_argument("--verbose", "-v", action="store_true", help="显示详细错误信息")
139
+
140
+
141
+ def _add_upsert_lsp_comment_args(parser: argparse.ArgumentParser) -> None:
142
+ _ = parser.add_argument(
143
+ "paths",
144
+ type=Path,
145
+ nargs="+",
146
+ help="一个或多个 YAML 文件路径",
147
+ )
148
+ _ = parser.add_argument(
149
+ "--type",
150
+ dest="schema_type",
151
+ type=str,
152
+ default=yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE,
153
+ help="Schema 类型(例如 demand/workflow)",
154
+ )
155
+ _ = parser.add_argument(
156
+ "--schema-path",
157
+ dest="schema_path",
158
+ type=str,
159
+ default=yaml_dsl_lsp.DEFAULT_SCHEMA_PATH,
160
+ help="Schema base URL/dir 或完整 .json URL/path(默认使用内置 schema 目录)",
161
+ )
162
+ _ = parser.add_argument(
163
+ "--comment-style",
164
+ dest="comment_style",
165
+ type=str,
166
+ choices=list(yaml_dsl_lsp.COMMENT_STYLE_CHOICES),
167
+ default=yaml_dsl_lsp.DEFAULT_COMMENT_STYLE,
168
+ help="Schema modeline 风格: all/jetbrains/redhat",
169
+ )
170
+
171
+
172
+ def _default_schema_path() -> Path:
173
+ return yaml_dsl_lsp.schema_dir() / "demand.gen.json"
174
+
175
+
176
+ def _resolve_schema_path(arg: Optional[Path]) -> Path:
177
+ return arg.resolve() if arg is not None else _default_schema_path()
178
+
179
+
180
+ _SCHEMA_TYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
181
+
182
+
183
+ def _schema_path_for_schema_type(schema_type: str) -> Path:
184
+ schema_type = (schema_type or "").strip() or yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE
185
+ if not _SCHEMA_TYPE_PATTERN.match(schema_type):
186
+ msg = "Invalid schema type: {}".format(schema_type)
187
+ raise ValueError(msg)
188
+ return yaml_dsl_lsp.schema_dir() / "{}.gen.json".format(schema_type)
189
+
190
+
191
+ def _load_json_schema(schema_path: Path) -> Dict[str, Any]:
192
+ with schema_path.open("r", encoding="utf-8") as handle:
193
+ return json.load(handle)
194
+
195
+
196
+ def _write_line(text: str) -> None:
197
+ _ = sys.stdout.write(text + "\n")
198
+
199
+
200
+ def _write_line_stderr(text: str) -> None:
201
+ _ = sys.stderr.write(text + "\n")
202
+
203
+
204
+ def _write_raw(text: str) -> None:
205
+ _ = sys.stdout.write(text)
206
+
207
+
208
+ def _display_issue_path(path: str) -> str:
209
+ return path or "(root)"
210
+
211
+
212
+ def _format_issue_location(yaml_path: Path, issue: ErrorEnvelope) -> str:
213
+ if issue.line is None:
214
+ return str(yaml_path)
215
+ if issue.column is not None:
216
+ return "{}:{}:{}".format(yaml_path, issue.line, issue.column)
217
+ return "{}:{}".format(yaml_path, issue.line)
218
+
219
+
220
+ def _emit_source_snippet(issue: ErrorEnvelope, source_lines: Optional[List[str]], *, verbose: bool) -> None:
221
+ if source_lines is None or issue.line is None:
222
+ return
223
+ total_lines = len(source_lines)
224
+ if total_lines == 0:
225
+ return
226
+ line_no = max(1, min(issue.line, total_lines))
227
+ if verbose:
228
+ start = max(1, line_no - 1)
229
+ end = min(total_lines, line_no + 1)
230
+ else:
231
+ start = line_no
232
+ end = line_no
233
+
234
+ _write_line(" |")
235
+ for current in range(start, end + 1):
236
+ text = source_lines[current - 1].rstrip("\n")
237
+ _write_line("{:>4} | {}".format(current, text))
238
+ if current == line_no:
239
+ column = issue.column or 1
240
+ pointer = " " * max(column - 1, 0)
241
+ _write_line(" | {}^".format(pointer))
242
+
243
+
244
+ def _print_linter_result(
245
+ yaml_path: Path,
246
+ *,
247
+ errors: List[ErrorEnvelope],
248
+ warnings: List[ErrorEnvelope],
249
+ verbose: bool,
250
+ source_lines: Optional[List[str]],
251
+ ) -> None:
252
+ if not errors and not warnings:
253
+ _write_line("OK {}".format(yaml_path.name))
254
+ return
255
+
256
+ for issue in errors:
257
+ path_suffix = _display_issue_path(issue.path)
258
+ message = issue.message
259
+ if path_suffix != "(root)":
260
+ message = "{} [{}]".format(message, path_suffix)
261
+ _write_line("ERROR {} --> {}".format(message, _format_issue_location(yaml_path, issue)))
262
+ _emit_source_snippet(issue, source_lines, verbose=verbose)
263
+ if issue.suggestions:
264
+ _write_line("help: {}".format(", ".join(issue.suggestions)))
265
+ _write_line("")
266
+
267
+ for issue in warnings:
268
+ path_suffix = _display_issue_path(issue.path)
269
+ message = issue.message
270
+ if path_suffix != "(root)":
271
+ message = "{} [{}]".format(message, path_suffix)
272
+ _write_line("WARN {} --> {}".format(message, _format_issue_location(yaml_path, issue)))
273
+ _emit_source_snippet(issue, source_lines, verbose=verbose)
274
+ if issue.suggestions:
275
+ _write_line("help: {}".format(", ".join(issue.suggestions)))
276
+ _write_line("")
277
+
278
+ summary_parts: List[str] = []
279
+ if errors:
280
+ summary_parts.append("{} error{}".format(len(errors), "" if len(errors) == 1 else "s"))
281
+ if warnings:
282
+ summary_parts.append("{} warning{}".format(len(warnings), "" if len(warnings) == 1 else "s"))
283
+ summary = ", ".join(summary_parts) if summary_parts else "no issues"
284
+ _write_line("Found {}.".format(summary))
285
+
286
+
287
+ def _render_result(
288
+ yaml_path: Path,
289
+ *,
290
+ errors: List[ErrorEnvelope],
291
+ warnings: List[ErrorEnvelope],
292
+ verbose: bool,
293
+ source_lines: Optional[List[str]],
294
+ ) -> None:
295
+ _print_linter_result(
296
+ yaml_path,
297
+ errors=errors,
298
+ warnings=warnings,
299
+ verbose=verbose,
300
+ source_lines=source_lines,
301
+ )
302
+
303
+
304
+ def _emit_error(
305
+ message: str,
306
+ *,
307
+ json_output: bool,
308
+ yaml_path: Optional[Path] = None,
309
+ schema_path: Optional[Path] = None,
310
+ mode: Optional[str] = None,
311
+ ) -> None:
312
+ if json_output:
313
+ source_path = str(yaml_path) if yaml_path is not None else "(unknown)"
314
+ payload = ValidationPayload(
315
+ mode=mode or "error",
316
+ ok=False,
317
+ yaml_path=str(yaml_path) if yaml_path is not None else None,
318
+ schema_path=str(schema_path) if schema_path is not None else None,
319
+ errors=[
320
+ ErrorEnvelope(
321
+ code="cli_error",
322
+ message=message,
323
+ source_path=source_path,
324
+ path="(root)",
325
+ loc=None,
326
+ )
327
+ ],
328
+ )
329
+ _write_line(json.dumps(payload.as_dict(), ensure_ascii=False))
330
+ return
331
+ _write_line_stderr("错误: {}".format(message))
332
+
333
+
334
+ def _infer_yaml_type(yaml_text: str) -> str:
335
+ try:
336
+ yaml_data, _locations, _lines = load_yaml_mapping_text(
337
+ yaml_text,
338
+ source_path="(memory)",
339
+ detect_duplicate_keys=False,
340
+ )
341
+ except Exception: # noqa: BLE001
342
+ return "demand"
343
+ if "workflow" in yaml_data:
344
+ return "workflow"
345
+ return "demand"
346
+
347
+
348
+ def _parse_path_aliases(raw_values: Iterable[str]) -> Tuple[Optional[Dict[str, str]], Optional[str]]:
349
+ output: Dict[str, str] = {}
350
+ for raw in raw_values:
351
+ item = str(raw or "").strip()
352
+ if not item:
353
+ continue
354
+ if "=" not in item:
355
+ return None, "Invalid --path-alias value: {!r} (expected <alias>=<path>)".format(item)
356
+ alias, base = item.split("=", 1)
357
+ alias = str(alias or "").strip()
358
+ base = str(base or "").strip()
359
+ if not alias:
360
+ return None, "Invalid --path-alias value: {!r} (alias must be non-empty)".format(item)
361
+ if not base:
362
+ return None, "Invalid --path-alias value: {!r} (path must be non-empty)".format(item)
363
+ output[alias] = base
364
+ return output, None
365
+
366
+
367
+ def _run_validate_workflow(
368
+ yaml_path: Path,
369
+ *,
370
+ schema_path: Path,
371
+ path_aliases: Optional[Dict[str, str]],
372
+ allowed_yaml_roots: Optional[List[Path]],
373
+ args: argparse.Namespace,
374
+ ) -> int:
375
+ try:
376
+ workflow_text = yaml_path.read_text(encoding="utf-8")
377
+ except Exception: # noqa: BLE001
378
+ _emit_error(
379
+ "YAML 文件读取失败: {}".format(yaml_path),
380
+ json_output=args.json,
381
+ yaml_path=yaml_path,
382
+ schema_path=schema_path,
383
+ mode="workflow-validate",
384
+ )
385
+ return 1
386
+
387
+ workflow_result: WorkflowValidationResult = validate_workflow_text(
388
+ workflow_text,
389
+ yaml_path=yaml_path,
390
+ schema_path=schema_path,
391
+ path_aliases=path_aliases,
392
+ allowed_yaml_roots=allowed_yaml_roots,
393
+ )
394
+
395
+ if args.json:
396
+ _write_line(json.dumps(workflow_result.payload.as_dict(), ensure_ascii=False))
397
+ else:
398
+ _render_result(
399
+ yaml_path,
400
+ errors=workflow_result.workflow_payload.errors,
401
+ warnings=workflow_result.workflow_payload.warnings,
402
+ verbose=args.verbose,
403
+ source_lines=workflow_result.workflow_source_lines,
404
+ )
405
+ for demand_result in workflow_result.demand_results:
406
+ demand_path_str = demand_result.payload.yaml_path or ""
407
+ demand_path = Path(demand_path_str) if demand_path_str else Path("demand.yaml")
408
+ _render_result(
409
+ demand_path,
410
+ errors=demand_result.payload.errors,
411
+ warnings=demand_result.payload.warnings,
412
+ verbose=args.verbose,
413
+ source_lines=demand_result.source_lines,
414
+ )
415
+
416
+ return 0 if workflow_result.payload.ok else 1
417
+
418
+
419
+ def _run_validate_demand(
420
+ yaml_path: Path,
421
+ *,
422
+ yaml_text: Optional[str],
423
+ schema_path: Path,
424
+ allowed_yaml_roots: Optional[List[Path]],
425
+ args: argparse.Namespace,
426
+ ) -> int:
427
+ try:
428
+ text = str(yaml_text or "") or yaml_path.read_text(encoding="utf-8")
429
+ except Exception: # noqa: BLE001
430
+ _emit_error(
431
+ "YAML 文件读取失败: {}".format(yaml_path),
432
+ json_output=args.json,
433
+ yaml_path=yaml_path,
434
+ schema_path=schema_path,
435
+ mode="validate",
436
+ )
437
+ return 1
438
+
439
+ demand_result: DemandValidationResult = validate_demand_text(
440
+ text,
441
+ yaml_path=yaml_path,
442
+ schema_path=schema_path,
443
+ allowed_yaml_roots=allowed_yaml_roots,
444
+ )
445
+ payload = demand_result.payload
446
+
447
+ if args.json:
448
+ _write_line(json.dumps(payload.as_dict(), ensure_ascii=False))
449
+ else:
450
+ _render_result(
451
+ yaml_path,
452
+ errors=payload.errors,
453
+ warnings=payload.warnings,
454
+ verbose=args.verbose,
455
+ source_lines=demand_result.source_lines,
456
+ )
457
+
458
+ return 0 if payload.ok else 1
459
+
460
+
461
+ def _run_validate(args: argparse.Namespace) -> int:
462
+ yaml_path = args.yaml_file.resolve()
463
+ schema_path = _resolve_schema_path(args.schema)
464
+ args_dict = vars(args)
465
+ yaml_type = str(args_dict.get("yaml_type", "auto") or "auto").strip()
466
+ inferred_yaml_text = ""
467
+ if yaml_type == "auto":
468
+ try:
469
+ inferred_yaml_text = yaml_path.read_text(encoding="utf-8")
470
+ except Exception: # noqa: BLE001
471
+ inferred_yaml_text = ""
472
+ yaml_type = _infer_yaml_type(inferred_yaml_text)
473
+ raw_aliases = list(args_dict.get("path_aliases", []) or [])
474
+ path_aliases, alias_error = _parse_path_aliases(raw_aliases)
475
+ if alias_error is not None:
476
+ _emit_error(alias_error, json_output=bool(args.json), yaml_path=yaml_path, schema_path=schema_path, mode="validate")
477
+ return 1
478
+ raw_allowed_yaml_roots = args_dict.get("allowed_yaml_roots")
479
+ allowed_yaml_roots = list(raw_allowed_yaml_roots) if raw_allowed_yaml_roots else None
480
+
481
+ if not schema_path.exists():
482
+ _emit_error(
483
+ "Schema 文件不存在: {}".format(schema_path),
484
+ json_output=args.json,
485
+ yaml_path=yaml_path,
486
+ schema_path=schema_path,
487
+ mode="validate",
488
+ )
489
+ return 1
490
+
491
+ if yaml_type == "workflow":
492
+ return _run_validate_workflow(
493
+ yaml_path,
494
+ schema_path=schema_path,
495
+ path_aliases=path_aliases,
496
+ allowed_yaml_roots=allowed_yaml_roots,
497
+ args=args,
498
+ )
499
+
500
+ return _run_validate_demand(
501
+ yaml_path,
502
+ yaml_text=inferred_yaml_text,
503
+ schema_path=schema_path,
504
+ allowed_yaml_roots=allowed_yaml_roots,
505
+ args=args,
506
+ )
507
+
508
+
509
+ def _run_schema_validate(args: argparse.Namespace) -> int:
510
+ schema_path = _resolve_schema_path(args.schema)
511
+ yaml_path = args.yaml_file.resolve()
512
+ schema, exit_code = _load_schema_or_error(schema_path, yaml_path=yaml_path, args=args)
513
+ if exit_code != 0 or schema is None:
514
+ return exit_code
515
+
516
+ try:
517
+ yaml_text = yaml_path.read_text(encoding="utf-8")
518
+ except Exception: # noqa: BLE001
519
+ _emit_error(
520
+ "YAML 文件读取失败: {}".format(yaml_path),
521
+ json_output=args.json,
522
+ yaml_path=yaml_path,
523
+ schema_path=schema_path,
524
+ mode="schema-validate",
525
+ )
526
+ return 1
527
+ source_lines: Optional[List[str]] = yaml_text.splitlines()
528
+
529
+ try:
530
+ yaml_data_dict, locations, _lines = load_yaml_mapping_text(
531
+ yaml_text,
532
+ source_path=str(yaml_path),
533
+ detect_duplicate_keys=True,
534
+ )
535
+ except ScalimYamlValidationError as exc:
536
+ return _emit_schema_result(
537
+ yaml_path,
538
+ schema_path,
539
+ list(exc.errors),
540
+ list(exc.warnings),
541
+ args,
542
+ ok=False,
543
+ source_lines=source_lines,
544
+ )
545
+
546
+ jsonschema_module = _get_jsonschema_module(args, yaml_path=yaml_path, schema_path=schema_path)
547
+ if jsonschema_module is None:
548
+ return 1
549
+
550
+ try:
551
+ if contains_import_syntax(yaml_data_dict) and contains_import_syntax(schema):
552
+ _ = expand_imports_inplace(yaml_data_dict, yaml_path=yaml_path)
553
+ except ScalimYamlImportExpansionError as exc:
554
+ logical_path = str(exc.logical_path or "(root)")
555
+ errors = [
556
+ ErrorEnvelope(
557
+ code="yaml_import_expansion_error",
558
+ message=str(exc),
559
+ source_path=str(yaml_path),
560
+ path=logical_path,
561
+ loc=error_loc_for_yaml_path(logical_path, locations),
562
+ )
563
+ ]
564
+ return _emit_schema_result(yaml_path, schema_path, errors, [], args, ok=False, source_lines=source_lines)
565
+
566
+ errors, warnings = _collect_schema_issues(
567
+ yaml_data_dict,
568
+ schema,
569
+ args,
570
+ jsonschema_module,
571
+ source_path=str(yaml_path),
572
+ locations=locations,
573
+ )
574
+ ok = not errors
575
+
576
+ return _emit_schema_result(
577
+ yaml_path,
578
+ schema_path,
579
+ errors,
580
+ warnings,
581
+ args,
582
+ ok=ok,
583
+ source_lines=source_lines,
584
+ )
585
+
586
+
587
+ def _load_schema_or_error(
588
+ schema_path: Path,
589
+ *,
590
+ yaml_path: Path,
591
+ args: argparse.Namespace,
592
+ ) -> Tuple[Optional[Dict[str, Any]], int]:
593
+ if not schema_path.exists():
594
+ _emit_error(
595
+ "Schema 文件不存在: {}".format(schema_path),
596
+ json_output=args.json,
597
+ yaml_path=yaml_path,
598
+ schema_path=schema_path,
599
+ mode="schema-validate",
600
+ )
601
+ return None, 1
602
+
603
+ try:
604
+ return _load_json_schema(schema_path), 0
605
+ except json.JSONDecodeError:
606
+ _emit_error(
607
+ "Schema JSON 无法解析: {}".format(schema_path),
608
+ json_output=args.json,
609
+ yaml_path=yaml_path,
610
+ schema_path=schema_path,
611
+ mode="schema-validate",
612
+ )
613
+ return None, 1
614
+
615
+
616
+ def _get_jsonschema_module(
617
+ args: argparse.Namespace,
618
+ *,
619
+ yaml_path: Path,
620
+ schema_path: Path,
621
+ ) -> Optional[Any]:
622
+ if _HAS_JSONSCHEMA and jsonschema is not None:
623
+ return jsonschema
624
+ _emit_error(
625
+ "缺少 jsonschema 依赖,请安装 scalim-cli",
626
+ json_output=args.json,
627
+ yaml_path=yaml_path,
628
+ schema_path=schema_path,
629
+ mode="schema-validate",
630
+ )
631
+ return None
632
+
633
+
634
+ def _collect_schema_issues(
635
+ yaml_data: Dict[str, Any],
636
+ schema: Dict[str, Any],
637
+ args: argparse.Namespace,
638
+ jsonschema_module: Any,
639
+ *,
640
+ source_path: str,
641
+ locations: YamlLocationIndex,
642
+ ) -> Tuple[List[ErrorEnvelope], List[ErrorEnvelope]]:
643
+ try:
644
+ issues = collect_jsonschema_validation_issues(
645
+ yaml_data,
646
+ schema,
647
+ jsonschema_module=jsonschema_module,
648
+ include_context=bool(args.verbose),
649
+ filter_additional_properties=True,
650
+ )
651
+ except ScalimJsonSchemaCollectorError as exc:
652
+ errors = [
653
+ ErrorEnvelope(
654
+ code="yaml_schema_validate_error",
655
+ message=str(exc),
656
+ source_path=source_path,
657
+ path="(root)",
658
+ loc=error_loc_for_yaml_path("(root)", locations),
659
+ )
660
+ ]
661
+ return errors, []
662
+
663
+ errors = issues_to_rows(
664
+ issues,
665
+ source_path=source_path,
666
+ locations=locations,
667
+ default_code="yaml_schema_validate_error",
668
+ )
669
+ errors.extend(
670
+ find_legacy_field_errors(
671
+ yaml_data,
672
+ source_path=source_path,
673
+ locations=locations,
674
+ )
675
+ )
676
+ errors.extend(
677
+ issues_to_rows(
678
+ find_unknown_fields(yaml_data, schema),
679
+ source_path=source_path,
680
+ locations=locations,
681
+ default_code="yaml_unknown_field",
682
+ )
683
+ )
684
+ demand_book_ids = extract_demand_book_ids(yaml_data)
685
+ errors.extend(
686
+ find_removed_outputs_defaults_errors(
687
+ yaml_data,
688
+ source_path=source_path,
689
+ locations=locations,
690
+ default_code="yaml_schema_validate_error",
691
+ )
692
+ )
693
+ errors.extend(
694
+ find_demand_book_binding_errors(
695
+ yaml_data,
696
+ source_path=source_path,
697
+ locations=locations,
698
+ available_book_ids=demand_book_ids,
699
+ default_code="yaml_schema_validate_error",
700
+ )
701
+ )
702
+ errors.extend(
703
+ find_retry_enabled_missing_should_retry_errors(
704
+ yaml_data,
705
+ source_path=source_path,
706
+ locations=locations,
707
+ default_code="yaml_schema_validate_error",
708
+ )
709
+ )
710
+ return errors, []
711
+
712
+
713
+ def _emit_schema_result(
714
+ yaml_path: Path,
715
+ schema_path: Path,
716
+ errors: List[ErrorEnvelope],
717
+ warnings: List[ErrorEnvelope],
718
+ args: argparse.Namespace,
719
+ *,
720
+ ok: bool,
721
+ source_lines: Optional[List[str]],
722
+ ) -> int:
723
+ if args.json:
724
+ payload = ValidationPayload(
725
+ mode="schema-validate",
726
+ ok=ok,
727
+ yaml_path=str(yaml_path),
728
+ schema_path=str(schema_path),
729
+ errors=errors,
730
+ warnings=warnings,
731
+ )
732
+ _write_line(json.dumps(payload.as_dict(), ensure_ascii=False))
733
+ else:
734
+ _render_result(
735
+ yaml_path,
736
+ errors=errors,
737
+ warnings=warnings,
738
+ verbose=args.verbose,
739
+ source_lines=source_lines,
740
+ )
741
+ return 0 if ok else 1
742
+
743
+
744
+ def _run_schema_show(args: argparse.Namespace) -> int:
745
+ args_dict = vars(args)
746
+ schema_type = str(args_dict.get("schema_type", yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE) or yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE)
747
+ try:
748
+ schema_path = _schema_path_for_schema_type(schema_type)
749
+ except ValueError as exc:
750
+ _emit_error(str(exc), json_output=False)
751
+ return 1
752
+ if not schema_path.exists():
753
+ _emit_error("Schema 文件不存在: {}".format(schema_path), json_output=False)
754
+ return 1
755
+ _write_raw(schema_path.read_text(encoding="utf-8"))
756
+ return 0
757
+
758
+
759
+ def _run_schema_path(args: argparse.Namespace) -> int:
760
+ args_dict = vars(args)
761
+ schema_type = str(args_dict.get("schema_type", yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE) or yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE)
762
+ try:
763
+ schema_path = _schema_path_for_schema_type(schema_type)
764
+ except ValueError as exc:
765
+ _emit_error(str(exc), json_output=False)
766
+ return 1
767
+ if not schema_path.exists():
768
+ _emit_error("Schema 文件不存在: {}".format(schema_path), json_output=False)
769
+ return 1
770
+ _write_line(str(schema_path))
771
+ return 0
772
+
773
+
774
+ def _run_upsert_lsp_comment(args: argparse.Namespace) -> int:
775
+ args_dict = vars(args)
776
+ schema_type = str(args_dict.get("schema_type", yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE) or yaml_dsl_lsp.DEFAULT_SCHEMA_TYPE)
777
+ schema_path = str(args_dict.get("schema_path", yaml_dsl_lsp.DEFAULT_SCHEMA_PATH) or yaml_dsl_lsp.DEFAULT_SCHEMA_PATH)
778
+ comment_style = str(args_dict.get("comment_style", yaml_dsl_lsp.DEFAULT_COMMENT_STYLE) or yaml_dsl_lsp.DEFAULT_COMMENT_STYLE).strip()
779
+
780
+ try:
781
+ schema_ref = yaml_dsl_lsp.resolve_schema_ref(schema_type, schema_path)
782
+ except ValueError as exc:
783
+ _emit_error(str(exc), json_output=False)
784
+ return 1
785
+
786
+ try:
787
+ schema_modelines = yaml_dsl_lsp.make_schema_modelines(schema_ref, comment_style=comment_style)
788
+ except ValueError as exc:
789
+ _emit_error(str(exc), json_output=False)
790
+ return 1
791
+
792
+ exit_code = 0
793
+ changed: List[Path] = []
794
+ unchanged: List[Path] = []
795
+
796
+ paths = list(args_dict.get("paths", []) or [])
797
+ for raw_path in paths:
798
+ path = raw_path
799
+ if not path.exists():
800
+ _write_line_stderr("错误: YAML 文件不存在: {}".format(path))
801
+ exit_code = 1
802
+ continue
803
+ if not path.is_file():
804
+ _write_line_stderr("错误: 不是文件: {}".format(path))
805
+ exit_code = 1
806
+ continue
807
+
808
+ result = yaml_dsl_lsp.upsert_schema_modelines_file(path, schema_modelines=schema_modelines)
809
+ if result.error:
810
+ _write_line_stderr("错误: {} ({})".format(result.error, path))
811
+ exit_code = 1
812
+ continue
813
+ if result.changed:
814
+ changed.append(path)
815
+ _write_line("UPDATED {}".format(path))
816
+ else:
817
+ unchanged.append(path)
818
+ _write_line("OK {}".format(path))
819
+
820
+ if changed or unchanged:
821
+ _write_line("")
822
+ _write_line(
823
+ "Summary: {} updated, {} ok".format(
824
+ len(changed),
825
+ len(unchanged),
826
+ )
827
+ )
828
+
829
+ return exit_code
830
+
831
+
832
+ __all__ = ()
@@ -0,0 +1,239 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from io import StringIO
4
+ from pathlib import Path
5
+ from typing import List, Optional, Sequence, Tuple
6
+
7
+ import scalim
8
+ from scalim.vendor.yamlx.ruamel.yaml import YAML
9
+
10
+ DEFAULT_SCHEMA_TYPE = "demand"
11
+ DEFAULT_MAX_SCAN_LINES = 10
12
+
13
+ _SCHEMA_TYPE_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
14
+
15
+ _INTELLIJ_SCHEMA_PATTERN = re.compile(r"^\s*#\s*\$schema\s*:\s*(?P<ref>.*)\s*$")
16
+ _YAML_LANGUAGE_SERVER_SCHEMA_PATTERN = re.compile(r"^\s*#\s*yaml-language-server\s*:\s*\$schema\s*=\s*(?P<ref>.*)\s*$")
17
+
18
+ COMMENT_STYLE_ALL = "all"
19
+ COMMENT_STYLE_JETBRAINS = "jetbrains"
20
+ COMMENT_STYLE_REDHAT = "redhat"
21
+
22
+ COMMENT_STYLE_CHOICES = (
23
+ COMMENT_STYLE_ALL,
24
+ COMMENT_STYLE_JETBRAINS,
25
+ COMMENT_STYLE_REDHAT,
26
+ )
27
+ DEFAULT_COMMENT_STYLE = COMMENT_STYLE_ALL
28
+
29
+
30
+ def schema_dir() -> Path:
31
+ return Path(scalim.__file__).resolve().parent / "dsl" / "yaml_dsl" / "schema"
32
+
33
+
34
+ DEFAULT_SCHEMA_PATH = str(schema_dir())
35
+
36
+
37
+ def resolve_schema_ref(schema_type: str, schema_path: str) -> str:
38
+ schema_type = (schema_type or "").strip() or DEFAULT_SCHEMA_TYPE
39
+ if not _SCHEMA_TYPE_PATTERN.match(schema_type):
40
+ msg = "Invalid schema type: {}".format(schema_type)
41
+ raise ValueError(msg)
42
+
43
+ schema_path = (schema_path or "").strip() or DEFAULT_SCHEMA_PATH
44
+ schema_filename = "{}.gen.json".format(schema_type)
45
+
46
+ if schema_path.endswith(".json"):
47
+ return schema_path
48
+
49
+ if schema_path.startswith(("http://", "https://")):
50
+ base_url = schema_path.rstrip("/")
51
+ return "{}/{}".format(base_url, schema_filename)
52
+
53
+ base_dir = Path(schema_path)
54
+ return str(base_dir / schema_filename)
55
+
56
+
57
+ def make_intellij_schema_modeline(schema_ref: str) -> str:
58
+ return "# $schema: {}".format(schema_ref)
59
+
60
+
61
+ def make_yaml_language_server_schema_modeline(schema_ref: str) -> str:
62
+ return "# yaml-language-server: $schema={}".format(schema_ref)
63
+
64
+
65
+ def make_schema_modelines(schema_ref: str, *, comment_style: str) -> List[str]:
66
+ comment_style = (comment_style or "").strip() or DEFAULT_COMMENT_STYLE
67
+
68
+ if comment_style == COMMENT_STYLE_ALL:
69
+ return [
70
+ make_yaml_language_server_schema_modeline(schema_ref),
71
+ make_intellij_schema_modeline(schema_ref),
72
+ ]
73
+ if comment_style == COMMENT_STYLE_JETBRAINS:
74
+ return [make_intellij_schema_modeline(schema_ref)]
75
+ if comment_style == COMMENT_STYLE_REDHAT:
76
+ return [make_yaml_language_server_schema_modeline(schema_ref)]
77
+
78
+ msg = "Invalid comment style: {} (expected one of: {})".format(comment_style, ", ".join(COMMENT_STYLE_CHOICES))
79
+ raise ValueError(msg)
80
+
81
+
82
+ def _is_schema_modeline(line: str) -> bool:
83
+ return bool(_INTELLIJ_SCHEMA_PATTERN.match(line) or _YAML_LANGUAGE_SERVER_SCHEMA_PATTERN.match(line))
84
+
85
+
86
+ def _roundtrip_noop_text(text: str) -> str:
87
+ """使用仓库内置的 `ruamel.yaml` `rt` `round-trip`,对文本执行 `load`→`dump` 的 `no-op` 字节级幂等门禁。"""
88
+ newline = "\r\n" if "\r\n" in text else "\n"
89
+
90
+ yaml_rt = YAML(typ="rt")
91
+ yaml_rt.line_break = newline # pyright: ignore[reportAttributeAccessIssue] # pragma: allow-dynattr third-party: ruamel YAML config
92
+ yaml_rt.preserve_quotes = True
93
+ yaml_rt.width = 4096
94
+ yaml_rt.indent(mapping=2, sequence=4, offset=2)
95
+
96
+ first_nonempty = ""
97
+ for line in text.splitlines():
98
+ if line.strip():
99
+ first_nonempty = line
100
+ break
101
+ yaml_rt.explicit_start = first_nonempty.strip() == "---"
102
+
103
+ data = yaml_rt.load(text)
104
+ buf = StringIO()
105
+ yaml_rt.dump(data, buf)
106
+ return buf.getvalue()
107
+
108
+
109
+ def _strip_schema_modelines(lines: Sequence[str], *, max_scan_lines: int) -> List[str]:
110
+ scan_limit = min(len(lines), int(max_scan_lines))
111
+ indices = [idx for idx in range(scan_limit) if _is_schema_modeline(lines[idx])]
112
+ if not indices:
113
+ return list(lines)
114
+
115
+ start = min(indices)
116
+ end = max(indices)
117
+ if end + 1 < len(lines) and not str(lines[end + 1]).strip():
118
+ end += 1
119
+ return [*lines[:start], *lines[end + 1 :]]
120
+
121
+
122
+ def _roundtrip_noop_gate_error(
123
+ text: str,
124
+ *,
125
+ failure_prefix: str,
126
+ mismatch_message: str,
127
+ ) -> Optional[str]:
128
+ try:
129
+ dumped = _roundtrip_noop_text(text)
130
+ except Exception as exc: # noqa: BLE001
131
+ return "{}: {}: {}".format(failure_prefix, type(exc).__name__, exc)
132
+ if dumped != text:
133
+ return mismatch_message
134
+ return None
135
+
136
+
137
+ def _minimal_edit_gate_error(old_text: str, new_text: str, *, max_scan_lines: int) -> Optional[str]:
138
+ old_lines = old_text.splitlines()
139
+ new_lines = new_text.splitlines()
140
+ if _strip_schema_modelines(old_lines, max_scan_lines=max_scan_lines) != _strip_schema_modelines(
141
+ new_lines, max_scan_lines=max_scan_lines
142
+ ):
143
+ return "Refusing to edit: upsert would modify YAML body beyond modeline header"
144
+ return None
145
+
146
+
147
+ def upsert_schema_modelines_text(
148
+ text: str,
149
+ *,
150
+ schema_modelines: Sequence[str],
151
+ max_scan_lines: int = DEFAULT_MAX_SCAN_LINES,
152
+ ) -> Tuple[str, bool]:
153
+ newline = "\r\n" if "\r\n" in text else "\n"
154
+ ends_with_newline = text.endswith("\n")
155
+
156
+ lines = text.splitlines()
157
+ scan_limit = min(len(lines), int(max_scan_lines))
158
+
159
+ cleaned_lines: List[str] = []
160
+ for idx, line in enumerate(lines):
161
+ if idx < scan_limit and _is_schema_modeline(line):
162
+ continue
163
+ cleaned_lines.append(line)
164
+
165
+ insert_at = 0
166
+ cleaned_scan_limit = min(len(cleaned_lines), int(max_scan_lines))
167
+ for idx in range(cleaned_scan_limit):
168
+ if cleaned_lines[idx].strip() != "---":
169
+ continue
170
+ prefix = cleaned_lines[:idx]
171
+ prefix_nonempty = [line for line in prefix if str(line).strip()]
172
+ prefix_noncomment = [line for line in prefix_nonempty if not str(line).lstrip().startswith("#")]
173
+ if not prefix_noncomment:
174
+ insert_at = idx + 1
175
+ break
176
+
177
+ new_lines: List[str] = list(cleaned_lines)
178
+ new_lines[insert_at:insert_at] = list(schema_modelines)
179
+
180
+ sep_idx = insert_at + len(schema_modelines)
181
+ if sep_idx < len(new_lines) and str(new_lines[sep_idx]).strip():
182
+ new_lines.insert(sep_idx, "")
183
+
184
+ new_text = newline.join(new_lines)
185
+ if ends_with_newline and not new_text.endswith(newline):
186
+ new_text += newline
187
+
188
+ return new_text, new_text != text
189
+
190
+
191
+ @dataclass
192
+ class UpsertResult:
193
+ path: Path
194
+ changed: bool
195
+ error: Optional[str] = None
196
+
197
+
198
+ def upsert_schema_modelines_file(
199
+ path: Path,
200
+ *,
201
+ schema_modelines: Sequence[str],
202
+ max_scan_lines: int = DEFAULT_MAX_SCAN_LINES,
203
+ ) -> UpsertResult:
204
+ try:
205
+ text = path.read_text(encoding="utf-8")
206
+ except Exception as exc: # noqa: BLE001
207
+ return UpsertResult(path=path, changed=False, error="Failed to read: {}".format(exc))
208
+
209
+ error = _roundtrip_noop_gate_error(
210
+ text,
211
+ failure_prefix="Round-trip no-op failed",
212
+ mismatch_message="Refusing to edit: YAML round-trip no-op is not byte-idempotent for this file",
213
+ )
214
+ if error is not None:
215
+ return UpsertResult(path=path, changed=False, error=error)
216
+
217
+ new_text, changed = upsert_schema_modelines_text(text, schema_modelines=schema_modelines, max_scan_lines=max_scan_lines)
218
+ if changed:
219
+ error = _minimal_edit_gate_error(text, new_text, max_scan_lines=max_scan_lines)
220
+ if error is not None:
221
+ return UpsertResult(path=path, changed=False, error=error)
222
+
223
+ error = _roundtrip_noop_gate_error(
224
+ new_text,
225
+ failure_prefix="Round-trip no-op failed after upsert",
226
+ mismatch_message="Refusing to edit: post-upsert YAML round-trip no-op is not byte-idempotent",
227
+ )
228
+ if error is not None:
229
+ return UpsertResult(path=path, changed=False, error=error)
230
+
231
+ try:
232
+ _ = path.write_text(new_text, encoding="utf-8")
233
+ except Exception as exc: # noqa: BLE001
234
+ return UpsertResult(path=path, changed=False, error="Failed to write: {}".format(exc))
235
+
236
+ return UpsertResult(path=path, changed=changed)
237
+
238
+
239
+ __all__ = ()
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: scalim-cli
3
+ Version: 0.8.3
4
+ Summary: Command-line tools for scalim
5
+ Author-email: straydragon <straydragonl@foxmail.com>
6
+ Classifier: Programming Language :: Python :: 3 :: Only
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: jsonschema>=4.0.0
14
+ Requires-Dist: scalim
15
+ Description-Content-Type: text/markdown
16
+
17
+ # scalim-cli
18
+
19
+ Standalone command-line utilities for `scalim` (dev/tooling only).
20
+
21
+ Install:
22
+
23
+ ```bash
24
+ uv tool install scalim-cli
25
+
26
+ # Run once without installing:
27
+ uvx scalim-cli --help
28
+ ```
29
+
30
+ Usage:
31
+
32
+ ```bash
33
+ scalim-cli --help
34
+ scalim-cli yaml-dsl --help
35
+ ```
@@ -0,0 +1,9 @@
1
+ scalim_cli/__init__.py,sha256=sAbzTWYAsclBU6OIRjc0ZUY7IjFHm24kKpG4x6sT3TQ,54
2
+ scalim_cli/log.py,sha256=vYVqtxeTG7C9TsvngYlJzbzQrSDEzG6mqgCdn2nmP54,7040
3
+ scalim_cli/main.py,sha256=WLkMK2cayo_lVcKJ4HwjVZqX7WHh6XP34NNr9ghyfCM,763
4
+ scalim_cli/yaml_dsl.py,sha256=vus8Wx-A1rLnwrlR-5r4AUJPhM8z8HYBaNzzINR65-Y,27035
5
+ scalim_cli/yaml_dsl_lsp.py,sha256=XrR909RrDc8-95iHGDlyN2jRu9k_HOEFJGSGPHkYMpE,8029
6
+ scalim_cli-0.8.3.dist-info/METADATA,sha256=wYLWc3wId2JNlhrbW0BGaNUAN-6rZRqEWuJ4LWAc6cw,828
7
+ scalim_cli-0.8.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ scalim_cli-0.8.3.dist-info/entry_points.txt,sha256=v-x5UgpO4CVXVFqogtmUoN-j_b-q6-0NZL3MsiSh8eM,52
9
+ scalim_cli-0.8.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scalim-cli = scalim_cli.main:main