diff --git a/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs index 689d747..d166f08 100644 --- a/bindings/csharp/FusionFramework/FusionApp.cs +++ b/bindings/csharp/FusionFramework/FusionApp.cs @@ -142,12 +142,26 @@ internal void AddRawRoute(string method, string path, Func handler) throw new InvalidOperationException($"Failed to register {method} {path}"); } - public void Listen(string? host = null, ushort port = 0) + public void Listen(string? host = null, ushort port = 0, bool? reload = null, IEnumerable? watchDirs = null) { - Mount(); var settings = SettingsStore.Current; + var settingsReload = Truthy(settings.Get("reload", false)); + var shouldReload = Reloader.Resolve(reload, settingsReload); + + if (shouldReload && !Reloader.IsChild) + { + Reloader.RunWithReloader(watchDirs); + return; + } + + Mount(); host ??= settings.Host; if (port == 0) port = settings.Port; + if (settings.Debug || shouldReload) + { + var mode = shouldReload ? " (reload)" : ""; + Console.WriteLine($"fusion listening on http://{host}:{port}{mode}"); + } var code = Native.fusion_app_listen(_app, host, port); // listen consumes the native app @@ -156,6 +170,18 @@ public void Listen(string? host = null, ushort port = 0) throw new InvalidOperationException("fusion_app_listen failed"); } + static bool Truthy(System.Text.Json.Nodes.JsonNode? node, bool fallback = false) + { + if (node is null) return fallback; + if (node is System.Text.Json.Nodes.JsonValue v) + { + if (v.TryGetValue(out var b)) return b; + if (v.TryGetValue(out var s)) + return s is "1" or "true" or "True" or "yes" or "on"; + } + return fallback; + } + public void Dispose() { if (_disposed) return; diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index f9e965e..353aee3 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -54,6 +54,22 @@ foreach (var mw in MIDDLEWARE) app.Use(mw); app.Listen(); ``` +### Auto-reload (development) + +```csharp +// Restart the process when source files change +app.Listen(reload: true); + +// Never reload (default) — same as omit / settings reload: false +app.Listen(reload: false); +``` + +Or in `fusion.dev.json`: + +```json +{ "reload": true } +``` + ## Custom HTTP routes Use method-level attributes alongside convention `get`/`post`/… handlers: diff --git a/bindings/csharp/FusionFramework/Reloader.cs b/bindings/csharp/FusionFramework/Reloader.cs new file mode 100644 index 0000000..722d75a --- /dev/null +++ b/bindings/csharp/FusionFramework/Reloader.cs @@ -0,0 +1,169 @@ +using System.Diagnostics; + +namespace FusionFramework; + +/// +/// Process-based auto-reload for development. +/// Parent watches files and restarts a child that runs the real server. +/// +public static class Reloader +{ + public const string ChildEnvVar = "FUSION_RELOAD_CHILD"; + + static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) + { + ".git", ".hg", "node_modules", "target", ".venv", "venv", + "__pycache__", "bin", "obj", "dist", "build", ".idea", ".vs", + }; + + static readonly HashSet Extensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cs", ".json", ".html", ".tera", ".js", ".mjs", ".py", + }; + + public static bool IsChild => + string.Equals(Environment.GetEnvironmentVariable(ChildEnvVar), "1", StringComparison.Ordinal); + + public static bool Resolve(bool? reload, bool settingsReload) => + reload ?? settingsReload; + + public static void RunWithReloader(IEnumerable? watchDirs = null) + { + if (IsChild) + throw new InvalidOperationException("RunWithReloader must not run inside the child process"); + + var roots = (watchDirs ?? new[] { Directory.GetCurrentDirectory() }) + .Select(Path.GetFullPath) + .Distinct(StringComparer.Ordinal) + .ToList(); + + Console.WriteLine($"fusion: reload enabled (watching {string.Join(", ", roots)})"); + + Process? child = null; + var mtimes = Snapshot(Collect(roots)); + + void StopChild() + { + if (child is null || child.HasExited) + { + child = null; + return; + } + try + { + child.Kill(entireProcessTree: true); + child.WaitForExit(5000); + } + catch + { + /* best effort */ + } + child = null; + } + + Process StartChild() + { + var fileName = Environment.ProcessPath + ?? throw new InvalidOperationException("Environment.ProcessPath is unavailable"); + var start = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + }; + // Skip argv[0] (executable path); forward the rest. + foreach (var arg in Environment.GetCommandLineArgs().Skip(1)) + start.ArgumentList.Add(arg); + start.Environment[ChildEnvVar] = "1"; + return Process.Start(start) + ?? throw new InvalidOperationException("failed to start reload child"); + } + + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + StopChild(); + Environment.Exit(0); + }; + + child = StartChild(); + while (true) + { + Thread.Sleep(500); + if (child.HasExited) + { + Console.WriteLine($"fusion: child exited ({child.ExitCode}); restarting…"); + Thread.Sleep(300); + child = StartChild(); + mtimes = Snapshot(Collect(roots)); + continue; + } + + var files = Collect(roots); + var next = Snapshot(files); + string? changed = null; + foreach (var (file, mtime) in next) + { + if (!mtimes.TryGetValue(file, out var prev) || mtime > prev) + { + changed = file; + break; + } + } + if (changed is null) + { + foreach (var file in mtimes.Keys) + { + if (!next.ContainsKey(file)) + { + changed = file; + break; + } + } + } + if (changed is null) continue; + + var label = changed; + try { label = Path.GetRelativePath(Directory.GetCurrentDirectory(), changed); } catch { /* keep */ } + Console.WriteLine($"fusion: change detected ({label}); reloading…"); + StopChild(); + child = StartChild(); + mtimes = Snapshot(Collect(roots)); + } + } + + static List Collect(IEnumerable roots) + { + var files = new List(); + foreach (var root in roots) + { + if (File.Exists(root)) + { + if (Extensions.Contains(Path.GetExtension(root))) + files.Add(root); + continue; + } + if (!Directory.Exists(root)) continue; + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(root, file); + if (rel.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(p => SkipDirs.Contains(p))) + continue; + if (Extensions.Contains(Path.GetExtension(file))) + files.Add(file); + } + } + return files; + } + + static Dictionary Snapshot(IEnumerable files) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var file in files) + { + try { map[file] = File.GetLastWriteTimeUtc(file).Ticks; } + catch { /* ignore */ } + } + return map; + } +} diff --git a/crates/fusion-core/src/settings.rs b/crates/fusion-core/src/settings.rs index 144157f..a42e2c9 100644 --- a/crates/fusion-core/src/settings.rs +++ b/crates/fusion-core/src/settings.rs @@ -97,6 +97,14 @@ impl Settings { self.get_bool("debug").unwrap_or(false) } + /// When true, host bindings should restart the process on source changes. + /// Default is ``false`` (no reload). Override with ``listen(reload=...)``. + pub fn reload(&self) -> bool { + self.get_bool("reload") + .or_else(|| self.get_bool("reload.enabled")) + .unwrap_or(false) + } + /// Directory for Tera templates (``templates.dir`` in settings). pub fn templates_dir(&self) -> String { self.get_str("templates.dir") @@ -378,6 +386,19 @@ mod tests { assert_eq!(settings.get_str("secret_key").as_deref(), Some("abc")); } + #[test] + fn reload_defaults_false() { + let settings = Settings::new(); + assert!(!settings.reload()); + let mut settings = Settings::new(); + settings.merge_map({ + let mut m = Map::new(); + m.insert("reload".into(), json!(true)); + m + }); + assert!(settings.reload()); + } + fn tempfile_dir() -> PathBuf { let dir = env::temp_dir().join(format!("fusion-settings-{}", std::process::id())); let _ = fs::create_dir_all(&dir); diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 86e0a30..656ef7e 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -64,7 +64,16 @@ export class FusionApp { constructor(settings?: Partial) use(middleware: FusionMiddleware): void mount(): void - listen(host?: string, port?: number): Promise + listen( + host?: string | { + host?: string + port?: number + reload?: boolean + watchDirs?: string[] + }, + port?: number, + options?: { reload?: boolean; watchDirs?: string[] }, + ): Promise } export type RouteOptions = { diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 3bc0512..a36a557 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -1,5 +1,6 @@ const path = require('path') const fs = require('fs') +const { spawn } = require('child_process') const { platform, arch } = process function napiTriple() { @@ -1077,18 +1078,200 @@ class FusionApp { this.mounted = true } - async listen(host, port) { - this.mount() + async listen(host, port, options = {}) { + const reloadOpt = + options && Object.prototype.hasOwnProperty.call(options, 'reload') + ? options.reload + : host && typeof host === 'object' + ? host.reload + : undefined + // Support listen({ host, port, reload }) as well as listen(host, port, { reload }) + let h = host + let p = port + let reloadArg = reloadOpt + let watchDirs = options?.watchDirs + if (host && typeof host === 'object' && !Array.isArray(host)) { + h = host.host + p = host.port + reloadArg = host.reload + watchDirs = host.watchDirs + } + const snapshot = getSettings() - const h = host ?? snapshot.host - const p = port ?? snapshot.port - if (snapshot.debug) { - console.log(`fusion listening on http://${h}:${p}`) + const settingsReload = Boolean(snapshot.get('reload', false)) + const shouldReload = + reloadArg === undefined || reloadArg === null ? settingsReload : Boolean(reloadArg) + + if (shouldReload && process.env.FUSION_RELOAD_CHILD !== '1') { + await runWithReloader({ watchDirs }) + return + } + + this.mount() + h = h ?? snapshot.host + p = p ?? snapshot.port + if (snapshot.debug || shouldReload) { + const mode = shouldReload ? ' (reload)' : '' + console.log(`fusion listening on http://${h}:${p}${mode}`) } await this.engine.listen(h, Number(p)) } } +const RELOAD_SKIP_DIRS = new Set([ + '.git', + '.hg', + 'node_modules', + 'target', + '.venv', + 'venv', + '__pycache__', + 'bin', + 'obj', + 'dist', + 'build', +]) + +const RELOAD_EXTENSIONS = new Set([ + '.js', + '.mjs', + '.cjs', + '.ts', + '.json', + '.html', + '.tera', + '.py', + '.cs', +]) + +function collectWatchedFiles(roots) { + const files = [] + const walk = (dir) => { + let entries + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (entry.name.startsWith('.') && entry.name !== '.') continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (RELOAD_SKIP_DIRS.has(entry.name)) continue + walk(full) + } else if (RELOAD_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + files.push(full) + } + } + } + for (const root of roots) { + const resolved = path.resolve(root) + try { + const st = fs.statSync(resolved) + if (st.isFile()) files.push(resolved) + else if (st.isDirectory()) walk(resolved) + } catch { + /* missing root */ + } + } + return files +} + +function snapshotMtimes(files) { + const map = new Map() + for (const file of files) { + try { + map.set(file, fs.statSync(file).mtimeMs) + } catch { + /* ignore */ + } + } + return map +} + +async function runWithReloader({ watchDirs } = {}) { + const roots = watchDirs?.length ? watchDirs : [process.cwd()] + console.log(`fusion: reload enabled (watching ${roots.join(', ')})`) + + let child = null + const spawnChild = () => { + const env = { ...process.env, FUSION_RELOAD_CHILD: '1' } + child = spawn(process.execPath, process.argv.slice(1), { + env, + stdio: 'inherit', + }) + return child + } + + const stopChild = () => + new Promise((resolve) => { + if (!child || child.exitCode !== null) { + child = null + resolve() + return + } + child.once('exit', () => { + child = null + resolve() + }) + child.kill('SIGTERM') + setTimeout(() => { + if (child) child.kill('SIGKILL') + }, 5000) + }) + + const shutdown = async () => { + await stopChild() + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + let mtimes = snapshotMtimes(collectWatchedFiles(roots)) + spawnChild() + + // eslint-disable-next-line no-constant-condition + while (true) { + await new Promise((r) => setTimeout(r, 500)) + if (child && child.exitCode !== null) { + console.log(`fusion: child exited (${child.exitCode}); restarting…`) + await new Promise((r) => setTimeout(r, 300)) + spawnChild() + mtimes = snapshotMtimes(collectWatchedFiles(roots)) + continue + } + const files = collectWatchedFiles(roots) + const next = snapshotMtimes(files) + let changed = null + for (const [file, mtime] of next) { + const prev = mtimes.get(file) + if (prev === undefined || mtime > prev) { + changed = file + break + } + } + if (!changed) { + for (const file of mtimes.keys()) { + if (!next.has(file)) { + changed = file + break + } + } + } + if (!changed) continue + let label = changed + try { + label = path.relative(process.cwd(), changed) || changed + } catch { + /* keep absolute */ + } + console.log(`fusion: change detected (${label}); reloading…`) + await stopChild() + spawnChild() + mtimes = snapshotMtimes(collectWatchedFiles(roots)) + } +} + async function run(options = {}) { const settingsModulePath = typeof options === 'string' ? options : options && options.settingsModule @@ -1105,7 +1288,12 @@ async function run(options = {}) { } const app = new FusionApp() for (const mw of middleware) app.use(mw) - await app.listen() + await app.listen({ + reload: options && Object.prototype.hasOwnProperty.call(options, 'reload') + ? options.reload + : undefined, + watchDirs: options?.watchDirs, + }) return app } diff --git a/crates/fusion-node/src/settings.rs b/crates/fusion-node/src/settings.rs index b32f146..542ccf7 100644 --- a/crates/fusion-node/src/settings.rs +++ b/crates/fusion-node/src/settings.rs @@ -124,6 +124,16 @@ impl Settings { Ok(guard.debug()) } + #[napi(getter)] + pub fn reload(&self) -> Result { + let mut guard = self + .inner + .lock() + .map_err(|_| Error::from_reason("settings lock poisoned"))?; + let _ = guard.ensure_loaded(&[]); + Ok(guard.reload()) + } + #[napi(getter)] pub fn env(&self) -> Result { let mut guard = self diff --git a/crates/fusion-py/README.md b/crates/fusion-py/README.md index dfcac83..8d51952 100644 --- a/crates/fusion-py/README.md +++ b/crates/fusion-py/README.md @@ -45,6 +45,14 @@ if __name__ == "__main__": main() ``` +### Auto-reload (development) + +```python +app.listen(reload=True) # restart when files change +app.listen(reload=False) # never reload (default) +# or in fusion.dev.json: { "reload": true } +``` + ## Docs Full guides (router, config, middleware, async): diff --git a/crates/fusion-py/python/fusion_framework/app.py b/crates/fusion-py/python/fusion_framework/app.py index b649ed7..50cbb3e 100644 --- a/crates/fusion-py/python/fusion_framework/app.py +++ b/crates/fusion-py/python/fusion_framework/app.py @@ -372,7 +372,35 @@ def use(self, middleware) -> None: """Register global middleware: ``(request, call_next) -> response``.""" self._middleware.append(middleware) - def listen(self, host: str | None = None, port: int | None = None) -> None: + def listen( + self, + host: str | None = None, + port: int | None = None, + *, + reload: bool | None = None, + watch_dirs: list[str] | None = None, + ) -> None: + """Start the HTTP server. + + ``reload`` controls auto-restart on source changes: + + - ``True`` — watch files and restart when anything changes + - ``False`` — never reload, even if files change + - ``None`` — use ``settings.reload`` / ``reload`` in ``fusion..json`` + (default ``false``) + + When reload is enabled, the parent process watches files and restarts a + child that actually listens. Pass ``watch_dirs`` to limit what is scanned. + """ + from fusion_framework.reload import is_reload_child, resolve_reload, run_with_reloader + + settings_reload = bool(self.settings.get("reload", default=False)) + should_reload = resolve_reload(reload, settings_reload=settings_reload) + + if should_reload and not is_reload_child(): + run_with_reloader(watch_dirs=watch_dirs) + return + if not self._mounted: set_active_global(self._middleware) self._engine.mount_routes() @@ -382,15 +410,21 @@ def listen(self, host: str | None = None, port: int | None = None) -> None: self._mounted = True host = host if host is not None else self.settings.host port = port if port is not None else self.settings.port - if self.settings.debug: - print(f"fusion listening on http://{host}:{port}", flush=True) + if self.settings.debug or should_reload: + mode = " (reload)" if should_reload else "" + print(f"fusion listening on http://{host}:{port}{mode}", flush=True) try: self._engine.listen(host, int(port)) except KeyboardInterrupt: print("fusion: stopped", flush=True) -def run(settings_module: str | None = "settings", middleware: list | None = None) -> None: +def run( + settings_module: str | None = "settings", + middleware: list | None = None, + *, + reload: bool | None = None, +) -> None: """Start the app. For middleware, prefer explicit ``FusionApp`` in ``main.py``.""" if settings_module: load_settings_module(settings_module) @@ -399,4 +433,4 @@ def run(settings_module: str | None = "settings", middleware: list | None = None app = FusionApp(get_settings()) for mw in middleware or []: app.use(mw) - app.listen() + app.listen(reload=reload) diff --git a/crates/fusion-py/python/fusion_framework/reload.py b/crates/fusion-py/python/fusion_framework/reload.py new file mode 100644 index 0000000..a9cdf40 --- /dev/null +++ b/crates/fusion-py/python/fusion_framework/reload.py @@ -0,0 +1,203 @@ +"""Process-based auto-reload for development (stdlib only). + +Parent process watches source files; on change it restarts a child that runs +the real server. Disable with ``listen(reload=False)`` or ``reload: false``. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Iterable, Sequence + +ENV_CHILD = "FUSION_RELOAD_CHILD" + +# Default: watch common project source extensions. +DEFAULT_EXTENSIONS = ( + ".py", + ".html", + ".tera", + ".json", + ".js", + ".mjs", + ".cjs", + ".ts", + ".cs", +) + +SKIP_DIR_NAMES = { + ".git", + ".hg", + ".svn", + ".venv", + "venv", + "node_modules", + "target", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "bin", + "obj", + "dist", + "build", + ".idea", + ".vscode", +} + + +def is_reload_child() -> bool: + return os.environ.get(ENV_CHILD) == "1" + + +def resolve_reload( + reload: bool | None, + *, + settings_reload: bool, +) -> bool: + """Explicit ``reload=`` wins; otherwise use settings (default false).""" + if reload is not None: + return bool(reload) + return bool(settings_reload) + + +def _iter_files(roots: Sequence[Path], extensions: Sequence[str]) -> Iterable[Path]: + ext_set = {e if e.startswith(".") else f".{e}" for e in extensions} + for root in roots: + if not root.exists(): + continue + if root.is_file(): + if root.suffix.lower() in ext_set: + yield root + continue + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES] + for name in filenames: + path = Path(dirpath) / name + if path.suffix.lower() in ext_set: + yield path + + +def snapshot_mtimes(roots: Sequence[Path], extensions: Sequence[str]) -> dict[str, float]: + out: dict[str, float] = {} + for path in _iter_files(roots, extensions): + try: + out[str(path)] = path.stat().st_mtime + except OSError: + continue + return out + + +def changed_since( + previous: dict[str, float], + roots: Sequence[Path], + extensions: Sequence[str], +) -> list[str]: + current = snapshot_mtimes(roots, extensions) + changed: list[str] = [] + for path, mtime in current.items(): + old = previous.get(path) + if old is None or mtime > old: + changed.append(path) + for path in previous: + if path not in current: + changed.append(path) + return changed + + +def default_watch_roots() -> list[Path]: + roots: list[Path] = [Path.cwd()] + main = sys.modules.get("__main__") + main_file = getattr(main, "__file__", None) + if main_file: + roots.append(Path(main_file).resolve().parent) + # Unique, existing paths + seen: set[str] = set() + unique: list[Path] = [] + for root in roots: + key = str(root.resolve()) if root.exists() else str(root) + if key in seen: + continue + seen.add(key) + unique.append(root) + return unique + + +def run_with_reloader( + *, + watch_dirs: Sequence[str | Path] | None = None, + extensions: Sequence[str] | None = None, + poll_interval: float = 0.5, +) -> None: + """Parent loop: spawn this same process as a child and restart on changes.""" + if is_reload_child(): + raise RuntimeError("run_with_reloader must not run inside the child process") + + roots = [Path(p) for p in watch_dirs] if watch_dirs else default_watch_roots() + exts = tuple(extensions) if extensions else DEFAULT_EXTENSIONS + env = os.environ.copy() + env[ENV_CHILD] = "1" + + print( + f"fusion: reload enabled (watching {', '.join(str(r) for r in roots)})", + flush=True, + ) + + process: subprocess.Popen[bytes] | None = None + + def stop_child() -> None: + nonlocal process + if process is None or process.poll() is not None: + process = None + return + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + process = None + + def start_child() -> subprocess.Popen[bytes]: + return subprocess.Popen([sys.executable, *sys.argv], env=env) + + def handle_signal(signum: int, _frame) -> None: + stop_child() + raise SystemExit(128 + signum) + + signal.signal(signal.SIGINT, handle_signal) + signal.signal(signal.SIGTERM, handle_signal) + + mtimes = snapshot_mtimes(roots, exts) + process = start_child() + + try: + while True: + time.sleep(poll_interval) + if process.poll() is not None: + # Child exited on its own — restart after a short pause. + code = process.returncode + print(f"fusion: child exited ({code}); restarting…", flush=True) + time.sleep(0.3) + process = start_child() + mtimes = snapshot_mtimes(roots, exts) + continue + + dirty = changed_since(mtimes, roots, exts) + if not dirty: + continue + rel = dirty[0] + try: + rel = str(Path(rel).relative_to(Path.cwd())) + except ValueError: + pass + print(f"fusion: change detected ({rel}); reloading…", flush=True) + stop_child() + process = start_child() + mtimes = snapshot_mtimes(roots, exts) + finally: + stop_child() diff --git a/crates/fusion-py/python/fusion_framework/test_reload.py b/crates/fusion-py/python/fusion_framework/test_reload.py new file mode 100644 index 0000000..24f1d75 --- /dev/null +++ b/crates/fusion-py/python/fusion_framework/test_reload.py @@ -0,0 +1,29 @@ +"""Tests for reload helpers (no live server).""" + +from __future__ import annotations + +import time +from pathlib import Path + +from fusion_framework.reload import ( + changed_since, + resolve_reload, + snapshot_mtimes, +) + + +def test_resolve_reload_explicit_wins(): + assert resolve_reload(True, settings_reload=False) is True + assert resolve_reload(False, settings_reload=True) is False + assert resolve_reload(None, settings_reload=True) is True + assert resolve_reload(None, settings_reload=False) is False + + +def test_snapshot_detects_change(tmp_path: Path): + watched = tmp_path / "app.py" + watched.write_text("print(1)\n", encoding="utf-8") + before = snapshot_mtimes([tmp_path], [".py"]) + time.sleep(0.05) + watched.write_text("print(2)\n", encoding="utf-8") + dirty = changed_since(before, [tmp_path], [".py"]) + assert any(str(watched) == p or p.endswith("app.py") for p in dirty) diff --git a/crates/fusion-py/src/lib.rs b/crates/fusion-py/src/lib.rs index 57945d2..a98d45c 100644 --- a/crates/fusion-py/src/lib.rs +++ b/crates/fusion-py/src/lib.rs @@ -161,6 +161,12 @@ impl PySettings { self.with_ref(|s| s.debug()) } + #[getter] + fn reload(&self) -> PyResult { + let _ = self.with_mut(|s| s.ensure_loaded(&[]).map(|_| ()))?; + self.with_ref(|s| s.reload()) + } + #[getter] fn config(&self, py: Python<'_>) -> PyResult { let _ = self.with_mut(|s| s.ensure_loaded(&[]).map(|_| ()))?; diff --git a/examples/main.py b/examples/main.py index 489f400..2fda4b7 100644 --- a/examples/main.py +++ b/examples/main.py @@ -14,7 +14,10 @@ def main() -> None: app = FusionApp(get_settings()) for middleware in MIDDLEWARE: app.use(middleware) - app.listen() + # Default: no reload. For development auto-restart on file changes: + # app.listen(reload=True) + # Or set "reload": true in fusion.dev.json + app.listen(reload=False) if __name__ == "__main__":