tauri-runtime 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.
- tauri_runtime/__init__.py +37 -0
- tauri_runtime/app.py +587 -0
- tauri_runtime/channel.py +79 -0
- tauri_runtime/cli/__init__.py +75 -0
- tauri_runtime/cli/bundle.py +127 -0
- tauri_runtime/cli/scaffold.py +310 -0
- tauri_runtime/cli/templates.py +92 -0
- tauri_runtime/command.py +206 -0
- tauri_runtime/event.py +35 -0
- tauri_runtime/protocol.py +483 -0
- tauri_runtime/py.typed +0 -0
- tauri_runtime/router.py +147 -0
- tauri_runtime-0.1.0.dist-info/METADATA +95 -0
- tauri_runtime-0.1.0.dist-info/RECORD +18 -0
- tauri_runtime-0.1.0.dist-info/WHEEL +4 -0
- tauri_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- tauri_runtime-0.1.0.dist-info/licenses/LICENSE-APACHE +202 -0
- tauri_runtime-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
tauri_runtime/channel.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`Channel`:命令执行期间由 Python 向前端 `Channel` 推送数据的流。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from .protocol import ChannelClose, ChannelData, Writer, split_payload
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Channel(Generic[T]):
|
|
13
|
+
"""指向前端 `Channel` 的单向数据流。
|
|
14
|
+
|
|
15
|
+
在命令处理器中以 `Channel[T]` 注解参数即可注入,参数名与前端传入的 `Channel` 参数名
|
|
16
|
+
对应(camelCase 自动转为 snake_case,如 `onProgress` → `on_progress`)。通道仅在命令
|
|
17
|
+
返回前有效;命令结束后通道被标记为已关闭,再次发送会抛出 `RuntimeError`。
|
|
18
|
+
|
|
19
|
+
`T` 为载荷类型,可以是任意可被 pydantic 序列化为 JSON 的值,也可以是 `Raw` / `bytes`
|
|
20
|
+
(以二进制帧体发送)。
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
id: 前端 `Channel` 的 id。
|
|
24
|
+
closed: 通道是否已关闭。
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
@app.command
|
|
29
|
+
async def count(n: int, on_item: Channel[int]) -> int:
|
|
30
|
+
for i in range(n):
|
|
31
|
+
await on_item.send(i)
|
|
32
|
+
return n
|
|
33
|
+
```
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, channel_id: int, writer: Writer) -> None:
|
|
37
|
+
self.id = channel_id
|
|
38
|
+
self.closed = False
|
|
39
|
+
self._writer = writer
|
|
40
|
+
|
|
41
|
+
def send_sync(self, value: T) -> None:
|
|
42
|
+
"""同步发送一条数据,线程安全,供同步(`def`)命令处理器使用。
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
value: 载荷。
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
RuntimeError: 通道已关闭。
|
|
49
|
+
ValueError: 载荷无法编码为合法 JSON(如包含 `NaN` / `±Infinity`)。
|
|
50
|
+
FrameTooLarge: 编码后超过帧大小上限。
|
|
51
|
+
"""
|
|
52
|
+
if self.closed:
|
|
53
|
+
raise RuntimeError(f"channel {self.id} is closed")
|
|
54
|
+
fields, body = split_payload(value)
|
|
55
|
+
self._writer.write(ChannelData(channel=self.id, **fields), body)
|
|
56
|
+
|
|
57
|
+
async def send(self, value: T) -> None:
|
|
58
|
+
"""发送一条数据,供异步(`async def`)命令处理器使用。
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
value: 载荷。
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
RuntimeError: 通道已关闭。
|
|
65
|
+
ValueError: 载荷无法编码为合法 JSON(如包含 `NaN` / `±Infinity`)。
|
|
66
|
+
FrameTooLarge: 编码后超过帧大小上限。
|
|
67
|
+
"""
|
|
68
|
+
self.send_sync(value)
|
|
69
|
+
|
|
70
|
+
def close_sync(self) -> None:
|
|
71
|
+
"""同步关闭通道并通知前端;重复调用无副作用。"""
|
|
72
|
+
if self.closed:
|
|
73
|
+
return
|
|
74
|
+
self.closed = True
|
|
75
|
+
self._writer.write(ChannelClose(channel=self.id))
|
|
76
|
+
|
|
77
|
+
async def close(self) -> None:
|
|
78
|
+
"""异步关闭通道并通知前端;重复调用无副作用。"""
|
|
79
|
+
self.close_sync()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`tauri-runtime` 命令行入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .bundle import BuildError, build
|
|
11
|
+
from .scaffold import MANAGERS, ScaffoldError, Source, create, init
|
|
12
|
+
from .templates import FRONTEND_SNIPPET
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_source_options(parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
group = parser.add_mutually_exclusive_group()
|
|
17
|
+
group.add_argument("--git", action="store_true", help="install the plugin from GitHub instead of the registries")
|
|
18
|
+
group.add_argument("--path", type=Path, metavar="REPO", help="install the plugin from a local checkout")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def source_of(options: argparse.Namespace) -> Source:
|
|
22
|
+
if options.path is not None:
|
|
23
|
+
return Source("path", options.path.resolve())
|
|
24
|
+
return Source("git") if options.git else Source()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def report(project: Path, hints: list[str]) -> None:
|
|
28
|
+
"""打印手动步骤与前端调用示例。"""
|
|
29
|
+
for hint in hints:
|
|
30
|
+
print(f"tauri-runtime: manual step: {hint}", file=sys.stderr)
|
|
31
|
+
print(f"tauri-runtime: Python backend ready in {project / 'src-python'}")
|
|
32
|
+
print(f"call it from the frontend:\n\n{FRONTEND_SNIPPET}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parser() -> argparse.ArgumentParser:
|
|
36
|
+
root = argparse.ArgumentParser(prog="tauri-runtime")
|
|
37
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
build_parser = commands.add_parser("build", help="package the Python backend with Nuitka (standalone)")
|
|
40
|
+
build_parser.add_argument("--project", type=Path, default=Path.cwd(), help="Python project directory")
|
|
41
|
+
|
|
42
|
+
init_parser = commands.add_parser("init", help="add a Python backend (src-python) to an existing Tauri project")
|
|
43
|
+
init_parser.add_argument("--project", type=Path, default=Path.cwd(), help="Tauri project root")
|
|
44
|
+
init_parser.add_argument("--force", action="store_true", help="continue when src-python already exists")
|
|
45
|
+
add_source_options(init_parser)
|
|
46
|
+
|
|
47
|
+
create_parser = commands.add_parser("create", help="create a Tauri project with a Python backend")
|
|
48
|
+
create_parser.add_argument("name", help="project directory name")
|
|
49
|
+
create_parser.add_argument("--template", default="vue-ts", help="create-tauri-app template")
|
|
50
|
+
create_parser.add_argument("--manager", default="pnpm", choices=MANAGERS, help="frontend package manager")
|
|
51
|
+
add_source_options(create_parser)
|
|
52
|
+
return root
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
56
|
+
"""命令行入口:`tauri-runtime {build,init,create} ...`;`build` 的 `--` 之后的参数传给 Nuitka。"""
|
|
57
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
58
|
+
extra: list[str] = []
|
|
59
|
+
if "--" in args:
|
|
60
|
+
index = args.index("--")
|
|
61
|
+
args, extra = args[:index], args[index + 1 :]
|
|
62
|
+
options = parser().parse_args(args)
|
|
63
|
+
try:
|
|
64
|
+
if options.command == "build":
|
|
65
|
+
print(f"tauri-runtime: built {build(options.project.resolve(), extra)}")
|
|
66
|
+
elif options.command == "init":
|
|
67
|
+
project = options.project.resolve()
|
|
68
|
+
report(project, init(project, source_of(options), force=options.force))
|
|
69
|
+
else:
|
|
70
|
+
project, hints = create(options.name, options.template, options.manager, source_of(options))
|
|
71
|
+
report(project, hints)
|
|
72
|
+
except (BuildError, ScaffoldError) as e:
|
|
73
|
+
print(f"tauri-runtime: {e}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
return 0
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""`tauri-runtime build`:用 Nuitka 将 Python 后端打包为 standalone 目录。
|
|
2
|
+
|
|
3
|
+
配置读取项目 `pyproject.toml` 的 `[tool.tauri-runtime.build]`,产物布局固定为
|
|
4
|
+
`<output>/<name>/<name>`(Windows 为 `<name>.exe`),与 Rust 插件的 `Builder::bundle(name)` 对应。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import tomllib
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
22
|
+
KEYS = frozenset({"entry", "name", "output", "nuitka-args"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BuildError(Exception):
|
|
26
|
+
"""打包失败(配置错误、缺少 Nuitka 或编译失败),消息直接展示给用户。"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class BuildConfig:
|
|
31
|
+
"""`[tool.tauri-runtime.build]` 配置。
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
entry: 入口脚本,相对项目目录。
|
|
35
|
+
name: 可执行文件名与产物目录名,只能包含字母、数字、`.`、`_`、`-`。
|
|
36
|
+
output: 输出目录,相对项目目录。
|
|
37
|
+
nuitka_args: 透传给 Nuitka 的额外参数。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
entry: str = "main.py"
|
|
41
|
+
name: str = "py-backend"
|
|
42
|
+
output: str = "dist"
|
|
43
|
+
nuitka_args: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config(project: Path) -> BuildConfig:
|
|
47
|
+
"""读取项目的打包配置;缺少配置段时使用默认值,未知键或非法值抛出 `BuildError`。"""
|
|
48
|
+
pyproject = project / "pyproject.toml"
|
|
49
|
+
if not pyproject.is_file():
|
|
50
|
+
raise BuildError(f"{pyproject} not found; run inside your Python project or pass --project")
|
|
51
|
+
with pyproject.open("rb") as f:
|
|
52
|
+
data: dict[str, Any] = tomllib.load(f)
|
|
53
|
+
table: dict[str, Any] = data.get("tool", {}).get("tauri-runtime", {}).get("build", {})
|
|
54
|
+
unknown = set(table) - KEYS
|
|
55
|
+
if unknown:
|
|
56
|
+
raise BuildError(f"unknown keys in [tool.tauri-runtime.build]: {', '.join(sorted(unknown))}")
|
|
57
|
+
config = BuildConfig(
|
|
58
|
+
entry=str(table.get("entry", BuildConfig.entry)),
|
|
59
|
+
name=str(table.get("name", BuildConfig.name)),
|
|
60
|
+
output=str(table.get("output", BuildConfig.output)),
|
|
61
|
+
nuitka_args=tuple(str(a) for a in table.get("nuitka-args", ())),
|
|
62
|
+
)
|
|
63
|
+
if not NAME_PATTERN.match(config.name):
|
|
64
|
+
raise BuildError(f"invalid name '{config.name}': use letters, digits, '.', '_' or '-'")
|
|
65
|
+
return config
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def executable_name(name: str, platform: str = sys.platform) -> str:
|
|
69
|
+
"""产物中可执行文件的文件名:Windows 追加 `.exe`。"""
|
|
70
|
+
return f"{name}.exe" if platform == "win32" else name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def nuitka_available() -> bool:
|
|
74
|
+
"""当前环境能否导入 Nuitka。"""
|
|
75
|
+
return importlib.util.find_spec("nuitka") is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def nuitka_command(config: BuildConfig, build_dir: Path, extra: Sequence[str]) -> list[str]:
|
|
79
|
+
"""构造 Nuitka standalone 编译命令;使用当前解释器,因此项目依赖均可被收集。"""
|
|
80
|
+
return [
|
|
81
|
+
sys.executable,
|
|
82
|
+
"-m",
|
|
83
|
+
"nuitka",
|
|
84
|
+
"--standalone",
|
|
85
|
+
"--assume-yes-for-downloads",
|
|
86
|
+
"--remove-output",
|
|
87
|
+
f"--output-dir={build_dir}",
|
|
88
|
+
f"--output-filename={config.name}",
|
|
89
|
+
*config.nuitka_args,
|
|
90
|
+
*extra,
|
|
91
|
+
config.entry,
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build(
|
|
96
|
+
project: Path,
|
|
97
|
+
extra: Sequence[str] = (),
|
|
98
|
+
run: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
|
|
99
|
+
) -> Path:
|
|
100
|
+
"""编译项目并将产物整理到 `<output>/<name>`,返回可执行文件路径。
|
|
101
|
+
|
|
102
|
+
在临时目录中编译,成功后才替换旧产物,失败时不会留下半成品。
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
project: Python 项目目录(包含 `pyproject.toml`)。
|
|
106
|
+
extra: 追加给 Nuitka 的命令行参数。
|
|
107
|
+
run: 执行子进程的函数,测试时可替换。
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
BuildError: 配置错误、缺少 Nuitka 或入口脚本,或 Nuitka 以非零退出码结束。
|
|
111
|
+
"""
|
|
112
|
+
config = load_config(project)
|
|
113
|
+
if not nuitka_available():
|
|
114
|
+
raise BuildError("Nuitka is not installed in this environment; run `uv add --dev nuitka`")
|
|
115
|
+
if not (project / config.entry).is_file():
|
|
116
|
+
raise BuildError(f"entry script {project / config.entry} not found")
|
|
117
|
+
target = project / config.output / config.name
|
|
118
|
+
with tempfile.TemporaryDirectory(prefix="tauri-runtime-build-") as tmp:
|
|
119
|
+
build_dir = Path(tmp)
|
|
120
|
+
result = run(nuitka_command(config, build_dir, extra), cwd=project, check=False)
|
|
121
|
+
if result.returncode != 0:
|
|
122
|
+
raise BuildError(f"nuitka failed with exit code {result.returncode}")
|
|
123
|
+
if target.exists():
|
|
124
|
+
shutil.rmtree(target)
|
|
125
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
shutil.move(str(build_dir / f"{Path(config.entry).stem}.dist"), str(target))
|
|
127
|
+
return target / executable_name(config.name)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""`tauri-runtime init` / `create`:为 Tauri 项目接入 Python 后端。
|
|
2
|
+
|
|
3
|
+
文件修改均为纯函数(输入文本 → 输出文本),可重复执行;外部命令经可替换的 `run` 执行。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from ..protocol import SDK_VERSION
|
|
17
|
+
from .templates import (
|
|
18
|
+
BUNDLE_DIR,
|
|
19
|
+
BUNDLE_NAME,
|
|
20
|
+
GITIGNORE,
|
|
21
|
+
MAIN_PY,
|
|
22
|
+
REPO_URL,
|
|
23
|
+
build_rs_snippet,
|
|
24
|
+
pyproject_toml,
|
|
25
|
+
rust_snippet,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
Runner = Callable[..., Any]
|
|
29
|
+
|
|
30
|
+
CRATE = "tauri-plugin-py-runtime"
|
|
31
|
+
NPM_PACKAGE = "tauri-plugin-py-runtime-api"
|
|
32
|
+
GITHUB_SPEC = "github:InfernalAzazel/tauri-plugin-py-runtime"
|
|
33
|
+
BUILD_STEP = "cd src-python && uv run tauri-runtime build"
|
|
34
|
+
RESOURCE_SRC = f"{BUNDLE_DIR}/"
|
|
35
|
+
RESOURCE_DST = f"{BUNDLE_NAME}/"
|
|
36
|
+
PERMISSION = "py-runtime:default"
|
|
37
|
+
BUILDER_CALL = "tauri::Builder::default()"
|
|
38
|
+
BUILD_CALL = "tauri_build::build()"
|
|
39
|
+
MANAGERS = ("pnpm", "yarn", "bun", "npm")
|
|
40
|
+
LOCKFILES = (
|
|
41
|
+
("pnpm-lock.yaml", "pnpm"),
|
|
42
|
+
("yarn.lock", "yarn"),
|
|
43
|
+
("bun.lock", "bun"),
|
|
44
|
+
("bun.lockb", "bun"),
|
|
45
|
+
("package-lock.json", "npm"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScaffoldError(Exception):
|
|
50
|
+
"""脚手架步骤失败(项目结构不符或外部命令出错)。"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Source:
|
|
55
|
+
"""插件三个包的来源:发布版本、GitHub 仓库或本地仓库目录(`path`)。"""
|
|
56
|
+
|
|
57
|
+
kind: Literal["registry", "git", "path"] = "registry"
|
|
58
|
+
path: Path | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def dump_json(data: object) -> str:
|
|
62
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def patch_tauri_conf(text: str) -> tuple[str, list[str]]:
|
|
66
|
+
"""在 `beforeBuildCommand` 后追加 Nuitka 打包,并把产物加入 `bundle.resources`。
|
|
67
|
+
|
|
68
|
+
返回新文本与无法自动处理、需用户手动完成的提示。
|
|
69
|
+
"""
|
|
70
|
+
conf = json.loads(text)
|
|
71
|
+
hints: list[str] = []
|
|
72
|
+
|
|
73
|
+
build = conf.setdefault("build", {})
|
|
74
|
+
command = build.get("beforeBuildCommand")
|
|
75
|
+
if not command:
|
|
76
|
+
build["beforeBuildCommand"] = BUILD_STEP
|
|
77
|
+
elif isinstance(command, str):
|
|
78
|
+
if BUILD_STEP not in command:
|
|
79
|
+
build["beforeBuildCommand"] = f"{command} && {BUILD_STEP}"
|
|
80
|
+
else:
|
|
81
|
+
hints.append(f'append "{BUILD_STEP}" to build.beforeBuildCommand')
|
|
82
|
+
|
|
83
|
+
bundle = conf.setdefault("bundle", {})
|
|
84
|
+
resources = bundle.get("resources")
|
|
85
|
+
if resources is None:
|
|
86
|
+
bundle["resources"] = {RESOURCE_SRC: RESOURCE_DST}
|
|
87
|
+
elif isinstance(resources, dict):
|
|
88
|
+
resources[RESOURCE_SRC] = RESOURCE_DST
|
|
89
|
+
else:
|
|
90
|
+
hints.append(f'add "{RESOURCE_SRC}" -> "{RESOURCE_DST}" to bundle.resources (map form)')
|
|
91
|
+
|
|
92
|
+
return dump_json(conf), hints
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def patch_capability(text: str) -> str:
|
|
96
|
+
"""为 capability 文件的 `permissions` 加上插件默认权限。"""
|
|
97
|
+
conf = json.loads(text)
|
|
98
|
+
permissions = conf.setdefault("permissions", [])
|
|
99
|
+
if PERMISSION not in permissions:
|
|
100
|
+
permissions.append(PERMISSION)
|
|
101
|
+
return dump_json(conf)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def patch_lib_rs(text: str) -> str | None:
|
|
105
|
+
"""在唯一的 `tauri::Builder::default()` 行后插入插件注册。
|
|
106
|
+
|
|
107
|
+
已注册时原样返回;无法识别(没有或有多处该调用、调用不在行尾)时返回 None。
|
|
108
|
+
"""
|
|
109
|
+
if "tauri_plugin_py_runtime" in text:
|
|
110
|
+
return text
|
|
111
|
+
if text.count(BUILDER_CALL) != 1:
|
|
112
|
+
return None
|
|
113
|
+
lines = text.splitlines(keepends=True)
|
|
114
|
+
for index, line in enumerate(lines):
|
|
115
|
+
if BUILDER_CALL in line:
|
|
116
|
+
if line.strip() != BUILDER_CALL:
|
|
117
|
+
return None
|
|
118
|
+
indent = line[: len(line) - len(line.lstrip())]
|
|
119
|
+
lines.insert(index + 1, rust_snippet(indent + " "))
|
|
120
|
+
return "".join(lines)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def patch_build_rs(text: str) -> str | None:
|
|
125
|
+
"""在唯一的 `tauri_build::build()` 前创建 Nuitka 产物目录。
|
|
126
|
+
|
|
127
|
+
tauri-build 在 dev / check 时也要求 `bundle.resources` 路径存在,而产物只在打包时生成。
|
|
128
|
+
已处理时原样返回;无法识别时返回 None。
|
|
129
|
+
"""
|
|
130
|
+
if BUNDLE_DIR in text:
|
|
131
|
+
return text
|
|
132
|
+
if text.count(BUILD_CALL) != 1:
|
|
133
|
+
return None
|
|
134
|
+
lines = text.splitlines(keepends=True)
|
|
135
|
+
for index, line in enumerate(lines):
|
|
136
|
+
if BUILD_CALL in line:
|
|
137
|
+
if line.strip() != BUILD_CALL:
|
|
138
|
+
return None
|
|
139
|
+
lines.insert(index, build_rs_snippet(line[: len(line) - len(line.lstrip())]))
|
|
140
|
+
return "".join(lines)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def detect_package_manager(root: Path) -> str:
|
|
145
|
+
"""依据锁文件判断前端包管理器;没有锁文件时看 `beforeDevCommand` 的首个单词,默认 npm。"""
|
|
146
|
+
for lockfile, manager in LOCKFILES:
|
|
147
|
+
if (root / lockfile).exists():
|
|
148
|
+
return manager
|
|
149
|
+
conf = root / "src-tauri" / "tauri.conf.json"
|
|
150
|
+
if conf.is_file():
|
|
151
|
+
command = json.loads(conf.read_text(encoding="utf-8")).get("build", {}).get("beforeDevCommand")
|
|
152
|
+
if isinstance(command, str) and command.split() and command.split()[0] in MANAGERS:
|
|
153
|
+
return command.split()[0]
|
|
154
|
+
return "npm"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cargo_add_command(source: Source) -> list[str]:
|
|
158
|
+
"""在项目根目录执行的 `cargo add` 命令。"""
|
|
159
|
+
if source.kind == "git":
|
|
160
|
+
spec = [CRATE, "--git", REPO_URL]
|
|
161
|
+
elif source.kind == "path":
|
|
162
|
+
assert source.path is not None
|
|
163
|
+
spec = [CRATE, "--path", str(source.path)]
|
|
164
|
+
else:
|
|
165
|
+
spec = [f"{CRATE}@{SDK_VERSION}"]
|
|
166
|
+
return ["cargo", "add", *spec, "--manifest-path", str(Path("src-tauri") / "Cargo.toml")]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def frontend_add_command(manager: str, source: Source) -> list[str]:
|
|
170
|
+
"""安装前端 API 包的命令。"""
|
|
171
|
+
if source.kind == "git":
|
|
172
|
+
spec = GITHUB_SPEC
|
|
173
|
+
elif source.kind == "path":
|
|
174
|
+
assert source.path is not None
|
|
175
|
+
spec = f"{'link' if manager == 'pnpm' else 'file'}:{source.path}"
|
|
176
|
+
else:
|
|
177
|
+
spec = f"{NPM_PACKAGE}@{SDK_VERSION}"
|
|
178
|
+
if manager == "pnpm" and source.kind == "git":
|
|
179
|
+
# pnpm 10+ 默认禁止 git 依赖执行构建脚本(`prepare` 编译 dist-js),需显式放行。
|
|
180
|
+
return ["pnpm", "add", f"--allow-build={NPM_PACKAGE}", spec]
|
|
181
|
+
return [manager, "install" if manager == "npm" else "add", spec]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def create_command(name: str, template: str, manager: str) -> list[str]:
|
|
185
|
+
"""调用 create-tauri-app 创建项目的命令。"""
|
|
186
|
+
options = [name, "--template", template, "--manager", manager, "--yes"]
|
|
187
|
+
if manager == "npm":
|
|
188
|
+
return ["npm", "create", "tauri-app@latest", "--", *options]
|
|
189
|
+
return [manager, "create", "tauri-app", *options]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_source(source: Source) -> None:
|
|
193
|
+
"""本地来源须指向本仓库根目录(含 `python-sdk` 与 `Cargo.toml`)。"""
|
|
194
|
+
if source.kind != "path":
|
|
195
|
+
return
|
|
196
|
+
assert source.path is not None
|
|
197
|
+
if not ((source.path / "python-sdk").is_dir() and (source.path / "Cargo.toml").is_file()):
|
|
198
|
+
raise ScaffoldError(f"{source.path} is not a tauri-plugin-py-runtime checkout")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def run_step(run: Runner, cmd: Sequence[str], cwd: Path) -> None:
|
|
202
|
+
"""执行外部命令,失败时抛出 ScaffoldError;经 `shutil.which` 解析以支持 Windows 的 `.cmd`。"""
|
|
203
|
+
print(f"tauri-runtime: $ {' '.join(cmd)}", flush=True)
|
|
204
|
+
program = shutil.which(cmd[0])
|
|
205
|
+
if program is None:
|
|
206
|
+
raise ScaffoldError(f"`{cmd[0]}` not found on PATH")
|
|
207
|
+
result = run([program, *cmd[1:]], cwd=cwd, check=False)
|
|
208
|
+
if result.returncode != 0:
|
|
209
|
+
raise ScaffoldError(f"`{' '.join(cmd)}` failed with exit code {result.returncode}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def write_backend(directory: Path, source: Source) -> None:
|
|
213
|
+
"""生成 `src-python`;已有文件保持不变。"""
|
|
214
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
files = {"pyproject.toml": pyproject_toml(source), "main.py": MAIN_PY, ".gitignore": GITIGNORE}
|
|
216
|
+
for name, content in files.items():
|
|
217
|
+
path = directory / name
|
|
218
|
+
if not path.exists():
|
|
219
|
+
path.write_text(content, encoding="utf-8")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def patch_file(path: Path, patch: Callable[[str], str]) -> None:
|
|
223
|
+
text = path.read_text(encoding="utf-8")
|
|
224
|
+
patched = patch(text)
|
|
225
|
+
if patched != text:
|
|
226
|
+
path.write_text(patched, encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def init(
|
|
230
|
+
project: Path,
|
|
231
|
+
source: Source | None = None,
|
|
232
|
+
force: bool = False,
|
|
233
|
+
manager: str | None = None,
|
|
234
|
+
run: Runner = subprocess.run,
|
|
235
|
+
) -> list[str]:
|
|
236
|
+
"""为已有 Tauri 项目接入 Python 后端,返回需用户手动完成的提示。"""
|
|
237
|
+
source = source or Source()
|
|
238
|
+
check_source(source)
|
|
239
|
+
tauri_dir = project / "src-tauri"
|
|
240
|
+
if not tauri_dir.is_dir():
|
|
241
|
+
raise ScaffoldError(f"{tauri_dir} not found; run inside a Tauri project or pass --project")
|
|
242
|
+
backend = project / "src-python"
|
|
243
|
+
if backend.exists() and not force:
|
|
244
|
+
raise ScaffoldError(f"{backend} already exists; pass --force to keep it and finish the setup")
|
|
245
|
+
hints: list[str] = []
|
|
246
|
+
|
|
247
|
+
write_backend(backend, source)
|
|
248
|
+
run_step(run, ["uv", "sync"], backend)
|
|
249
|
+
|
|
250
|
+
conf = tauri_dir / "tauri.conf.json"
|
|
251
|
+
if conf.is_file():
|
|
252
|
+
text, conf_hints = patch_tauri_conf(conf.read_text(encoding="utf-8"))
|
|
253
|
+
conf.write_text(text, encoding="utf-8")
|
|
254
|
+
hints += [f"tauri.conf.json: {hint}" for hint in conf_hints]
|
|
255
|
+
else:
|
|
256
|
+
hints.append(
|
|
257
|
+
f'Tauri config: append "{BUILD_STEP}" to build.beforeBuildCommand and map '
|
|
258
|
+
f'"{RESOURCE_SRC}" to "{RESOURCE_DST}" in bundle.resources'
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
capability = tauri_dir / "capabilities" / "default.json"
|
|
262
|
+
if capability.is_file():
|
|
263
|
+
patch_file(capability, patch_capability)
|
|
264
|
+
else:
|
|
265
|
+
hints.append(f'add "{PERMISSION}" to the permissions of your capability file')
|
|
266
|
+
|
|
267
|
+
run_step(run, cargo_add_command(source), project)
|
|
268
|
+
|
|
269
|
+
if (project / "package.json").is_file():
|
|
270
|
+
run_step(run, frontend_add_command(manager or detect_package_manager(project), source), project)
|
|
271
|
+
if source.kind == "path" and source.path is not None and not (source.path / "dist-js").is_dir():
|
|
272
|
+
hints.append(f"build the frontend package first: run `pnpm install && pnpm build` in {source.path}")
|
|
273
|
+
else:
|
|
274
|
+
hints.append(f"install the frontend package {NPM_PACKAGE}")
|
|
275
|
+
|
|
276
|
+
lib_rs = tauri_dir / "src" / "lib.rs"
|
|
277
|
+
patched = patch_lib_rs(lib_rs.read_text(encoding="utf-8")) if lib_rs.is_file() else None
|
|
278
|
+
if patched is None:
|
|
279
|
+
hints.append(f"register the plugin on your tauri::Builder:\n{rust_snippet(' ')}")
|
|
280
|
+
else:
|
|
281
|
+
patch_file(lib_rs, lambda _: patched)
|
|
282
|
+
|
|
283
|
+
build_rs = tauri_dir / "build.rs"
|
|
284
|
+
patched = patch_build_rs(build_rs.read_text(encoding="utf-8")) if build_rs.is_file() else None
|
|
285
|
+
if patched is None:
|
|
286
|
+
hints.append(f"create the Python bundle directory before tauri_build::build() in build.rs:\n{build_rs_snippet(' ')}")
|
|
287
|
+
else:
|
|
288
|
+
patch_file(build_rs, lambda _: patched)
|
|
289
|
+
return hints
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def create(
|
|
293
|
+
name: str,
|
|
294
|
+
template: str = "vue-ts",
|
|
295
|
+
manager: str = "pnpm",
|
|
296
|
+
source: Source | None = None,
|
|
297
|
+
cwd: Path | None = None,
|
|
298
|
+
run: Runner = subprocess.run,
|
|
299
|
+
) -> tuple[Path, list[str]]:
|
|
300
|
+
"""用 create-tauri-app 新建项目后执行 `init`,返回项目目录与提示。"""
|
|
301
|
+
source = source or Source()
|
|
302
|
+
check_source(source)
|
|
303
|
+
cwd = cwd or Path.cwd()
|
|
304
|
+
project = cwd / name
|
|
305
|
+
if project.exists():
|
|
306
|
+
raise ScaffoldError(f"{project} already exists")
|
|
307
|
+
run_step(run, create_command(name, template, manager), cwd)
|
|
308
|
+
if not project.is_dir():
|
|
309
|
+
raise ScaffoldError(f"create-tauri-app did not create {project}")
|
|
310
|
+
return project, init(project, source, manager=manager, run=run)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`tauri-runtime init` 生成的文件模板与打印给用户的代码片段。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .scaffold import Source
|
|
10
|
+
|
|
11
|
+
REPO_URL = "https://github.com/InfernalAzazel/tauri-plugin-py-runtime"
|
|
12
|
+
BUNDLE_NAME = "py-backend"
|
|
13
|
+
BUNDLE_DIR = f"../src-python/dist/{BUNDLE_NAME}"
|
|
14
|
+
|
|
15
|
+
MAIN_PY = '''\
|
|
16
|
+
from tauri_runtime import App
|
|
17
|
+
|
|
18
|
+
app = App()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command
|
|
22
|
+
async def greet(name: str) -> str:
|
|
23
|
+
return f"Hello {name}, from Python!"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
app.run()
|
|
27
|
+
'''
|
|
28
|
+
|
|
29
|
+
GITIGNORE = """\
|
|
30
|
+
.venv/
|
|
31
|
+
dist/
|
|
32
|
+
__pycache__/
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
FRONTEND_SNIPPET = """\
|
|
36
|
+
import { pyInvoke } from "tauri-plugin-py-runtime-api";
|
|
37
|
+
|
|
38
|
+
const message = await pyInvoke<string>("greet", { name: "Tauri" });
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rust_snippet(indent: str = "") -> str:
|
|
43
|
+
"""注册插件的链式调用片段,每行加 `indent` 前缀。"""
|
|
44
|
+
lines = [
|
|
45
|
+
".plugin(",
|
|
46
|
+
" tauri_plugin_py_runtime::Builder::new()",
|
|
47
|
+
' .script("../src-python/main.py")',
|
|
48
|
+
f' .bundle("{BUNDLE_NAME}")',
|
|
49
|
+
" .build(),",
|
|
50
|
+
")",
|
|
51
|
+
]
|
|
52
|
+
return "".join(f"{indent}{line}\n" for line in lines)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_rs_snippet(indent: str = "") -> str:
|
|
56
|
+
"""`build.rs` 中创建 Nuitka 产物目录的语句,供 tauri-build 校验 `bundle.resources`。"""
|
|
57
|
+
lines = [
|
|
58
|
+
"// tauri-build 要求 bundle.resources 的路径存在;Nuitka 产物由 `tauri-runtime build` 生成。",
|
|
59
|
+
f'std::fs::create_dir_all("{BUNDLE_DIR}").expect("failed to create the Python bundle directory");',
|
|
60
|
+
]
|
|
61
|
+
return "".join(f"{indent}{line}\n" for line in lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def uv_source(source: Source) -> str | None:
|
|
65
|
+
"""`[tool.uv.sources]` 中 `tauri-runtime` 的取值;registry 来源无需此项。"""
|
|
66
|
+
if source.kind == "git":
|
|
67
|
+
return f'{{ git = "{REPO_URL}", subdirectory = "python-sdk" }}'
|
|
68
|
+
if source.kind == "path":
|
|
69
|
+
assert source.path is not None
|
|
70
|
+
return f"{{ path = {json.dumps((source.path / 'python-sdk').as_posix())}, editable = true }}"
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pyproject_toml(source: Source) -> str:
|
|
75
|
+
"""`src-python/pyproject.toml`:依赖 SDK、dev 组含 Nuitka,打包名与 Rust `bundle` 对应。"""
|
|
76
|
+
text = """\
|
|
77
|
+
[project]
|
|
78
|
+
name = "backend"
|
|
79
|
+
version = "0.1.0"
|
|
80
|
+
requires-python = ">=3.11"
|
|
81
|
+
dependencies = ["tauri-runtime"]
|
|
82
|
+
|
|
83
|
+
[dependency-groups]
|
|
84
|
+
dev = ["nuitka"]
|
|
85
|
+
|
|
86
|
+
[tool.uv]
|
|
87
|
+
package = false
|
|
88
|
+
"""
|
|
89
|
+
value = uv_source(source)
|
|
90
|
+
if value is not None:
|
|
91
|
+
text += f"\n[tool.uv.sources]\ntauri-runtime = {value}\n"
|
|
92
|
+
return text + f'\n[tool.tauri-runtime.build]\nname = "{BUNDLE_NAME}"\n'
|