Skill Tracer
Skills › Scientific Agent Skills › matlab › plan_batch_command.py

plan_batch_command.py

271 lines · skills/matlab/scripts/plan_batch_command.py at 5f2bc03

1#!/usr/bin/env python3
2"""Create a bounded MATLAB or Octave command plan without executing it."""
3
4from __future__ import annotations
5
6import argparse
7import math
8import re
9import sys
10from pathlib import Path
11from typing import Any
13from _common import (
14 CliError,
15 checked_input,
16 checked_root,
17 emit_json,
18 fail_json,
19 parse_json_text,
20 relative_id,
21 validate_identifier,
24TOOL = "plan_batch_command"
25SAFE_COMMAND = re.compile(r"^[A-Za-z0-9_.+-]{1,64}$")
28def matlab_string(value: str, *, engine: str = "matlab") -> str:
29 if len(value) > 100_000:
30 raise CliError("MATLAB string literal exceeds 100000 characters")
31 if any(ord(character) < 32 for character in value):
32 raise CliError("control characters are not accepted in MATLAB strings")
33 if engine == "octave":
34 # Octave interprets backslash escapes inside double quotes. Its single
35 # quotes preserve Windows paths and other literal backslashes.
36 return "'" + value.replace("'", "''") + "'"
37 return '"' + value.replace('"', '""') + '"'
40def _numeric_row(values: list[Any]) -> str | None:
41 if not values or not all(
42 isinstance(item, (bool, int, float)) and not isinstance(item, str)
43 for item in values
44 ):
45 return None
46 return "[" + " ".join(matlab_literal(item) for item in values) + "]"
49def matlab_literal(value: Any, *, depth: int = 0, engine: str = "matlab") -> str:
50 if depth > 12:
51 raise CliError("argument nesting exceeds 12")
52 if value is None:
53 return "[]"
54 if isinstance(value, bool):
55 return "true" if value else "false"
56 if isinstance(value, int):
57 if abs(value) > 2**53:
58 raise CliError("JSON integers outside exact MATLAB double range are refused")
59 return str(value)
60 if isinstance(value, float):
61 if not math.isfinite(value):
62 raise CliError("nonfinite JSON numbers are refused")
63 return format(value, ".17g")
64 if isinstance(value, str):
65 return matlab_string(value, engine=engine)
66 if isinstance(value, list):
67 row = _numeric_row(value)
68 if row is not None:
69 return row
70 if value and all(isinstance(item, list) for item in value):
71 rows = [_numeric_row(item) for item in value]
72 if all(row_value is not None for row_value in rows):
73 widths = {len(item) for item in value}
74 if len(widths) == 1:
75 return "[" + "; ".join(
76 row_value[1:-1] for row_value in rows if row_value
77 ) + "]"
78 if value and all(isinstance(item, str) for item in value):
79 if engine == "matlab":
80 return "[" + " ".join(matlab_string(item) for item in value) + "]"
81 return "{" + ", ".join(
82 matlab_string(item, engine=engine) for item in value
83 ) + "}"
84 return "{" + ", ".join(
85 matlab_literal(item, depth=depth + 1, engine=engine) for item in value
86 ) + "}"
87 if isinstance(value, dict):
88 fields: list[str] = []
89 for key in sorted(value):
90 validate_identifier(key, name="JSON object field")
91 fields.extend(
92 [
93 matlab_string(key, engine=engine),
94 # A scalar outer cell prevents struct() from expanding a
95 # cell-valued field into a struct array (or empty struct).
96 "{" + matlab_literal(
97 value[key], depth=depth + 1, engine=engine
98 ) + "}",
99 ]
100 )
101 return "struct(" + ", ".join(fields) + ")"
102 raise CliError(f"unsupported argument type: {type(value).__name__}")
105def _executable(value: str, engine: str) -> str:
106 default = "matlab" if engine == "matlab" else "octave"
107 command = value or default
108 if not SAFE_COMMAND.fullmatch(command):
109 raise CliError(
110 "executable must be a bare command name; edit the reviewed argv "
111 "manually for an absolute installation path"
112 )
113 return command
116def build_plan(args: argparse.Namespace) -> dict[str, Any]:
117 root = checked_root(args.root)
118 target = checked_input(
119 args.target,
120 root=root,
121 suffixes={".m"},
122 max_bytes=args.max_input_bytes,
123 )
124 executable = _executable(args.executable, args.engine)
125 function_name = args.function_name or target.stem
126 validate_identifier(function_name, name="function name")
127 if args.mode == "function" and function_name != target.stem:
128 raise CliError("function name must match the target .m filename")
129 if args.arg_json and args.mode != "function":
130 raise CliError("--arg-json is accepted only in function mode")
131 literals = [
132 matlab_literal(parse_json_text(argument), engine=args.engine)
133 for argument in args.arg_json
134 ]
135 target_literal = matlab_string(str(target), engine=args.engine)
136 warnings = [
137 "This plan does not execute or prove the target safe.",
138 "Confirm the exact runtime, products, licenses, startup behavior, "
139 "inputs, outputs, and external effects before execution.",
140 ]
141 argv: list[str]
142 statement: str | None
143 if args.engine == "matlab":
144 argv = [executable]
145 if args.disable_graphics:
146 argv.append("-noFigureWindows")
147 startup = target.parent if args.mode == "function" else root
148 argv.extend(["-sd", str(startup)])
149 if args.mode == "script":
150 statement = f"run({target_literal})"
151 elif args.mode == "function":
152 statement = f"{function_name}({', '.join(literals)})"
153 else:
154 statement = (
155 f"results = runtests({target_literal}); assertSuccess(results)"
156 )
157 warnings.append(
158 "R2026a runtests can automatically open and close a containing "
159 "project, including reviewed startup/shutdown actions."
160 )
161 argv.extend(["-batch", statement])
162 warnings.append(
163 "MATLAB -batch still processes launcher/startup configuration; "
164 "-sd is not isolation."
165 )
166 prerequisites = [
167 "Installed MATLAB compatible with the reviewed source",
168 "Confirmed MATLAB and required-product license availability",
169 ]
170 else:
171 argv = [
172 executable,
173 "--no-init-all",
174 "--no-history",
175 "--quiet",
176 "--no-gui",
177 ]
178 if args.disable_graphics:
179 argv.append("--no-window-system")
180 statement = None
181 if args.mode == "script":
182 argv.append(str(target))
183 elif args.mode == "function":
184 statement = f"{function_name}({', '.join(literals)})"
185 argv.extend(["--path", str(target.parent), "--eval", statement])
186 else:
187 statement = (
188 f"success = test({target_literal}, "
189 f'{matlab_string("quiet", engine="octave")}); assert(success)'
190 )
191 argv.extend(["--eval", statement])
192 warnings.append(
193 "Octave BIST test is not MATLAB matlab.unittest compatibility."
194 )
195 prerequisites = [
196 "Installed GNU Octave compatible with the reviewed source",
197 "Confirmed required Octave package availability",
198 ]
199 return {
200 "arguments": {
201 "count": len(literals),
202 "json_arrays": "numeric rectangular arrays map to numeric arrays; "
203 "string lists map to MATLAB string arrays or Octave cell arrays; "
204 "other arrays map to cells; JSON objects map to scalar structs",
205 },
206 "command_argv": argv,
207 "disable_graphics": bool(args.disable_graphics),
208 "engine": args.engine,
209 "executes": False,
210 "mode": args.mode,
211 "network_accessed": False,
212 "ok": True,
213 "prerequisites": prerequisites,
214 "root": str(root),
215 "statement": statement,
216 "target": relative_id(target, root),
217 "tool": TOOL,
218 "warnings": warnings,
219 }
222def parser() -> argparse.ArgumentParser:
223 result = argparse.ArgumentParser(
224 description=(
225 "Plan a MATLAB -batch or GNU Octave command. The tool validates "
226 "local paths and JSON literals but never launches either runtime."
227 )
228 )
229 result.add_argument("engine", choices=("matlab", "octave"))
230 result.add_argument("mode", choices=("script", "function", "tests"))
231 result.add_argument("target", help="reviewed local .m file")
232 result.add_argument("--root", default=".", help="allowed local root")
233 result.add_argument(
234 "--arg-json",
235 action="append",
236 default=[],
237 help="one bounded JSON function argument; repeat as needed",
238 )
239 result.add_argument("--function-name", help="must match target stem")
240 result.add_argument(
241 "--executable",
242 default="",
243 help="bare command name only (default: matlab or octave)",
244 )
245 result.add_argument(
246 "--disable-graphics",
247 action="store_true",
248 help="plan no figure windows/window system",
249 )
250 result.add_argument(
251 "--max-input-bytes",
252 type=int,
253 default=4 * 1024 * 1024,
254 help="maximum target .m bytes (default: 4194304)",
255 )
256 return result
259def main() -> int:
260 try:
261 args = parser().parse_args()
262 if args.max_input_bytes < 1 or args.max_input_bytes > 64 * 1024 * 1024:
263 raise CliError("--max-input-bytes must be between 1 and 67108864")
264 emit_json(build_plan(args))
265 return 0
266 except CliError as exc:
267 return fail_json(TOOL, exc)
270if __name__ == "__main__":
271 sys.exit(main())