Source code for forkbit_sdk.base_plugin

from __future__ import annotations

import io
import json
import logging
from pathlib import Path
from typing import Callable

from PySide6.QtWidgets import QWidget

from forkbit_sdk.git_client import GitClient

log = logging.getLogger(__name__)


[docs] class BasePlugin: """Base class for all ForkBit plugins. Subclass this and override :meth:`on_ready` to build your plugin UI. The app creates an instance, injects context (project, settings, git), and calls ``on_ready()`` when everything is wired up. Example:: from forkbit_sdk import BasePlugin from PySide6.QtWidgets import QLabel, QVBoxLayout class MyPlugin(BasePlugin): def on_ready(self): layout = QVBoxLayout(self.container) layout.addWidget(QLabel("Hello!")) """
[docs] def __init__(self): self.container = QWidget() """The root widget for your plugin UI. Build your layout inside this.""" self.container.setObjectName("pluginContainer") self.git = GitClient() """Git operations for the current project. See :class:`GitClient`.""" self._settings: dict = {} self._project_id: str = "" self._project_path: str = "" self._plugin_id: str = "" self._version: str = "" self._plugin_name: str = "" self._plugin_subtitle: str = "" self._plugin_icon: str = "" self._plugin_icon_colors: tuple[str, str] = ("", "") self._plugin_icon_path: str = "" self._data_dir: Path | None = None self._notify_fn: Callable[[str, str], None] | None = None self._subtitle_changed_fn: Callable[[str], None] | None = None self._translate_fn: Callable[ [str, str, str, Callable[[str], None] | None, Callable[[str], None] | None], str, ] | None = None self._status_chip: QWidget | None = None
@property def plugin_id(self) -> str: """The unique plugin identifier (e.g. ``com.mycompany.myplugin``).""" return self._plugin_id @property def version(self) -> str: """The plugin version from ``plugin.json``.""" return self._version @property def plugin_name(self) -> str: """The display name shown in the sidebar.""" return self._plugin_name @property def plugin_subtitle(self) -> str: """The subtitle shown below the plugin name in the header.""" return self._plugin_subtitle @property def plugin_icon(self) -> str: """The icon name (Lucide icon ID) for this plugin.""" return self._plugin_icon @property def plugin_icon_colors(self) -> tuple[str, str]: """Gradient colors ``(start, end)`` for the plugin icon background.""" return self._plugin_icon_colors @property def plugin_icon_path(self) -> str: """Path to a custom icon file, if set.""" return self._plugin_icon_path @property def data_dir(self) -> Path: """Per-instance directory for plugin file storage. Created automatically by the app before :meth:`on_ready` is called. Use :meth:`read_json` and :meth:`write_json` for convenient access. :raises RuntimeError: If accessed before ``on_ready()``. """ if self._data_dir is None: raise RuntimeError("data_dir is not available before on_ready()") return self._data_dir
[docs] def read_json(self, filename: str) -> dict: """Read a JSON file from :attr:`data_dir`. :param filename: Name of the JSON file (e.g. ``"state.json"``). :returns: Parsed dict, or empty dict if the file doesn't exist. """ path = self.data_dir / filename if not path.exists(): return {} return json.loads(path.read_text(encoding="utf-8"))
[docs] def write_json(self, filename: str, data: dict) -> None: """Atomic-write a JSON file to :attr:`data_dir`. Writes to a temporary file first, then renames — safe against crashes. :param filename: Name of the JSON file (e.g. ``"state.json"``). :param data: The dict to serialize. """ path = self.data_dir / filename tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") tmp.replace(path)
@property def settings(self) -> dict: """Current plugin settings values as defined in ``settings.json``.""" return self._settings
[docs] def secret_file(self, field_id: str) -> bytes: """Return the raw bytes for a ``secret_file`` settings field. :param field_id: The field ID from ``settings.json``. :returns: Raw file content as bytes. :raises KeyError: If no secret file is loaded for the given field. """ value = self._settings.get(field_id) if not isinstance(value, bytes): raise KeyError(f"No secret file loaded for '{field_id}'") return value
[docs] def secret_file_stream(self, field_id: str) -> io.BytesIO: """Return a file-like ``BytesIO`` for a ``secret_file`` settings field. Useful when a library expects a file object instead of raw bytes. :param field_id: The field ID from ``settings.json``. :returns: A seekable ``BytesIO`` stream. """ return io.BytesIO(self.secret_file(field_id))
@property def project_id(self) -> str: """The unique ID of the currently open project.""" return self._project_id @property def project_path(self) -> str: """Filesystem path to the project directory.""" return self._project_path
[docs] def on_ready(self) -> None: """Called when the plugin is fully initialized. Override this to build your UI inside :attr:`container` and set up initial state. All context properties (:attr:`settings`, :attr:`data_dir`, :attr:`git`, etc.) are available at this point. """ pass
[docs] def on_settings_changed(self, settings: dict) -> None: """Called when the user saves plugin settings. Override this to react to settings changes at runtime (e.g. refresh data with a new API key). The base implementation updates :attr:`settings` — call ``super()`` if you override. :param settings: The full updated settings dict. """ self._settings = settings
@property def status_chip(self) -> QWidget | None: """Optional status chip widget in the plugin header. Injected by the app — ``None`` until :meth:`on_ready` runs. """ return self._status_chip
[docs] def set_subtitle(self, text: str) -> None: """Update the subtitle shown in the plugin header. :param text: New subtitle text (e.g. ``"v1.2.0 — 3 locales"``). """ self._plugin_subtitle = text if self._subtitle_changed_fn: self._subtitle_changed_fn(text)
[docs] def notify(self, message: str, level: str = "info") -> None: """Show a toast notification to the user. :param message: The notification text. :param level: One of ``"info"``, ``"success"``, ``"warning"``, ``"error"``. """ if self._notify_fn: self._notify_fn(message, level) else: log.info("[%s] %s: %s", self._plugin_id, level, message)
[docs] def translate( self, text: str, source_lang: str, target_lang: str, on_done: Callable[[str], None] | None = None, on_error: Callable[[str], None] | None = None, ) -> str: """Request an asynchronous translation via the app's translation service. The translation runs in a background thread. Results arrive via callbacks. :param text: The text to translate. :param source_lang: Source language code (e.g. ``"en"``). :param target_lang: Target language code (e.g. ``"de"``). :param on_done: Callback receiving the translated text. :param on_error: Callback receiving an error message. :returns: A request ID for tracking. :raises RuntimeError: If no translation service is configured. """ if self._translate_fn: return self._translate_fn(text, source_lang, target_lang, on_done, on_error) raise RuntimeError("Translation service is not available")