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 +3 -0
- scalim_cli/log.py +199 -0
- scalim_cli/main.py +31 -0
- scalim_cli/yaml_dsl.py +832 -0
- scalim_cli/yaml_dsl_lsp.py +239 -0
- scalim_cli-0.8.3.dist-info/METADATA +35 -0
- scalim_cli-0.8.3.dist-info/RECORD +9 -0
- scalim_cli-0.8.3.dist-info/WHEEL +4 -0
- scalim_cli-0.8.3.dist-info/entry_points.txt +2 -0
scalim_cli/__init__.py
ADDED
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,,
|