From d71a207c57dc5181980acc7f2e4e00c7901c840a Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 16 Mar 2025 18:05:32 +0000 Subject: [PATCH] Add Python wrapper built using PyO3 It's missing logging integration, specific error types, and the ability to retrieve loaded plugin data. --- Cargo.lock | 110 +++++ Cargo.toml | 2 +- pyo3/.github/workflows/CI.yml | 181 +++++++ pyo3/.gitignore | 72 +++ pyo3/Cargo.toml | 13 + pyo3/README.md | 60 +++ pyo3/pyproject.toml | 16 + pyo3/src/database.rs | 347 +++++++++++++ pyo3/src/game.rs | 170 +++++++ pyo3/src/lib.rs | 108 ++++ pyo3/src/metadata.rs | 903 ++++++++++++++++++++++++++++++++++ pyo3/src/plugin.rs | 95 ++++ 12 files changed, 2076 insertions(+), 1 deletion(-) create mode 100644 pyo3/.github/workflows/CI.yml create mode 100644 pyo3/.gitignore create mode 100644 pyo3/Cargo.toml create mode 100644 pyo3/README.md create mode 100644 pyo3/pyproject.toml create mode 100644 pyo3/src/database.rs create mode 100644 pyo3/src/game.rs create mode 100644 pyo3/src/lib.rs create mode 100644 pyo3/src/metadata.rs create mode 100644 pyo3/src/plugin.rs diff --git a/Cargo.lock b/Cargo.lock index b94dabfc..8b5f4c29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "indexmap" version = "2.7.1" @@ -527,6 +533,12 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "keyvalues-parser" version = "0.2.0" @@ -595,6 +607,14 @@ dependencies = [ "unicase", ] +[[package]] +name = "libloot_pyo3" +version = "0.25.5" +dependencies = [ + "libloot", + "pyo3", +] + [[package]] name = "libredox" version = "0.1.3" @@ -645,6 +665,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -784,6 +813,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "portable-atomic" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -811,6 +846,69 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1c6c3591120564d64db2261bec5f910ae454f01def849b9c22835a84695e86" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b6c2b34cf71427ea37c7001aefbaeb85886a074795e35f161f5aecc7620a7a" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5507651906a46432cdda02cd02dd0319f6064f1374c9147c45b978621d2c3a9c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d394b5b4fd8d97d48336bb0dd2aebabad39f1d294edd6bcd2cccf2eefe6f42" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd72da09cfa943b1080f621f024d2ef7e2773df7badd51aa30a2be1f8caa7c8e" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.38" @@ -1074,6 +1172,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" + [[package]] name = "tempfile" version = "3.17.1" @@ -1199,6 +1303,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 06d9c37a..30b8dfdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,4 @@ rstest_reuse = "0.7.0" tempfile = "3.17.1" [workspace] -members = ["cxx"] +members = ["cxx", "pyo3"] diff --git a/pyo3/.github/workflows/CI.yml b/pyo3/.github/workflows/CI.yml new file mode 100644 index 00000000..6574c1fa --- /dev/null +++ b/pyo3/.github/workflows/CI.yml @@ -0,0 +1,181 @@ +# This file is autogenerated by maturin v1.8.3 +# To update, run +# +# maturin generate-ci github +# +name: CI + +on: + push: + branches: + - main + - master + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + - runner: ubuntu-22.04 + target: s390x + - runner: ubuntu-22.04 + target: ppc64le + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.platform.target }} + path: dist + + musllinux: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: ubuntu-22.04 + target: x86_64 + - runner: ubuntu-22.04 + target: x86 + - runner: ubuntu-22.04 + target: aarch64 + - runner: ubuntu-22.04 + target: armv7 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + manylinux: musllinux_1_2 + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-musllinux-${{ matrix.platform.target }} + path: dist + + windows: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: windows-latest + target: x64 + - runner: windows-latest + target: x86 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.platform.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.platform.target }} + path: dist + + macos: + runs-on: ${{ matrix.platform.runner }} + strategy: + matrix: + platform: + - runner: macos-13 + target: x86_64 + - runner: macos-14 + target: aarch64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.platform.target }} + args: --release --out dist --find-interpreter + sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.platform.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} + needs: [linux, musllinux, windows, macos, sdist] + permissions: + # Use to sign the release artifacts + id-token: write + # Used to upload release artifacts + contents: write + # Used to generate artifact attestation + attestations: write + steps: + - uses: actions/download-artifact@v4 + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: 'wheels-*/*' + - name: Publish to PyPI + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/pyo3/.gitignore b/pyo3/.gitignore new file mode 100644 index 00000000..c8f04429 --- /dev/null +++ b/pyo3/.gitignore @@ -0,0 +1,72 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/pyo3/Cargo.toml b/pyo3/Cargo.toml new file mode 100644 index 00000000..d3bf2db9 --- /dev/null +++ b/pyo3/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "libloot_pyo3" +version = "0.25.5" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "loot" +crate-type = ["cdylib"] + +[dependencies] +libloot = { path = ".." } +pyo3 = "0.24.0" diff --git a/pyo3/README.md b/pyo3/README.md new file mode 100644 index 00000000..771b8fcf --- /dev/null +++ b/pyo3/README.md @@ -0,0 +1,60 @@ +# libloot-pyo3 + +An **incomplete** and **experimental** Python wrapper around the libloot Rust implementation, built using [PyO3](https://pyo3.rs). + +## Current coverage + +- [x] `LIBLOOT_VERSION_MAJOR` +- [x] `LIBLOOT_VERSION_MINOR` +- [x] `LIBLOOT_VERSION_PATCH` +- [x] `is_compatible()` +- [x] `libloot_revision()` +- [x] `libloot_version()` +- [ ] `set_logging_callback()` +- [ ] `set_log_level()` +- [x] `EdgeType` +- [x] `GameType` +- [ ] `LogLevel` +- [x] `Database` +- [ ] `Game` +- [x] `Plugin` +- [x] `Vertex` +- [x] `File` +- [x] `Filename` +- [x] `Group` +- [x] `Location` +- [x] `Message` +- [x] `MessageContent` +- [x] `PluginCleaningData` +- [x] `PluginMetadata` +- [x] `Tag` +- [x] `MessageType` +- [x] `TagSuggestion` +- [x] `select_message_content()` +- [ ] Error types + - All errors are currently raised as Python `ValueError` values that contain the full detail of the error. + +## Build + +To build, first set up a Python venv and install [maturin](https://github.com/PyO3/maturin): + +``` +python -m venv .venv +.\.venv\Scripts\activate +pip install maturin +maturin develop +``` + +Then build the library: + +``` +.\.venv\Scripts\activate +maturin develop +``` + +Then import it in Python: + +``` +py +> import loot +``` diff --git a/pyo3/pyproject.toml b/pyo3/pyproject.toml new file mode 100644 index 00000000..42b8bb36 --- /dev/null +++ b/pyo3/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "libloot" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/pyo3/src/database.rs b/pyo3/src/database.rs new file mode 100644 index 00000000..59ed3550 --- /dev/null +++ b/pyo3/src/database.rs @@ -0,0 +1,347 @@ +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + path::PathBuf, + sync::{Arc, RwLock}, +}; + +use libloot::error::DatabaseLockPoisonError; +use pyo3::{ + Bound, PyResult, pyclass, pymethods, + types::{PyAnyMethods, PyTypeMethods}, +}; + +use crate::{ + UnsupportedEnumValueError, VerboseError, + metadata::{Group, Message, NONE_REPR, PluginMetadata}, +}; + +#[pyclass] +#[derive(Clone, Debug)] +pub struct Database(Arc>); + +#[pymethods] +impl Database { + pub fn load_masterlist(&self, path: PathBuf) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist(&path) + .map_err(Into::into) + } + + pub fn load_masterlist_with_prelude( + &self, + masterlist_path: PathBuf, + prelude_path: PathBuf, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_masterlist_with_prelude(&masterlist_path, &prelude_path) + .map_err(Into::into) + } + + pub fn load_userlist(&self, path: PathBuf) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .load_userlist(&path) + .map_err(Into::into) + } + + pub fn write_user_metadata( + &self, + output_path: PathBuf, + overwrite: bool, + ) -> Result<(), VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_user_metadata(&output_path, overwrite) + .map_err(Into::into) + } + + pub fn write_minimal_list( + &self, + output_path: PathBuf, + overwrite: bool, + ) -> Result<(), VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .write_minimal_list(&output_path, overwrite) + .map_err(Into::into) + } + + pub fn known_bash_tags(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .known_bash_tags()) + } + + pub fn general_messages( + &self, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .general_messages(evaluate_conditions) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn groups(&self, include_user_metadata: bool) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups(include_user_metadata) + .into_iter() + .map(Into::into) + .collect()) + } + + fn user_groups(&self) -> Result, VerboseError> { + Ok(self + .0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .user_groups() + .iter() + .cloned() + .map(Into::into) + .collect()) + } + + pub fn set_user_groups(&self, groups: Vec) -> Result<(), VerboseError> { + let groups = groups.into_iter().map(Into::into).collect(); + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_user_groups(groups); + Ok(()) + } + + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .groups_path(from_group_name, to_group_name) + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_metadata(plugin_name, include_user_metadata, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, VerboseError> { + self.0 + .read() + .map_err(|_| DatabaseLockPoisonError)? + .plugin_user_metadata(plugin_name, evaluate_conditions) + .map(|p| p.map(Into::into)) + .map_err(Into::into) + } + + pub fn set_plugin_user_metadata( + &mut self, + plugin_metadata: PluginMetadata, + ) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .set_plugin_user_metadata(plugin_metadata.into()); + Ok(()) + } + + pub fn discard_plugin_user_metadata(&self, plugin: &str) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_plugin_user_metadata(plugin); + Ok(()) + } + + pub fn discard_all_user_metadata(&self) -> Result<(), VerboseError> { + self.0 + .write() + .map_err(|_| DatabaseLockPoisonError)? + .discard_all_user_metadata(); + Ok(()) + } +} + +impl From>> for Database { + fn from(value: Arc>) -> Self { + Self(value) + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Vertex(libloot::Vertex); + +#[pymethods] +impl Vertex { + #[new] + fn new(name: String) -> Self { + Self(libloot::Vertex::new(name)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn out_edge_type(&self) -> Result, UnsupportedEnumValueError> { + self.0.out_edge_type().map(|e| e.try_into()).transpose() + } + + #[setter] + fn set_out_edge_type( + &mut self, + out_edge_type: EdgeType, + ) -> Result<(), UnsupportedEnumValueError> { + let out_edge_type = out_edge_type.try_into()?; + self.0.set_out_edge_type(out_edge_type); + Ok(()) + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.name(), + inner + .out_edge_type() + .map(repr_edge_type) + .unwrap_or(NONE_REPR), + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Vertex { + fn from(value: libloot::Vertex) -> Self { + Self(value) + } +} + +impl From for libloot::Vertex { + fn from(value: Vertex) -> Self { + value.0 + } +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl TryFrom for EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::EdgeType) -> Result { + match value { + libloot::EdgeType::Hardcoded => Ok(EdgeType::Hardcoded), + libloot::EdgeType::MasterFlag => Ok(EdgeType::MasterFlag), + libloot::EdgeType::Master => Ok(EdgeType::Master), + libloot::EdgeType::MasterlistRequirement => Ok(EdgeType::MasterlistRequirement), + libloot::EdgeType::UserRequirement => Ok(EdgeType::UserRequirement), + libloot::EdgeType::MasterlistLoadAfter => Ok(EdgeType::MasterlistLoadAfter), + libloot::EdgeType::UserLoadAfter => Ok(EdgeType::UserLoadAfter), + libloot::EdgeType::MasterlistGroup => Ok(EdgeType::MasterlistGroup), + libloot::EdgeType::UserGroup => Ok(EdgeType::UserGroup), + libloot::EdgeType::RecordOverlap => Ok(EdgeType::RecordOverlap), + libloot::EdgeType::AssetOverlap => Ok(EdgeType::AssetOverlap), + libloot::EdgeType::TieBreak => Ok(EdgeType::TieBreak), + libloot::EdgeType::BlueprintMaster => Ok(EdgeType::BlueprintMaster), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::EdgeType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: EdgeType) -> Result { + match value { + EdgeType::Hardcoded => Ok(libloot::EdgeType::Hardcoded), + EdgeType::MasterFlag => Ok(libloot::EdgeType::MasterFlag), + EdgeType::Master => Ok(libloot::EdgeType::Master), + EdgeType::MasterlistRequirement => Ok(libloot::EdgeType::MasterlistRequirement), + EdgeType::UserRequirement => Ok(libloot::EdgeType::UserRequirement), + EdgeType::MasterlistLoadAfter => Ok(libloot::EdgeType::MasterlistLoadAfter), + EdgeType::UserLoadAfter => Ok(libloot::EdgeType::UserLoadAfter), + EdgeType::MasterlistGroup => Ok(libloot::EdgeType::MasterlistGroup), + EdgeType::UserGroup => Ok(libloot::EdgeType::UserGroup), + EdgeType::RecordOverlap => Ok(libloot::EdgeType::RecordOverlap), + EdgeType::AssetOverlap => Ok(libloot::EdgeType::AssetOverlap), + EdgeType::TieBreak => Ok(libloot::EdgeType::TieBreak), + EdgeType::BlueprintMaster => Ok(libloot::EdgeType::BlueprintMaster), + } + } +} + +fn repr_edge_type(value: libloot::EdgeType) -> &'static str { + match value { + libloot::EdgeType::Hardcoded => "EdgeType.Hardcoded", + libloot::EdgeType::MasterFlag => "EdgeType.MasterFlag", + libloot::EdgeType::Master => "EdgeType.Master", + libloot::EdgeType::MasterlistRequirement => "EdgeType.MasterlistRequirement", + libloot::EdgeType::UserRequirement => "EdgeType.UserRequirement", + libloot::EdgeType::MasterlistLoadAfter => "EdgeType.MasterlistLoadAfter", + libloot::EdgeType::UserLoadAfter => "EdgeType.UserLoadAfter", + libloot::EdgeType::MasterlistGroup => "EdgeType.MasterlistGroup", + libloot::EdgeType::UserGroup => "EdgeType.UserGroup", + libloot::EdgeType::RecordOverlap => "EdgeType.RecordOverlap", + libloot::EdgeType::AssetOverlap => "EdgeType.AssetOverlap", + libloot::EdgeType::TieBreak => "EdgeType.TieBreak", + libloot::EdgeType::BlueprintMaster => "EdgeType.BlueprintMaster", + _ => "", + } +} diff --git a/pyo3/src/game.rs b/pyo3/src/game.rs new file mode 100644 index 00000000..78459fec --- /dev/null +++ b/pyo3/src/game.rs @@ -0,0 +1,170 @@ +use std::path::{Path, PathBuf}; + +use pyo3::{pyclass, pymethods}; + +use crate::{UnsupportedEnumValueError, VerboseError, database::Database, plugin::Plugin}; + +#[allow(non_camel_case_types)] +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum GameType { + tes4, + tes5, + fo3, + fonv, + fo4, + tes5se, + fo4vr, + tes5vr, + tes3, + starfield, + openmw, +} + +impl TryFrom for GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: libloot::GameType) -> Result { + match value { + libloot::GameType::TES4 => Ok(GameType::tes4), + libloot::GameType::TES5 => Ok(GameType::tes5), + libloot::GameType::FO3 => Ok(GameType::fo3), + libloot::GameType::FONV => Ok(GameType::fonv), + libloot::GameType::FO4 => Ok(GameType::fo4), + libloot::GameType::TES5SE => Ok(GameType::tes5se), + libloot::GameType::FO4VR => Ok(GameType::fo4vr), + libloot::GameType::TES5VR => Ok(GameType::tes5vr), + libloot::GameType::TES3 => Ok(GameType::tes3), + libloot::GameType::Starfield => Ok(GameType::starfield), + libloot::GameType::OpenMW => Ok(GameType::openmw), + _ => Err(UnsupportedEnumValueError), + } + } +} + +impl TryFrom for libloot::GameType { + type Error = UnsupportedEnumValueError; + + fn try_from(value: GameType) -> Result { + match value { + GameType::tes4 => Ok(libloot::GameType::TES4), + GameType::tes5 => Ok(libloot::GameType::TES5), + GameType::fo3 => Ok(libloot::GameType::FO3), + GameType::fonv => Ok(libloot::GameType::FONV), + GameType::fo4 => Ok(libloot::GameType::FO4), + GameType::tes5se => Ok(libloot::GameType::TES5SE), + GameType::fo4vr => Ok(libloot::GameType::FO4VR), + GameType::tes5vr => Ok(libloot::GameType::TES5VR), + GameType::tes3 => Ok(libloot::GameType::TES3), + GameType::starfield => Ok(libloot::GameType::Starfield), + GameType::openmw => Ok(libloot::GameType::OpenMW), + } + } +} + +#[pyclass] +#[derive(Debug)] +pub struct Game(libloot::Game); + +#[pymethods] +impl Game { + #[new] + #[pyo3(signature = (game_type, game_path, local_path = None))] + fn new( + game_type: GameType, + game_path: PathBuf, + local_path: Option, + ) -> Result { + match local_path { + Some(local_path) => Ok(Game(libloot::Game::with_local_path( + game_type.try_into()?, + &game_path, + &local_path, + )?)), + None => Ok(Game(libloot::Game::new(game_type.try_into()?, &game_path)?)), + } + } + + fn game_type(&self) -> Result { + self.0.game_type().try_into().map_err(Into::into) + } + + fn additional_data_paths(&self) -> &[PathBuf] { + self.0.additional_data_paths() + } + + fn set_additional_data_paths(&mut self, paths: Vec) -> Result<(), VerboseError> { + self.0.set_additional_data_paths(&as_paths(&paths))?; + Ok(()) + } + + fn database(&self) -> Database { + self.0.database().into() + } + + fn is_valid_plugin(&self, plugin_path: PathBuf) -> bool { + self.0.is_valid_plugin(&plugin_path) + } + + fn load_plugins(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugins(&as_paths(&plugin_paths))?; + Ok(()) + } + + fn load_plugin_headers(&mut self, plugin_paths: Vec) -> Result<(), VerboseError> { + self.0.load_plugin_headers(&as_paths(&plugin_paths))?; + Ok(()) + } + + fn clear_loaded_plugins(&mut self) { + self.0.clear_loaded_plugins(); + } + + // TODO: Game::plugin(). + // fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + // self.0.plugin(plugin_name).map(Plugin::wrap) + // } + + // TODO: Game::loaded_plugins(). + // fn loaded_plugins(&self) -> Vec<&Plugin> { + // wrap_plugins(self.0.loaded_plugins()) + // } + + fn sort_plugins(&self, plugin_names: Vec) -> Result, VerboseError> { + Ok(self.0.sort_plugins(&as_strs(&plugin_names))?) + } + + fn load_current_load_order_state(&mut self) -> Result<(), VerboseError> { + self.0.load_current_load_order_state()?; + Ok(()) + } + + fn is_load_order_ambiguous(&self) -> Result { + Ok(self.0.is_load_order_ambiguous()?) + } + + fn active_plugins_file_path(&self) -> &PathBuf { + self.0.active_plugins_file_path() + } + + fn is_plugin_active(&self, plugin_name: &str) -> bool { + self.0.is_plugin_active(plugin_name) + } + + fn load_order(&self) -> Vec<&str> { + self.0.load_order() + } + + fn set_load_order(&mut self, load_order: Vec) -> Result<(), VerboseError> { + self.0.set_load_order(&as_strs(&load_order))?; + Ok(()) + } +} + +fn as_paths(pathbufs: &[PathBuf]) -> Vec<&Path> { + pathbufs.iter().map(PathBuf::as_ref).collect() +} + +fn as_strs(strings: &[String]) -> Vec<&str> { + strings.iter().map(String::as_ref).collect() +} diff --git a/pyo3/src/lib.rs b/pyo3/src/lib.rs new file mode 100644 index 00000000..9eb43ed1 --- /dev/null +++ b/pyo3/src/lib.rs @@ -0,0 +1,108 @@ +mod database; +mod game; +mod metadata; +mod plugin; + +use database::{Database, EdgeType, Vertex}; +use game::{Game, GameType}; +use metadata::{ + File, Filename, Group, Location, Message, MessageContent, MessageType, PluginMetadata, Tag, + TagSuggestion, select_message_content, +}; +use plugin::Plugin; +use pyo3::{exceptions::PyValueError, prelude::*}; + +#[pyfunction] +fn is_compatible(major: u32, minor: u32, patch: u32) -> bool { + libloot::is_compatible(major, minor, patch) +} + +#[pyfunction] +fn libloot_revision() -> String { + libloot::libloot_revision() +} + +#[pyfunction] +fn libloot_version() -> String { + libloot::libloot_version() +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct UnsupportedEnumValueError; + +impl std::fmt::Display for UnsupportedEnumValueError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Enum value is unsupported") + } +} + +impl std::error::Error for UnsupportedEnumValueError {} + +impl From for PyErr { + fn from(value: UnsupportedEnumValueError) -> Self { + PyValueError::new_err(value.to_string()) + } +} + +#[derive(Debug)] +pub struct VerboseError(Box); + +impl std::fmt::Display for VerboseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let e = &self.0; + write!(f, "{}", e)?; + let mut error = e.as_ref(); + while let Some(source) = error.source() { + write!(f, ": {}", source)?; + error = source; + } + Ok(()) + } +} + +// impl std::error::Error for VerboseError {} + +impl From for VerboseError { + fn from(value: T) -> Self { + VerboseError(Box::new(value)) + } +} + +impl From for PyErr { + fn from(value: VerboseError) -> Self { + PyValueError::new_err(value.to_string()) + } +} + +/// A Python module implemented in Rust. +#[pymodule(name = "loot")] +fn libloot_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("LIBLOOT_VERSION_MAJOR", libloot::LIBLOOT_VERSION_MAJOR)?; + m.add("LIBLOOT_VERSION_MINOR", libloot::LIBLOOT_VERSION_MINOR)?; + m.add("LIBLOOT_VERSION_PATCH", libloot::LIBLOOT_VERSION_PATCH)?; + + m.add_function(wrap_pyfunction!(is_compatible, m)?)?; + m.add_function(wrap_pyfunction!(libloot_revision, m)?)?; + m.add_function(wrap_pyfunction!(libloot_version, m)?)?; + + m.add_function(wrap_pyfunction!(select_message_content, m)?)?; + + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} diff --git a/pyo3/src/metadata.rs b/pyo3/src/metadata.rs new file mode 100644 index 00000000..3537210c --- /dev/null +++ b/pyo3/src/metadata.rs @@ -0,0 +1,903 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; + +use pyo3::{ + Bound, FromPyObject, PyResult, pyclass, pyfunction, pymethods, + types::{PyAnyMethods, PyTypeMethods}, +}; + +use crate::{UnsupportedEnumValueError, VerboseError}; + +pub const NONE_REPR: &str = "None"; + +#[pyclass(eq, ord, str = "{0:?}")] +#[repr(transparent)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Group(libloot::metadata::Group); + +#[pymethods] +impl Group { + #[classattr] + fn default_name() -> &'static str { + libloot::metadata::Group::DEFAULT_NAME + } + + #[new] + #[pyo3(signature = (name, description = None, after_groups = None))] + fn new(name: String, description: Option, after_groups: Option>) -> Self { + let mut group = libloot::metadata::Group::new(name); + + if let Some(description) = description { + group.set_description(description); + } + + if let Some(after_groups) = after_groups { + group.set_after_groups(after_groups); + } + + Self(group) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn description(&self) -> Option<&str> { + self.0.description() + } + + #[setter] + fn set_description(&mut self, description: String) { + self.0.set_description(description); + } + + #[getter] + fn after_groups(&self) -> &[String] { + self.0.after_groups() + } + + #[setter] + fn set_after_groups(&mut self, after_groups: Vec) { + self.0.set_after_groups(after_groups); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, [{}])", + class_name, + inner.name(), + inner.description().unwrap_or(NONE_REPR), + inner.after_groups().join(",") + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Group { + fn from(value: libloot::metadata::Group) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Group { + fn from(value: Group) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[repr(transparent)] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MessageContent(libloot::metadata::MessageContent); + +#[pymethods] +impl MessageContent { + #[classattr] + fn default_language() -> &'static str { + libloot::metadata::MessageContent::DEFAULT_LANGUAGE + } + + #[new] + #[pyo3(signature = (text, language = None))] + fn new(text: String, language: Option) -> Self { + let mut content = libloot::metadata::MessageContent::new(text); + + if let Some(language) = language { + content.set_language(language); + } + + Self(content) + } + + #[getter] + fn text(&self) -> &str { + self.0.text() + } + + #[getter] + fn language(&self) -> &str { + self.0.language() + } + + #[setter] + fn set_language(&mut self, language: String) { + self.0.set_language(language); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.text(), + inner.language() + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for MessageContent { + fn from(value: libloot::metadata::MessageContent) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::MessageContent { + fn from(value: MessageContent) -> Self { + value.0 + } +} + +fn repr_message_contents(contents: &[libloot::metadata::MessageContent]) -> String { + contents + .iter() + .map(|c| format!("MessageContent({}, {})", c.text(), c.language())) + .collect::>() + .join(",") +} + +#[pyfunction] +pub fn select_message_content( + content: Vec, + language: &str, +) -> Option { + let content: Vec<_> = content.into_iter().map(Into::into).collect(); + libloot::metadata::select_message_content(&content, language) + .cloned() + .map(Into::into) +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum MessageType { + Say, + Warn, + Error, +} + +impl From for MessageType { + fn from(value: libloot::metadata::MessageType) -> Self { + match value { + libloot::metadata::MessageType::Say => MessageType::Say, + libloot::metadata::MessageType::Warn => MessageType::Warn, + libloot::metadata::MessageType::Error => MessageType::Error, + } + } +} + +impl From for libloot::metadata::MessageType { + fn from(value: MessageType) -> Self { + match value { + MessageType::Say => libloot::metadata::MessageType::Say, + MessageType::Warn => libloot::metadata::MessageType::Warn, + MessageType::Error => libloot::metadata::MessageType::Error, + } + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Message(libloot::metadata::Message); + +#[derive(FromPyObject)] +enum MessageContents { + Monolingual(String), + Multilingual(Vec), +} + +#[pymethods] +impl Message { + #[new] + #[pyo3(signature = (message_type, contents, condition = None))] + fn new( + message_type: MessageType, + contents: MessageContents, + condition: Option, + ) -> Result { + let mut message = match contents { + MessageContents::Monolingual(c) => { + libloot::metadata::Message::new(message_type.into(), c) + } + MessageContents::Multilingual(c) => { + let c = c.into_iter().map(Into::into).collect(); + libloot::metadata::Message::multilingual(message_type.into(), c)? + } + }; + + if let Some(condition) = condition { + message.set_condition(condition); + } + + Ok(Self(message)) + } + + #[getter] + fn message_type(&self) -> MessageType { + self.0.message_type().into() + } + + #[getter] + fn content(&self) -> Vec { + self.0.content().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[setter] + fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, [{}], {})", + class_name, + inner.message_type(), + repr_message_contents(inner.content()), + inner.condition().unwrap_or(NONE_REPR), + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Message { + fn from(value: libloot::metadata::Message) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Message { + fn from(value: Message) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct File(libloot::metadata::File); + +#[pymethods] +impl File { + #[new] + #[pyo3(signature = (name, display_name = None, detail = None, condition = None))] + fn new( + name: String, + display_name: Option, + detail: Option>, + condition: Option, + ) -> Result { + let mut file = libloot::metadata::File::new(name); + + if let Some(display_name) = display_name { + file.set_display_name(display_name); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().map(Into::into).collect(); + file.set_detail(detail)?; + } + + if let Some(condition) = condition { + file.set_condition(condition); + } + + Ok(Self(file)) + } + + #[getter] + fn name(&self) -> Filename { + self.0.name().clone().into() + } + + #[getter] + fn display_name(&self) -> Option<&str> { + self.0.display_name() + } + + #[setter] + fn set_display_name(&mut self, description: String) { + self.0.set_display_name(description); + } + + #[getter] + fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_detail(&mut self, detail: Vec) -> Result<(), VerboseError> { + let detail = detail.into_iter().map(Into::into).collect(); + self.0.set_detail(detail)?; + Ok(()) + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[setter] + fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, {}, {})", + class_name, + inner.name(), + inner.display_name().unwrap_or(NONE_REPR), + repr_message_contents(inner.detail()), + inner.condition().unwrap_or(NONE_REPR) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for File { + fn from(value: libloot::metadata::File) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::File { + fn from(value: File) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Filename(libloot::metadata::Filename); + +#[pymethods] +impl Filename { + #[new] + fn new(name: String) -> Self { + Self(libloot::metadata::Filename::new(name)) + } + + fn as_str(&self) -> &str { + self.0.as_str() + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!("{}({})", class_name, inner.as_str(),)) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Filename { + fn from(value: libloot::metadata::Filename) -> Self { + Self(value) + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct PluginCleaningData(libloot::metadata::PluginCleaningData); + +#[pymethods] +impl PluginCleaningData { + #[new] + #[pyo3(signature = (crc, cleaning_utility, itm_count = None, deleted_reference_count = None, deleted_navmesh_count = None, detail = None))] + fn new( + crc: u32, + cleaning_utility: String, + itm_count: Option, + deleted_reference_count: Option, + deleted_navmesh_count: Option, + detail: Option>, + ) -> Result { + let mut data = libloot::metadata::PluginCleaningData::new(crc, cleaning_utility); + + if let Some(count) = itm_count { + data.set_itm_count(count); + } + + if let Some(count) = deleted_reference_count { + data.set_deleted_reference_count(count); + } + + if let Some(count) = deleted_navmesh_count { + data.set_deleted_navmesh_count(count); + } + + if let Some(detail) = detail { + let detail = detail.into_iter().map(Into::into).collect(); + data.set_detail(detail)?; + } + + Ok(Self(data)) + } + + #[getter] + fn crc(&self) -> u32 { + self.0.crc() + } + + #[getter] + fn itm_count(&self) -> u32 { + self.0.itm_count() + } + + #[setter] + fn set_itm_count(&mut self, count: u32) { + self.0.set_itm_count(count); + } + + #[getter] + fn deleted_reference_count(&self) -> u32 { + self.0.deleted_reference_count() + } + + #[setter] + fn set_deleted_reference_count(&mut self, count: u32) { + self.0.set_deleted_reference_count(count); + } + + #[getter] + fn deleted_navmesh_count(&self) -> u32 { + self.0.deleted_navmesh_count() + } + + #[setter] + fn set_deleted_navmesh_count(&mut self, count: u32) { + self.0.set_deleted_navmesh_count(count); + } + + #[getter] + fn cleaning_utility(&self) -> &str { + self.0.cleaning_utility() + } + + #[getter] + fn detail(&self) -> Vec { + self.0.detail().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_detail(&mut self, detail: Vec) -> Result<(), VerboseError> { + let detail = detail.into_iter().map(Into::into).collect(); + self.0.set_detail(detail)?; + Ok(()) + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {}, {}, {}, {}, {})", + class_name, + inner.crc(), + inner.cleaning_utility(), + inner.itm_count(), + inner.deleted_reference_count(), + inner.deleted_navmesh_count(), + repr_message_contents(inner.detail()) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for PluginCleaningData { + fn from(value: libloot::metadata::PluginCleaningData) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginCleaningData { + fn from(value: PluginCleaningData) -> Self { + value.0 + } +} + +#[pyclass(eq, frozen, hash, ord)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TagSuggestion { + Addition, + Removal, +} + +impl TryFrom for libloot::metadata::TagSuggestion { + type Error = UnsupportedEnumValueError; + + fn try_from(value: TagSuggestion) -> Result { + match value { + TagSuggestion::Addition => Ok(libloot::metadata::TagSuggestion::Addition), + TagSuggestion::Removal => Ok(libloot::metadata::TagSuggestion::Removal), + } + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Tag(libloot::metadata::Tag); + +#[pymethods] +impl Tag { + #[new] + #[pyo3(signature = (name, suggestion, condition = None))] + fn new( + name: String, + suggestion: TagSuggestion, + condition: Option, + ) -> Result { + let mut tag = libloot::metadata::Tag::new(name, suggestion.try_into()?); + + if let Some(condition) = condition { + tag.set_condition(condition); + } + + Ok(Self(tag)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn is_addition(&self) -> bool { + self.0.is_addition() + } + + #[getter] + fn condition(&self) -> Option<&str> { + self.0.condition() + } + + #[setter] + fn set_condition(&mut self, condition: String) { + self.0.set_condition(condition); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + let suggestion = if inner.is_addition() { + "TagSuggestion.Addition" + } else { + "TagSuggestion.Removal" + }; + Ok(format!( + "{}({}, {}, {})", + class_name, + inner.name(), + suggestion, + inner.condition().unwrap_or(NONE_REPR) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Tag { + fn from(value: libloot::metadata::Tag) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Tag { + fn from(value: Tag) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct Location(libloot::metadata::Location); + +#[pymethods] +impl Location { + #[new] + #[pyo3(signature = (url, name = None))] + fn new(url: String, name: Option) -> Self { + let mut location = libloot::metadata::Location::new(url); + + if let Some(name) = name { + location.set_name(name); + } + + Self(location) + } + + #[getter] + fn url(&self) -> &str { + self.0.url() + } + + #[getter] + fn name(&self) -> Option<&str> { + self.0.name() + } + + #[setter] + fn set_name(&mut self, name: String) { + self.0.set_name(name); + } + + fn __repr__(slf: &Bound<'_, Self>) -> PyResult { + let class_name = slf.get_type().qualname()?; + let inner = &slf.borrow().0; + Ok(format!( + "{}({}, {})", + class_name, + inner.url(), + inner.name().unwrap_or(NONE_REPR) + )) + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for Location { + fn from(value: libloot::metadata::Location) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::Location { + fn from(value: Location) -> Self { + value.0 + } +} + +#[pyclass(eq, ord, str = "{0:?}")] +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[repr(transparent)] +pub struct PluginMetadata(libloot::metadata::PluginMetadata); + +#[pymethods] +impl PluginMetadata { + #[new] + fn new(name: String) -> Result { + Ok(Self(libloot::metadata::PluginMetadata::new(&name)?)) + } + + #[getter] + fn name(&self) -> &str { + self.0.name() + } + + #[getter] + fn group(&self) -> Option<&str> { + self.0.group() + } + + #[getter] + fn load_after_files(&self) -> Vec { + self.0 + .load_after_files() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn requirements(&self) -> Vec { + self.0 + .requirements() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn incompatibilities(&self) -> Vec { + self.0 + .incompatibilities() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn messages(&self) -> Vec { + self.0.messages().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn tags(&self) -> Vec { + self.0.tags().iter().cloned().map(Into::into).collect() + } + + #[getter] + fn dirty_info(&self) -> Vec { + self.0 + .dirty_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn clean_info(&self) -> Vec { + self.0 + .clean_info() + .iter() + .cloned() + .map(Into::into) + .collect() + } + + #[getter] + fn locations(&self) -> Vec { + self.0.locations().iter().cloned().map(Into::into).collect() + } + + #[setter] + fn set_group(&mut self, group: Option) { + match group { + Some(g) => self.0.set_group(g), + None => self.0.unset_group(), + } + } + + #[setter] + fn set_load_after_files(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_load_after_files(value); + } + + #[setter] + fn set_requirements(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_requirements(value); + } + + #[setter] + fn set_incompatibilities(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_incompatibilities(value); + } + + #[setter] + fn set_messages(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_messages(value); + } + + #[setter] + fn set_tags(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_tags(value); + } + + #[setter] + fn set_dirty_info(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_dirty_info(value); + } + + #[setter] + fn set_clean_info(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_clean_info(value); + } + + #[setter] + fn set_locations(&mut self, value: Vec) { + let value = value.into_iter().map(Into::into).collect(); + self.0.set_locations(value); + } + + fn merge_metadata(&mut self, other: PluginMetadata) { + self.0.merge_metadata(&other.0); + } + + fn has_name_only(&self) -> bool { + self.0.has_name_only() + } + + fn is_regex_plugin(&self) -> bool { + self.0.is_regex_plugin() + } + + fn name_matches(&self, other_name: &str) -> bool { + self.0.name_matches(other_name) + } + + fn as_yaml(&self) -> String { + self.0.as_yaml() + } + + fn __hash__(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } +} + +impl From for PluginMetadata { + fn from(value: libloot::metadata::PluginMetadata) -> Self { + Self(value) + } +} + +impl From for libloot::metadata::PluginMetadata { + fn from(value: PluginMetadata) -> Self { + value.0 + } +} diff --git a/pyo3/src/plugin.rs b/pyo3/src/plugin.rs new file mode 100644 index 00000000..37ed9ff9 --- /dev/null +++ b/pyo3/src/plugin.rs @@ -0,0 +1,95 @@ +use pyo3::{pyclass, pymethods}; + +use crate::VerboseError; + +#[pyclass(eq, frozen)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct Plugin(libloot::Plugin); + +#[pymethods] +impl Plugin { + fn name(&self) -> &str { + self.0.name() + } + + fn header_version(&self) -> Option { + self.0.header_version() + } + + fn version(&self) -> Option<&str> { + self.0.version() + } + + fn masters(&self) -> Result, VerboseError> { + Ok(self.0.masters()?) + } + + fn bash_tags(&self) -> &[String] { + self.0.bash_tags() + } + + fn crc(&self) -> Option { + self.0.crc() + } + + fn is_master(&self) -> bool { + self.0.is_master() + } + + fn is_light_plugin(&self) -> bool { + self.0.is_light_plugin() + } + + fn is_medium_plugin(&self) -> bool { + self.0.is_medium_plugin() + } + + fn is_update_plugin(&self) -> bool { + self.0.is_update_plugin() + } + + fn is_blueprint_plugin(&self) -> bool { + self.0.is_blueprint_plugin() + } + + fn is_valid_as_light_plugin(&self) -> Result { + Ok(self.0.is_valid_as_light_plugin()?) + } + + fn is_valid_as_medium_plugin(&self) -> Result { + Ok(self.0.is_valid_as_medium_plugin()?) + } + + fn is_valid_as_update_plugin(&self) -> Result { + Ok(self.0.is_valid_as_update_plugin()?) + } + + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn loads_archive(&self) -> bool { + self.0.loads_archive() + } + + fn do_records_overlap(&self, plugin: &Self) -> Result { + Ok(self.0.do_records_overlap(&plugin.0)?) + } +} + +impl Plugin { + pub fn wrap(plugin: &libloot::Plugin) -> &Plugin { + let p = plugin as *const libloot::Plugin; + // SAFETY: Plugin is a transparent wrapper, so reinterpreting the pointer is safe. + unsafe { + let p = p as *const Plugin; + &*p + } + } +} + +pub fn wrap_plugins(mut vec: Vec<&libloot::Plugin>) -> Vec<&Plugin> { + // SAFETY: This is safe because Plugin is a transparent wrapper around the libloot type. + unsafe { Vec::from_raw_parts(vec.as_mut_ptr().cast(), vec.len(), vec.capacity()) } +}