use std::{
path::Path,
sync::{Arc, Mutex, OnceLock},
time::{Duration, Instant},
};
use bevy::{
app::{
App, First, FixedFirst, FixedLast, FixedPostUpdate, FixedPreUpdate, FixedUpdate, Last,
Main, Plugin, PostStartup, PostUpdate, PreStartup, PreUpdate, Startup, Update,
},
ecs::schedule::{IntoScheduleConfigs, Schedules},
};
#[cfg(feature = "native-hot-reload")]
use notify::{EventKind, RecursiveMode, Watcher};
use pybevy_core::{ComponentBridge, DynamicComponentRegistry, registry::global_registry};
use pyo3::{
exceptions::{PyAttributeError, PyImportError},
prelude::*,
types::PyList,
};
use crate::{
_pybevy,
app::{
PyStage, SimTick,
hot_reload::{
HotReloadGeneration, HotReloadResource, HotReloadState, SystemStage,
check_hot_reload_system, generation_matches, handle_f5_reload_system,
startup_or_reload,
},
},
ecs::dynamic_system::DynamicSystem,
};
static PYTHON_INITIALIZED: OnceLock<()> = OnceLock::new();
fn ensure_python_initialized() {
PYTHON_INITIALIZED.get_or_init(|| {
let already_initialized = unsafe { pyo3::ffi::Py_IsInitialized() != 0 };
if !already_initialized {
pyo3::append_to_inittab!(_pybevy);
Python::initialize();
}
Python::attach(|py| {
let module = if !already_initialized {
py.import("_pybevy")
.expect("Failed to import _pybevy from inittab")
} else {
let m = PyModule::new(py, "_pybevy").expect("Failed to create _pybevy module");
crate::init_module(&m).expect("Failed to populate _pybevy module");
let sys = py.import("sys").expect("Failed to import sys");
sys.getattr("modules")
.expect("Failed to get sys.modules")
.set_item("_pybevy", &m)
.expect("Failed to register _pybevy in sys.modules");
m
};
let sys = py.import("sys").expect("Failed to import sys");
let modules = sys.getattr("modules").expect("Failed to get sys.modules");
modules
.set_item("pybevy._pybevy", module)
.expect("Failed to register pybevy._pybevy in sys.modules");
});
});
}
#[derive(Clone)]
pub struct PySystemBuilder {
module_name: String,
function_name: String,
stage: PyStage,
}
impl PySystemBuilder {
pub fn new(module_name: impl Into<String>, function_name: impl Into<String>) -> Self {
Self {
module_name: module_name.into(),
function_name: function_name.into(),
stage: PyStage::Update,
}
}
pub fn in_stage(mut self, stage: PyStage) -> Self {
self.stage = stage;
self
}
fn build(self) -> PyResult<(DynamicSystem, PyStage)> {
self.build_with(0, Arc::new(Mutex::new(Vec::new())))
}
fn build_with(
self,
generation: u32,
error_state: Arc<Mutex<Vec<PyErr>>>,
) -> PyResult<(DynamicSystem, PyStage)> {
ensure_python_initialized();
Python::attach(|py| {
let module = py.import(&self.module_name).map_err(|e| {
PyImportError::new_err(format!(
"Failed to import module '{}': {}",
self.module_name, e
))
})?;
let func = module.getattr(&self.function_name).map_err(|_| {
PyAttributeError::new_err(format!(
"Module '{}' has no function '{}'",
self.module_name, self.function_name
))
})?;
let system_stage = match self.stage {
PyStage::Startup | PyStage::PreStartup | PyStage::PostStartup => {
SystemStage::Startup
}
_ => SystemStage::UpdateOrLast,
};
let dynamic_system =
DynamicSystem::new(func.unbind(), generation, error_state, system_stage)?;
Ok((dynamic_system, self.stage))
})
}
}
const NATIVE_LOADER_PY: &str = r#"
def _make_native_loader(module_name, systems):
import importlib, importlib.util, os, runpy, sys, sysconfig
def _flush_user_modules(project_dir):
"""Remove all user project modules from sys.modules."""
project_prefix = os.path.realpath(project_dir) + os.sep
stdlib_prefix = os.path.realpath(sysconfig.get_paths()['stdlib']) + os.sep
to_remove = []
for name, mod in list(sys.modules.items()):
if name.startswith(('pybevy.', '_pybevy', 'pybevy')):
continue
fpath = getattr(mod, '__file__', None)
if fpath is None:
continue
fpath = os.path.realpath(fpath)
if not fpath.startswith(project_prefix):
continue
if 'site-packages' in fpath or 'dist-packages' in fpath:
continue
if fpath.startswith(stdlib_prefix):
continue
to_remove.append(name)
for name in to_remove:
del sys.modules[name]
importlib.invalidate_caches()
def loader():
spec = importlib.util.find_spec(module_name)
if spec is None or spec.origin is None:
raise ImportError(f"Cannot find source for module '{module_name}'")
# Flush all user modules under the module's parent directory
project_dir = os.path.dirname(os.path.realpath(spec.origin))
_flush_user_modules(project_dir)
mod_globals = runpy.run_path(spec.origin, run_name=module_name)
def configurator(app):
for func_name, stage in systems:
func = mod_globals.get(func_name)
if func is not None:
app.add_systems(stage, (func,))
return app
return configurator
return loader
"#;
#[cfg(feature = "native-hot-reload")]
fn start_file_watcher(paths: &[String], reload_state: HotReloadState) {
let paths: Vec<String> = paths.to_vec();
std::thread::spawn(move || {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher =
match notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
if matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
let has_py = event
.paths
.iter()
.any(|p| p.extension().is_some_and(|e| e == "py"));
if has_py {
let _ = tx.send(());
}
}
}
}) {
Ok(w) => w,
Err(e) => {
eprintln!("[Hot Reload] Warning: Failed to create file watcher: {}", e);
return;
}
};
for path in &paths {
if let Err(e) = watcher.watch(Path::new(path), RecursiveMode::Recursive) {
eprintln!("[Hot Reload] Warning: Failed to watch '{}': {}", path, e);
}
}
eprintln!("[Hot Reload] File watcher started for: {:?}", paths);
let debounce = Duration::from_millis(50);
let mut last_reload = Instant::now() - debounce;
loop {
match rx.recv() {
Ok(()) => {
let deadline = Instant::now() + debounce;
while rx
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.is_ok()
{}
if last_reload.elapsed() >= debounce {
let mode = reload_state.get_default_mode();
eprintln!(
"[Hot Reload] File change detected, requesting {:?} reload",
mode
);
reload_state.request_reload(mode);
last_reload = Instant::now();
}
}
Err(_) => break, }
}
});
}
pub struct PyBevyPlugin {
module_name: String,
systems: Vec<PySystemBuilder>,
python_paths: Vec<String>,
component_bridges: Vec<Arc<dyn ComponentBridge>>,
hot_reload: bool,
}
impl PyBevyPlugin {
pub fn new(module_name: impl Into<String>) -> Self {
Self {
module_name: module_name.into(),
systems: Vec::new(),
python_paths: Vec::new(),
component_bridges: Vec::new(),
hot_reload: false,
}
}
pub fn with_python_path(mut self, path: impl Into<String>) -> Self {
self.python_paths.push(path.into());
self
}
pub fn with_system(mut self, function_name: impl Into<String>, stage: PyStage) -> Self {
let builder = PySystemBuilder::new(self.module_name.clone(), function_name).in_stage(stage);
self.systems.push(builder);
self
}
pub fn with_startup_system(self, function_name: impl Into<String>) -> Self {
self.with_system(function_name, PyStage::Startup)
}
pub fn with_update_system(self, function_name: impl Into<String>) -> Self {
self.with_system(function_name, PyStage::Update)
}
pub fn with_last_system(self, function_name: impl Into<String>) -> Self {
self.with_system(function_name, PyStage::Last)
}
pub fn with_auto_discovery(mut self) -> Self {
for (func_name, stage) in [
("startup", PyStage::Startup),
("update", PyStage::Update),
("last", PyStage::Last),
] {
let builder = PySystemBuilder::new(self.module_name.clone(), func_name).in_stage(stage);
self.systems.push(builder);
}
self
}
pub fn with_hot_reload(mut self) -> Self {
self.hot_reload = true;
self
}
pub fn register_component(mut self, bridge: impl ComponentBridge) -> Self {
self.component_bridges.push(Arc::new(bridge));
self
}
}
impl Plugin for PyBevyPlugin {
fn build(&self, app: &mut App) {
ensure_python_initialized();
if !self.component_bridges.is_empty() {
if !app.world().contains_resource::<DynamicComponentRegistry>() {
app.insert_resource(DynamicComponentRegistry::new());
}
let mut registry = app.world_mut().resource_mut::<DynamicComponentRegistry>();
for bridge in &self.component_bridges {
global_registry::register_component_bridge_arc(Arc::clone(bridge));
registry.register_arc(Arc::clone(bridge));
}
Python::attach(|py| {
let module = py
.import("_pybevy")
.expect("Failed to import _pybevy for type injection");
for bridge in &self.component_bridges {
let py_type = bridge.py_type(py);
let name = bridge.name();
module.setattr(name, py_type).unwrap_or_else(|e| {
eprintln!(
"Warning: Failed to inject '{}' into _pybevy module: {}",
name, e
);
});
}
});
}
if !self.python_paths.is_empty() {
Python::attach(|py| {
let sys = py.import("sys").expect("Failed to import sys");
let sys_path = sys.getattr("path").expect("Failed to get sys.path");
let path = sys_path
.cast_into::<PyList>()
.expect("sys.path is not a list");
for p in &self.python_paths {
path.insert(0, p).expect("Failed to insert into sys.path");
}
});
}
let systems_to_add = if self.systems.is_empty() {
vec![
PySystemBuilder::new(self.module_name.clone(), "startup")
.in_stage(PyStage::Startup),
PySystemBuilder::new(self.module_name.clone(), "update").in_stage(PyStage::Update),
PySystemBuilder::new(self.module_name.clone(), "last").in_stage(PyStage::Last),
]
} else {
self.systems.clone()
};
if self.hot_reload {
self.build_with_hot_reload(app, &systems_to_add);
} else {
self.build_without_hot_reload(app, systems_to_add);
}
}
}
impl PyBevyPlugin {
fn build_without_hot_reload(&self, app: &mut App, systems_to_add: Vec<PySystemBuilder>) {
for builder in systems_to_add {
match builder.build() as PyResult<(DynamicSystem, PyStage)> {
Ok((dynamic_system, stage)) => {
add_dynamic_system_to_schedule(app, dynamic_system, stage);
}
Err(e) => {
if !e.to_string().contains("has no function") {
eprintln!("Warning: Failed to load Python system: {}", e);
}
}
}
}
}
fn build_with_hot_reload(&self, app: &mut App, systems_to_add: &[PySystemBuilder]) {
eprintln!(
"[Hot Reload] Enabling hot reload for native plugin '{}'",
self.module_name
);
let reload_state = HotReloadState::new();
let error_state: Arc<Mutex<Vec<PyErr>>> = Arc::new(Mutex::new(Vec::new()));
let generation_counter = reload_state.generation();
app.insert_resource(HotReloadResource::new(
reload_state.clone(),
error_state.clone(),
));
app.insert_resource(HotReloadGeneration::new(generation_counter));
let module_name = self.module_name.clone();
Python::attach(|py| {
let locals = pyo3::types::PyDict::new(py);
let code =
std::ffi::CString::new(NATIVE_LOADER_PY).expect("loader code contains null byte");
py.run(&code, None, Some(&locals))
.expect("Failed to define native loader Python code");
let make_loader = locals
.get_item("_make_native_loader")
.expect("_make_native_loader not found")
.expect("_make_native_loader not found");
let systems_list: Vec<(String, Py<PyAny>)> = systems_to_add
.iter()
.map(|b| {
let stage_obj: Py<PyAny> = b
.stage
.into_pyobject(py)
.expect("Failed to convert stage")
.into_any()
.unbind();
(b.function_name.clone(), stage_obj)
})
.collect();
let loader_py = make_loader
.call1((&module_name, systems_list))
.expect("Failed to create native loader");
reload_state.set_loader(loader_py.unbind());
});
let initial_generation = 0u32;
for builder in systems_to_add {
match builder
.clone()
.build_with(initial_generation, error_state.clone())
{
Ok((dynamic_system, stage)) => {
add_dynamic_system_to_schedule_with_run_condition(
app,
dynamic_system,
stage,
initial_generation,
);
}
Err(e) => {
if !e.to_string().contains("has no function") {
eprintln!("Warning: Failed to load Python system: {}", e);
}
}
}
}
if !app.world().resource::<Schedules>().contains(Last) {
app.init_schedule(Last);
}
app.add_systems(
Last,
(handle_f5_reload_system, check_hot_reload_system).chain(),
);
#[cfg(feature = "native-hot-reload")]
if !self.python_paths.is_empty() {
start_file_watcher(&self.python_paths, reload_state);
}
eprintln!("[Hot Reload] Native plugin hot reload ready (F5=reload, F6=toggle mode)");
}
}
fn add_dynamic_system_to_schedule(app: &mut App, dynamic_system: DynamicSystem, stage: PyStage) {
match stage {
PyStage::Startup => app.add_systems(Startup, dynamic_system),
PyStage::Update => app.add_systems(Update, dynamic_system),
PyStage::Last => app.add_systems(Last, dynamic_system),
PyStage::FixedUpdate => app.add_systems(FixedUpdate, dynamic_system),
PyStage::Main => app.add_systems(Main, dynamic_system),
PyStage::First => app.add_systems(First, dynamic_system),
PyStage::PreUpdate => app.add_systems(PreUpdate, dynamic_system),
PyStage::PostUpdate => app.add_systems(PostUpdate, dynamic_system),
PyStage::PreStartup => app.add_systems(PreStartup, dynamic_system),
PyStage::PostStartup => app.add_systems(PostStartup, dynamic_system),
PyStage::FixedFirst => app.add_systems(FixedFirst, dynamic_system),
PyStage::FixedPreUpdate => app.add_systems(FixedPreUpdate, dynamic_system),
PyStage::FixedPostUpdate => app.add_systems(FixedPostUpdate, dynamic_system),
PyStage::FixedLast => app.add_systems(FixedLast, dynamic_system),
PyStage::SimTick => app.add_systems(SimTick, dynamic_system),
};
}
fn add_dynamic_system_to_schedule_with_run_condition(
app: &mut App,
dynamic_system: DynamicSystem,
stage: PyStage,
generation: u32,
) {
match stage {
PyStage::Startup => app.add_systems(
Startup,
dynamic_system.run_if(startup_or_reload(generation)),
),
PyStage::PreStartup => app.add_systems(
PreStartup,
dynamic_system.run_if(startup_or_reload(generation)),
),
PyStage::PostStartup => app.add_systems(
PostStartup,
dynamic_system.run_if(startup_or_reload(generation)),
),
PyStage::Update => app.add_systems(
Update,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::Last => {
app.add_systems(Last, dynamic_system.run_if(generation_matches(generation)))
}
PyStage::FixedUpdate => app.add_systems(
FixedUpdate,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::Main => {
app.add_systems(Main, dynamic_system.run_if(generation_matches(generation)))
}
PyStage::First => {
app.add_systems(First, dynamic_system.run_if(generation_matches(generation)))
}
PyStage::PreUpdate => app.add_systems(
PreUpdate,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::PostUpdate => app.add_systems(
PostUpdate,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::FixedFirst => app.add_systems(
FixedFirst,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::FixedPreUpdate => app.add_systems(
FixedPreUpdate,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::FixedPostUpdate => app.add_systems(
FixedPostUpdate,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::FixedLast => app.add_systems(
FixedLast,
dynamic_system.run_if(generation_matches(generation)),
),
PyStage::SimTick => app.add_systems(
SimTick,
dynamic_system.run_if(generation_matches(generation)),
),
};
}