Skip to main content

tauri_plugin_py_runtime/
lib.rs

1//! 用 Python 编写 Tauri 后端的 Tauri 2 插件(插件名 `py-runtime`)。
2//!
3//! 插件在 setup 阶段启动一个 Python 子进程(sidecar),通过其 stdin / stdout 上的长度前缀帧协议
4//! 在前端与 Python 之间转发命令调用、事件和 channel 数据流;Python 的 stderr 按行转发到 `log`。
5//! Python 侧使用 `tauri-runtime` SDK(`tauri_runtime`),前端使用 `tauri-plugin-py-runtime-api`。
6//!
7//! # 架构
8//!
9//! 前端 -> Tauri 命令(`commands`)-> [`bridge::Bridge`] -> [`bridge::Writer`] /
10//! [`bridge::protocol::FrameCodec`] -> Python 进程;[`runtime::Supervisor`] 负责进程生命周期
11//! (启动、握手、崩溃重启、关闭)。
12//!
13//! # 快速开始
14//!
15//! ```rust,no_run
16//! use std::time::Duration;
17//!
18//! let _app = tauri::Builder::default().plugin(
19//!   tauri_plugin_py_runtime::Builder::new()
20//!     .script("../src-python/main.py")
21//!     .invoke_timeout(Duration::from_secs(60))
22//!     .build(),
23//! );
24//! ```
25//!
26//! 也可以只通过 `tauri.conf.json` 配置([`Builder`] 上设置的值优先),此时使用 [`init`]:
27//!
28//! ```json
29//! {
30//!   "plugins": {
31//!     "py-runtime": {
32//!       "python": "python3",
33//!       "script": "../src-python/main.py",
34//!       "invokeTimeoutMs": 30000,
35//!       "restartOnCrash": false
36//!     }
37//!   }
38//! }
39//! ```
40//!
41//! 前端需要在 capability 中加入 `"py-runtime:default"`。Rust 代码可通过 [`PyRuntimeExt`]
42//! (`app.py_runtime().supervisor()`)直接调用 Python 命令或查询状态。
43//!
44//! 帧协议、错误码与进程生命周期的完整说明见仓库 README。
45
46use std::{
47  path::{Component, Path, PathBuf},
48  sync::Arc,
49  time::Duration,
50};
51
52use serde::Deserialize;
53use serde_json::Value;
54use tauri::{
55  plugin::{Builder as PluginBuilder, TauriPlugin},
56  AppHandle, Emitter, Manager, RunEvent, Runtime,
57};
58
59pub mod bridge;
60pub mod runtime;
61
62mod commands;
63mod error;
64
65pub use error::{codes, Error, Result};
66
67use bridge::{EventSink, Payload, DEFAULT_MAX_FRAME_SIZE};
68use runtime::{ProcessConfig, Supervisor, SupervisorConfig};
69
70const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
71const DEFAULT_INVOKE_TIMEOUT: Duration = Duration::from_secs(30);
72const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
73
74/// `tauri.conf.json` 中的 `plugins.py-runtime` 配置,[`Builder`] 上设置的值优先。
75///
76/// 键名为 camelCase,且拒绝未知键,拼写错误会在启动时报错而不是被静默忽略。
77/// `env` 与 `handshake_timeout` 只能通过 [`Builder`] 设置。
78#[derive(Debug, Default, Deserialize)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct Config {
81  /// Python 解释器,见 [`Builder::python`]。
82  pub python: Option<String>,
83  /// Python 入口脚本;相对路径的解析规则见 [`Builder::script`]。
84  pub script: Option<PathBuf>,
85  /// Nuitka 打包产物名,见 [`Builder::bundle`]。
86  pub bundle: Option<String>,
87  /// 追加在脚本路径(或打包产物)之后的命令行参数。
88  pub args: Option<Vec<String>>,
89  /// 每次调用的默认超时(毫秒),见 [`Builder::invoke_timeout`]。
90  pub invoke_timeout_ms: Option<u64>,
91  /// 关闭超时(毫秒),见 [`Builder::shutdown_timeout`]。
92  pub shutdown_timeout_ms: Option<u64>,
93  /// 崩溃后是否自动重启,见 [`Builder::restart_on_crash`]。
94  pub restart_on_crash: Option<bool>,
95}
96
97/// 由插件注册到 Tauri 状态中的运行时句柄,通过 [`PyRuntimeExt::py_runtime`] 获取。
98pub struct PyRuntime {
99  supervisor: Arc<Supervisor>,
100}
101
102impl PyRuntime {
103  /// 返回底层的 [`Supervisor`],可用于从 Rust 调用 Python 命令、发送事件或查询状态。
104  pub fn supervisor(&self) -> &Arc<Supervisor> {
105    &self.supervisor
106  }
107}
108
109/// 为 [`AppHandle`]、`App`、`Window` 等实现了 [`Manager`] 的类型提供 [`PyRuntime`] 访问入口。
110pub trait PyRuntimeExt<R: Runtime> {
111  /// 返回插件注册的 [`PyRuntime`]。
112  ///
113  /// # Panics
114  ///
115  /// 插件尚未完成 setup(未注册 [`PyRuntime`] 状态)时 panic。
116  fn py_runtime(&self) -> &PyRuntime;
117}
118
119impl<R: Runtime, T: Manager<R>> PyRuntimeExt<R> for T {
120  fn py_runtime(&self) -> &PyRuntime {
121    self.state::<PyRuntime>().inner()
122  }
123}
124
125/// 把 Python 事件转发为 Tauri 事件 `py://<event>` 的 [`EventSink`] 实现。
126struct TauriSink<R: Runtime>(AppHandle<R>);
127
128/// Tauri 事件载荷只能是 JSON,因此二进制载荷会变成数字数组。
129fn payload_to_value(payload: Payload) -> Value {
130  match payload {
131    Payload::None => Value::Null,
132    Payload::Json(value) => value,
133    Payload::Raw(bytes) => Value::from(bytes),
134  }
135}
136
137impl<R: Runtime> EventSink for TauriSink<R> {
138  fn emit(&self, event: &str, target: Option<&str>, payload: Payload) {
139    let name = format!("py://{event}");
140    let value = payload_to_value(payload);
141    let result = match target {
142      Some(label) => self.0.emit_to(label, &name, value),
143      None => self.0.emit(&name, value),
144    };
145    if let Err(e) = result {
146      log::warn!(target: "py-runtime", "failed to emit {name}: {e}");
147    }
148  }
149
150  fn exited(&self, code: Option<i32>) {
151    if let Err(e) = self.0.emit("py://exited", serde_json::json!({ "code": code })) {
152      log::warn!(target: "py-runtime", "failed to emit py://exited: {e}");
153    }
154  }
155}
156
157/// 把 `..` 替换为 `_up_`,与 Tauri 打包器放置 `bundle.resources` 条目的方式一致。
158fn resource_relative(script: &Path) -> PathBuf {
159  script
160    .components()
161    .map(|c| match c {
162      Component::ParentDir => Component::Normal("_up_".as_ref()),
163      other => other,
164    })
165    .collect()
166}
167
168/// 按 [`Builder::script`] 描述的规则查找入口脚本,第一个存在的文件胜出;
169/// 找不到时返回列出所有尝试路径的错误信息。
170fn resolve_script(
171  script: &Path,
172  cwd: Option<&Path>,
173  resource_dir: Option<&Path>,
174  dev: bool,
175) -> std::result::Result<PathBuf, String> {
176  let mut candidates = Vec::new();
177  if script.is_absolute() {
178    candidates.push(script.to_path_buf());
179  } else {
180    if dev {
181      if let Some(cwd) = cwd {
182        candidates.push(cwd.join(script));
183      }
184    }
185    if let Some(dir) = resource_dir {
186      candidates.push(dir.join(resource_relative(script)));
187    }
188  }
189  if let Some(found) = candidates.iter().find(|p| p.is_file()) {
190    return Ok(found.clone());
191  }
192  let tried = if candidates.is_empty() {
193    "no candidate directories available".to_string()
194  } else {
195    candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
196  };
197  Err(format!("py-runtime: Python entry script '{}' not found (tried: {tried})", script.display()))
198}
199
200/// 打包产物中可执行文件的文件名:Windows 追加 `.exe`。
201fn bundle_executable(name: &str) -> String {
202  if cfg!(windows) {
203    format!("{name}.exe")
204  } else {
205    name.to_string()
206  }
207}
208
209/// 在资源目录中查找打包产物 `<name>/<name>[.exe]`;找不到时返回包含尝试路径的错误信息。
210fn resolve_bundle(name: &str, resource_dir: Option<&Path>) -> std::result::Result<PathBuf, String> {
211  let dir = resource_dir.ok_or("py-runtime: resource directory unavailable, cannot locate the packaged Python backend")?;
212  let path = dir.join(name).join(bundle_executable(name));
213  if path.is_file() {
214    Ok(path)
215  } else {
216    Err(format!(
217      "py-runtime: packaged Python backend not found at {}; run `tauri-runtime build` and add it to bundle.resources",
218      path.display()
219    ))
220  }
221}
222
223/// 解析配置所需的宿主环境信息,独立出来以便在不启动 Tauri 应用的情况下测试。
224struct Environment<'a> {
225  cwd: Option<&'a Path>,
226  resource_dir: Option<&'a Path>,
227  dev: bool,
228  app_name: String,
229  app_version: String,
230}
231
232/// 脚本目录下 uv / venv 创建的虚拟环境解释器(`.venv/bin/python`,Windows 为 `.venv\Scripts\python.exe`)。
233fn venv_python(script_dir: &Path) -> Option<PathBuf> {
234  let path = if cfg!(windows) {
235    script_dir.join(".venv").join("Scripts").join("python.exe")
236  } else {
237    script_dir.join(".venv").join("bin").join("python")
238  };
239  path.is_file().then_some(path)
240}
241
242fn default_python() -> &'static str {
243  if cfg!(windows) {
244    "python"
245  } else {
246    "python3"
247  }
248}
249
250/// 插件构建器。未设置的选项回退到 `tauri.conf.json` 中的 [`Config`],再回退到默认值。
251///
252/// 唯一必填项是入口脚本([`Builder::script`] 或 `plugins.py-runtime.script`)。
253///
254/// # Examples
255///
256/// ```rust,no_run
257/// use std::time::Duration;
258///
259/// let plugin = tauri_plugin_py_runtime::Builder::new()
260///   .script("../src-python/main.py")
261///   .python("python3")
262///   .args(["--verbose"])
263///   .env("APP_MODE", "dev")
264///   .shutdown_timeout(Duration::from_secs(5))
265///   .restart_on_crash(true)
266///   .build::<tauri::Wry>();
267/// let _app = tauri::Builder::default().plugin(plugin);
268/// ```
269#[derive(Debug, Default)]
270pub struct Builder {
271  python: Option<String>,
272  script: Option<PathBuf>,
273  bundle: Option<String>,
274  args: Option<Vec<String>>,
275  envs: Vec<(String, String)>,
276  invoke_timeout: Option<Duration>,
277  shutdown_timeout: Option<Duration>,
278  handshake_timeout: Option<Duration>,
279  restart_on_crash: Option<bool>,
280}
281
282impl Builder {
283  /// 创建所有选项均未设置的构建器。
284  pub fn new() -> Self {
285    Self::default()
286  }
287
288  /// 设置 Python 解释器(路径或命令名)。
289  ///
290  /// 未设置时,若脚本目录下存在 `.venv`(如 `uv sync` 创建的)则使用其中的解释器,
291  /// 否则为 `python3`(Windows 上为 `python`)。该解释器必须能导入 `tauri_runtime`
292  /// (及其依赖 `pydantic`)。
293  pub fn python(mut self, program: impl Into<String>) -> Self {
294    self.python = Some(program.into());
295    self
296  }
297
298  /// 设置 Python 入口脚本。
299  ///
300  /// 绝对路径原样使用。相对路径按以下顺序尝试,第一个存在的文件胜出:仅在 debug 构建中,
301  /// 相对于当前工作目录(`tauri dev` 下为 `src-tauri`);然后在所有构建中,相对于资源目录,
302  /// 并把每个 `..` 替换为 `_up_`(与打包器布局一致)。因此当 `bundle.resources` 包含
303  /// `"../src-python/**/*"` 时,`"../src-python/main.py"` 在开发与发布构建中都能找到。
304  /// Python 以脚本所在目录作为工作目录运行。
305  pub fn script(mut self, path: impl Into<PathBuf>) -> Self {
306    self.script = Some(path.into());
307    self
308  }
309
310  /// 设置 Nuitka 打包产物名(即 `tauri-runtime build` 配置中的 `name`)。
311  ///
312  /// 仅在 release 构建中生效:运行资源目录下的 `<name>/<name>`(Windows 为 `<name>.exe`),
313  /// 以其所在目录为工作目录,并忽略 [`Builder::python`] 与 [`Builder::script`];debug 构建
314  /// 仍以解释器运行脚本。产物需通过 `bundle.resources` 打进应用,找不到时 setup 失败。
315  pub fn bundle(mut self, name: impl Into<String>) -> Self {
316    self.bundle = Some(name.into());
317    self
318  }
319
320  /// 设置追加在脚本路径(或打包产物)之后的命令行参数,覆盖配置文件中的 `args`。
321  pub fn args<I, S>(mut self, args: I) -> Self
322  where
323    I: IntoIterator<Item = S>,
324    S: Into<String>,
325  {
326    self.args = Some(args.into_iter().map(Into::into).collect());
327    self
328  }
329
330  /// 为 Python 进程追加一个环境变量,可多次调用。
331  ///
332  /// `PYTHONUNBUFFERED=1` 与 `PYTHONIOENCODING=utf-8` 总会被设置,无法覆盖。
333  pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
334    self.envs.push((key.into(), value.into()));
335    self
336  }
337
338  /// 设置每次调用的默认超时,默认 30 秒。前端的 `timeoutMs` 最多可将其放宽到该值的 10 倍。
339  pub fn invoke_timeout(mut self, timeout: Duration) -> Self {
340    self.invoke_timeout = Some(timeout);
341    self
342  }
343
344  /// 设置 Python 执行 `shutdown` 钩子的时间预算,默认 3 秒;超过该时间再加 1 秒宽限后进程会被杀死。
345  pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
346    self.shutdown_timeout = Some(timeout);
347    self
348  }
349
350  /// 设置 Python 启动并发送握手的时限,默认 10 秒;也是调用等待启动中的运行时就绪的最长时间。
351  pub fn handshake_timeout(mut self, timeout: Duration) -> Self {
352    self.handshake_timeout = Some(timeout);
353    self
354  }
355
356  /// 设置 Python 意外退出后是否以指数退避自动重启,默认关闭。
357  ///
358  /// 详见 [`SupervisorConfig::restart_on_crash`]。
359  pub fn restart_on_crash(mut self, enabled: bool) -> Self {
360    self.restart_on_crash = Some(enabled);
361    self
362  }
363
364  fn resolve<R: Runtime>(self, file: Option<&Config>, app: &AppHandle<R>) -> std::result::Result<SupervisorConfig, String> {
365    let cwd = std::env::current_dir().ok();
366    let resource_dir = app.path().resource_dir().ok();
367    let package = app.package_info();
368    self.into_config(
369      file,
370      &Environment {
371        cwd: cwd.as_deref(),
372        resource_dir: resource_dir.as_deref(),
373        dev: cfg!(debug_assertions),
374        app_name: package.name.clone(),
375        app_version: package.version.to_string(),
376      },
377    )
378  }
379
380  /// 按“Builder > 配置文件 > 默认值”的优先级合并出 [`SupervisorConfig`]。
381  fn into_config(self, file: Option<&Config>, env: &Environment) -> std::result::Result<SupervisorConfig, String> {
382    let empty = Config::default();
383    let file = file.unwrap_or(&empty);
384    let extra = self.args.or_else(|| file.args.clone()).unwrap_or_default();
385    let (program, args, cwd) = match self.bundle.or_else(|| file.bundle.clone()) {
386      // release 构建运行打包产物;debug 构建即使配置了 bundle 也运行脚本,改动即时生效。
387      Some(name) if !env.dev => {
388        let exe = resolve_bundle(&name, env.resource_dir)?;
389        let cwd = exe.parent().map(Path::to_path_buf);
390        (exe, extra, cwd)
391      }
392      _ => {
393        let script = self
394          .script
395          .or_else(|| file.script.clone())
396          .ok_or("py-runtime: no Python entry script configured; call Builder::script or set plugins.py-runtime.script")?;
397        let script = resolve_script(&script, env.cwd, env.resource_dir, env.dev)?;
398        let mut args = vec![script.to_string_lossy().into_owned()];
399        args.extend(extra);
400        let script_dir = script.parent().map(Path::to_path_buf);
401        let python = match self.python.or_else(|| file.python.clone()) {
402          Some(python) => PathBuf::from(python),
403          None => script_dir
404            .as_deref()
405            .and_then(venv_python)
406            .unwrap_or_else(|| default_python().into()),
407        };
408        (python, args, script_dir)
409      }
410    };
411    Ok(SupervisorConfig {
412      process: ProcessConfig { program, args, envs: self.envs, cwd },
413      handshake_timeout: self.handshake_timeout.unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT),
414      invoke_timeout: self
415        .invoke_timeout
416        .or(file.invoke_timeout_ms.map(Duration::from_millis))
417        .unwrap_or(DEFAULT_INVOKE_TIMEOUT),
418      shutdown_timeout: self
419        .shutdown_timeout
420        .or(file.shutdown_timeout_ms.map(Duration::from_millis))
421        .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT),
422      restart_on_crash: self.restart_on_crash.or(file.restart_on_crash).unwrap_or(false),
423      max_frame_size: DEFAULT_MAX_FRAME_SIZE,
424      app_name: env.app_name.clone(),
425      app_version: env.app_version.clone(),
426    })
427  }
428
429  /// 构建 Tauri 插件。
430  ///
431  /// 插件 setup 时解析配置与入口脚本、注册 [`PyRuntime`] 状态并在后台启动 Python;
432  /// 应用退出(`RunEvent::Exit`)时阻塞等待 [`Supervisor::shutdown`] 完成。
433  /// 未配置入口脚本或脚本不存在时 setup 失败,错误信息列出所有尝试过的路径。
434  pub fn build<R: Runtime>(self) -> TauriPlugin<R, Option<Config>> {
435    PluginBuilder::<R, Option<Config>>::new("py-runtime")
436      .invoke_handler(tauri::generate_handler![
437        commands::invoke,
438        commands::cancel,
439        commands::emit,
440        commands::status
441      ])
442      .setup(move |app, api| {
443        let config = self.resolve(api.config().as_ref(), app)?;
444        let supervisor = Supervisor::new(config, Arc::new(TauriSink(app.clone())));
445        supervisor.start();
446        app.manage(PyRuntime { supervisor });
447        Ok(())
448      })
449      .on_event(|app, event| {
450        if let RunEvent::Exit = event {
451          if let Some(runtime) = app.try_state::<PyRuntime>() {
452            let supervisor = runtime.supervisor.clone();
453            // 必须同步等待:Exit 事件返回后进程即退出,异步任务没有机会让 Python 执行关闭钩子。
454            tauri::async_runtime::block_on(async move { supervisor.shutdown().await });
455          }
456        }
457      })
458      .build()
459  }
460}
461
462/// 仅使用 `tauri.conf.json` 中的 `plugins.py-runtime` 初始化插件,等价于 `Builder::new().build()`。
463pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
464  Builder::new().build()
465}
466
467#[cfg(test)]
468mod tests {
469  use super::*;
470  use std::sync::atomic::{AtomicUsize, Ordering};
471
472  struct TempDir(PathBuf);
473
474  impl TempDir {
475    fn new() -> Self {
476      static COUNTER: AtomicUsize = AtomicUsize::new(0);
477      let n = COUNTER.fetch_add(1, Ordering::SeqCst);
478      let dir = std::env::temp_dir().join(format!("py-runtime-test-{}-{n}", std::process::id()));
479      let _ = std::fs::remove_dir_all(&dir);
480      std::fs::create_dir_all(&dir).unwrap();
481      Self(dir)
482    }
483
484    fn touch(&self, rel: &str) -> PathBuf {
485      let path = self.0.join(rel);
486      std::fs::create_dir_all(path.parent().unwrap()).unwrap();
487      std::fs::write(&path, "").unwrap();
488      path
489    }
490  }
491
492  impl Drop for TempDir {
493    fn drop(&mut self) {
494      let _ = std::fs::remove_dir_all(&self.0);
495    }
496  }
497
498  fn env<'a>(cwd: Option<&'a Path>, resource_dir: Option<&'a Path>, dev: bool) -> Environment<'a> {
499    Environment { cwd, resource_dir, dev, app_name: "app".into(), app_version: "1.0.0".into() }
500  }
501
502  #[test]
503  fn absolute_script_is_used_as_is() {
504    let tmp = TempDir::new();
505    let script = tmp.touch("main.py");
506    assert_eq!(resolve_script(&script, None, None, false).unwrap(), script);
507  }
508
509  #[test]
510  fn missing_absolute_script_is_an_error() {
511    let tmp = TempDir::new();
512    let script = tmp.0.join("nope.py");
513    let err = resolve_script(&script, None, None, true).unwrap_err();
514    assert!(err.contains(&script.display().to_string()), "{err}");
515  }
516
517  #[test]
518  fn dev_relative_script_found_via_cwd() {
519    let tmp = TempDir::new();
520    let expected = tmp.touch("python/main.py");
521    let cwd = tmp.0.join("src-tauri");
522    std::fs::create_dir_all(&cwd).unwrap();
523    let resources = tmp.0.join("resources");
524    let found = resolve_script(Path::new("../python/main.py"), Some(&cwd), Some(&resources), true).unwrap();
525    assert_eq!(found.canonicalize().unwrap(), expected.canonicalize().unwrap());
526  }
527
528  #[test]
529  fn release_relative_script_found_in_resource_dir_with_up_mapping() {
530    let tmp = TempDir::new();
531    let cwd = tmp.0.join("src-tauri");
532    tmp.touch("python/main.py");
533    let expected = tmp.touch("resources/_up_/python/main.py");
534    let resources = tmp.0.join("resources");
535    let found = resolve_script(Path::new("../python/main.py"), Some(&cwd), Some(&resources), false).unwrap();
536    assert_eq!(found, expected);
537  }
538
539  #[test]
540  fn dev_falls_back_to_resource_dir() {
541    let tmp = TempDir::new();
542    let expected = tmp.touch("resources/main.py");
543    let cwd = tmp.0.join("cwd");
544    let resources = tmp.0.join("resources");
545    let found = resolve_script(Path::new("main.py"), Some(&cwd), Some(&resources), true).unwrap();
546    assert_eq!(found, expected);
547  }
548
549  #[test]
550  fn missing_relative_script_lists_tried_paths() {
551    let tmp = TempDir::new();
552    let cwd = tmp.0.join("cwd");
553    let resources = tmp.0.join("resources");
554    let err = resolve_script(Path::new("../py/main.py"), Some(&cwd), Some(&resources), true).unwrap_err();
555    assert!(err.contains(&cwd.join("../py/main.py").display().to_string()), "{err}");
556    assert!(err.contains(&resources.join("_up_").join("py").join("main.py").display().to_string()), "{err}");
557  }
558
559  #[test]
560  fn config_deserializes_camel_case_keys() {
561    let config: Config = serde_json::from_value(serde_json::json!({
562      "python": "py",
563      "script": "main.py",
564      "args": ["-v"],
565      "invokeTimeoutMs": 5,
566      "shutdownTimeoutMs": 6,
567      "restartOnCrash": true
568    }))
569    .unwrap();
570    assert_eq!(config.python.as_deref(), Some("py"));
571    assert_eq!(config.script, Some(PathBuf::from("main.py")));
572    assert_eq!(config.args, Some(vec!["-v".to_string()]));
573    assert_eq!(config.invoke_timeout_ms, Some(5));
574    assert_eq!(config.shutdown_timeout_ms, Some(6));
575    assert_eq!(config.restart_on_crash, Some(true));
576  }
577
578  #[test]
579  fn config_rejects_unknown_keys() {
580    let err = serde_json::from_value::<Config>(serde_json::json!({ "scirpt": "main.py" })).unwrap_err();
581    assert!(err.to_string().contains("scirpt"), "{err}");
582  }
583
584  #[test]
585  fn builder_values_override_file_config() {
586    let tmp = TempDir::new();
587    let builder_script = tmp.touch("builder.py");
588    tmp.touch("file.py");
589    let file = Config {
590      python: Some("file-python".into()),
591      script: Some("file.py".into()),
592      bundle: None,
593      args: Some(vec!["file-arg".into()]),
594      invoke_timeout_ms: Some(1000),
595      shutdown_timeout_ms: Some(2000),
596      restart_on_crash: Some(true),
597    };
598    let config = Builder::new()
599      .python("builder-python")
600      .script(&builder_script)
601      .args(["builder-arg"])
602      .invoke_timeout(Duration::from_secs(7))
603      .restart_on_crash(false)
604      .into_config(Some(&file), &env(Some(&tmp.0), None, true))
605      .unwrap();
606    assert_eq!(config.process.program, PathBuf::from("builder-python"));
607    assert_eq!(config.process.args, vec![builder_script.to_string_lossy().into_owned(), "builder-arg".into()]);
608    assert_eq!(config.process.cwd.as_deref(), Some(tmp.0.as_path()));
609    assert_eq!(config.invoke_timeout, Duration::from_secs(7));
610    assert_eq!(config.shutdown_timeout, Duration::from_millis(2000));
611    assert!(!config.restart_on_crash);
612    assert_eq!(config.app_name, "app");
613  }
614
615  #[test]
616  fn file_config_and_defaults_apply_when_builder_is_empty() {
617    let tmp = TempDir::new();
618    let script = tmp.touch("file.py");
619    let file = Config { script: Some("file.py".into()), invoke_timeout_ms: Some(1000), ..Default::default() };
620    let config = Builder::new().into_config(Some(&file), &env(Some(&tmp.0), None, true)).unwrap();
621    assert_eq!(config.process.program, PathBuf::from(default_python()));
622    assert_eq!(config.process.args, vec![tmp.0.join("file.py").to_string_lossy().into_owned()]);
623    assert_eq!(config.process.cwd.as_deref(), script.parent());
624    assert_eq!(config.invoke_timeout, Duration::from_millis(1000));
625    assert_eq!(config.shutdown_timeout, DEFAULT_SHUTDOWN_TIMEOUT);
626    assert_eq!(config.handshake_timeout, DEFAULT_HANDSHAKE_TIMEOUT);
627    assert!(!config.restart_on_crash);
628  }
629
630  #[test]
631  fn missing_script_is_a_config_error() {
632    let err = Builder::new().into_config(None, &env(None, None, true)).unwrap_err();
633    assert!(err.contains("no Python entry script"), "{err}");
634  }
635
636  fn venv_rel() -> &'static str {
637    if cfg!(windows) {
638      ".venv/Scripts/python.exe"
639    } else {
640      ".venv/bin/python"
641    }
642  }
643
644  #[test]
645  fn script_dir_venv_is_used_when_python_unset() {
646    let tmp = TempDir::new();
647    let script = tmp.touch("main.py");
648    let python = tmp.touch(venv_rel());
649    let config = Builder::new().script(&script).into_config(None, &env(None, None, true)).unwrap();
650    assert_eq!(config.process.program, python);
651  }
652
653  #[test]
654  fn explicit_python_wins_over_venv() {
655    let tmp = TempDir::new();
656    let script = tmp.touch("main.py");
657    tmp.touch(venv_rel());
658    let config = Builder::new()
659      .script(&script)
660      .python("custom-python")
661      .into_config(None, &env(None, None, true))
662      .unwrap();
663    assert_eq!(config.process.program, PathBuf::from("custom-python"));
664  }
665
666  fn exe(name: &str) -> String {
667    if cfg!(windows) {
668      format!("{name}.exe")
669    } else {
670      name.to_string()
671    }
672  }
673
674  #[test]
675  fn release_with_bundle_runs_packaged_executable() {
676    let tmp = TempDir::new();
677    let resources = tmp.0.join("resources");
678    let expected = tmp.touch(&format!("resources/py-backend/{}", exe("py-backend")));
679    let config = Builder::new()
680      .bundle("py-backend")
681      .args(["--flag"])
682      .into_config(None, &env(None, Some(&resources), false))
683      .unwrap();
684    assert_eq!(config.process.program, expected);
685    assert_eq!(config.process.args, vec!["--flag".to_string()]);
686    assert_eq!(config.process.cwd.as_deref(), expected.parent());
687  }
688
689  #[test]
690  fn dev_with_bundle_still_runs_script() {
691    let tmp = TempDir::new();
692    let script = tmp.touch("main.py");
693    let config = Builder::new()
694      .bundle("py-backend")
695      .script(&script)
696      .into_config(None, &env(None, None, true))
697      .unwrap();
698    assert_eq!(config.process.args, vec![script.to_string_lossy().into_owned()]);
699  }
700
701  #[test]
702  fn missing_bundle_lists_tried_path() {
703    let tmp = TempDir::new();
704    let resources = tmp.0.join("resources");
705    let err = Builder::new()
706      .bundle("py-backend")
707      .into_config(None, &env(None, Some(&resources), false))
708      .unwrap_err();
709    assert!(err.contains(&resources.join("py-backend").join(exe("py-backend")).display().to_string()), "{err}");
710  }
711
712  #[test]
713  fn bundle_from_file_config() {
714    let tmp = TempDir::new();
715    let resources = tmp.0.join("resources");
716    let expected = tmp.touch(&format!("resources/app/{}", exe("app")));
717    let file: Config = serde_json::from_value(serde_json::json!({ "bundle": "app" })).unwrap();
718    let config = Builder::new().into_config(Some(&file), &env(None, Some(&resources), false)).unwrap();
719    assert_eq!(config.process.program, expected);
720  }
721}