ppx-py 6.0.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.
- ppx_py/__init__.py +21 -0
- ppx_py/application.py +95 -0
- ppx_py/bridge.py +147 -0
- ppx_py/cli.py +91 -0
- ppx_py/commands/__init__.py +1 -0
- ppx_py/commands/build.py +47 -0
- ppx_py/commands/dev.py +78 -0
- ppx_py/commands/doctor.py +159 -0
- ppx_py/commands/icon.py +73 -0
- ppx_py/commands/update.py +359 -0
- ppx_py/packaging/__init__.py +11 -0
- ppx_py/packaging/installer.py +190 -0
- ppx_py/packaging/spec.py +81 -0
- ppx_py/paths.py +40 -0
- ppx_py/project.py +63 -0
- ppx_py/runtime.py +31 -0
- ppx_py/scaffold.py +286 -0
- ppx_py/services/__init__.py +3 -0
- ppx_py/services/system.py +140 -0
- ppx_py/settings.py +385 -0
- ppx_py/storage/__init__.py +3 -0
- ppx_py/storage/json_store.py +76 -0
- ppx_py/template/assets/dmg-background.png +0 -0
- ppx_py/template/assets/logo.icns +0 -0
- ppx_py/template/assets/logo.ico +0 -0
- ppx_py/template/assets/logo.png +0 -0
- ppx_py/update/__init__.py +3 -0
- ppx_py/update/application_update.py +244 -0
- ppx_py-6.0.0.dist-info/METADATA +100 -0
- ppx_py-6.0.0.dist-info/RECORD +34 -0
- ppx_py-6.0.0.dist-info/WHEEL +5 -0
- ppx_py-6.0.0.dist-info/entry_points.txt +2 -0
- ppx_py-6.0.0.dist-info/licenses/LICENSE +299 -0
- ppx_py-6.0.0.dist-info/top_level.txt +1 -0
ppx_py/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""PPX V6 public runtime API."""
|
|
2
|
+
|
|
3
|
+
from .application import Application
|
|
4
|
+
from .bridge import Bridge, BridgeError, api_method
|
|
5
|
+
from .settings import Settings, SettingsError
|
|
6
|
+
from .runtime import create_application, run_project
|
|
7
|
+
from .paths import app_data_path, resource_path
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Application",
|
|
11
|
+
"Bridge",
|
|
12
|
+
"BridgeError",
|
|
13
|
+
"Settings",
|
|
14
|
+
"SettingsError",
|
|
15
|
+
"api_method",
|
|
16
|
+
"app_data_path",
|
|
17
|
+
"create_application",
|
|
18
|
+
"resource_path",
|
|
19
|
+
"run_project",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "6.0.0"
|
ppx_py/application.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""PPX V6 application lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Union
|
|
8
|
+
|
|
9
|
+
from .bridge import Bridge, JavascriptAPI
|
|
10
|
+
from .services import SystemService
|
|
11
|
+
from .settings import Settings
|
|
12
|
+
from .storage import JsonStorage
|
|
13
|
+
from .update import ApplicationUpdater
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Application:
|
|
17
|
+
def __init__(self, settings: Settings) -> None:
|
|
18
|
+
self.settings = settings
|
|
19
|
+
self.bridge = Bridge()
|
|
20
|
+
self.window: Any = None
|
|
21
|
+
self.storage = JsonStorage(settings.app_data_dir / settings.storage.filename)
|
|
22
|
+
self.system = SystemService(settings, lambda: self.window)
|
|
23
|
+
self.application_updater = ApplicationUpdater(
|
|
24
|
+
settings, lambda progress: self.bridge.emit("applicationUpdate.progress", progress)
|
|
25
|
+
)
|
|
26
|
+
self._register_core_api()
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_config(cls, path: Union[str, Path] = "ppx.toml") -> "Application":
|
|
30
|
+
return cls(Settings.load(path))
|
|
31
|
+
|
|
32
|
+
def register_api(self, api: object) -> "Application":
|
|
33
|
+
self.bridge.register_api(api)
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def _register_core_api(self) -> None:
|
|
37
|
+
methods = {
|
|
38
|
+
"system.getAppInfo": self.system.get_app_info,
|
|
39
|
+
"system.getOwner": self.system.get_owner,
|
|
40
|
+
"system.openPath": self.system.open_path,
|
|
41
|
+
"system.openFileDialog": self.system.open_file_dialog,
|
|
42
|
+
"system.saveFileDialog": self.system.save_file_dialog,
|
|
43
|
+
"system.selectDirectory": self.system.select_directory,
|
|
44
|
+
"window.getState": self.system.get_window_state,
|
|
45
|
+
"window.minimize": self.system.minimize_window,
|
|
46
|
+
"window.maximize": self.system.maximize_window,
|
|
47
|
+
"window.restore": self.system.restore_window,
|
|
48
|
+
"window.toggleFullscreen": self.system.toggle_fullscreen,
|
|
49
|
+
"window.close": self.system.close_window,
|
|
50
|
+
"storage.get": self.storage.get,
|
|
51
|
+
"storage.set": self.storage.set,
|
|
52
|
+
"storage.delete": self.storage.delete,
|
|
53
|
+
"applicationUpdate.check": self.application_updater.check,
|
|
54
|
+
"applicationUpdate.download": self.application_updater.download,
|
|
55
|
+
"applicationUpdate.cancel": self.application_updater.cancel,
|
|
56
|
+
}
|
|
57
|
+
for name, handler in methods.items():
|
|
58
|
+
self.bridge.register(name, handler)
|
|
59
|
+
|
|
60
|
+
def run(self, *, dev: bool = False, cef: bool = False) -> None:
|
|
61
|
+
import webview
|
|
62
|
+
|
|
63
|
+
if dev:
|
|
64
|
+
url = f"http://127.0.0.1:{self.settings.development.port}/"
|
|
65
|
+
else:
|
|
66
|
+
url = str(self.settings.frontend_dir / "index.html")
|
|
67
|
+
if not Path(url).is_file():
|
|
68
|
+
raise FileNotFoundError(f"前端产物不存在,请先执行 ppx build: {url}")
|
|
69
|
+
mimetypes.add_type("application/javascript", ".js")
|
|
70
|
+
|
|
71
|
+
screen = webview.screens[0]
|
|
72
|
+
width = int(screen.width * self.settings.window.width_ratio)
|
|
73
|
+
height = int(screen.height * self.settings.window.height_ratio)
|
|
74
|
+
self.window = webview.create_window(
|
|
75
|
+
title=self.settings.project.name,
|
|
76
|
+
url=url,
|
|
77
|
+
js_api=JavascriptAPI(self.bridge),
|
|
78
|
+
width=width,
|
|
79
|
+
height=height,
|
|
80
|
+
min_size=(
|
|
81
|
+
int(width * self.settings.window.min_width_ratio),
|
|
82
|
+
int(height * self.settings.window.min_height_ratio),
|
|
83
|
+
),
|
|
84
|
+
resizable=self.settings.window.resizable,
|
|
85
|
+
fullscreen=self.settings.window.fullscreen,
|
|
86
|
+
on_top=self.settings.window.always_on_top,
|
|
87
|
+
confirm_close=self.settings.window.confirm_close,
|
|
88
|
+
background_color=self.settings.window.background_color,
|
|
89
|
+
)
|
|
90
|
+
self.bridge.set_window(self.window)
|
|
91
|
+
self.window.events.shown += self._on_shown
|
|
92
|
+
webview.start(debug=dev, http_server=True, gui="cef" if cef else None)
|
|
93
|
+
|
|
94
|
+
def _on_shown(self, *_args: Any) -> None:
|
|
95
|
+
self.storage.initialize()
|
ppx_py/bridge.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""A small, stable Python/JavaScript RPC boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BridgeError(Exception):
|
|
19
|
+
def __init__(self, code: str, message: str):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code = code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def api_method(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
25
|
+
"""Explicitly expose a user method under a stable RPC name."""
|
|
26
|
+
|
|
27
|
+
if not name or "." not in name:
|
|
28
|
+
raise ValueError("PPX API 名称必须包含命名空间,例如 user.greet")
|
|
29
|
+
|
|
30
|
+
def decorate(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
31
|
+
setattr(function, "__ppx_api_method__", name)
|
|
32
|
+
return function
|
|
33
|
+
|
|
34
|
+
return decorate
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Bridge:
|
|
38
|
+
"""The only object exposed to ``window.pywebview.api``."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._methods: dict[str, Callable[..., Any]] = {}
|
|
42
|
+
self._window: Any = None
|
|
43
|
+
|
|
44
|
+
def set_window(self, window: Any) -> None:
|
|
45
|
+
self._window = window
|
|
46
|
+
|
|
47
|
+
def register(self, name: str, handler: Callable[..., Any]) -> None:
|
|
48
|
+
if name in self._methods:
|
|
49
|
+
raise ValueError(f"PPX API 已注册: {name}")
|
|
50
|
+
self._methods[name] = handler
|
|
51
|
+
|
|
52
|
+
def register_api(self, api: object) -> None:
|
|
53
|
+
found = 0
|
|
54
|
+
for _, member in inspect.getmembers(api, predicate=callable):
|
|
55
|
+
name = getattr(member, "__ppx_api_method__", None)
|
|
56
|
+
if name:
|
|
57
|
+
self.register(name, member)
|
|
58
|
+
found += 1
|
|
59
|
+
if not found:
|
|
60
|
+
raise ValueError("业务 API 没有使用 @api_method 注册任何方法")
|
|
61
|
+
|
|
62
|
+
def call(self, method: str, params: Any = None, request_id: Optional[str] = None) -> dict[str, Any]:
|
|
63
|
+
request_id = request_id or uuid.uuid4().hex
|
|
64
|
+
try:
|
|
65
|
+
handler = self._methods.get(method)
|
|
66
|
+
if handler is None:
|
|
67
|
+
raise BridgeError("METHOD_NOT_FOUND", f"未注册的 API: {method}")
|
|
68
|
+
if params is None:
|
|
69
|
+
arguments, keywords = (), {}
|
|
70
|
+
elif isinstance(params, Mapping):
|
|
71
|
+
arguments, keywords = (), dict(params)
|
|
72
|
+
elif isinstance(params, Sequence) and not isinstance(params, (str, bytes, bytearray)):
|
|
73
|
+
arguments, keywords = tuple(params), {}
|
|
74
|
+
else:
|
|
75
|
+
raise BridgeError("INVALID_PARAMS", "params 必须是对象、数组或 null")
|
|
76
|
+
try:
|
|
77
|
+
inspect.signature(handler).bind(*arguments, **keywords)
|
|
78
|
+
except TypeError as exc:
|
|
79
|
+
raise BridgeError("INVALID_PARAMS", str(exc)) from exc
|
|
80
|
+
data = _resolve_awaitable(handler(*arguments, **keywords))
|
|
81
|
+
try:
|
|
82
|
+
json.dumps(data, allow_nan=False)
|
|
83
|
+
except (TypeError, ValueError) as exc:
|
|
84
|
+
raise BridgeError(
|
|
85
|
+
"INVALID_RESULT", "Python API 返回值必须是可序列化的 JSON 数据"
|
|
86
|
+
) from exc
|
|
87
|
+
return {"ok": True, "data": data, "error": None, "requestId": request_id}
|
|
88
|
+
except BridgeError as exc:
|
|
89
|
+
return self._failure(exc.code, str(exc), request_id)
|
|
90
|
+
except Exception:
|
|
91
|
+
LOGGER.exception("PPX Python API 执行失败: method=%s requestId=%s", method, request_id)
|
|
92
|
+
return self._failure("INTERNAL_ERROR", "Python API 执行失败", request_id)
|
|
93
|
+
|
|
94
|
+
def emit(self, event: str, data: Any) -> None:
|
|
95
|
+
if self._window is None:
|
|
96
|
+
return
|
|
97
|
+
# ASCII escaping also protects JavaScript parsing from U+2028/U+2029 separators.
|
|
98
|
+
event_json = json.dumps(event)
|
|
99
|
+
data_json = json.dumps(data, allow_nan=False)
|
|
100
|
+
self._window.evaluate_js(
|
|
101
|
+
f"if (typeof window.__ppxDispatch === 'function') window.__ppxDispatch({event_json}, {data_json})"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _failure(code: str, message: str, request_id: str) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"ok": False,
|
|
108
|
+
"data": None,
|
|
109
|
+
"error": {"code": code, "message": message},
|
|
110
|
+
"requestId": request_id,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_awaitable(value: Any) -> Any:
|
|
115
|
+
"""Resolve async business methods behind pywebview's synchronous JS API."""
|
|
116
|
+
if not inspect.isawaitable(value):
|
|
117
|
+
return value
|
|
118
|
+
try:
|
|
119
|
+
asyncio.get_running_loop()
|
|
120
|
+
except RuntimeError:
|
|
121
|
+
return asyncio.run(value)
|
|
122
|
+
|
|
123
|
+
result: list[Any] = []
|
|
124
|
+
failure: list[BaseException] = []
|
|
125
|
+
|
|
126
|
+
def run() -> None:
|
|
127
|
+
try:
|
|
128
|
+
result.append(asyncio.run(value))
|
|
129
|
+
except BaseException as exc: # Re-raised on the calling thread below.
|
|
130
|
+
failure.append(exc)
|
|
131
|
+
|
|
132
|
+
worker = threading.Thread(target=run, name="ppx-async-api", daemon=True)
|
|
133
|
+
worker.start()
|
|
134
|
+
worker.join()
|
|
135
|
+
if failure:
|
|
136
|
+
raise failure[0]
|
|
137
|
+
return result[0]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class JavascriptAPI:
|
|
141
|
+
"""Only expose RPC dispatch; registration and event emission stay in Python."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, bridge: Bridge) -> None:
|
|
144
|
+
self._bridge = bridge
|
|
145
|
+
|
|
146
|
+
def call(self, method: str, params: Any = None, request_id: Optional[str] = None) -> dict[str, Any]:
|
|
147
|
+
return self._bridge.call(method, params, request_id)
|
ppx_py/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""PPX command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .commands import build, dev, doctor, icon, update
|
|
11
|
+
from .scaffold import create_project, initialize_project
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _new(args: object) -> int:
|
|
15
|
+
try:
|
|
16
|
+
target = create_project(
|
|
17
|
+
str(getattr(args, "name")),
|
|
18
|
+
getattr(args, "directory", None),
|
|
19
|
+
str(getattr(args, "frontend", "vanilla")),
|
|
20
|
+
)
|
|
21
|
+
print(f"PPX 项目已创建: {target}")
|
|
22
|
+
print(f"下一步: cd {target.name} && ppx init && ppx dev")
|
|
23
|
+
return 0
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
print(f"[失败] {exc}")
|
|
26
|
+
return 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _init(_args: object) -> int:
|
|
30
|
+
return initialize_project()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(prog="ppx", description="PPX developer tools")
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"PPX {__version__}")
|
|
36
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
37
|
+
|
|
38
|
+
new_parser = commands.add_parser("new", help="创建只有业务代码与可配置资源的新项目")
|
|
39
|
+
new_parser.add_argument("name", help="项目名称")
|
|
40
|
+
new_parser.add_argument("--directory", help="目标目录,默认使用项目名生成目录")
|
|
41
|
+
new_parser.add_argument(
|
|
42
|
+
"--frontend",
|
|
43
|
+
choices=("vanilla", "vue", "react"),
|
|
44
|
+
default="vanilla",
|
|
45
|
+
help="前端模板,默认 vanilla",
|
|
46
|
+
)
|
|
47
|
+
new_parser.set_defaults(handler=_new)
|
|
48
|
+
|
|
49
|
+
init_parser = commands.add_parser("init", help="安装业务依赖并检查项目")
|
|
50
|
+
init_parser.set_defaults(handler=_init)
|
|
51
|
+
|
|
52
|
+
doctor_parser = commands.add_parser("doctor", help="检查项目和开发环境")
|
|
53
|
+
doctor_parser.add_argument("--json", action="store_true", dest="json_output", help="输出机器可读 JSON")
|
|
54
|
+
doctor_parser.set_defaults(handler=doctor.run)
|
|
55
|
+
|
|
56
|
+
icon_parser = commands.add_parser("icon", help="从一张方形主图生成三端应用图标")
|
|
57
|
+
icon_parser.add_argument("source", help="至少 512×512 的方形 PNG/JPEG/WebP 图片")
|
|
58
|
+
icon_parser.add_argument("--output", help="输出目录,默认使用 ppx.toml 的 paths.assets")
|
|
59
|
+
icon_parser.set_defaults(handler=icon.run)
|
|
60
|
+
|
|
61
|
+
update_parser = commands.add_parser("update", help="安全更新 PPX 框架依赖")
|
|
62
|
+
mode = update_parser.add_mutually_exclusive_group()
|
|
63
|
+
mode.add_argument("--check", action="store_true", help="只检查可用更新")
|
|
64
|
+
mode.add_argument("--dry-run", action="store_true", help="显示更新计划,不修改文件或环境")
|
|
65
|
+
update_parser.add_argument("--to", metavar="VERSION", help="更新到指定 V6 版本")
|
|
66
|
+
update_parser.set_defaults(handler=update.run)
|
|
67
|
+
|
|
68
|
+
dev_parser = commands.add_parser("dev", help="同时启动前端与桌面窗口")
|
|
69
|
+
dev_parser.add_argument("--cef", action="store_true", help="使用 CEF(仅 Windows)")
|
|
70
|
+
dev_parser.add_argument("--skip-frontend", action="store_true", help="前端服务已启动时不重复启动")
|
|
71
|
+
dev_parser.set_defaults(handler=dev.run)
|
|
72
|
+
|
|
73
|
+
build_parser = commands.add_parser("build", help="构建前端并用 PyInstaller 打包")
|
|
74
|
+
build_parser.add_argument("--console", action="store_true", help="保留控制台窗口")
|
|
75
|
+
build_parser.add_argument("--skip-frontend", action="store_true", help="不重复构建前端")
|
|
76
|
+
build_parser.set_defaults(handler=build.run)
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
81
|
+
# Windows redirects stdout/stderr using the legacy system code page.
|
|
82
|
+
# CLI output is UTF-8 so Chinese diagnostics also work in pipes and logs.
|
|
83
|
+
for stream in (sys.stdout, sys.stderr):
|
|
84
|
+
if stream is not None and hasattr(stream, "reconfigure") and not stream.isatty():
|
|
85
|
+
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
86
|
+
args = make_parser().parse_args(argv)
|
|
87
|
+
return int(args.handler(args))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command implementations."""
|
ppx_py/commands/build.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Build the frontend and package the desktop application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from ..packaging import create_installer, create_spec
|
|
10
|
+
from ..project import find_project_root, load_settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args: object) -> int:
|
|
14
|
+
root = find_project_root()
|
|
15
|
+
settings = load_settings(root)
|
|
16
|
+
if not getattr(args, "skip_frontend", False):
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
[shutil.which("pnpm") or "pnpm", "-C", settings.paths.frontend, "run", "build"], cwd=root, check=False
|
|
19
|
+
)
|
|
20
|
+
if result.returncode:
|
|
21
|
+
return result.returncode
|
|
22
|
+
spec = create_spec(root, settings, bool(getattr(args, "console", False)))
|
|
23
|
+
command = [
|
|
24
|
+
sys.executable,
|
|
25
|
+
"-m",
|
|
26
|
+
"PyInstaller",
|
|
27
|
+
"--clean",
|
|
28
|
+
"--noconfirm",
|
|
29
|
+
"--distpath",
|
|
30
|
+
str(root / "build"),
|
|
31
|
+
"--workpath",
|
|
32
|
+
str(root / "build" / "cache" / "work"),
|
|
33
|
+
str(spec),
|
|
34
|
+
]
|
|
35
|
+
result = subprocess.call(command, cwd=root)
|
|
36
|
+
if result:
|
|
37
|
+
return result
|
|
38
|
+
if getattr(args, "console", False):
|
|
39
|
+
print(f"调试应用已生成: {root / 'build'}")
|
|
40
|
+
return 0
|
|
41
|
+
try:
|
|
42
|
+
installer = create_installer(root, settings)
|
|
43
|
+
print(f"安装包已生成并验证: {installer}")
|
|
44
|
+
return 0
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
print(f"[失败] {exc}")
|
|
47
|
+
return 1
|
ppx_py/commands/dev.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Run Vite and the PPX desktop runtime as one developer command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
from ..project import find_project_root, load_settings
|
|
13
|
+
from ..runtime import run_project
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _wait_for_port(port: int, process: subprocess.Popen[bytes], timeout: float = 30.0) -> None:
|
|
17
|
+
deadline = time.monotonic() + timeout
|
|
18
|
+
while time.monotonic() < deadline:
|
|
19
|
+
if process.poll() is not None:
|
|
20
|
+
raise RuntimeError(f"前端开发服务提前退出,退出码 {process.returncode}")
|
|
21
|
+
try:
|
|
22
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
|
23
|
+
return
|
|
24
|
+
except OSError:
|
|
25
|
+
time.sleep(0.1)
|
|
26
|
+
raise TimeoutError(f"等待前端开发服务端口 {port} 超时")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _stop(process: subprocess.Popen[bytes]) -> None:
|
|
30
|
+
if os.name == "nt":
|
|
31
|
+
if process.poll() is None:
|
|
32
|
+
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, check=False)
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
36
|
+
except ProcessLookupError:
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
process.wait(timeout=5)
|
|
40
|
+
except subprocess.TimeoutExpired:
|
|
41
|
+
try:
|
|
42
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
43
|
+
except ProcessLookupError:
|
|
44
|
+
pass
|
|
45
|
+
process.wait()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(args: object) -> int:
|
|
49
|
+
root = find_project_root()
|
|
50
|
+
settings = load_settings(root)
|
|
51
|
+
process = None
|
|
52
|
+
try:
|
|
53
|
+
if not getattr(args, "skip_frontend", False):
|
|
54
|
+
try:
|
|
55
|
+
with socket.create_connection(("127.0.0.1", settings.development.port), timeout=0.2):
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"端口 {settings.development.port} 已被占用。请修改 development.port;"
|
|
58
|
+
"如果该端口是你已启动的当前项目前端,请使用 --skip-frontend。"
|
|
59
|
+
)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
process = subprocess.Popen(
|
|
63
|
+
[shutil.which("pnpm") or "pnpm", "-C", settings.paths.frontend, "run", "dev",
|
|
64
|
+
"--host", "127.0.0.1", "--port", str(settings.development.port), "--strictPort"],
|
|
65
|
+
cwd=root,
|
|
66
|
+
start_new_session=os.name != "nt",
|
|
67
|
+
)
|
|
68
|
+
_wait_for_port(settings.development.port, process)
|
|
69
|
+
run_project(root / "ppx.toml", dev=True, cef=bool(getattr(args, "cef", False)))
|
|
70
|
+
return 0
|
|
71
|
+
except KeyboardInterrupt:
|
|
72
|
+
return 130
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
print(f"[失败] {exc}")
|
|
75
|
+
return 1
|
|
76
|
+
finally:
|
|
77
|
+
if process is not None:
|
|
78
|
+
_stop(process)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Validate a PPX V6 project and its current-platform toolchain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from packaging.version import InvalidVersion, Version
|
|
16
|
+
|
|
17
|
+
from ..project import find_project_root, load_settings, read_lock
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Check:
|
|
22
|
+
ok: bool
|
|
23
|
+
name: str
|
|
24
|
+
detail: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _command_version(command: str, args: List[str]) -> Optional[str]:
|
|
28
|
+
executable = shutil.which(command)
|
|
29
|
+
if not executable:
|
|
30
|
+
return None
|
|
31
|
+
result = subprocess.run([executable, *args], capture_output=True, text=True, check=False)
|
|
32
|
+
value = (result.stdout or result.stderr).strip()
|
|
33
|
+
return value.splitlines()[0] if value else "unknown"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _at_least(value: Optional[str], required: str) -> bool:
|
|
37
|
+
if value is None:
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
return Version(value.lstrip("vV")) >= Version(required)
|
|
41
|
+
except InvalidVersion:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _in_range(value: Optional[str], minimum: str, maximum_exclusive: str) -> bool:
|
|
46
|
+
if value is None:
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
parsed = Version(value.lstrip("vV"))
|
|
50
|
+
return Version(minimum) <= parsed < Version(maximum_exclusive)
|
|
51
|
+
except InvalidVersion:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def collect_checks(root: Path) -> List[Check]:
|
|
56
|
+
settings = load_settings(root)
|
|
57
|
+
checks = [
|
|
58
|
+
Check(settings.project.project_format == 6, "项目格式", f"V{settings.project.project_format}"),
|
|
59
|
+
Check(sys.version_info >= (3, 10), "Python", sys.version.split()[0]),
|
|
60
|
+
]
|
|
61
|
+
node = _command_version("node", ["--version"])
|
|
62
|
+
pnpm = _command_version("pnpm", ["--version"])
|
|
63
|
+
checks.extend([
|
|
64
|
+
Check(_at_least(node, "22.13.0"), "Node.js", node or "未安装"),
|
|
65
|
+
Check(_in_range(pnpm, "11.0.0", "12.0.0"), "pnpm", pnpm or "未安装"),
|
|
66
|
+
])
|
|
67
|
+
try:
|
|
68
|
+
installed = importlib.metadata.version("ppx-py")
|
|
69
|
+
checks.append(Check(installed == settings.framework.python, "ppx-py", f"已安装 {installed},配置 {settings.framework.python}"))
|
|
70
|
+
except importlib.metadata.PackageNotFoundError:
|
|
71
|
+
checks.append(Check(False, "ppx-py", "未安装"))
|
|
72
|
+
|
|
73
|
+
js_manifest = settings.frontend_source_dir / "node_modules" / "ppx-js" / "package.json"
|
|
74
|
+
if js_manifest.is_file():
|
|
75
|
+
installed_js = str(json.loads(js_manifest.read_text(encoding="utf-8")).get("version", "未知"))
|
|
76
|
+
checks.append(Check(installed_js == settings.framework.javascript, "ppx-js", f"已安装 {installed_js},配置 {settings.framework.javascript}"))
|
|
77
|
+
else:
|
|
78
|
+
checks.append(Check(False, "ppx-js", "未安装,请执行 ppx init"))
|
|
79
|
+
|
|
80
|
+
for name, target in (
|
|
81
|
+
("api", root / "api"),
|
|
82
|
+
("gui", settings.frontend_source_dir),
|
|
83
|
+
("ppx/assets", settings.asset_dir),
|
|
84
|
+
("api/requirements.txt", root / "api/requirements.txt"),
|
|
85
|
+
):
|
|
86
|
+
checks.append(Check(target.exists(), name, "存在" if target.exists() else "缺失"))
|
|
87
|
+
for filename in ("logo.png", "logo.ico", "logo.icns", "dmg-background.png"):
|
|
88
|
+
target = settings.asset_dir / filename
|
|
89
|
+
checks.append(Check(target.is_file(), f"资源 {filename}", "存在" if target.is_file() else "缺失"))
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
lock = read_lock(root)
|
|
93
|
+
framework = lock.get("framework", {})
|
|
94
|
+
compatibility = lock.get("compatibility", {})
|
|
95
|
+
lock_ok = (
|
|
96
|
+
lock.get("projectFormat") == 6
|
|
97
|
+
and framework.get("python") == settings.framework.python
|
|
98
|
+
and framework.get("javascript") == settings.framework.javascript
|
|
99
|
+
and compatibility.get("pythonApi") == settings.compatibility.python_api
|
|
100
|
+
and compatibility.get("javascriptApi") == settings.compatibility.javascript_api
|
|
101
|
+
and compatibility.get("dataSchema") == settings.compatibility.data_schema
|
|
102
|
+
)
|
|
103
|
+
checks.append(Check(lock_ok, "ppx.lock", "与 ppx.toml 一致" if lock_ok else "与 ppx.toml 不一致"))
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
checks.append(Check(False, "ppx.lock", str(exc)))
|
|
106
|
+
|
|
107
|
+
system = platform.system()
|
|
108
|
+
if system == "Darwin":
|
|
109
|
+
checks.extend([
|
|
110
|
+
Check(shutil.which("hdiutil") is not None, "hdiutil", shutil.which("hdiutil") or "未安装"),
|
|
111
|
+
Check(_module_available("dmgbuild"), "dmgbuild", "已安装" if _module_available("dmgbuild") else "未安装"),
|
|
112
|
+
])
|
|
113
|
+
elif system == "Windows":
|
|
114
|
+
from ..packaging.installer import _find_iscc
|
|
115
|
+
iscc = _find_iscc()
|
|
116
|
+
checks.append(Check(iscc is not None, "Inno Setup 6", str(iscc) if iscc else "未安装"))
|
|
117
|
+
elif system == "Linux":
|
|
118
|
+
checks.append(Check(shutil.which("dpkg-deb") is not None, "dpkg-deb", shutil.which("dpkg-deb") or "未安装"))
|
|
119
|
+
return checks
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _module_available(name: str) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
importlib.metadata.version(name)
|
|
125
|
+
return True
|
|
126
|
+
except importlib.metadata.PackageNotFoundError:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run(args: object) -> int:
|
|
131
|
+
json_output = bool(getattr(args, "json_output", False))
|
|
132
|
+
try:
|
|
133
|
+
root = find_project_root()
|
|
134
|
+
checks = collect_checks(root)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
if json_output:
|
|
137
|
+
print(json.dumps({
|
|
138
|
+
"ok": False,
|
|
139
|
+
"project": str(locals().get("root", "")),
|
|
140
|
+
"summary": {"passed": 0, "failed": 1, "total": 1},
|
|
141
|
+
"checks": [asdict(Check(False, "项目", str(exc)))],
|
|
142
|
+
}, ensure_ascii=False, indent=2))
|
|
143
|
+
return 1
|
|
144
|
+
print(f"[失败] 项目: {exc}")
|
|
145
|
+
return 1
|
|
146
|
+
failed = sum(not item.ok for item in checks)
|
|
147
|
+
if json_output:
|
|
148
|
+
print(json.dumps({
|
|
149
|
+
"ok": failed == 0,
|
|
150
|
+
"project": str(root),
|
|
151
|
+
"summary": {"passed": len(checks) - failed, "failed": failed, "total": len(checks)},
|
|
152
|
+
"checks": [asdict(check) for check in checks],
|
|
153
|
+
}, ensure_ascii=False, indent=2))
|
|
154
|
+
return 1 if failed else 0
|
|
155
|
+
print(f"PPX Doctor: {root}")
|
|
156
|
+
for check in checks:
|
|
157
|
+
print(f"[{'通过' if check.ok else '失败'}] {check.name}: {check.detail}")
|
|
158
|
+
print(f"检查完成:{len(checks) - failed} 项通过,{failed} 项失败")
|
|
159
|
+
return 1 if failed else 0
|