Files
2026-07-13 12:55:23 +08:00

613 lines
21 KiB
Python

"""cli-anything-eez-studio: CLI harness for EEZ Studio.
The CLI edits native .eez-project JSON and calls the real EEZ Studio source
backend for build/export workflows when configured.
"""
from __future__ import annotations
import json
import os
import shlex
import sys
from typing import Any
import click
from cli_anything.eez_studio import __version__
from cli_anything.eez_studio.core import export as export_mod
from cli_anything.eez_studio.core import project as project_mod
from cli_anything.eez_studio.core import scpi as scpi_mod
from cli_anything.eez_studio.core.session import Session
from cli_anything.eez_studio.utils import eez_studio_backend
from cli_anything.eez_studio.utils.repl_skin import ReplSkin
_session: Session | None = None
_json_output = False
_repl_mode = False
def get_session() -> Session:
global _session
if _session is None:
_session = Session()
return _session
def _same_project_path(session: Session, project_path: str) -> bool:
return session.project_path == os.path.abspath(project_path)
def _configure_session(session_id: str | None, project_path: str | None) -> None:
global _session
if _repl_mode:
session = get_session()
if session_id and session.session_id != session_id:
session.session_id = session_id
if project_path and not _same_project_path(session, project_path):
session.open_project(project_path)
return
_session = Session(session_id)
if project_path:
_session.open_project(project_path)
def output(data: Any, message: str = "") -> None:
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
return
if message:
click.echo(message)
_pretty(data)
def _pretty(data: Any, indent: int = 0) -> None:
prefix = " " * indent
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)):
click.echo(f"{prefix}{key}:")
_pretty(value, indent + 1)
else:
click.echo(f"{prefix}{key}: {value}")
elif isinstance(data, list):
for index, item in enumerate(data):
if isinstance(item, dict):
click.echo(f"{prefix}[{index}]")
_pretty(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
else:
click.echo(f"{prefix}{data}")
def _emit_error(exc: Exception) -> None:
if _json_output:
click.echo(
json.dumps({"error": str(exc), "type": type(exc).__name__}, indent=2),
err=True,
)
else:
click.echo(f"Error: {exc}", err=True)
def handle_error(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (FileNotFoundError, FileExistsError, ValueError, RuntimeError, OSError) as exc:
_emit_error(exc)
if not _repl_mode:
sys.exit(1)
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
def require_project() -> Session:
session = get_session()
if session.project is None:
raise RuntimeError("no project is open; use --project PATH or `project new -o PATH`")
return session
def _mark_changed(session: Session) -> None:
session._modified = True
@click.group(invoke_without_command=True)
@click.option("--json", "json_mode", is_flag=True, help="Output machine-readable JSON.")
@click.option("--project", "-p", "project_path", default=None, help="Open a .eez-project file.")
@click.option("--session", "session_id", default=None, help="Session ID to use.")
@click.option("--dry-run", is_flag=True, help="Do not auto-save modified project files.")
@click.pass_context
def cli(ctx: click.Context, json_mode: bool, project_path: str | None, session_id: str | None, dry_run: bool) -> None:
"""EEZ Studio CLI harness for project, LVGL, and SCPI workflows.
Run without a subcommand to enter the interactive REPL.
"""
global _json_output
_json_output = json_mode
_configure_session(session_id, project_path)
ctx.ensure_object(dict)
ctx.obj["project_path"] = project_path
ctx.obj["json"] = json_mode
ctx.obj["dry_run"] = dry_run
@ctx.call_on_close
def _auto_save() -> None:
if dry_run or _repl_mode:
return
session = get_session()
if session.project is not None and session.is_modified and session.project_path:
session.save_project()
if ctx.invoked_subcommand is None:
ctx.invoke(repl, project_path=None)
@cli.command()
@click.option("--project", "-p", "project_path", default=None, help="Project file to open in REPL.")
@click.pass_context
def repl(ctx: click.Context, project_path: str | None) -> None:
"""Start the interactive REPL."""
global _repl_mode
_repl_mode = True
skin = ReplSkin("eez-studio", version=__version__)
skin.print_banner()
session = get_session()
if project_path:
try:
session.open_project(project_path)
except Exception as exc:
skin.error(str(exc))
commands = {
"project new -o app.eez-project": "Create an LVGL project",
"project info": "Show project summary",
"project save": "Save current project",
"project widgets": "List LVGL widgets",
"lvgl add-label --text Hello": "Add a label to a screen",
"lvgl add-button --text Run": "Add a button to a screen",
"lvgl ensure-destination": "Create the build destination directory",
"lvgl backend-inspect": "Inspect through EEZ Studio backend",
"lvgl simulator-build out": "Run full LVGL simulator backend",
"scpi subsystem-add SOURCE": "Add a SCPI subsystem",
"scpi command-add SOURCE :VOLTage?": "Add a SCPI command",
"session undo / redo": "Undo or redo project mutations",
"backend status": "Check EEZ Studio backend availability",
"help": "Show this help",
"quit / exit": "Exit",
}
pt_session = skin.create_prompt_session()
while True:
try:
line = skin.get_input(pt_session, project_name=session.name, modified=session.is_modified)
except (EOFError, KeyboardInterrupt):
break
if not line:
continue
if line.lower() in {"quit", "exit", "q"}:
if session.is_modified:
skin.warning("Unsaved changes. Use `project save` before exiting.")
break
if line.lower() in {"help", "h", "?"}:
skin.help(commands)
continue
try:
args = shlex.split(line)
if session.project_path and "--project" not in args and "-p" not in args:
args = ["--project", session.project_path] + args
if ctx.obj and ctx.obj.get("json"):
args = ["--json"] + args
cli.main(args=args, standalone_mode=False)
except SystemExit:
pass
except Exception as exc:
skin.error(str(exc))
skin.print_goodbye()
_repl_mode = False
@cli.group()
def project() -> None:
"""Project management and native .eez-project inspection."""
@project.command("new")
@click.option("--name", "-n", default="Untitled", help="Project name.")
@click.option("--width", type=int, default=800, help="LVGL display width.")
@click.option("--height", type=int, default=480, help="LVGL display height.")
@click.option("--lvgl-version", default=project_mod.DEFAULT_LVGL_VERSION, help="LVGL version.")
@click.option("--destination", default=project_mod.DEFAULT_DESTINATION, help="Build destination folder.")
@click.option("--flow-support", is_flag=True, help="Enable EEZ Flow support flag.")
@click.option("--output", "-o", "output_path", required=True, help="Output .eez-project path.")
@handle_error
def project_new(
name: str,
width: int,
height: int,
lvgl_version: str,
destination: str,
flow_support: bool,
output_path: str,
) -> None:
"""Create a new native EEZ Studio LVGL project."""
project_data = project_mod.create_project(
name=name,
display_width=width,
display_height=height,
lvgl_version=lvgl_version,
destination=destination,
flow_support=flow_support,
)
session = get_session()
session.set_project(project_data, output_path)
result = session.save_project(output_path)
result.update(project_mod.project_info(project_data))
output(result, f"Created EEZ Studio project: {result['path']}")
@project.command("open")
@click.argument("path")
@handle_error
def project_open(path: str) -> None:
"""Open a .eez-project file."""
session = get_session()
session.open_project(path)
output(session.status(), f"Opened: {path}")
@project.command("save")
@click.argument("path", required=False)
@handle_error
def project_save(path: str | None) -> None:
"""Save the current project."""
session = require_project()
result = session.save_project(path)
output(result, f"Saved: {result['path']}")
@project.command("info")
@handle_error
def project_info() -> None:
"""Show project summary."""
session = require_project()
output(project_mod.project_info(session.project or {}), "Project info:")
@project.command("validate")
@click.argument("path", required=False)
@handle_error
def project_validate(path: str | None) -> None:
"""Validate native EEZ project structure."""
data = project_mod.load_project(path) if path else (require_project().project or {})
project_mod.validate_project(data)
output({"valid": True, **project_mod.project_info(data)}, "Project is valid.")
@project.command("pages")
@handle_error
def project_pages() -> None:
"""List LVGL pages/screens."""
session = require_project()
output(project_mod.list_pages(session.project or {}), "Pages:")
@project.command("widgets")
@click.option("--page", "page_name", default=None, help="Filter by page name.")
@handle_error
def project_widgets(page_name: str | None) -> None:
"""List LVGL widgets."""
session = require_project()
output(project_mod.list_widgets(session.project or {}, page_name), "Widgets:")
@project.command("set")
@click.argument("key")
@click.argument("value")
@handle_error
def project_set(key: str, value: str) -> None:
"""Set a supported settings.general key."""
session = require_project()
session.checkpoint()
result = project_mod.set_general(session.project or {}, key, value)
output(result, f"Set settings.general.{key}")
@project.command("set-destination")
@click.argument("destination")
@handle_error
def project_set_destination(destination: str) -> None:
"""Set settings.build.destinationFolder."""
session = require_project()
session.checkpoint()
result = project_mod.set_build_destination(session.project or {}, destination)
output(result, f"Set build destination: {destination}")
@project.command("add-build-file")
@click.argument("file_name")
@click.option("--template", "-t", required=True, help="Template text with EEZ Studio markers.")
@click.option("--description", default="", help="Build file description.")
@click.option("--replace", is_flag=True, help="Replace an existing build file.")
@handle_error
def project_add_build_file(file_name: str, template: str, description: str, replace: bool) -> None:
"""Add or replace a settings.build.files template."""
session = require_project()
session.checkpoint()
result = project_mod.add_build_file(session.project or {}, file_name, template, description, replace)
output(result, f"Updated build file: {file_name}")
@cli.group()
def lvgl() -> None:
"""LVGL screen/widget and real backend build commands."""
@lvgl.command("add-page")
@click.argument("name")
@click.option("--width", type=int, default=None, help="Screen width.")
@click.option("--height", type=int, default=None, help="Screen height.")
@handle_error
def lvgl_add_page(name: str, width: int | None, height: int | None) -> None:
"""Add a LVGL page/screen."""
session = require_project()
session.checkpoint()
result = project_mod.add_page(session.project or {}, name, width, height)
output(result, f"Added page: {name}")
@lvgl.command("add-label")
@click.option("--page", "page_name", default="Main", help="Page name.")
@click.option("--text", required=True, help="Label text.")
@click.option("--name", default=None, help="Widget name.")
@click.option("--x", type=int, default=20)
@click.option("--y", type=int, default=20)
@click.option("--width", type=int, default=160)
@click.option("--height", type=int, default=32)
@handle_error
def lvgl_add_label(page_name: str, text: str, name: str | None, x: int, y: int, width: int, height: int) -> None:
"""Add a LVGL label widget."""
session = require_project()
session.checkpoint()
result = project_mod.add_label(session.project or {}, page_name, text, name, x, y, width, height)
output(result, f"Added label: {result['name']}")
@lvgl.command("add-button")
@click.option("--page", "page_name", default="Main", help="Page name.")
@click.option("--text", required=True, help="Button label.")
@click.option("--name", default=None, help="Widget name.")
@click.option("--x", type=int, default=20)
@click.option("--y", type=int, default=72)
@click.option("--width", type=int, default=140)
@click.option("--height", type=int, default=48)
@handle_error
def lvgl_add_button(page_name: str, text: str, name: str | None, x: int, y: int, width: int, height: int) -> None:
"""Add a LVGL button widget with child label."""
session = require_project()
session.checkpoint()
result = project_mod.add_button(session.project or {}, page_name, text, name, x, y, width, height)
output(result, f"Added button: {result['name']}")
@lvgl.command("ensure-destination")
@handle_error
def lvgl_ensure_destination() -> None:
"""Create the build destination directory beside the project."""
session = require_project()
if not session.project_path:
raise RuntimeError("project must be saved before creating destination directory")
result = export_mod.ensure_destination(session.project_path)
output(result, f"Destination ready: {result['destination']}")
@lvgl.command("backend-inspect")
@click.option("--source", default=None, help="EEZ Studio source tree; defaults to EEZ_STUDIO_SOURCE.")
@click.option("--path", "project_path", default=None, help="Project path; defaults to open project.")
@handle_error
def lvgl_backend_inspect(source: str | None, project_path: str | None) -> None:
"""Inspect project metadata through EEZ Studio's real Node backend."""
session = get_session()
path = project_path or session.project_path
if not path:
raise RuntimeError("project path required")
result = export_mod.inspect_with_backend(path, source=source)
output(result, "EEZ backend project info:")
@lvgl.command("build-files")
@click.option("--path", "project_path", default=None, help="Project path; defaults to open project.")
@click.option("--timeout", type=int, default=300)
@handle_error
def lvgl_build_files(project_path: str | None, timeout: int) -> None:
"""Run a configured real EEZ Studio build-files command."""
session = get_session()
path = project_path or session.project_path
if not path:
raise RuntimeError("project path required")
result = export_mod.build_files(path, timeout=timeout)
output(result, "EEZ build command finished:")
@lvgl.command("simulator-build")
@click.argument("output_dir")
@click.option("--source", default=None, help="EEZ Studio source tree; defaults to EEZ_STUDIO_SOURCE.")
@click.option("--path", "project_path", default=None, help="Project path; defaults to open project.")
@click.option("--repository-name", default="eez-framework", help="Repository used by EEZ docker build.")
@click.option("--docker-volume", default="eez-studio-cli-anything", help="Docker volume name.")
@click.option("--timeout", type=int, default=900)
@handle_error
def lvgl_simulator_build(
output_dir: str,
source: str | None,
project_path: str | None,
repository_name: str,
docker_volume: str,
timeout: int,
) -> None:
"""Run EEZ Studio's full LVGL simulator Docker backend."""
session = get_session()
path = project_path or session.project_path
if not path:
raise RuntimeError("project path required")
result = export_mod.simulator_build(
project_path=path,
output_dir=output_dir,
source=source,
repository_name=repository_name,
docker_volume_name=docker_volume,
timeout=timeout,
)
result["verification"] = export_mod.verify_simulator_output(output_dir)
output(result, f"Simulator built: {os.path.abspath(output_dir)}")
@lvgl.command("verify-simulator")
@click.argument("output_dir")
@handle_error
def lvgl_verify_simulator(output_dir: str) -> None:
"""Verify simulator output artifacts by structure and magic bytes."""
result = export_mod.verify_simulator_output(output_dir)
output(result, f"Verified simulator output: {result['output_dir']}")
@cli.group()
def scpi() -> None:
"""SCPI subsystem and command model editing."""
@scpi.command("subsystem-list")
@handle_error
def scpi_subsystem_list() -> None:
"""List SCPI subsystems."""
session = require_project()
output(scpi_mod.list_subsystems(session.project or {}), "SCPI subsystems:")
@scpi.command("subsystem-add")
@click.argument("name")
@click.option("--description", "-d", default="", help="Subsystem description.")
@handle_error
def scpi_subsystem_add(name: str, description: str) -> None:
"""Add a SCPI subsystem."""
session = require_project()
session.checkpoint()
result = scpi_mod.add_subsystem(session.project or {}, name, description)
output(result, f"Added SCPI subsystem: {name}")
@scpi.command("command-list")
@click.option("--subsystem", "-s", default=None, help="Filter by subsystem.")
@handle_error
def scpi_command_list(subsystem: str | None) -> None:
"""List SCPI commands."""
session = require_project()
output(scpi_mod.list_commands(session.project or {}, subsystem), "SCPI commands:")
@scpi.command("command-add")
@click.argument("subsystem")
@click.argument("name")
@click.option("--description", "-d", default="", help="Command description.")
@click.option("--response-type", default=None, help="Query response type.")
@handle_error
def scpi_command_add(subsystem: str, name: str, description: str, response_type: str | None) -> None:
"""Add a SCPI command to a subsystem."""
session = require_project()
session.checkpoint()
result = scpi_mod.add_command(session.project or {}, subsystem, name, description, response_type)
output(result, f"Added SCPI command: {name}")
@scpi.command("parameter-add")
@click.argument("subsystem")
@click.argument("command")
@click.argument("name")
@click.option("--type", "parameter_type", default="nr1", help="SCPI parameter type.")
@click.option("--optional", is_flag=True, help="Mark parameter optional.")
@click.option("--description", "-d", default="", help="Parameter description.")
@handle_error
def scpi_parameter_add(
subsystem: str,
command: str,
name: str,
parameter_type: str,
optional: bool,
description: str,
) -> None:
"""Add a parameter to a SCPI command."""
session = require_project()
session.checkpoint()
result = scpi_mod.add_parameter(session.project or {}, subsystem, command, name, parameter_type, optional, description)
output(result, f"Added SCPI parameter: {name}")
@cli.group()
def backend() -> None:
"""Backend detection and version probes."""
@backend.command("status")
@click.option("--source", default=None, help="EEZ Studio source tree; defaults to EEZ_STUDIO_SOURCE.")
@handle_error
def backend_status(source: str | None) -> None:
"""Show backend availability."""
result = eez_studio_backend.backend_status(source)
output(result, "Backend status:")
@cli.group()
def session() -> None:
"""Session management and undo/redo."""
@session.command("status")
@handle_error
def session_status() -> None:
"""Show session status."""
output(get_session().status(), "Session status:")
@session.command("undo")
@handle_error
def session_undo() -> None:
"""Undo the last mutation."""
ok = get_session().undo()
output({"undone": ok, **get_session().status()}, "Undo" if ok else "Nothing to undo")
@session.command("redo")
@handle_error
def session_redo() -> None:
"""Redo the last undone mutation."""
ok = get_session().redo()
output({"redone": ok, **get_session().status()}, "Redo" if ok else "Nothing to redo")
@session.command("save-state")
@handle_error
def session_save_state() -> None:
"""Save session metadata to disk."""
path = get_session().save_state()
output({"path": path}, f"Saved session state: {path}")
@session.command("list")
@handle_error
def session_list() -> None:
"""List saved session metadata."""
output(Session.list_states(), "Saved sessions:")
main = cli
if __name__ == "__main__":
main()