forkbit-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forkbit_sdk/__init__.py +5 -0
- forkbit_sdk/base_plugin.py +238 -0
- forkbit_sdk/cli.py +41 -0
- forkbit_sdk/commands/__init__.py +0 -0
- forkbit_sdk/commands/_shared.py +183 -0
- forkbit_sdk/commands/build.py +72 -0
- forkbit_sdk/commands/init_plugin.py +117 -0
- forkbit_sdk/commands/release.py +239 -0
- forkbit_sdk/commands/validate.py +245 -0
- forkbit_sdk/git_client.py +91 -0
- forkbit_sdk/ipc_client.py +77 -0
- forkbit_sdk-0.1.0.dist-info/METADATA +140 -0
- forkbit_sdk-0.1.0.dist-info/RECORD +16 -0
- forkbit_sdk-0.1.0.dist-info/WHEEL +4 -0
- forkbit_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- forkbit_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
forkbit_sdk/__init__.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from PySide6.QtWidgets import QWidget
|
|
10
|
+
|
|
11
|
+
from forkbit_sdk.git_client import GitClient
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BasePlugin:
|
|
17
|
+
"""Base class for all ForkBit plugins.
|
|
18
|
+
|
|
19
|
+
Subclass this and override :meth:`on_ready` to build your plugin UI.
|
|
20
|
+
The app creates an instance, injects context (project, settings, git),
|
|
21
|
+
and calls ``on_ready()`` when everything is wired up.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
from forkbit_sdk import BasePlugin
|
|
26
|
+
from PySide6.QtWidgets import QLabel, QVBoxLayout
|
|
27
|
+
|
|
28
|
+
class MyPlugin(BasePlugin):
|
|
29
|
+
def on_ready(self):
|
|
30
|
+
layout = QVBoxLayout(self.container)
|
|
31
|
+
layout.addWidget(QLabel("Hello!"))
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.container = QWidget()
|
|
36
|
+
"""The root widget for your plugin UI. Build your layout inside this."""
|
|
37
|
+
self.container.setObjectName("pluginContainer")
|
|
38
|
+
self.git = GitClient()
|
|
39
|
+
"""Git operations for the current project. See :class:`GitClient`."""
|
|
40
|
+
self._settings: dict = {}
|
|
41
|
+
self._project_id: str = ""
|
|
42
|
+
self._project_path: str = ""
|
|
43
|
+
self._plugin_id: str = ""
|
|
44
|
+
self._version: str = ""
|
|
45
|
+
self._plugin_name: str = ""
|
|
46
|
+
self._plugin_subtitle: str = ""
|
|
47
|
+
self._plugin_icon: str = ""
|
|
48
|
+
self._plugin_icon_colors: tuple[str, str] = ("", "")
|
|
49
|
+
self._plugin_icon_path: str = ""
|
|
50
|
+
self._data_dir: Path | None = None
|
|
51
|
+
self._notify_fn: Callable[[str, str], None] | None = None
|
|
52
|
+
self._subtitle_changed_fn: Callable[[str], None] | None = None
|
|
53
|
+
self._translate_fn: Callable[
|
|
54
|
+
[str, str, str, Callable[[str], None] | None, Callable[[str], None] | None],
|
|
55
|
+
str,
|
|
56
|
+
] | None = None
|
|
57
|
+
self._status_chip: QWidget | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def plugin_id(self) -> str:
|
|
61
|
+
"""The unique plugin identifier (e.g. ``com.mycompany.myplugin``)."""
|
|
62
|
+
return self._plugin_id
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def version(self) -> str:
|
|
66
|
+
"""The plugin version from ``plugin.json``."""
|
|
67
|
+
return self._version
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def plugin_name(self) -> str:
|
|
71
|
+
"""The display name shown in the sidebar."""
|
|
72
|
+
return self._plugin_name
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def plugin_subtitle(self) -> str:
|
|
76
|
+
"""The subtitle shown below the plugin name in the header."""
|
|
77
|
+
return self._plugin_subtitle
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def plugin_icon(self) -> str:
|
|
81
|
+
"""The icon name (Lucide icon ID) for this plugin."""
|
|
82
|
+
return self._plugin_icon
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def plugin_icon_colors(self) -> tuple[str, str]:
|
|
86
|
+
"""Gradient colors ``(start, end)`` for the plugin icon background."""
|
|
87
|
+
return self._plugin_icon_colors
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def plugin_icon_path(self) -> str:
|
|
91
|
+
"""Path to a custom icon file, if set."""
|
|
92
|
+
return self._plugin_icon_path
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def data_dir(self) -> Path:
|
|
96
|
+
"""Per-instance directory for plugin file storage.
|
|
97
|
+
|
|
98
|
+
Created automatically by the app before :meth:`on_ready` is called.
|
|
99
|
+
Use :meth:`read_json` and :meth:`write_json` for convenient access.
|
|
100
|
+
|
|
101
|
+
:raises RuntimeError: If accessed before ``on_ready()``.
|
|
102
|
+
"""
|
|
103
|
+
if self._data_dir is None:
|
|
104
|
+
raise RuntimeError("data_dir is not available before on_ready()")
|
|
105
|
+
return self._data_dir
|
|
106
|
+
|
|
107
|
+
def read_json(self, filename: str) -> dict:
|
|
108
|
+
"""Read a JSON file from :attr:`data_dir`.
|
|
109
|
+
|
|
110
|
+
:param filename: Name of the JSON file (e.g. ``"state.json"``).
|
|
111
|
+
:returns: Parsed dict, or empty dict if the file doesn't exist.
|
|
112
|
+
"""
|
|
113
|
+
path = self.data_dir / filename
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return {}
|
|
116
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
|
|
118
|
+
def write_json(self, filename: str, data: dict) -> None:
|
|
119
|
+
"""Atomic-write a JSON file to :attr:`data_dir`.
|
|
120
|
+
|
|
121
|
+
Writes to a temporary file first, then renames — safe against crashes.
|
|
122
|
+
|
|
123
|
+
:param filename: Name of the JSON file (e.g. ``"state.json"``).
|
|
124
|
+
:param data: The dict to serialize.
|
|
125
|
+
"""
|
|
126
|
+
path = self.data_dir / filename
|
|
127
|
+
tmp = path.with_suffix(".tmp")
|
|
128
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
129
|
+
tmp.replace(path)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def settings(self) -> dict:
|
|
133
|
+
"""Current plugin settings values as defined in ``settings.json``."""
|
|
134
|
+
return self._settings
|
|
135
|
+
|
|
136
|
+
def secret_file(self, field_id: str) -> bytes:
|
|
137
|
+
"""Return the raw bytes for a ``secret_file`` settings field.
|
|
138
|
+
|
|
139
|
+
:param field_id: The field ID from ``settings.json``.
|
|
140
|
+
:returns: Raw file content as bytes.
|
|
141
|
+
:raises KeyError: If no secret file is loaded for the given field.
|
|
142
|
+
"""
|
|
143
|
+
value = self._settings.get(field_id)
|
|
144
|
+
if not isinstance(value, bytes):
|
|
145
|
+
raise KeyError(f"No secret file loaded for '{field_id}'")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
def secret_file_stream(self, field_id: str) -> io.BytesIO:
|
|
149
|
+
"""Return a file-like ``BytesIO`` for a ``secret_file`` settings field.
|
|
150
|
+
|
|
151
|
+
Useful when a library expects a file object instead of raw bytes.
|
|
152
|
+
|
|
153
|
+
:param field_id: The field ID from ``settings.json``.
|
|
154
|
+
:returns: A seekable ``BytesIO`` stream.
|
|
155
|
+
"""
|
|
156
|
+
return io.BytesIO(self.secret_file(field_id))
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def project_id(self) -> str:
|
|
160
|
+
"""The unique ID of the currently open project."""
|
|
161
|
+
return self._project_id
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def project_path(self) -> str:
|
|
165
|
+
"""Filesystem path to the project directory."""
|
|
166
|
+
return self._project_path
|
|
167
|
+
|
|
168
|
+
def on_ready(self) -> None:
|
|
169
|
+
"""Called when the plugin is fully initialized.
|
|
170
|
+
|
|
171
|
+
Override this to build your UI inside :attr:`container` and set up
|
|
172
|
+
initial state. All context properties (:attr:`settings`, :attr:`data_dir`,
|
|
173
|
+
:attr:`git`, etc.) are available at this point.
|
|
174
|
+
"""
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def on_settings_changed(self, settings: dict) -> None:
|
|
178
|
+
"""Called when the user saves plugin settings.
|
|
179
|
+
|
|
180
|
+
Override this to react to settings changes at runtime (e.g. refresh
|
|
181
|
+
data with a new API key). The base implementation updates
|
|
182
|
+
:attr:`settings` — call ``super()`` if you override.
|
|
183
|
+
|
|
184
|
+
:param settings: The full updated settings dict.
|
|
185
|
+
"""
|
|
186
|
+
self._settings = settings
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def status_chip(self) -> QWidget | None:
|
|
190
|
+
"""Optional status chip widget in the plugin header.
|
|
191
|
+
|
|
192
|
+
Injected by the app — ``None`` until :meth:`on_ready` runs.
|
|
193
|
+
"""
|
|
194
|
+
return self._status_chip
|
|
195
|
+
|
|
196
|
+
def set_subtitle(self, text: str) -> None:
|
|
197
|
+
"""Update the subtitle shown in the plugin header.
|
|
198
|
+
|
|
199
|
+
:param text: New subtitle text (e.g. ``"v1.2.0 — 3 locales"``).
|
|
200
|
+
"""
|
|
201
|
+
self._plugin_subtitle = text
|
|
202
|
+
if self._subtitle_changed_fn:
|
|
203
|
+
self._subtitle_changed_fn(text)
|
|
204
|
+
|
|
205
|
+
def notify(self, message: str, level: str = "info") -> None:
|
|
206
|
+
"""Show a toast notification to the user.
|
|
207
|
+
|
|
208
|
+
:param message: The notification text.
|
|
209
|
+
:param level: One of ``"info"``, ``"success"``, ``"warning"``, ``"error"``.
|
|
210
|
+
"""
|
|
211
|
+
if self._notify_fn:
|
|
212
|
+
self._notify_fn(message, level)
|
|
213
|
+
else:
|
|
214
|
+
log.info("[%s] %s: %s", self._plugin_id, level, message)
|
|
215
|
+
|
|
216
|
+
def translate(
|
|
217
|
+
self,
|
|
218
|
+
text: str,
|
|
219
|
+
source_lang: str,
|
|
220
|
+
target_lang: str,
|
|
221
|
+
on_done: Callable[[str], None] | None = None,
|
|
222
|
+
on_error: Callable[[str], None] | None = None,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Request an asynchronous translation via the app's translation service.
|
|
225
|
+
|
|
226
|
+
The translation runs in a background thread. Results arrive via callbacks.
|
|
227
|
+
|
|
228
|
+
:param text: The text to translate.
|
|
229
|
+
:param source_lang: Source language code (e.g. ``"en"``).
|
|
230
|
+
:param target_lang: Target language code (e.g. ``"de"``).
|
|
231
|
+
:param on_done: Callback receiving the translated text.
|
|
232
|
+
:param on_error: Callback receiving an error message.
|
|
233
|
+
:returns: A request ID for tracking.
|
|
234
|
+
:raises RuntimeError: If no translation service is configured.
|
|
235
|
+
"""
|
|
236
|
+
if self._translate_fn:
|
|
237
|
+
return self._translate_fn(text, source_lang, target_lang, on_done, on_error)
|
|
238
|
+
raise RuntimeError("Translation service is not available")
|
forkbit_sdk/cli.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from forkbit_sdk import __version__
|
|
7
|
+
from forkbit_sdk.commands import build, init_plugin, release, validate
|
|
8
|
+
|
|
9
|
+
_COMMANDS = {
|
|
10
|
+
"init": init_plugin,
|
|
11
|
+
"validate": validate,
|
|
12
|
+
"build": build,
|
|
13
|
+
"release": release,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="forkbit", description="ForkBit Plugin SDK")
|
|
19
|
+
parser.add_argument("--version", action="version", version=f"forkbit-sdk {__version__}")
|
|
20
|
+
|
|
21
|
+
sub = parser.add_subparsers(dest="command")
|
|
22
|
+
plugin_parser = sub.add_parser("plugin", help="Plugin authoring tools")
|
|
23
|
+
plugin_sub = plugin_parser.add_subparsers(dest="action")
|
|
24
|
+
|
|
25
|
+
for cmd in _COMMANDS.values():
|
|
26
|
+
cmd.register(plugin_sub)
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
if args.command == "plugin":
|
|
31
|
+
if args.action is None:
|
|
32
|
+
plugin_parser.print_help()
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
cmd = _COMMANDS[args.action]
|
|
35
|
+
sys.exit(cmd.run(args))
|
|
36
|
+
|
|
37
|
+
parser.print_help()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
PLATFORM_TAGS = {
|
|
11
|
+
("darwin", "arm64"): "macos_arm64",
|
|
12
|
+
("darwin", "x86_64"): "macos_x86_64",
|
|
13
|
+
("win32", "AMD64"): "windows_x86_64",
|
|
14
|
+
("linux", "x86_64"): "linux_x86_64",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ALL_PLATFORMS = ["macos_arm64", "macos_x86_64", "windows_x86_64", "linux_x86_64"]
|
|
18
|
+
|
|
19
|
+
PIP_PLATFORM_MAP = {
|
|
20
|
+
"macos_arm64": "macosx_11_0_arm64",
|
|
21
|
+
"macos_x86_64": "macosx_11_0_x86_64",
|
|
22
|
+
"windows_x86_64": "win_amd64",
|
|
23
|
+
"linux_x86_64": "manylinux2014_x86_64",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def current_platform_tag() -> str:
|
|
28
|
+
return PLATFORM_TAGS.get((sys.platform, platform.machine()), "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def python_version_tag() -> str:
|
|
32
|
+
return f"{sys.version_info.major}{sys.version_info.minor}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_pure_wheel(whl_path: Path) -> bool:
|
|
36
|
+
return "-none-any.whl" in whl_path.name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unpack_wheel(whl_path: Path, dest: Path) -> None:
|
|
40
|
+
with zipfile.ZipFile(whl_path, "r") as zf:
|
|
41
|
+
for info in zf.infolist():
|
|
42
|
+
if info.filename.endswith(".dist-info/") or ".dist-info/" in info.filename:
|
|
43
|
+
continue
|
|
44
|
+
zf.extract(info, dest)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def download_for_current_platform(
|
|
48
|
+
requirements: list[str],
|
|
49
|
+
vendor_dir: Path,
|
|
50
|
+
platform_tag: str,
|
|
51
|
+
) -> bool:
|
|
52
|
+
tmp = vendor_dir / "_download"
|
|
53
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
print(f" Resolving {', '.join(requirements)} ...")
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[
|
|
58
|
+
sys.executable, "-m", "pip", "download",
|
|
59
|
+
*requirements,
|
|
60
|
+
"--dest", str(tmp),
|
|
61
|
+
"--only-binary=:all:",
|
|
62
|
+
],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
stderr = result.stderr.strip()
|
|
68
|
+
last_line = stderr.splitlines()[-1] if stderr else "unknown error"
|
|
69
|
+
print(f" ✗ Failed to download dependencies for {platform_tag}:")
|
|
70
|
+
print(f" {last_line}")
|
|
71
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
_sort_wheels(tmp, vendor_dir, platform_tag)
|
|
75
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def download_for_platform(
|
|
80
|
+
requirements: list[str],
|
|
81
|
+
vendor_dir: Path,
|
|
82
|
+
platform_tag: str,
|
|
83
|
+
) -> bool:
|
|
84
|
+
pip_platform = PIP_PLATFORM_MAP.get(platform_tag)
|
|
85
|
+
if not pip_platform:
|
|
86
|
+
print(f" ✗ Unknown platform: {platform_tag}")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
tmp = vendor_dir / f"_download_{platform_tag}"
|
|
90
|
+
tmp.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
py_ver = python_version_tag()
|
|
93
|
+
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
[
|
|
96
|
+
sys.executable, "-m", "pip", "download",
|
|
97
|
+
*requirements,
|
|
98
|
+
"--dest", str(tmp),
|
|
99
|
+
"--only-binary=:all:",
|
|
100
|
+
"--platform", pip_platform,
|
|
101
|
+
"--python-version", py_ver,
|
|
102
|
+
],
|
|
103
|
+
capture_output=True,
|
|
104
|
+
text=True,
|
|
105
|
+
)
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
stderr = result.stderr.strip()
|
|
108
|
+
last_line = stderr.splitlines()[-1] if stderr else "unknown error"
|
|
109
|
+
print(f" ✗ Failed to download dependencies for {platform_tag}:")
|
|
110
|
+
print(f" {last_line}")
|
|
111
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
_sort_wheels(tmp, vendor_dir, platform_tag)
|
|
115
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _sort_wheels(download_dir: Path, vendor_dir: Path, platform_tag: str) -> None:
|
|
120
|
+
common_dir = vendor_dir / "common"
|
|
121
|
+
plat_dir = vendor_dir / platform_tag
|
|
122
|
+
|
|
123
|
+
for whl in download_dir.glob("*.whl"):
|
|
124
|
+
if is_pure_wheel(whl):
|
|
125
|
+
dest = common_dir
|
|
126
|
+
else:
|
|
127
|
+
dest = plat_dir
|
|
128
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
unpack_wheel(whl, dest)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def copy_plugin_source(plugin_dir: Path, build_dir: Path) -> None:
|
|
133
|
+
for item in plugin_dir.iterdir():
|
|
134
|
+
if item.name.startswith("_") or item.name.startswith("."):
|
|
135
|
+
continue
|
|
136
|
+
if item.suffix == ".forkbit":
|
|
137
|
+
continue
|
|
138
|
+
if item.is_file():
|
|
139
|
+
shutil.copy2(item, build_dir / item.name)
|
|
140
|
+
elif item.is_dir() and item.name != "vendor":
|
|
141
|
+
shutil.copytree(item, build_dir / item.name)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def compile_pyc(build_dir: Path) -> None:
|
|
145
|
+
import compileall
|
|
146
|
+
import py_compile
|
|
147
|
+
|
|
148
|
+
compileall.compile_dir(
|
|
149
|
+
str(build_dir),
|
|
150
|
+
quiet=1,
|
|
151
|
+
legacy=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for py_file in list(build_dir.rglob("*.py")):
|
|
155
|
+
pyc_file = py_file.with_suffix(".pyc")
|
|
156
|
+
if not pyc_file.exists():
|
|
157
|
+
try:
|
|
158
|
+
py_compile.compile(str(py_file), cfile=str(pyc_file), doraise=True)
|
|
159
|
+
except py_compile.PyCompileError:
|
|
160
|
+
continue
|
|
161
|
+
py_file.unlink()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_zip(plugin_dir: Path, build_dir: Path, manifest: dict) -> Path:
|
|
165
|
+
plugin_id = manifest["id"]
|
|
166
|
+
version = manifest["version"]
|
|
167
|
+
output_name = f"{plugin_id}-{version}.forkbit"
|
|
168
|
+
output_path = plugin_dir / output_name
|
|
169
|
+
|
|
170
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
171
|
+
for file_path in sorted(build_dir.rglob("*")):
|
|
172
|
+
if file_path.is_file():
|
|
173
|
+
arcname = file_path.relative_to(build_dir)
|
|
174
|
+
zf.write(file_path, arcname)
|
|
175
|
+
|
|
176
|
+
return output_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def format_size(size_bytes: int) -> str:
|
|
180
|
+
size_kb = size_bytes / 1024
|
|
181
|
+
if size_kb > 1024:
|
|
182
|
+
return f"{size_kb / 1024:.1f} MB"
|
|
183
|
+
return f"{size_kb:.0f} KB"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from forkbit_sdk.commands import validate
|
|
9
|
+
from forkbit_sdk.commands._shared import (
|
|
10
|
+
copy_plugin_source,
|
|
11
|
+
create_zip,
|
|
12
|
+
current_platform_tag,
|
|
13
|
+
download_for_current_platform,
|
|
14
|
+
format_size,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run(args) -> int:
|
|
19
|
+
plugin_dir = Path(args.dir).resolve()
|
|
20
|
+
|
|
21
|
+
print(f"Building plugin in {plugin_dir}/\n")
|
|
22
|
+
print("Running validation...")
|
|
23
|
+
|
|
24
|
+
result = validate._Result()
|
|
25
|
+
manifest = validate._check_manifest(plugin_dir, result)
|
|
26
|
+
if manifest:
|
|
27
|
+
validate._check_entry_point(plugin_dir, manifest, result)
|
|
28
|
+
validate._check_imports(plugin_dir, manifest, result)
|
|
29
|
+
|
|
30
|
+
if not result.success:
|
|
31
|
+
print()
|
|
32
|
+
result.print()
|
|
33
|
+
print(f"\nBuild aborted: {len(result.failed)} validation error(s).")
|
|
34
|
+
return 1
|
|
35
|
+
|
|
36
|
+
print(f" ✓ All {len(result.passed)} checks passed\n")
|
|
37
|
+
|
|
38
|
+
platform_tag = current_platform_tag()
|
|
39
|
+
if not platform_tag:
|
|
40
|
+
print(f" ✗ Unsupported platform: {sys.platform}/{platform.machine()}")
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
for old in plugin_dir.glob("*.forkbit"):
|
|
44
|
+
old.unlink()
|
|
45
|
+
|
|
46
|
+
build_dir = plugin_dir / "_build"
|
|
47
|
+
if build_dir.exists():
|
|
48
|
+
shutil.rmtree(build_dir)
|
|
49
|
+
build_dir.mkdir()
|
|
50
|
+
|
|
51
|
+
copy_plugin_source(plugin_dir, build_dir)
|
|
52
|
+
|
|
53
|
+
requirements = manifest.get("requirements", [])
|
|
54
|
+
if requirements:
|
|
55
|
+
print(f"Vendoring dependencies for {platform_tag}...")
|
|
56
|
+
vendor_dir = build_dir / "vendor"
|
|
57
|
+
if not download_for_current_platform(requirements, vendor_dir, platform_tag):
|
|
58
|
+
shutil.rmtree(build_dir, ignore_errors=True)
|
|
59
|
+
return 1
|
|
60
|
+
print(f" ✓ Dependencies vendored\n")
|
|
61
|
+
|
|
62
|
+
output_path = create_zip(plugin_dir, build_dir, manifest)
|
|
63
|
+
shutil.rmtree(build_dir, ignore_errors=True)
|
|
64
|
+
|
|
65
|
+
print(f"Built: {output_path.name} ({format_size(output_path.stat().st_size)})")
|
|
66
|
+
print(f"Platform: {platform_tag}")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register(subparsers) -> None:
|
|
71
|
+
p = subparsers.add_parser("build", help="Build plugin for current platform (dev)")
|
|
72
|
+
p.add_argument("dir", nargs="?", default=".", help="Plugin directory to build")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_ID_PATTERN = re.compile(r"^com\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _prompt(label: str, default: str = "") -> str:
|
|
12
|
+
suffix = f" [{default}]" if default else ""
|
|
13
|
+
value = input(f"{label}{suffix}: ").strip()
|
|
14
|
+
return value or default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_class_name(name: str) -> str:
|
|
18
|
+
return re.sub(r"[^a-zA-Z0-9]", "", name.title()) + "Plugin"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _generate_manifest(plugin_id: str, name: str, description: str) -> str:
|
|
22
|
+
data = {
|
|
23
|
+
"id": plugin_id,
|
|
24
|
+
"name": name,
|
|
25
|
+
"version": "0.1.0",
|
|
26
|
+
"description": description,
|
|
27
|
+
"entry": "main.py",
|
|
28
|
+
"min_app_version": "0.2.0",
|
|
29
|
+
"requirements": [],
|
|
30
|
+
}
|
|
31
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _generate_main(name: str) -> str:
|
|
35
|
+
cls = _to_class_name(name)
|
|
36
|
+
return f'''\
|
|
37
|
+
from PySide6.QtCore import Qt
|
|
38
|
+
from PySide6.QtWidgets import QLabel, QVBoxLayout
|
|
39
|
+
|
|
40
|
+
from forkbit_sdk import BasePlugin
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class {cls}(BasePlugin):
|
|
44
|
+
|
|
45
|
+
def on_ready(self) -> None:
|
|
46
|
+
layout = QVBoxLayout(self.container)
|
|
47
|
+
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
48
|
+
|
|
49
|
+
status = QLabel("Plugin loaded successfully.")
|
|
50
|
+
layout.addWidget(status)
|
|
51
|
+
'''
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _generate_settings() -> str:
|
|
55
|
+
return json.dumps({"fields": []}, indent=2, ensure_ascii=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(args) -> int:
|
|
59
|
+
target = Path(args.dir)
|
|
60
|
+
|
|
61
|
+
if not args.non_interactive:
|
|
62
|
+
print("ForkBit Plugin Scaffold\n")
|
|
63
|
+
plugin_id = _prompt("Plugin ID (com.<vendor>.<name>)", "")
|
|
64
|
+
name = _prompt("Display name", "")
|
|
65
|
+
description = _prompt("Description", "")
|
|
66
|
+
else:
|
|
67
|
+
plugin_id = args.id or ""
|
|
68
|
+
name = args.name or ""
|
|
69
|
+
description = args.description or ""
|
|
70
|
+
|
|
71
|
+
if not plugin_id:
|
|
72
|
+
print("Error: Plugin ID is required.", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
if not _ID_PATTERN.match(plugin_id):
|
|
75
|
+
print(
|
|
76
|
+
f"Error: Plugin ID '{plugin_id}' does not match required format "
|
|
77
|
+
"com.<vendor>.<name> (lowercase, alphanumeric + underscore).",
|
|
78
|
+
file=sys.stderr,
|
|
79
|
+
)
|
|
80
|
+
return 1
|
|
81
|
+
if not name:
|
|
82
|
+
print("Error: Display name is required.", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
if target.name == ".":
|
|
86
|
+
target = Path.cwd() / plugin_id.split(".")[-1]
|
|
87
|
+
|
|
88
|
+
if target.exists() and any(target.iterdir()):
|
|
89
|
+
print(f"Error: Directory '{target}' already exists and is not empty.", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(target / "plugin.json").write_text(_generate_manifest(plugin_id, name, description) + "\n")
|
|
94
|
+
(target / "main.py").write_text(_generate_main(name))
|
|
95
|
+
(target / "settings.json").write_text(_generate_settings() + "\n")
|
|
96
|
+
|
|
97
|
+
print(f"\nCreated plugin scaffold in {target}/")
|
|
98
|
+
print(" plugin.json — manifest")
|
|
99
|
+
print(f" main.py — {_to_class_name(name)} (BasePlugin subclass)")
|
|
100
|
+
print(" settings.json — settings schema (empty)")
|
|
101
|
+
print("\nNext steps:")
|
|
102
|
+
print(" 1. Build your UI in on_ready()")
|
|
103
|
+
print(f" 2. Run: forkbit plugin validate {target}")
|
|
104
|
+
print(f" 3. Run: forkbit plugin build {target}")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def register(subparsers) -> None:
|
|
109
|
+
p = subparsers.add_parser("init", help="Scaffold a new plugin project")
|
|
110
|
+
p.add_argument("dir", nargs="?", default=".", help="Directory to create plugin in")
|
|
111
|
+
p.add_argument("--id", help="Plugin ID (non-interactive mode)")
|
|
112
|
+
p.add_argument("--name", help="Display name (non-interactive mode)")
|
|
113
|
+
p.add_argument("--description", default="", help="Description (non-interactive mode)")
|
|
114
|
+
p.add_argument(
|
|
115
|
+
"--non-interactive", action="store_true",
|
|
116
|
+
help="Skip prompts, use --id/--name/--description flags",
|
|
117
|
+
)
|