From c4e921a49de75c799ef2b7e2af042aa4bed2c6df Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 20 Jun 2025 14:47:18 +0200 Subject: [PATCH] feat(plugin manager): add cli commands --- .../utils/bec_plugin_manager/__init__.py | 0 .../bec_plugin_manager/create/__init__.py | 0 .../utils/bec_plugin_manager/create/widget.py | 83 ++++++++++++++ .../utils/bec_plugin_manager/edit_ui.py | 105 ++++++++++++++++++ pyproject.toml | 1 + 5 files changed, 189 insertions(+) create mode 100644 bec_widgets/utils/bec_plugin_manager/__init__.py create mode 100644 bec_widgets/utils/bec_plugin_manager/create/__init__.py create mode 100644 bec_widgets/utils/bec_plugin_manager/create/widget.py create mode 100644 bec_widgets/utils/bec_plugin_manager/edit_ui.py diff --git a/bec_widgets/utils/bec_plugin_manager/__init__.py b/bec_widgets/utils/bec_plugin_manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/utils/bec_plugin_manager/create/__init__.py b/bec_widgets/utils/bec_plugin_manager/create/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/utils/bec_plugin_manager/create/widget.py b/bec_widgets/utils/bec_plugin_manager/create/widget.py new file mode 100644 index 00000000..befa0bb4 --- /dev/null +++ b/bec_widgets/utils/bec_plugin_manager/create/widget.py @@ -0,0 +1,83 @@ +import traceback +from pathlib import Path +from typing import Annotated + +import copier +import typer +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_repo_path +from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS +from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit + +from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor + +logger = bec_logger.logger +_app = typer.Typer(rich_markup_mode="rich") + + +def _commit_added_widget(repo: Path, name: str): + git_stage_files(repo, [".copier-answers.yml"]) + git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, []) + make_commit(repo, f"plugin-manager added new widget: {name}") + logger.info(f"Committing new widget {name}") + + +def _widget_exists(widget_list: list[dict[str, str]], name: str): + return name in [w["name"] for w in widget_list] + + +def _editor_cb(ctx: typer.Context, value: bool): + if value and not ctx.params["use_ui"]: + raise typer.BadParameter("Can only open the editor if creating a .ui file!") + return value + + +@_app.command() +def widget( + name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")], + use_ui: Annotated[ + bool, typer.Option(prompt=True, help="Generate a .ui file for use in bec-designer.") + ] = True, + open_editor: Annotated[ + bool, + typer.Option( + prompt=True, help="Open the created widget in bec-designer.", callback=_editor_cb + ), + ] = True, +): + """Create a new widget plugin with the given name. + +If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \ +[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \ +bec-designer and the compiled python version will be updated when changes are made.""" + if (formatted_name := name.lower().replace("-", "_")) != name: + logger.warning(f"Adjusting widget name from {name} to {formatted_name}") + if not formatted_name.isidentifier(): + logger.error( + f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case" + ) + exit(-1) + logger.info(f"Adding new widget {formatted_name} to the template...") + try: + repo = Path(plugin_repo_path()) + plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS]) + if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name): + logger.error(f"Widget {formatted_name} already exists!") + exit(-1) + plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui}) + copier.run_update( + repo, + data=plugin_data, + defaults=True, + unsafe=True, + overwrite=True, + vcs_ref=plugin_data[ANSWER_KEYS.VERSION], + ) + _commit_added_widget(repo, formatted_name) + except Exception: + logger.error(traceback.format_exc()) + logger.error("exiting...") + exit(-1) + logger.success(f"Added widget {formatted_name}!") + if open_editor: + open_and_watch_ui_editor(formatted_name) diff --git a/bec_widgets/utils/bec_plugin_manager/edit_ui.py b/bec_widgets/utils/bec_plugin_manager/edit_ui.py new file mode 100644 index 00000000..a841eb89 --- /dev/null +++ b/bec_widgets/utils/bec_plugin_manager/edit_ui.py @@ -0,0 +1,105 @@ +import re +import subprocess +from pathlib import Path + +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_repo_path +from watchdog.events import ( + DirCreatedEvent, + DirModifiedEvent, + DirMovedEvent, + FileCreatedEvent, + FileModifiedEvent, + FileMovedEvent, + FileSystemEvent, + FileSystemEventHandler, +) +from watchdog.observers import Observer + +from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets +from bec_widgets.utils.plugin_utils import get_custom_classes + +logger = bec_logger.logger + + +class RecompileHandler(FileSystemEventHandler): + def __init__(self, in_file: Path, out_file: Path) -> None: + super().__init__() + self.in_file = str(in_file) + self.out_file = str(out_file) + self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ") + self._widget_import_re = re.compile( + r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE + ) + self._widget_modules = { + c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets()) + } + + def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None: + self.recompile(event) + + def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None: + self.recompile(event) + + def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None: + self.recompile(event) + + def recompile(self, event: FileSystemEvent) -> None: + if event.src_path == self.in_file or event.dest_path == self.in_file: + self._recompile() + + def _recompile(self): + logger.success(".ui file modified, recompiling...") + code = subprocess.call( + ["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file] + ) + logger.success(f"compilation exited with code {code}") + if code == 0: + logger.success("updating imports...") + self._update_imports() + + def _update_imports(self): + with open(self.out_file, "r+") as f: + initial = f.read() + f.seek(0) + qtpy_imports = re.sub( + self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial + ) + print(self._widget_modules) + print(re.findall(self._widget_import_re, qtpy_imports)) + widget_imports = re.sub( + self._widget_import_re, + lambda ob: ( + f"from {module} import {ob.group(2)}" + if (module := self._widget_modules.get(ob.group(2))) is not None + else ob.group(1) + ), + qtpy_imports, + ) + f.write(widget_imports) + f.truncate() + + +def open_and_watch_ui_editor(widget_name: str): + logger.info(f"Opening the editor for {widget_name}... ") + + try: + from bec_widgets.utils.bec_designer import open_designer + except ImportError: + logger.error("BEC Widgets must be installed to use the UI editor tool") + exit(127) + + repo = Path(plugin_repo_path()) + widget_dir = repo / repo.name / "bec_widgets" / "widgets" / widget_name + ui_file = widget_dir / f"{widget_name}.ui" + ui_outfile = widget_dir / f"{widget_name}_ui.py" + recompile_handler = RecompileHandler(ui_file, ui_outfile) + observer = Observer() + observer.schedule(recompile_handler, str(ui_file.parent)) + observer.start() + try: + open_designer([str(ui_file)]) + finally: + observer.stop() + observer.join() + logger.info("Editing session ended, exiting...") diff --git a/pyproject.toml b/pyproject.toml index effc906b..b9f291d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dev = [ "pytest-xvfb~=3.0", "pytest~=8.0", "pytest-cov~=6.1.1", + "watchdog~=6.0", ] [project.urls]