changeset 778:816e1f01d650

Partially replace the Loader with a Rust one
author Link Mauve <linkmauve@linkmauve.fr>
date Sat, 08 Nov 2025 18:26:01 +0100
parents 11249e4b4e03
children ee09657d3789
files python/Cargo.toml python/src/lib.rs pytouhou/resource/loader.py
diffstat 3 files changed, 95 insertions(+), 92 deletions(-) [+]
line wrap: on
line diff
--- a/python/Cargo.toml
+++ b/python/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
 name = "touhou-python"
 version = "0.1.0"
-edition = "2021"
+edition = "2024"
 authors = [
   "Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>",
 ]
@@ -14,8 +14,9 @@
 
 [dependencies]
 touhou-formats = "*"
-pyo3 = "0.26"
+pyo3 = "0.27"
 image = { version = "0.25", default-features = false, features = ["png"], optional = true }
+glob = "0.3.3"
 
 [features]
 default = []
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -1,27 +1,42 @@
+use pyo3::exceptions::PyIOError;
 use pyo3::prelude::*;
 use pyo3::types::PyBytes;
 use touhou_formats::th06::pbg3;
+use std::collections::HashMap;
 use std::fs::File;
 use std::io::BufReader;
+use std::path::PathBuf;
 
 #[cfg(feature = "glide")]
 mod glide;
 
-#[pyclass]
+#[pyclass(module = "libtouhou")]
 struct PBG3 {
+    filename: PathBuf,
     inner: pbg3::PBG3<BufReader<File>>,
 }
 
