From a1e2103a3a966e3ae1040f90bf03979ae0668e4d Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 27 May 2026 17:25:45 +0200 Subject: [PATCH 1/8] Initial cli implementation --- pyproject.toml | 6 +- src/pyedb/cli/__init__.py | 481 ++++++++++++++++++++++++++++++++++++++ src/pyedb/cli/__main__.py | 4 + src/pyedb/cli/common.py | 187 +++++++++++++++ tests/unit/test_cli.py | 408 ++++++++++++++++++++++++++++++++ 5 files changed, 1085 insertions(+), 1 deletion(-) create mode 100644 src/pyedb/cli/__init__.py create mode 100644 src/pyedb/cli/__main__.py create mode 100644 src/pyedb/cli/common.py create mode 100644 tests/unit/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index 008c27416a..c98f255df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "ansys-edb-core>=0.3.1", "psutil", "defusedxml>=0.7,<8.0", - "xmltodict" + "xmltodict", + "typer>=0.20.0", ] [project.optional-dependencies] @@ -96,6 +97,9 @@ doc = [ [tool.flit.module] name = "pyedb" +[project.scripts] +pyedb = "pyedb.cli:app" + [project.urls] Bugs = "https://github.com/ansys/pyedb/issues" Documentation = "https://edb.docs.pyansys.com" diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py new file mode 100644 index 0000000000..f6c0852523 --- /dev/null +++ b/src/pyedb/cli/__init__.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import code +from pathlib import Path + +try: + import typer +except ImportError as e: # pragma: no cover + raise ImportError( + "typer is required for the PyEDB CLI. Please install with 'pip install pyedb' or 'pip install typer'." + ) from e + +from pyedb import __version__ +from pyedb.cli import common + +app = typer.Typer(no_args_is_help=True, help="PyEDB command line interface.") +config_app = typer.Typer(help="Configuration commands.") +export_app = typer.Typer(help="Export commands.") + +_JSON_OPTION = typer.Option(False, "--json", help="Output results as JSON.") + + +def _set_json(json_output: bool) -> None: + if json_output: + common.json_mode = True + + +@app.callback() +def main_callback( + json_output: bool = _JSON_OPTION, +) -> None: + """CLI entrypoint for PyEDB.""" + _set_json(json_output) + + +@export_app.callback() +def export_callback( + json_output: bool = _JSON_OPTION, +) -> None: + """Export commands.""" + _set_json(json_output) + + +@config_app.callback() +def config_callback( + json_output: bool = _JSON_OPTION, +) -> None: + """Configuration commands.""" + _set_json(json_output) + + +@app.command() +def version() -> None: + """Display the installed PyEDB version.""" + data = {"version": __version__} + if common.json_mode: + common.print_output(data=data) + else: + typer.echo("PyEDB version: ", nl=False) + typer.secho(__version__, fg="cyan") + + +@app.command() +def create( + path: str = typer.Option(..., "--path", "-p", help="Path for the new .aedb database."), + version: str = typer.Option(None, "--version", help="AEDT/EDB version to use."), +) -> None: + """Create a new EDB.""" + + def _run() -> None: + new_path = common.ensure_new_aedb_path(path) + resolved_version = common.resolve_version(version) + Edb = common.get_edb_class() + edb = Edb(new_path, version=resolved_version) + edb.save() + resolved_path = edb.edbpath + edb.close() + data = {"edb_path": resolved_path, "version": resolved_version} + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"Created EDB '{resolved_path}'", fg="green") + + common.run_with_error_handling(_run) + + +@app.command() +def save( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option( + None, + "--output", + "-o", + help="Optional output .aedb path. When provided, save as a copy.", + ), +) -> None: + """Open the current EDB and save it, optionally to a new path.""" + + def _run() -> None: + with common.managed_edb(edb_path=path) as (db, context): + if output: + output_path = common.ensure_new_aedb_path(output) + db.save_as(output_path) + data = {"saved": True, "source_edb": context["edb_path"], "edb_path": output_path} + else: + db.save() + data = {"saved": True, "edb_path": db.edbpath} + if common.json_mode: + common.print_output(data=data) + else: + target_path = data["edb_path"] + typer.secho(f"Saved '{target_path}'", fg="green") + + common.run_with_error_handling(_run) + + +def _export_aedt_project( + method_name: str, + output_kind: str, + path: str, + output: str, + nets: list[str] | None, + num_cores: int | None, + aedt_file_name: str | None, + hidden: bool, +) -> None: + """Execute an AEDT project export with a shared option set.""" + + def _run() -> None: + output_path = str(Path(output).expanduser().resolve()) + with common.managed_edb(edb_path=path) as (db, _): + export_method = getattr(db, method_name, None) + if export_method is None: + raise RuntimeError(f"Current backend does not support '{output_kind}' export.") + exported = export_method( + output_path, + net_list=nets or None, + num_cores=num_cores, + aedt_file_name=aedt_file_name, + hidden=hidden, + ) + data = { + "exported": True, + "edb_path": db.edbpath, + "export_type": output_kind, + "project_path": exported, + } + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"{output_kind.upper()} exported to '{exported}'", fg="green") + + common.run_with_error_handling(_run) + + +@export_app.command("ipc2581") +def export_ipc2581( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output IPC2581 file path."), + translator_path: str = typer.Option(None, "--translator-path", help="Optional Ansys translator executable path."), +) -> None: + """Export the design to IPC2581.""" + + def _run() -> None: + output_path = str(Path(output).expanduser().resolve()) + with common.managed_edb(edb_path=path) as (db, _): + exported = db.export_to_ipc2581( + anstranslator_full_path=translator_path or "", + ipc_path=output_path, + ) + data = {"exported": True, "edb_path": db.edbpath, "ipc2581_path": exported} + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"IPC2581 exported to '{exported}'", fg="green") + + common.run_with_error_handling(_run) + + +@export_app.command("hfss") +def export_hfss( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output directory."), + net: list[str] | None = typer.Option(None, "--net", help="Repeat to export only selected nets."), + num_cores: int | None = typer.Option(None, "--num-cores", help="Number of cores to use."), + aedt_file_name: str | None = typer.Option(None, "--aedt-file-name", help="Custom AEDT file name."), + hidden: bool = typer.Option(False, "--hidden", help="Run Siwave in hidden mode."), +) -> None: + """Export the design to an HFSS AEDT project.""" + _export_aedt_project("export_hfss", "hfss", path, output, net, num_cores, aedt_file_name, hidden) + + +@export_app.command("q3d") +def export_q3d( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output directory."), + net: list[str] | None = typer.Option(None, "--net", help="Repeat to export only selected nets."), + num_cores: int | None = typer.Option(None, "--num-cores", help="Number of cores to use."), + aedt_file_name: str | None = typer.Option(None, "--aedt-file-name", help="Custom AEDT file name."), + hidden: bool = typer.Option(False, "--hidden", help="Run Siwave in hidden mode."), +) -> None: + """Export the design to a Q3D AEDT project.""" + _export_aedt_project("export_q3d", "q3d", path, output, net, num_cores, aedt_file_name, hidden) + + +@export_app.command("maxwell") +def export_maxwell( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output directory."), + net: list[str] | None = typer.Option(None, "--net", help="Repeat to export only selected nets."), + num_cores: int | None = typer.Option(None, "--num-cores", help="Number of cores to use."), + aedt_file_name: str | None = typer.Option(None, "--aedt-file-name", help="Custom AEDT file name."), + hidden: bool = typer.Option(False, "--hidden", help="Run Siwave in hidden mode."), +) -> None: + """Export the design to a Maxwell AEDT project.""" + _export_aedt_project("export_maxwell", "maxwell", path, output, net, num_cores, aedt_file_name, hidden) + + +@export_app.command("siwave-dc-results") +def export_siwave_dc_results( + siwave_project: str = typer.Option(..., "--siwave-project", help="Path to the SIwave project."), + solution_name: str = typer.Option(..., "--solution-name", help="DC analysis solution name."), + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output_folder: str | None = typer.Option(None, "--output-folder", help="Optional report output folder."), + html_report: bool = typer.Option(True, "--html-report/--no-html-report", help="Export the HTML report."), + vias: bool = typer.Option(True, "--vias/--no-vias", help="Export the vias report."), + voltage_probes: bool = typer.Option( + True, + "--voltage-probes/--no-voltage-probes", + help="Export the voltage probe report.", + ), + current_sources: bool = typer.Option( + True, + "--current-sources/--no-current-sources", + help="Export the current source report.", + ), + voltage_sources: bool = typer.Option( + True, + "--voltage-sources/--no-voltage-sources", + help="Export the voltage source report.", + ), + power_tree: bool = typer.Option(True, "--power-tree/--no-power-tree", help="Export the power tree image."), + loop_res: bool = typer.Option( + True, + "--loop-res/--no-loop-res", + help="Export the loop resistance report.", + ), +) -> None: + """Export SIwave DC analysis results.""" + + def _run() -> None: + project_path = str(Path(siwave_project).expanduser().resolve()) + reports_folder = str(Path(output_folder).expanduser().resolve()) if output_folder else None + with common.managed_edb(edb_path=path) as (db, _): + exported = db.export_siwave_dc_results( + project_path, + solution_name, + output_folder=reports_folder, + html_report=html_report, + vias=vias, + voltage_probes=voltage_probes, + current_sources=current_sources, + voltage_sources=voltage_sources, + power_tree=power_tree, + loop_res=loop_res, + ) + data = { + "exported": True, + "edb_path": db.edbpath, + "export_type": "siwave-dc-results", + "files": exported, + } + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"SIwave DC results exported ({len(exported)} files)", fg="green") + + common.run_with_error_handling(_run) + + +@export_app.command("gds-comp-xml") +def export_gds_comp_xml( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output XML control file path."), + component: list[str] | None = typer.Option( + None, + "--component", + help="Repeat to export selected components only. Exports all when omitted.", + ), + unit: str = typer.Option("mm", "--unit", help="Output length unit."), +) -> None: + """Export the GDS component XML control file.""" + + def _run() -> None: + output_path = str(Path(output).expanduser().resolve()) + with common.managed_edb(edb_path=path) as (db, _): + exported = db.export_gds_comp_xml(component or None, gds_comps_unit=unit, control_path=output_path) + if not exported: + raise RuntimeError("Failed to export GDS component XML.") + data = { + "exported": True, + "edb_path": db.edbpath, + "export_type": "gds-comp-xml", + "xml_path": output_path, + } + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"GDS component XML exported to '{output_path}'", fg="green") + + common.run_with_error_handling(_run) + + +@export_app.command("layout-component") +def export_layout_component( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output .aedbcomp path."), +) -> None: + """Export the current layout as an AEDB component.""" + + def _run() -> None: + output_path = str(Path(output).expanduser().resolve()) + with common.managed_edb(edb_path=path) as (db, _): + export_method = getattr(db, "export_layout_component", None) + if export_method is None: + raise RuntimeError("Current backend does not support layout-component export.") + exported = export_method(output_path) + if not exported: + raise RuntimeError("Failed to export layout component.") + data = { + "exported": True, + "edb_path": db.edbpath, + "export_type": "layout-component", + "component_path": output_path, + } + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"Layout component exported to '{output_path}'", fg="green") + + common.run_with_error_handling(_run) + + +@config_app.command("export") +def export_config( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + output: str = typer.Option(..., "--output", "-o", help="Output config file (.json or .toml)."), +) -> None: + """Export configuration data from the current EDB.""" + + def _run() -> None: + with common.managed_edb(edb_path=path) as (db, _): + payload = db.configuration.get_data_from_db(**common.CONFIG_EXPORT_FLAGS) + output_path = common.save_config_payload(output, payload) + data = {"exported": True, "config_path": output_path} + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"Configuration exported to '{output_path}'", fg="green") + + common.run_with_error_handling(_run) + + +@config_app.command("apply") +def apply_config( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + config: str = typer.Option(..., "--config", "-c", help="Config file path (.json or .toml)."), + output: str = typer.Option(None, "--output", "-o", help="Optional output .aedb path."), +) -> None: + """Apply a config file to the current EDB.""" + + def _run() -> None: + config_path = common.normalize_path(config) + if output: + output_path = common.ensure_new_aedb_path(output) + else: + output_path = None + with common.managed_edb(edb_path=path) as (db, context): + db.configuration.load(config_path, apply_file=True, output_file=output_path, open_at_the_end=False) + if not output_path: + db.save() + active_path = output_path or context["edb_path"] + data = {"applied": True, "config_path": config_path, "edb_path": active_path} + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"Applied config '{config_path}' to '{active_path}'", fg="green") + + common.run_with_error_handling(_run) + + +@config_app.command("validate") +def validate_config(path: str = typer.Option(..., "--path", "-p", help="Config file path (.json or .toml).")) -> None: + """Validate a config file shape without applying it.""" + + def _run() -> None: + payload, normalized_path = common.load_config_payload(path) + CfgData = common.get_cfg_data_class() + validated = CfgData(**payload).to_dict() + data = {"valid": True, "config_path": normalized_path, "sections": sorted(validated)} + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"Config '{normalized_path}' is valid", fg="green") + + common.run_with_error_handling(_run) + + +def exec_code( + script_path: str | None = typer.Argument(None, help="Python script to execute against the EDB."), + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), + code_snippet: str | None = typer.Option(None, "--code", help="Inline Python code to execute."), + save_on_success: bool = typer.Option( + True, + "--save/--no-save", + help="Save the database after successful execution.", + ), +) -> None: + """Open an EDB, execute Python code, then close it.""" + + def _run() -> None: + if bool(script_path) == bool(code_snippet): + raise RuntimeError("Provide either a script path or --code.") + + with common.managed_edb(edb_path=path) as (db, context): + namespace = common.build_console_namespace(db) + if code_snippet: + exec(compile(code_snippet, "", "exec"), namespace, namespace) + executed = "" + else: + script_file = Path(script_path).expanduser() + if not script_file.exists(): + raise RuntimeError(f"Script file '{script_file}' does not exist.") + exec(compile(script_file.read_text(encoding="utf-8"), str(script_file), "exec"), namespace, namespace) + executed = str(script_file.resolve()) + + if save_on_success: + db.save() + + data = { + "executed": True, + "script": executed, + "edb_path": context["edb_path"], + "saved": save_on_success, + } + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"Executed script against '{context['edb_path']}'", fg="green") + + common.run_with_error_handling(_run) + + +app.command(name="exec")(exec_code) + + +@app.command() +def attach( + path: str = typer.Option(..., "--path", "-p", help="EDB path (.aedb folder or edb.def file)."), +) -> None: + """Open an interactive Python console with a live EDB session.""" + + def _run() -> None: + with common.managed_edb(edb_path=path) as (db, _): + banner = ( + "PyEDB interactive console\n" + f"Active EDB: {db.edbpath}\n" + "Available names: edb, save(), save_as(path), export_ipc2581(...), export_hfss(...), " + "export_q3d(...), export_maxwell(...), export_siwave_dc_results(...), export_gds_comp_xml(...), " + "export_layout_component(...), close()\n" + "Exit with Ctrl-Z then Enter on Windows." + ) + code.interact(banner=banner, local=common.build_console_namespace(db)) + + common.run_with_error_handling(_run) + + +app.add_typer(export_app, name="export") +app.add_typer(config_app, name="config") + +__all__ = ["app"] diff --git a/src/pyedb/cli/__main__.py b/src/pyedb/cli/__main__.py new file mode 100644 index 0000000000..5bcd012741 --- /dev/null +++ b/src/pyedb/cli/__main__.py @@ -0,0 +1,4 @@ +from pyedb.cli import app + +if __name__ == "__main__": + app() diff --git a/src/pyedb/cli/common.py b/src/pyedb/cli/common.py new file mode 100644 index 0000000000..2fcc9aea96 --- /dev/null +++ b/src/pyedb/cli/common.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from typing import Any + +import toml + +try: + import typer +except ImportError as e: # pragma: no cover + raise ImportError( + "typer is required for the PyEDB CLI. Please install with 'pip install pyedb' or 'pip install typer'." + ) from e + +json_mode = False + +CONFIG_EXPORT_FLAGS = { + "general": True, + "variables": True, + "stackup": True, + "package_definitions": True, + "setups": True, + "terminals": True, + "sources": True, + "ports": True, + "nets": True, + "pin_groups": True, + "operations": True, + "components": True, + "boundaries": True, + "s_parameters": True, + "padstacks": True, +} + + +def print_output(data: Any = None, error: str | None = None) -> None: + """Print structured output for JSON mode.""" + if not json_mode: + return + if error: + typer.echo(json.dumps({"status": "error", "error": str(error)})) + else: + typer.echo(json.dumps({"status": "ok", "data": data})) + + +def run_with_error_handling(func) -> None: + """Execute a command body with consistent CLI error reporting.""" + try: + func() + except typer.Exit: + raise + except Exception as e: + if json_mode: + print_output(error=str(e)) + else: + typer.secho(f"Error: {e}", fg="red") + raise typer.Exit(code=1) from e + + +def get_edb_class(): + """Return the runtime Edb class.""" + from pyedb import Edb + + return Edb + + +def resolve_version(version: str | None) -> str | None: + """Return *version* if given, otherwise detect the latest installed AEDT version.""" + if version: + return version + from pyedb.misc.misc import current_version + + detected = current_version() + if detected: + return detected + return None + + +def get_cfg_data_class(): + """Return the runtime configuration model class.""" + from pyedb.configuration.cfg_data import CfgData + + return CfgData + + +def normalize_path(path: str | Path) -> str: + """Normalize a path to an absolute string.""" + return str(Path(path).expanduser().resolve()) + + +def ensure_existing_edb_path(path: str | Path) -> str: + """Validate and normalize an existing EDB path.""" + resolved = Path(path).expanduser() + if resolved.is_file() and resolved.name.lower() == "edb.def": + return str(resolved.resolve()) + if resolved.is_dir() and resolved.suffix.lower() == ".aedb" and (resolved / "edb.def").exists(): + return str(resolved.resolve()) + raise RuntimeError(f"'{path}' is not an existing .aedb database or edb.def file.") + + +def ensure_new_aedb_path(path: str | Path) -> str: + """Validate and normalize a new AEDB directory path.""" + resolved = Path(path).expanduser() + if resolved.suffix.lower() != ".aedb": + raise RuntimeError("Output path must end with '.aedb'.") + if resolved.exists(): + raise RuntimeError(f"'{resolved}' already exists.") + return str(resolved.resolve()) + + +@contextmanager +def managed_edb( + edb_path: str, + version: str | None = None, + cellname: str | None = None, + isreadonly: bool = False, +): + """Open an EDB from an explicit path and close it on exit.""" + existing_path = ensure_existing_edb_path(edb_path) + context = { + "edb_path": existing_path, + "version": version, + "cellname": cellname, + "isreadonly": bool(isreadonly), + } + Edb = get_edb_class() + edb = Edb( + existing_path, + version=resolve_version(version), + cellname=cellname, + isreadonly=isreadonly, + ) + try: + yield edb, context + finally: + edb.close() + + +def load_config_payload(path: str | Path) -> tuple[dict[str, Any], str]: + """Load a JSON or TOML config payload.""" + normalized_path = normalize_path(path) + config_path = Path(normalized_path) + if not config_path.exists(): + raise RuntimeError(f"Config file '{config_path}' does not exist.") + if config_path.suffix.lower() == ".json": + payload = json.loads(config_path.read_text(encoding="utf-8")) + elif config_path.suffix.lower() == ".toml": + payload = toml.loads(config_path.read_text(encoding="utf-8")) + else: + raise RuntimeError("Config file must end with '.json' or '.toml'.") + if not isinstance(payload, dict): + raise RuntimeError("Config file must contain a JSON/TOML object at the top level.") + return payload, normalized_path + + +def save_config_payload(path: str | Path, payload: dict[str, Any]) -> str: + """Write a JSON or TOML config payload to disk.""" + output_path = Path(path).expanduser() + if output_path.suffix.lower() not in {".json", ".toml"}: + output_path = output_path.with_suffix(".json") + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.suffix.lower() == ".json": + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + else: + output_path.write_text(toml.dumps(payload), encoding="utf-8") + return str(output_path.resolve()) + + +def build_console_namespace(edb) -> dict[str, Any]: + """Create the interactive namespace exposed by attach/run commands.""" + return { + "edb": edb, + "save": edb.save, + "save_as": edb.save_as, + "close": edb.close, + "export_ipc2581": edb.export_to_ipc2581, + "export_hfss": getattr(edb, "export_hfss", None), + "export_q3d": getattr(edb, "export_q3d", None), + "export_maxwell": getattr(edb, "export_maxwell", None), + "export_siwave_dc_results": getattr(edb, "export_siwave_dc_results", None), + "export_gds_comp_xml": getattr(edb, "export_gds_comp_xml", None), + "export_layout_component": getattr(edb, "export_layout_component", None), + "Path": Path, + "json": json, + } diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000000..aeb2ac0928 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from pyedb.cli import app, common + +pytestmark = [pytest.mark.unit, pytest.mark.no_licence] + + +class FakeConfiguration: + def __init__(self, edb): + self._edb = edb + self.loaded = [] + self.export_flags = None + + def get_data_from_db(self, **kwargs): + self.export_flags = kwargs + return {"general": {"anti_pads_always_on": False}, "nets": {"signal_nets": ["SIG1"]}} + + def load(self, path, apply_file=False, output_file=None, open_at_the_end=True): + self.loaded.append( + { + "path": str(path), + "apply_file": apply_file, + "output_file": output_file, + "open_at_the_end": open_at_the_end, + } + ) + if output_file: + output_dir = Path(output_file) + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "edb.def").write_text("", encoding="utf-8") + + +class FakeEdb: + instances = [] + + def __init__(self, edbpath, version=None, isreadonly=False, **kwargs): + self.initial_path = str(edbpath) + self.version = version + self.isreadonly = isreadonly + self.kwargs = kwargs + self.saved = False + self.closed = False + self.saved_as_paths = [] + self.exported_ipc_path = None + self.exported_projects = {} + self.exported_dc_results = None + self.exported_gds_xml = None + self.exported_layout_component_path = None + self.configuration = FakeConfiguration(self) + self.marker = None + + path = Path(edbpath) + if path.name.lower() == "edb.def": + self.edbpath = str(path.parent.resolve()) + elif path.suffix.lower() == ".aedb": + path.mkdir(parents=True, exist_ok=True) + (path / "edb.def").write_text("", encoding="utf-8") + self.edbpath = str(path.resolve()) + elif path.suffix.lower() in {".brd", ".gds", ".xml", ".dxf", ".tgz", ".mcm", ".zip"}: + output = path.with_suffix(".aedb") + output.mkdir(parents=True, exist_ok=True) + (output / "edb.def").write_text("", encoding="utf-8") + self.edbpath = str(output.resolve()) + else: + self.edbpath = str(path.resolve()) + + FakeEdb.instances.append(self) + + def save(self): + self.saved = True + return True + + def save_as(self, path, version=""): + output = Path(path) + output.mkdir(parents=True, exist_ok=True) + (output / "edb.def").write_text("", encoding="utf-8") + self.saved_as_paths.append(str(output.resolve())) + return True + + def close(self, terminate_rpc_session=None): + self.closed = True + return True + + def export_to_ipc2581(self, edbpath="", anstranslator_full_path="", ipc_path=None): + output = Path(ipc_path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("ipc2581", encoding="utf-8") + self.exported_ipc_path = str(output.resolve()) + return self.exported_ipc_path + + def _export_aedt_project( + self, + export_type, + path_to_output, + net_list=None, + num_cores=None, + aedt_file_name=None, + hidden=False, + ): + output_dir = Path(path_to_output) + output_dir.mkdir(parents=True, exist_ok=True) + file_name = aedt_file_name or export_type + project_path = output_dir / f"{file_name}.aedt" + project_path.write_text(export_type, encoding="utf-8") + self.exported_projects[export_type] = { + "path": str(project_path.resolve()), + "net_list": net_list, + "num_cores": num_cores, + "aedt_file_name": aedt_file_name, + "hidden": hidden, + } + return str(project_path.resolve()) + + def export_hfss(self, path_to_output, net_list=None, num_cores=None, aedt_file_name=None, hidden=False): + return self._export_aedt_project("hfss", path_to_output, net_list, num_cores, aedt_file_name, hidden) + + def export_q3d(self, path_to_output, net_list=None, num_cores=None, aedt_file_name=None, hidden=False): + return self._export_aedt_project("q3d", path_to_output, net_list, num_cores, aedt_file_name, hidden) + + def export_maxwell(self, path_to_output, net_list=None, num_cores=None, aedt_file_name=None, hidden=False): + return self._export_aedt_project("maxwell", path_to_output, net_list, num_cores, aedt_file_name, hidden) + + def export_siwave_dc_results( + self, + siwave_project, + solution_name, + output_folder=None, + html_report=True, + vias=True, + voltage_probes=True, + current_sources=True, + voltage_sources=True, + power_tree=True, + loop_res=True, + ): + base_dir = Path(output_folder) if output_folder else Path(self.edbpath) / "dc-results" + base_dir.mkdir(parents=True, exist_ok=True) + files = [] + requested = { + "html_report": html_report, + "vias": vias, + "voltage_probes": voltage_probes, + "current_sources": current_sources, + "voltage_sources": voltage_sources, + "power_tree": power_tree, + "loop_res": loop_res, + } + for name, enabled in requested.items(): + if enabled: + output = base_dir / f"{name}.txt" + output.write_text(name, encoding="utf-8") + files.append(str(output.resolve())) + self.exported_dc_results = { + "siwave_project": siwave_project, + "solution_name": solution_name, + "output_folder": str(base_dir.resolve()), + "files": files, + } + return files + + def export_gds_comp_xml(self, comps_to_export, gds_comps_unit="mm", control_path=None): + output = Path(control_path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("", encoding="utf-8") + self.exported_gds_xml = { + "components": comps_to_export, + "unit": gds_comps_unit, + "path": str(output.resolve()), + } + return True + + def export_layout_component(self, component_path): + output = Path(component_path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("aedbcomp", encoding="utf-8") + self.exported_layout_component_path = str(output.resolve()) + return True + + +class FakeCfgData: + def __init__(self, **kwargs): + self._data = kwargs + + def to_dict(self): + return self._data + + +@pytest.fixture(autouse=True) +def cli_test_environment(monkeypatch, tmp_path): + FakeEdb.instances.clear() + common.json_mode = False + monkeypatch.setattr(common, "get_edb_class", lambda: FakeEdb) + monkeypatch.setattr(common, "get_cfg_data_class", lambda: FakeCfgData) + + +@pytest.fixture +def runner(): + return CliRunner() + + +def make_aedb(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + (path / "edb.def").write_text("", encoding="utf-8") + return path + + +def test_version_json(runner): + result = runner.invoke(app, ["--json", "version"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "data" in data and "version" in data["data"] + + +def test_json_flag_at_export_subgroup_level(runner): + """--json placed after the export sub-group name must still activate JSON mode.""" + result = runner.invoke(app, ["export", "--json", "ipc2581", "--help"]) + assert result.exit_code == 0 + + +def test_json_flag_at_config_subgroup_level(runner): + """--json placed after the config sub-group name must still activate JSON mode.""" + result = runner.invoke(app, ["config", "--json", "validate", "--help"]) + assert result.exit_code == 0 + + +def test_export_ipc2581_uses_explicit_edb(runner, tmp_path): + edb_path = make_aedb(tmp_path / "active.aedb") + ipc_path = tmp_path / "exports" / "board.xml" + + result = runner.invoke(app, ["export", "ipc2581", "--path", str(edb_path), "--output", str(ipc_path)]) + + assert result.exit_code == 0 + assert ipc_path.exists() + assert FakeEdb.instances[-1].exported_ipc_path == str(ipc_path.resolve()) + + +@pytest.mark.parametrize("command", ["hfss", "q3d", "maxwell"]) +def test_export_aedt_projects_use_shared_options(runner, tmp_path, command): + edb_path = make_aedb(tmp_path / "active.aedb") + output_dir = tmp_path / command + + result = runner.invoke( + app, + [ + "export", + command, + "--path", + str(edb_path), + "--output", + str(output_dir), + "--net", + "SIG1", + "--net", + "SIG2", + "--num-cores", + "4", + "--aedt-file-name", + "custom_name", + "--hidden", + ], + ) + + assert result.exit_code == 0 + exported = FakeEdb.instances[-1].exported_projects[command] + assert Path(exported["path"]).exists() + assert exported["net_list"] == ["SIG1", "SIG2"] + assert exported["num_cores"] == 4 + assert exported["aedt_file_name"] == "custom_name" + assert exported["hidden"] is True + + +def test_export_siwave_dc_results(runner, tmp_path): + edb_path = make_aedb(tmp_path / "active.aedb") + siwave_project = tmp_path / "board.siw" + siwave_project.write_text("siwave", encoding="utf-8") + output_dir = tmp_path / "dc-results" + + result = runner.invoke( + app, + [ + "export", + "siwave-dc-results", + "--path", + str(edb_path), + "--siwave-project", + str(siwave_project), + "--solution-name", + "DC1", + "--output-folder", + str(output_dir), + "--no-power-tree", + ], + ) + + assert result.exit_code == 0 + exported = FakeEdb.instances[-1].exported_dc_results + assert exported["solution_name"] == "DC1" + assert exported["output_folder"] == str(output_dir.resolve()) + assert all(Path(file).exists() for file in exported["files"]) + assert not any("power_tree" in file for file in exported["files"]) + + +def test_export_gds_comp_xml(runner, tmp_path): + edb_path = make_aedb(tmp_path / "active.aedb") + xml_path = tmp_path / "gds_components.xml" + + result = runner.invoke( + app, + [ + "export", + "gds-comp-xml", + "--path", + str(edb_path), + "--output", + str(xml_path), + "--component", + "U1", + "--component", + "J1", + "--unit", + "mil", + ], + ) + + assert result.exit_code == 0 + assert xml_path.exists() + assert FakeEdb.instances[-1].exported_gds_xml == { + "components": ["U1", "J1"], + "unit": "mil", + "path": str(xml_path.resolve()), + } + + +def test_export_layout_component(runner, tmp_path): + edb_path = make_aedb(tmp_path / "active.aedb") + component_path = tmp_path / "layout.aedbcomp" + + result = runner.invoke( + app, + ["export", "layout-component", "--path", str(edb_path), "--output", str(component_path)], + ) + + assert result.exit_code == 0 + assert component_path.exists() + assert FakeEdb.instances[-1].exported_layout_component_path == str(component_path.resolve()) + + +def test_config_apply_uses_explicit_paths(runner, tmp_path): + edb_path = make_aedb(tmp_path / "source.aedb") + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({"general": {"anti_pads_always_on": False}}), encoding="utf-8") + output_path = tmp_path / "configured.aedb" + + apply_result = runner.invoke( + app, + ["config", "apply", "--path", str(edb_path), "--config", str(config_path), "--output", str(output_path)], + ) + + assert apply_result.exit_code == 0 + assert FakeEdb.instances[-1].configuration.loaded[-1]["output_file"] == str(output_path.resolve()) + + +def test_exec_executes_inline_code_and_saves(runner, tmp_path): + edb_path = make_aedb(tmp_path / "scripted.aedb") + + result = runner.invoke(app, ["exec", "--path", str(edb_path), "--code", "edb.marker = 'updated'"]) + + assert result.exit_code == 0 + assert FakeEdb.instances[-1].marker == "updated" + assert FakeEdb.instances[-1].saved is True + + +def test_save_with_path_saves_copy(runner, tmp_path): + edb_path = make_aedb(tmp_path / "source.aedb") + output_path = tmp_path / "copy.aedb" + + result = runner.invoke(app, ["save", "--path", str(edb_path), "--output", str(output_path)]) + + assert result.exit_code == 0 + assert output_path.exists() + assert FakeEdb.instances[-1].saved_as_paths[-1] == str(output_path.resolve()) + + +def test_attach_opens_interactive_console_namespace(runner, tmp_path, monkeypatch): + edb_path = make_aedb(tmp_path / "interactive.aedb") + captured = {} + + def fake_interact(banner, local): + captured["banner"] = banner + captured["local"] = local + + monkeypatch.setattr("pyedb.cli.code.interact", fake_interact) + + result = runner.invoke(app, ["attach", "--path", str(edb_path)]) + + assert result.exit_code == 0 + assert "PyEDB interactive console" in captured["banner"] + assert "edb" in captured["local"] + assert "save" in captured["local"] + assert "export_hfss" in captured["local"] + assert FakeEdb.instances[-1].closed is True + From 7d13f111abcab5d445d7dd9a2ac51be3b5f37295 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 15:29:38 +0000 Subject: [PATCH 2/8] CHORE: Auto fixes from pre-commit hooks --- src/pyedb/cli/__init__.py | 22 ++++++++++++++++++++++ src/pyedb/cli/__main__.py | 22 ++++++++++++++++++++++ src/pyedb/cli/common.py | 22 ++++++++++++++++++++++ tests/unit/test_cli.py | 23 ++++++++++++++++++++++- 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py index f6c0852523..f41e763741 100644 --- a/src/pyedb/cli/__init__.py +++ b/src/pyedb/cli/__init__.py @@ -1,3 +1,25 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + from __future__ import annotations import code diff --git a/src/pyedb/cli/__main__.py b/src/pyedb/cli/__main__.py index 5bcd012741..6644ec6416 100644 --- a/src/pyedb/cli/__main__.py +++ b/src/pyedb/cli/__main__.py @@ -1,3 +1,25 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + from pyedb.cli import app if __name__ == "__main__": diff --git a/src/pyedb/cli/common.py b/src/pyedb/cli/common.py index 2fcc9aea96..2ae27a0015 100644 --- a/src/pyedb/cli/common.py +++ b/src/pyedb/cli/common.py @@ -1,3 +1,25 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + from __future__ import annotations from contextlib import contextmanager diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index aeb2ac0928..76b2dbaab2 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,3 +1,25 @@ +# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + from __future__ import annotations import json @@ -405,4 +427,3 @@ def fake_interact(banner, local): assert "save" in captured["local"] assert "export_hfss" in captured["local"] assert FakeEdb.instances[-1].closed is True - From a7e88a33de5364c5a9ccd71454818c54ee06753f Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Wed, 27 May 2026 15:30:46 +0000 Subject: [PATCH 3/8] chore: adding changelog file 2209.added.md [dependabot-skip] --- doc/changelog.d/2209.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/2209.added.md diff --git a/doc/changelog.d/2209.added.md b/doc/changelog.d/2209.added.md new file mode 100644 index 0000000000..8d09e4c756 --- /dev/null +++ b/doc/changelog.d/2209.added.md @@ -0,0 +1 @@ +Initial cli implementation From b58116e3df114c5ceb4acb47b1b55b45879c5b26 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 27 May 2026 17:39:52 +0200 Subject: [PATCH 4/8] Fixed codacy --- src/pyedb/cli/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py index f41e763741..2f3c5156ae 100644 --- a/src/pyedb/cli/__init__.py +++ b/src/pyedb/cli/__init__.py @@ -24,6 +24,7 @@ import code from pathlib import Path +import runpy try: import typer @@ -447,13 +448,13 @@ def _run() -> None: with common.managed_edb(edb_path=path) as (db, context): namespace = common.build_console_namespace(db) if code_snippet: - exec(compile(code_snippet, "", "exec"), namespace, namespace) + exec(compile(code_snippet, "", "exec"), namespace, namespace) # nosec B102 executed = "" else: script_file = Path(script_path).expanduser() if not script_file.exists(): raise RuntimeError(f"Script file '{script_file}' does not exist.") - exec(compile(script_file.read_text(encoding="utf-8"), str(script_file), "exec"), namespace, namespace) + runpy.run_path(str(script_file), init_globals=namespace, run_name="__main__") executed = str(script_file.resolve()) if save_on_success: From 1f590f3c36700c8b911a3cda89bc6ef79e2cbf25 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 27 May 2026 17:54:36 +0200 Subject: [PATCH 5/8] Fix --- src/pyedb/cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py index 2f3c5156ae..1fa0ed9494 100644 --- a/src/pyedb/cli/__init__.py +++ b/src/pyedb/cli/__init__.py @@ -448,7 +448,7 @@ def _run() -> None: with common.managed_edb(edb_path=path) as (db, context): namespace = common.build_console_namespace(db) if code_snippet: - exec(compile(code_snippet, "", "exec"), namespace, namespace) # nosec B102 + exec(compile(code_snippet, "", "exec"), namespace, namespace) # nosec executed = "" else: script_file = Path(script_path).expanduser() From e86a7340132261fc20507f67163d33cf71b86cb7 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Fri, 29 May 2026 11:41:42 +0200 Subject: [PATCH 6/8] Apply suggestions from code review Co-authored-by: svandenb-dev <74993647+svandenb-dev@users.noreply.github.com> Signed-off-by: Eduardo Blanco --- src/pyedb/cli/__init__.py | 6 +++--- src/pyedb/cli/common.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py index 1fa0ed9494..3d3bb2a003 100644 --- a/src/pyedb/cli/__init__.py +++ b/src/pyedb/cli/__init__.py @@ -44,8 +44,8 @@ def _set_json(json_output: bool) -> None: - if json_output: - common.json_mode = True + common.json_mode = json_output + @app.callback() @@ -442,7 +442,7 @@ def exec_code( """Open an EDB, execute Python code, then close it.""" def _run() -> None: - if bool(script_path) == bool(code_snippet): + if (script_path is not None) == (code_snippet is not None): raise RuntimeError("Provide either a script path or --code.") with common.managed_edb(edb_path=path) as (db, context): diff --git a/src/pyedb/cli/common.py b/src/pyedb/cli/common.py index 2ae27a0015..a071bd570c 100644 --- a/src/pyedb/cli/common.py +++ b/src/pyedb/cli/common.py @@ -37,7 +37,11 @@ ) from e json_mode = False - +def reset_state() -> None: + """Reset module-level state. Call between CLI invocations (e.g. in tests).""" + global json_mode + json_mode = False + CONFIG_EXPORT_FLAGS = { "general": True, "variables": True, From 33c873b1d2e3becabf54466a90601532679a7b44 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 09:41:57 +0000 Subject: [PATCH 7/8] CHORE: Auto fixes from pre-commit hooks --- src/pyedb/cli/__init__.py | 1 - src/pyedb/cli/common.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py index 3d3bb2a003..80dc404871 100644 --- a/src/pyedb/cli/__init__.py +++ b/src/pyedb/cli/__init__.py @@ -45,7 +45,6 @@ def _set_json(json_output: bool) -> None: common.json_mode = json_output - @app.callback() diff --git a/src/pyedb/cli/common.py b/src/pyedb/cli/common.py index a071bd570c..608c8e8873 100644 --- a/src/pyedb/cli/common.py +++ b/src/pyedb/cli/common.py @@ -37,11 +37,14 @@ ) from e json_mode = False + + def reset_state() -> None: """Reset module-level state. Call between CLI invocations (e.g. in tests).""" global json_mode json_mode = False - + + CONFIG_EXPORT_FLAGS = { "general": True, "variables": True, From 8fde64de7fa224e647a102b4f389151ddb3f97b7 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Fri, 29 May 2026 11:47:40 +0200 Subject: [PATCH 8/8] Fix --- src/pyedb/cli/__init__.py | 49 +++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/pyedb/cli/__init__.py b/src/pyedb/cli/__init__.py index 80dc404871..a2ab1ee2ad 100644 --- a/src/pyedb/cli/__init__.py +++ b/src/pyedb/cli/__init__.py @@ -273,29 +273,32 @@ def export_siwave_dc_results( def _run() -> None: project_path = str(Path(siwave_project).expanduser().resolve()) reports_folder = str(Path(output_folder).expanduser().resolve()) if output_folder else None - with common.managed_edb(edb_path=path) as (db, _): - exported = db.export_siwave_dc_results( - project_path, - solution_name, - output_folder=reports_folder, - html_report=html_report, - vias=vias, - voltage_probes=voltage_probes, - current_sources=current_sources, - voltage_sources=voltage_sources, - power_tree=power_tree, - loop_res=loop_res, - ) - data = { - "exported": True, - "edb_path": db.edbpath, - "export_type": "siwave-dc-results", - "files": exported, - } - if common.json_mode: - common.print_output(data=data) - else: - typer.secho(f"SIwave DC results exported ({len(exported)} files)", fg="green") + # and do NOT wrap in managed_edb to avoid a double-close + existing_path = common.ensure_existing_edb_path(path) + Edb = common.get_edb_class() + db = Edb(existing_path, version=common.resolve_version(None)) + exported = db.export_siwave_dc_results( + project_path, + solution_name, + output_folder=reports_folder, + html_report=html_report, + vias=vias, + voltage_probes=voltage_probes, + current_sources=current_sources, + voltage_sources=voltage_sources, + power_tree=power_tree, + loop_res=loop_res, + ) + data = { + "exported": True, + "edb_path": existing_path, + "export_type": "siwave-dc-results", + "files": exported, + } + if common.json_mode: + common.print_output(data=data) + else: + typer.secho(f"SIwave DC results exported ({len(exported)} files)", fg="green") common.run_with_error_handling(_run)