+impl PBG3 {
+    fn get_file_internal(&mut self, name: &str) -> Vec<u8> {
+        self.inner.get_file(name, true).unwrap()
+    }
+}
+
 #[pymethods]
 impl PBG3 {
     #[staticmethod]
-    fn from_filename(filename: &str) -> PyResult<PBG3> {
-        let inner = pbg3::from_path_buffered(filename)?;
+    fn from_filename(filename: PathBuf) -> PyResult<PBG3> {
+        let inner = pbg3::from_path_buffered(&filename)?;
         Ok(PBG3 {
+            filename,
             inner
         })
     }
 
+    fn __repr__(&self) -> String {
+        format!("PBG3({})", self.filename.to_str().unwrap())
+    }
+
     #[getter]
     fn file_list(&self) -> Vec<String> {
         self.inner.list_files().cloned().collect()
@@ -31,16 +46,85 @@
         self.inner.list_files().cloned().collect()
     }
 
-    fn get_file(&mut self, py: Python, name: &str) -> Py<PyAny> {
-        let data = self.inner.get_file(name, true).unwrap();
+    fn get_file(&mut self, py: Python, name: &str) -> Py<PyBytes> {
+        let data = self.get_file_internal(name);
         PyBytes::new(py, &data).into()
     }
 }
 
+/// A loader for Touhou files.
+#[pyclass(module = "libtouhou", get_all, subclass)]
+#[derive(Default)]
+struct Loader {
+    /// The file names to the possible executable.
+    exe_files: Vec<PathBuf>,
+
+    /// The path to the game directory.
+    game_dir: Option<PathBuf>,
+
+    /// A map from inner filenames to the archive containing them.
+    known_files: HashMap<String, Py<PBG3>>,
+}
+
+#[pymethods]
+impl Loader {
+    /// Create a new Loader for the given game_dir.
+    #[new]
+    fn new(game_dir: Option<PathBuf>) -> Loader {
+        Loader {
+            exe_files: Vec::new(),
+            game_dir,
+            known_files: HashMap::new(),
+        }
+    }
+
+    /// Scan the game_dir for archives.
+    ///
+    /// paths_lists is a list of ':'-separated glob patterns, the first matching file will be used
+    /// and the other ones ignored.
+    fn scan_archives(&mut self, py: Python, paths_lists: Vec<String>) -> PyResult<()> {
+        for paths in paths_lists.iter() {
+            let found_paths: Vec<_> = paths.split(':').map(|path| {
+                glob::glob(if let Some(game_dir) = self.game_dir.as_ref() {
+                    game_dir.join(path)
+                } else {
+                    PathBuf::from(path)
+                }.to_str().unwrap()).unwrap()
+            }).flatten().map(Result::unwrap).map(PathBuf::from).collect();
+            if found_paths.is_empty() {
+                return Err(PyIOError::new_err(format!("No path found for {paths:?}")));
+            }
+            let path = &found_paths[0];
+            if let Some(extension) = path.extension() && extension == "exe" {
+                self.exe_files.extend(found_paths);
+            } else {
+                let pbg3 = PBG3::from_filename(path.to_owned())?;
+                let filenames = pbg3.list_files();
+                let pbg3 = Py::new(py, pbg3)?;
+                for name in filenames {
+                    self.known_files.insert(name.clone(), Py::clone_ref(&pbg3, py));
+                }
+            }
+        }
+        Ok(())
+    }
+
+    /// Return the given file as an io.BytesIO object.
+    fn get_file(&self, py: Python, name: String) -> PyResult<Py<PyAny>> {
+        let archive = self.known_files.get(&name).unwrap();
+        let mut archive = archive.borrow_mut(py);
+        let bytes = archive.get_file(py, &name);
+        let io = py.import("io")?;
+        let bytesio_class = io.dict().get_item("BytesIO")?.unwrap();
+        let file = bytesio_class.call1((bytes,))?;
+        Ok(file.unbind())
+    }
+}
+
 #[pymodule]
 mod libtouhou {
     #[pymodule_export]
-    use super::PBG3;
+    use super::Loader;
 
     #[cfg(feature = "glide")]
     #[pymodule_export]
--- a/pytouhou/resource/loader.py
+++ b/pytouhou/resource/loader.py
@@ -12,13 +12,7 @@
 ## GNU General Public License for more details.
 ##
 
-import os
-from glob import glob
-from itertools import chain
-from io import BytesIO
-
-from pytouhou.formats import WrongFormatError
-from libtouhou import PBG3
+from libtouhou import Loader as RustLoader
 from pytouhou.formats.std import Stage
 from pytouhou.formats.ecl import ECL
 from pytouhou.formats.anm0 import ANM0
@@ -34,88 +28,12 @@
 
 
 
-class Directory:
-    def __init__(self, path):
-        self.path = path
-
-
-    def __enter__(self):
-        return self
-
-
-    def __exit__(self, type, value, traceback):
-        return False
-
-
-    @property
-    def file_list(self):
-        return self.list_files()
-
-    def list_files(self):
-        file_list = []
-        for path in os.listdir(self.path):
-            if os.path.isfile(os.path.join(self.path, path)):
-                file_list.append(path)
-        return file_list
-
-
-    def get_file(self, name):
-        return open(os.path.join(self.path, str(name)), 'rb')
-
-
-
-class ArchiveDescription:
-    _formats = {b'PBG3': PBG3}
-
-    @classmethod
-    def get_from_path(cls, path):
-        if os.path.isdir(path):
-            instance = Directory(path)
-            file_list = instance.list_files()
-            return instance
-        with open(path, 'rb') as file:
-            magic = file.read(4)
-            format_class = cls._formats[magic]
-        return format_class.from_filename(path)
-
-
-
-class Loader:
+class Loader(RustLoader):
     def __init__(self, game_dir=None):
-        self.exe_files = []
-        self.game_dir = game_dir
-        self.known_files = {}
         self.instanced_anms = {}  # Cache for the textures.
         self.loaded_anms = []  # For the double loading warnings.
 
 
-    def scan_archives(self, paths_lists):
-        for paths in paths_lists:
-            def _expand_paths():
-                for path in paths.split(os.path.pathsep):
-                    if self.game_dir and not os.path.isabs(path):
-                        path = os.path.join(self.game_dir, path)
-                    yield glob(path)
-            paths = list(chain(*_expand_paths()))
-            if not paths:
-                raise IOError
-            path = paths[0]
-            if os.path.splitext(path)[1] == '.exe':
-                self.exe_files.extend(paths)
-            else:
-                archive_description = ArchiveDescription.get_from_path(path)
-                for name in archive_description.file_list:
-                    self.known_files[name] = archive_description
-
-
-    def get_file(self, name):
-        archive = self.known_files[name]
-        file = archive.get_file(name)
-        if isinstance(file, bytes):
-            return BytesIO(file)
-        return file
-
-
     def get_anm(self, name):
         if name in self.loaded_anms:
             logger.warning('ANM0 %s already loaded', name)