diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3843259..63de4d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,40 +7,12 @@ on: [ ] env: - # Important to pin the clang version, cause we also use it for linting - CLANG_VERSION: 17 - CLANG_TIDY_JOBS: 4 - # LLVM MinGW download - LLVM_MINGW_VERSION: llvm-mingw-20231128-msvcrt-ubuntu-20.04-x86_64 - LLVM_MINGW_DOWNLOAD: https://github.com/mstorsjo/llvm-mingw/releases/download/20231128/llvm-mingw-20231128-msvcrt-ubuntu-20.04-x86_64.tar.xz - # xwin settings - XWIN_VERSION: xwin-0.5.0-x86_64-unknown-linux-musl - XWIN_DOWNLOAD: https://github.com/Jake-Shadle/xwin/releases/download/0.5.0/xwin-0.5.0-x86_64-unknown-linux-musl.tar.gz + LLVM_MINGW_VERSION: llvm-mingw-20240619-msvcrt-ubuntu-20.04-x86_64 + LLVM_MINGW_DOWNLOAD: https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-msvcrt-ubuntu-20.04-x86_64.tar.xz jobs: - cache-clang: - runs-on: windows-latest - - steps: - - name: Cache Clang - uses: actions/cache@v4 - id: cache-clang - with: - path: C:\Program Files\LLVM - key: ${{ runner.os }}-clang-${{ env.CLANG_VERSION }} - lookup-only: true - - - name: Setup Clang - if: steps.cache-clang.outputs.cache-hit != 'true' - uses: egor-tensin/setup-clang@v1 - with: - version: ${{ env.CLANG_VERSION }} - -# ============================================================================== - build-windows: runs-on: windows-latest - needs: cache-clang strategy: fail-fast: false @@ -53,13 +25,9 @@ jobs: ] steps: - - name: Restore Clang Cache + - name: Setup Clang if: startswith(matrix.preset, 'clang') - uses: actions/cache/restore@v4 - with: - path: C:\Program Files\LLVM - key: ${{ runner.os }}-clang-${{ env.CLANG_VERSION }} - fail-on-cache-miss: true + uses: egor-tensin/setup-clang@v1 - name: Add MSVC to PATH if: startswith(matrix.preset, 'msvc') @@ -94,7 +62,8 @@ jobs: run: cmake --build out/build/${{ matrix.preset }} build-ubuntu: - runs-on: ubuntu-latest + # Require at least 24 for the mingw build + runs-on: ubuntu-24.04 strategy: fail-fast: false @@ -104,49 +73,20 @@ jobs: "clang-cross-ue4-x64-release", "llvm-mingw-ue3-x86-release", "llvm-mingw-ue4-x64-release", - # Currently, ubuntu-latest is 22.04, whose mingw version is too old, so disabling these - # builds for now - # Not sure of the exact threshold, 13.1.0 works - # "mingw-ue3-x86-release", - # "mingw-ue4-x64-release", + "mingw-ue3-x86-release", + "mingw-ue4-x64-release", ] steps: - name: Setup CMake and Ninja uses: lukka/get-cmake@latest - - name: Setup msitools + - name: Setup apt packages uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: msitools + packages: msitools python3-requests version: ${{ runner.os }}-apt - # Both Clang and MinGW install quick enough that it's not worth caching - # Caching would also lose the +x - so we'd have to tar before caching/untar after, making it - # even slower - - name: Setup Clang - if: startswith(matrix.preset, 'clang') - run: | - wget https://apt.llvm.org/llvm.sh - chmod +x llvm.sh - sudo ./llvm.sh ${{ env.CLANG_VERSION }} - - sudo update-alternatives --install \ - /usr/bin/clang \ - clang \ - /usr/bin/clang-${{ env.CLANG_VERSION }} \ - 200 - sudo update-alternatives --install \ - /usr/bin/clang++ \ - clang++ \ - /usr/bin/clang++-${{ env.CLANG_VERSION }} \ - 200 - sudo update-alternatives --install \ - /usr/bin/llvm-rc \ - llvm-rc \ - /usr/bin/llvm-rc-${{ env.CLANG_VERSION }} \ - 200 - - name: Setup LLVM MinGW if: startswith(matrix.preset, 'llvm-mingw') run: | @@ -154,48 +94,45 @@ jobs: tar -xf ${{ env.LLVM_MINGW_VERSION }}.tar.xz -C ~/ echo $(readlink -f ~/${{ env.LLVM_MINGW_VERSION }}/bin) >> $GITHUB_PATH - - name: Set up MinGW + - name: Setup MinGW if: startswith(matrix.preset, 'mingw') uses: egor-tensin/setup-mingw@v2 with: platform: ${{ fromJSON('["x86", "x64"]')[contains(matrix.preset, 'x64')] }} - # xwin does take long enough that caching's worth it - - name: Restore xwin cache - if: contains(matrix.preset, 'cross') + - name: Setup Clang + if: startswith(matrix.preset, 'clang-cross') + uses: egor-tensin/setup-clang@v1 + + - name: Restore win sdk cache + if: startswith(matrix.preset, 'clang-cross') uses: actions/cache@v4 - id: cache-xwin + id: cache-win-sdk with: - path: ~/xwin - key: ${{ runner.os }}-xwin + path: ~/win-sdk + key: ${{ runner.os }}-win-sdk - - name: Setup xwin - if: contains(matrix.preset, 'cross') && steps.cache-xwin.outputs.cache-hit != 'true' + - name: Setup win sdk + if: startswith(matrix.preset, 'clang-cross') && steps.cache-win-sdk.outputs.cache-hit != 'true' run: | - wget -nv ${{ env.XWIN_DOWNLOAD }} - tar -xf ${{ env.XWIN_VERSION }}.tar.gz - ${{ env.XWIN_VERSION }}/xwin \ - --accept-license \ - --arch x86,x86_64 \ - splat \ - --include-debug-libs \ - --output ~/xwin + git clone https://github.com/mstorsjo/msvc-wine.git + msvc-wine/vsdownload.py --accept-license --dest ~/win-sdk Microsoft.VisualStudio.Workload.VCTools + msvc-wine/install.sh ~/win-sdk + rm -r msvc-wine - name: Checkout repository and submodules uses: actions/checkout@v4 with: submodules: recursive - - name: Configure build + - name: Configure CMake working-directory: ${{ env.GITHUB_WORKSPACE }} - run: | - pip install requests - - cmake . \ - --preset ${{ matrix.preset }} \ - -G Ninja \ - -DXWIN_DIR=$(readlink -f ~)/xwin - # The extra xwin dir arg will be ignored if we don't need it + # The extra msvc wine arg won't do anything if we're not cross compiling + run: > + cmake . + --preset ${{ matrix.preset }} + -G Ninja + -DMSVC_WINE_ENV_SCRIPT=$(readlink -f ~)/win-sdk/bin/${{ fromJSON('["x86", "x64"]')[contains(matrix.preset, 'x64')] }}/msvcenv.sh - name: Build working-directory: ${{ env.GITHUB_WORKSPACE }} @@ -205,7 +142,6 @@ jobs: clang-tidy: runs-on: windows-latest - needs: cache-clang strategy: fail-fast: false @@ -216,37 +152,34 @@ jobs: ] steps: - - name: Restore Clang Cache - uses: actions/cache/restore@v4 - with: - path: C:\Program Files\LLVM - key: ${{ runner.os }}-clang-${{ env.CLANG_VERSION }} - fail-on-cache-miss: true + - name: Setup Clang + if: startswith(matrix.preset, 'clang') + uses: egor-tensin/setup-clang@v1 - name: Setup CMake and Ninja uses: lukka/get-cmake@latest + # Need newer python to run the python lib downloader script - name: Setup Python uses: actions/setup-python@v5 with: python-version: ">=3.10" + # Needed pyyaml for clang tidy to enable `-export-fixes` and requests for the python lib downloader + - name: Install pip packages + run: pip install pyyaml requests + - name: Checkout repository and submodules uses: actions/checkout@v4 with: submodules: recursive - - name: Configure build + - name: Configure CMake working-directory: ${{ env.GITHUB_WORKSPACE }} - # Also need pyyaml for clang tidy to enable `-export-fixes` - run: | - pip install pyyaml requests - - cmake . ` - --preset ${{ matrix.preset }} ` - -G Ninja ` - -DCMAKE_DISABLE_PRECOMPILE_HEADERS=True + run: cmake . --preset ${{ matrix.preset }} -DCMAKE_DISABLE_PRECOMPILE_HEADERS=On + - name: Remove `.modmap`s from compile commands + run: | (Get-Content "out\build\${{ matrix.preset }}\compile_commands.json") ` -replace "@CMakeFiles.+?\.modmap", "" ` | Set-Content ` @@ -256,33 +189,22 @@ jobs: working-directory: ${{ env.GITHUB_WORKSPACE }} run: | python (Get-Command run-clang-tidy).Source ` - -j ${{ env.CLANG_TIDY_JOBS }} ` -p "out\build\${{ matrix.preset }}" ` -export-fixes clang-tidy-fixes.yml ` - $([Regex]::Escape("$pwd\src") + ".+\.(c|cpp|h|hpp)$") ` - -extra-arg="-Wno-unknown-pragmas" - # For some reason, the above started giving unknown pragma errors in library headers (both - # unrealsdk and python) in clang-tidy 17 - # It compiles fine, doesn't show up in clangd, and doesn't happen in the unrealsdk build, so - # just suppressing it for now + $([Regex]::Escape("$pwd\src") + ".+\.(c|cpp|h|hpp)$") - name: Process clang-tidy warnings uses: asarium/clang-tidy-action@v1 with: fixesFile: clang-tidy-fixes.yml - clang-format: runs-on: windows-latest - needs: cache-clang steps: - - name: Restore Clang Cache - uses: actions/cache/restore@v4 - with: - path: C:\Program Files\LLVM - key: ${{ runner.os }}-clang-${{ env.CLANG_VERSION }} - fail-on-cache-miss: true + - name: Setup Clang + if: startswith(matrix.preset, 'clang') + uses: egor-tensin/setup-clang@v1 - name: Checkout repository uses: actions/checkout@v4 diff --git a/CMakeLists.txt b/CMakeLists.txt index 544311f..3951f05 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(pyunrealsdk VERSION 1.1.1) +project(pyunrealsdk VERSION 1.2.0) function(_pyunrealsdk_add_base_target_args target_name) target_compile_features(${target_name} PUBLIC cxx_std_20) diff --git a/changelog.md b/changelog.md index 197950e..520c0c1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,57 @@ # Changelog +## v1.2.0 (Upcoming) + +Also see the unrealsdk v1.2.0 changelog [here](https://github.com/bl-sdk/unrealsdk/blob/master/changelog.md#v120). + +- Added bindings for the new classes introduced in unrealsdk v1.2.0 - `UByteAttributeProperty`, + `UComponentProperty`, `UFloatAttributeProperty`, `UIntAttributeProperty`, and + `UByteProperty::Enum`. + + [ab211486](https://github.com/bl-sdk/pyunrealsdk/commit/ab211486) + +- Getting a byte property which has an associated enum will now return the appropriate Python enum, + in the same way as an enum property does. Byte properties without an enum still return an int. + + [ab211486](https://github.com/bl-sdk/pyunrealsdk/commit/ab211486) + +- Fixed that it was impossible to set Python properties on unreal objects. + + [8b75fbbf](https://github.com/bl-sdk/pyunrealsdk/commit/8b75fbbf) + +- Changed the log level specific printers, `unrealsdk.logging.error` et al., to each use their own, + logger objects rather than modifying `sys.stdout` in place. + + [285e276a](https://github.com/bl-sdk/pyunrealsdk/commit/285e276a) + +- Updated various docstrings and type stubs to be more accurately. + + [d66295ef](https://github.com/bl-sdk/pyunrealsdk/commit/d66295ef), + [0df05cea](https://github.com/bl-sdk/pyunrealsdk/commit/0df05cea), + [285e276a](https://github.com/bl-sdk/pyunrealsdk/commit/285e276a) + +- Restructured CMake to allow you to define the Python version to link against directly within it, + similarly to unrealsdk. + + ```cmake + set(UNREALSDK_ARCH x64) + set(UNREALSDK_UE_VERSION UE4) + set(EXPLICIT_PYTHON_ARCH win64) + set(EXPLICIT_PYTHON_VERSION 3.12.3) + + add_subdirectory(libs/pyunrealsdk) + ``` + [abca72b3](https://github.com/bl-sdk/pyunrealsdk/commit/abca72b3) + +- Release the GIL during unreal function calls, to try avoid a deadlock when running with + `UNREALSDK_LOCKING_PROCESS_EVENT`. + + [31fdb4ee](https://github.com/bl-sdk/pyunrealsdk/commit/31fdb4ee) + +- Upgraded pybind. + + [b1335304](https://github.com/bl-sdk/pyunrealsdk/commit/b1335304) + ## v1.1.1 - Updated CI and stubs to Python 3.12 diff --git a/libs/pybind11 b/libs/pybind11 index 8b03ffa..941f45b 160000 --- a/libs/pybind11 +++ b/libs/pybind11 @@ -1 +1 @@ -Subproject commit 8b03ffa7c06cd9c8a38297b1c8923695d1ff1b07 +Subproject commit 941f45bcb51457884fa1afd6e24a67377d70f75c diff --git a/libs/unrealsdk b/libs/unrealsdk index d121ba2..6389629 160000 --- a/libs/unrealsdk +++ b/libs/unrealsdk @@ -1 +1 @@ -Subproject commit d121ba258e6751d5fa522aa9b803aaa0ea59fec7 +Subproject commit 63896291d93bb3216e4167a255d48e28f8113292 diff --git a/src/pyunrealsdk/base_bindings.cpp b/src/pyunrealsdk/base_bindings.cpp index 401b3f3..dd88416 100644 --- a/src/pyunrealsdk/base_bindings.cpp +++ b/src/pyunrealsdk/base_bindings.cpp @@ -98,12 +98,14 @@ void register_base_bindings(py::module_& mod) { }, "Finds a class by name.\n" "\n" + "Throws a ValueError if not found.\n" + "\n" "Args:\n" " name: The class name.\n" " fully_qualified: If the class name is fully qualified, or None (the default)\n" " to autodetect.\n" "Returns:\n" - " The class, or None if not found.", + " The unreal class.", "name"_a, "fully_qualified"_a = std::nullopt); mod.def( @@ -118,12 +120,14 @@ void register_base_bindings(py::module_& mod) { }, "Finds an enum by name.\n" "\n" + "Throws a ValueError if not found.\n" + "\n" "Args:\n" " name: The enum name.\n" " fully_qualified: If the enum name is fully qualified, or None (the default)\n" " to autodetect.\n" "Returns:\n" - " The enum, or None if not found.", + " The unreal enum.", "name"_a, "fully_qualified"_a = std::nullopt); mod.def( @@ -161,13 +165,15 @@ void register_base_bindings(py::module_& mod) { }, "Finds an object by name.\n" "\n" + "Throws a ValueError if not found.\n" + "\n" "Args:\n" " cls: The object's class, or class name. If given as the name, always\n" " autodetects if fully qualified - call find_class() directly if you need\n" " to specify.\n" " name: The object's name.\n" "Returns:\n" - " The object, or None if not found.", + " The unreal object.", "cls"_a, "name"_a); mod.def( @@ -206,7 +212,7 @@ void register_base_bindings(py::module_& mod) { " exact: If true (the default), only finds exact class matches. If false, also\n" " matches subclasses.\n" "Returns:\n" - " A list of all instances of the class.", + " An iterator over all instances of the class.", "cls"_a, "exact"_a = true); mod.def( diff --git a/src/pyunrealsdk/debugging.cpp b/src/pyunrealsdk/debugging.cpp index 2ccbba9..fa4da18 100644 --- a/src/pyunrealsdk/debugging.cpp +++ b/src/pyunrealsdk/debugging.cpp @@ -26,34 +26,26 @@ PYUNREALSDK_CAPI(void, debug_this_thread) { return; } - static StaticPyObject debugpy_debug_this_thread{}; - - // Since we initialize with a null object, this is only true on first run - we'll set disabled - // if we fail to find the object anyway - if (!(bool)debugpy_debug_this_thread) { - if (!env::defined(env::DEBUGPY)) { - disabled = true; - return; - } - - { - const py::gil_scoped_acquire gil{}; - try { - debugpy_debug_this_thread = - py::module_::import("debugpy").attr("debug_this_thread"); - } catch (const py::error_already_set&) { + const py::gil_scoped_acquire gil{}; + + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store storage; + auto& debugpy_debug_this_thread = + storage + .call_once_and_store_result([]() -> py::object { + if (env::defined(env::DEBUGPY)) { + try { + return py::module_::import("debugpy").attr("debug_this_thread"); + } catch (const py::error_already_set&) {} + } disabled = true; - return; - } - } - - if (!(bool)debugpy_debug_this_thread) { - disabled = true; - return; - } + return py::none{}; + }) + .get_stored(); + + if (disabled) { + return; } - const py::gil_scoped_acquire gil{}; debugpy_debug_this_thread(); } diff --git a/src/pyunrealsdk/logging.cpp b/src/pyunrealsdk/logging.cpp index 7748733..31e235e 100644 --- a/src/pyunrealsdk/logging.cpp +++ b/src/pyunrealsdk/logging.cpp @@ -86,7 +86,7 @@ class Logger { std::string str; for (size_t i = 0; i < lines_to_flush && std::getline(this->stream, str); i++) { - unrealsdk::logging::log(clamped_level, str, location.c_str(), line_num); + unrealsdk::logging::log(clamped_level, str, location, line_num); } // We need to clear the stream occasionally @@ -103,34 +103,31 @@ class Logger { /** * @brief Registers a function which prints at a specific log level. * + * @tparam level The log level this printer is for. * @param logging The logging module to register within. - * @param level The log level this printer is for. * @param func_name The name of the printing function. * @param docstring_name The name of the log level to include in the docstring. */ +template void register_per_log_level_printer(py::module_& logging, - Level level, const char* func_name, - const char* docstring_name) { - auto docstring = unrealsdk::fmt::format( - "Wrapper around print(), which temporarily changes the log level of stdout to\n" - "{}.\n" + std::string_view docstring_name) { + const auto docstring = unrealsdk::fmt::format( + "Wrapper around print(), which uses a custom file at the {} log level.\n" "\n" "Args:\n" " *args: Forwarded to print().\n" - " **kwargs: Forwarded to print().", + " **kwargs: Except for 'file', forwarded to print().", docstring_name); - logging.def( - func_name, - [level](const py::args& args, const py::kwargs& kwargs) { - auto py_stdout = py::module_::import("sys").attr("stdout"); - auto old_level = py::cast(py_stdout.attr("level")); - py_stdout.attr("level") = level; + // NOLINTNEXTLINE(misc-const-correctness) + static Logger logger{level}; + logging.def( + func_name, + [](const py::args& args, const py::kwargs& kwargs) { + kwargs["file"] = logger; py::print(*args, **kwargs); - - py_stdout.attr("level") = old_level; }, docstring.c_str()); } @@ -193,11 +190,11 @@ void register_module(py::module_& mod) { "Returns:\n" " True if the console hook is ready, false otherwise."); - register_per_log_level_printer(logging, Level::MISC, "misc", "misc"); - register_per_log_level_printer(logging, Level::DEV_WARNING, "dev_warning", "dev warning"); - register_per_log_level_printer(logging, Level::INFO, "info", "info"); - register_per_log_level_printer(logging, Level::WARNING, "warning", "warning"); - register_per_log_level_printer(logging, Level::ERROR, "error", "error"); + register_per_log_level_printer(logging, "misc", "misc"); + register_per_log_level_printer(logging, "dev_warning", "dev warning"); + register_per_log_level_printer(logging, "info", "info"); + register_per_log_level_printer(logging, "warning", "warning"); + register_per_log_level_printer(logging, "error", "error"); } void py_init(void) { @@ -230,8 +227,7 @@ void log_python_exception(const std::exception& exc) { std::istringstream stream{exc.what()}; std::string msg_line; while (std::getline(stream, msg_line)) { - unrealsdk::logging::log(unrealsdk::logging::Level::ERROR, msg_line, location.c_str(), - line_num); + unrealsdk::logging::log(unrealsdk::logging::Level::ERROR, msg_line, location, line_num); } } diff --git a/src/pyunrealsdk/static_py_object.h b/src/pyunrealsdk/static_py_object.h index 9254f97..5b07a91 100644 --- a/src/pyunrealsdk/static_py_object.h +++ b/src/pyunrealsdk/static_py_object.h @@ -18,10 +18,14 @@ namespace pyunrealsdk { /* A pybind object wrapper which can safely be stored statically. -The issue with the standard class is it's destructor assumes Python is still running - but when -stored in static memory, it may be called after finalization. Most obviously, this means we can't -grab the GIL, so it will throw an exception during a destructor, and thus crash the game. While this -only happens when you close the game anyway, we don't want the user to see us causing crashes. +Pybind has an official type with a similar purpose, `py::gil_safe_call_once_and_store`. However, +it's more suited to something which exists forever, it has a leaking destructor. This class is +better for static objects which may keep getting replaced. + +The issue with py::object is it's destructor assumes Python is still running - but when stored in +static memory, it may be called after finalization. Most obviously, this means we can't grab the +GIL, so it will throw an exception during a destructor, and thus crash the game. While this only +happens when you close the game anyway, we don't want the user to see us causing crashes. */ class PY_OBJECT_VISIBILITY StaticPyObject { diff --git a/src/pyunrealsdk/unreal_bindings/bound_function.cpp b/src/pyunrealsdk/unreal_bindings/bound_function.cpp index be9768d..b17bb42 100644 --- a/src/pyunrealsdk/unreal_bindings/bound_function.cpp +++ b/src/pyunrealsdk/unreal_bindings/bound_function.cpp @@ -53,7 +53,8 @@ std::pair> fill_py_params(WrappedStruct& par // If we still have positional args left if (arg_idx != args.size()) { - py_setattr(prop, reinterpret_cast(params.base.get()), args[arg_idx++]); + py_setattr_direct(prop, reinterpret_cast(params.base.get()), + args[arg_idx++]); if (kwargs.contains(prop->Name)) { throw py::type_error(unrealsdk::fmt::format( @@ -67,8 +68,8 @@ std::pair> fill_py_params(WrappedStruct& par if (kwargs.contains(prop->Name)) { // Extract the value with pop, so we can check that kwargs are empty at the // end - py_setattr(prop, reinterpret_cast(params.base.get()), - kwargs.attr("pop")(prop->Name)); + py_setattr_direct(prop, reinterpret_cast(params.base.get()), + kwargs.attr("pop")(prop->Name)); continue; } @@ -179,7 +180,16 @@ void register_bound_function(py::module_& mod) { if (args.size() == 1 && kwargs.empty() && py::isinstance(args[0])) { auto args_struct = py::cast(args[0]); if (args_struct.type == self.func) { - self.call(args_struct); + { + // Release the GIL to avoid a deadlock if ProcessEvent is locking. + // If a hook tries to call into Python, it will be holding the process + // event lock, and it will try to acquire the GIL. + // If at the same time python code on a different thread tries to call + // an unreal function, it would be holding the GIL, and trying to + // acquire the process event lock. + const py::gil_scoped_release gil{}; + self.call(args_struct); + } return get_py_return(args_struct); } } @@ -187,7 +197,10 @@ void register_bound_function(py::module_& mod) { WrappedStruct params{self.func}; auto [return_param, out_params] = fill_py_params(params, args, kwargs); - self.call(params); + { + const py::gil_scoped_release gil{}; + self.call(params); + } return get_py_return(params, return_param, out_params); }, diff --git a/src/pyunrealsdk/unreal_bindings/property_access.cpp b/src/pyunrealsdk/unreal_bindings/property_access.cpp index b5ec38c..ef7254c 100644 --- a/src/pyunrealsdk/unreal_bindings/property_access.cpp +++ b/src/pyunrealsdk/unreal_bindings/property_access.cpp @@ -1,10 +1,10 @@ #include "pyunrealsdk/pch.h" #include "pyunrealsdk/unreal_bindings/property_access.h" +#include "pyunrealsdk/static_py_object.h" #include "pyunrealsdk/unreal_bindings/uenum.h" #include "pyunrealsdk/unreal_bindings/wrapped_array.h" #include "unrealsdk/unreal/cast.h" #include "unrealsdk/unreal/classes/properties/uarrayproperty.h" -#include "unrealsdk/unreal/classes/properties/uenumproperty.h" #include "unrealsdk/unreal/classes/uconst.h" #include "unrealsdk/unreal/classes/uenum.h" #include "unrealsdk/unreal/classes/ufield.h" @@ -53,8 +53,14 @@ void register_property_helpers(py::module_& mod) { std::vector py_dir(const py::object& self, const UStruct* type) { // Start by calling the base dir function - auto names = py::cast>( - py::module_::import("builtins").attr("object").attr("__dir__")(self)); + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store storage; + auto& dir = storage + .call_once_and_store_result([]() { + return py::module_::import("builtins").attr("object").attr("__dir__"); + }) + .get_stored(); + + auto names = py::cast>(dir(self)); if (dir_includes_unreal) { // Append our fields @@ -85,13 +91,22 @@ py::object py_getattr(UField* field, for (size_t i = 0; i < (size_t)prop->ArrayDim; i++) { auto val = get_property(prop, i, base_addr, parent); - if constexpr (std::is_same_v) { - // If the value we're reading is an enum, convert it to a python enum - ret[i] = enum_as_py_enum(prop->get_enum())(val); - } else { - // Otherwise store as is - ret[i] = std::move(val); + // Multiple property types expose a get enum method + constexpr bool is_enum = requires(const T* type) { + { type->get_enum() } -> std::same_as; + }; + + // If the value we're reading is an enum, convert it to a python enum + if constexpr (is_enum) { + auto ue_enum = prop->get_enum(); + if (ue_enum != nullptr) { + ret[i] = enum_as_py_enum(ue_enum)(val); + continue; + } } + // Otherwise store as is + + ret[i] = std::move(val); } }); if (prop->ArrayDim == 1) { @@ -124,7 +139,7 @@ py::object py_getattr(UField* field, field->Name, field->Class->Name)); } -void py_setattr(UField* field, uintptr_t base_addr, const py::object& value) { +void py_setattr_direct(UField* field, uintptr_t base_addr, const py::object& value) { if (!field->is_instance(find_class())) { throw py::attribute_error(unrealsdk::fmt::format( "attribute '{}' is not a property, and thus cannot be set", field->Name)); diff --git a/src/pyunrealsdk/unreal_bindings/property_access.h b/src/pyunrealsdk/unreal_bindings/property_access.h index 72e53ab..50d3ce0 100644 --- a/src/pyunrealsdk/unreal_bindings/property_access.h +++ b/src/pyunrealsdk/unreal_bindings/property_access.h @@ -59,13 +59,19 @@ py::object py_getattr(unrealsdk::unreal::UField* field, unrealsdk::unreal::UObject* func_obj = nullptr); /** - * @brief Implements `__setattr__`. + * @brief Sets an unreal field to a python object directly. + * @note This is not suitable as an implementation of `__setattr__`, as it bypasses any other Python + * field setting logic (e.g. properties). Getattr is only called on failed gets, while + * setattr is called on *all* sets. To make it symmetric, try call the super setattr first, + * and only call this if it fails. * * @param field The field to get. * @param base_addr The base address of the object. * @param value The value to set. */ -void py_setattr(unrealsdk::unreal::UField* field, uintptr_t base_addr, const py::object& value); +void py_setattr_direct(unrealsdk::unreal::UField* field, + uintptr_t base_addr, + const py::object& value); } // namespace pyunrealsdk::unreal diff --git a/src/pyunrealsdk/unreal_bindings/uenum.cpp b/src/pyunrealsdk/unreal_bindings/uenum.cpp index 6ca3651..ea0569b 100644 --- a/src/pyunrealsdk/unreal_bindings/uenum.cpp +++ b/src/pyunrealsdk/unreal_bindings/uenum.cpp @@ -26,30 +26,33 @@ py::object enum_as_py_enum(const UEnum* enum_obj) { const py::gil_scoped_acquire gil{}; // Use IntFlag, as it natively supports unknown values - static const StaticPyObject intflag = (py::object)py::module_::import("enum").attr("IntFlag"); + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store storage; + auto& intflag = storage + .call_once_and_store_result( + []() { return py::module_::import("enum").attr("IntFlag"); }) + .get_stored(); + static std::unordered_map enum_cache{}; if (!enum_cache.contains(enum_obj)) { - py::object py_enum; + std::unordered_map enum_names{}; #ifdef UE4 // UE4 enums include the enum name and a namespace separator before the name - strip them - std::unordered_map stripped_enum_names{}; for (const auto& [key, value] : enum_obj->get_names()) { const std::string str_key{key}; auto after_colons = str_key.find_first_not_of(':', str_key.find_first_of(':')); - stripped_enum_names.emplace( + enum_names.emplace( after_colons == std::string::npos ? str_key : str_key.substr(after_colons), value); } - - py_enum = intflag(enum_obj->Name, stripped_enum_names); #else // UE3 enums are just the name, so we can use the dict directly - py_enum = intflag(enum_obj->Name, enum_obj->get_names()); + enum_names = enum_obj->get_names(); #endif + auto py_enum = intflag(enum_obj->Name, enum_names); py_enum.attr("_unreal") = enum_obj; enum_cache.emplace(enum_obj, py_enum); diff --git a/src/pyunrealsdk/unreal_bindings/uobject.cpp b/src/pyunrealsdk/unreal_bindings/uobject.cpp index 9488952..e7a9995 100644 --- a/src/pyunrealsdk/unreal_bindings/uobject.cpp +++ b/src/pyunrealsdk/unreal_bindings/uobject.cpp @@ -1,5 +1,6 @@ #include "pyunrealsdk/pch.h" #include "pyunrealsdk/unreal_bindings/uobject.h" +#include "pyunrealsdk/static_py_object.h" #include "pyunrealsdk/unreal_bindings/bindings.h" #include "pyunrealsdk/unreal_bindings/property_access.h" #include "unrealsdk/format.h" @@ -36,9 +37,7 @@ void register_uobject(py::module_& mod) { "The base class of all unreal objects.\n" "\n" "Most objects you interact with will be this type in python, even if their unreal\n" - "class is something different.", - // Need dynamic attr to create a `__dict__`, so that we can handle `__dir__` properly - py::dynamic_attr()) + "class is something different.") .def("__new__", [](const py::args&, const py::kwargs&) { throw py::type_error("Cannot create new instances of unreal objects."); @@ -107,9 +106,32 @@ void register_uobject(py::module_& mod) { "field"_a) .def( "__setattr__", - [](UObject* self, const FName& name, const py::object& value) { - py_setattr(py_find_field(name, self->Class), reinterpret_cast(self), - value); + [](py::object& self, const py::str& name, const py::object& value) { + // See if the standard setattr would work first, in case we're being called on an + // existing field. Getattr is only called on failure, but setattr is always called. + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store storage; + auto& setattr = storage + .call_once_and_store_result([]() { + return py::module::import("builtins") + .attr("object") + .attr("__setattr__"); + }) + .get_stored(); + + try { + setattr(self, name, value); + return; + } catch (py::error_already_set& e) { + if (!e.matches(PyExc_AttributeError)) { + throw; + } + } + + auto ue_self = py::cast(self); + auto ue_name = py::cast(name); + + py_setattr_direct(py_find_field(ue_name, ue_self->Class), + reinterpret_cast(ue_self), value); }, "Writes a value to an unreal field on the object.\n" "\n" @@ -125,7 +147,7 @@ void register_uobject(py::module_& mod) { if (field == nullptr) { throw py::attribute_error("cannot access null attribute"); } - py_setattr(field, reinterpret_cast(self), value); + py_setattr_direct(field, reinterpret_cast(self), value); }, "Writes a value to an unreal field on the object.\n" "\n" diff --git a/src/pyunrealsdk/unreal_bindings/uobject_children.cpp b/src/pyunrealsdk/unreal_bindings/uobject_children.cpp index d9438ac..3de66ab 100644 --- a/src/pyunrealsdk/unreal_bindings/uobject_children.cpp +++ b/src/pyunrealsdk/unreal_bindings/uobject_children.cpp @@ -2,10 +2,13 @@ #include "pyunrealsdk/unreal_bindings/uobject_children.h" #include "pyunrealsdk/unreal_bindings/bindings.h" #include "pyunrealsdk/unreal_bindings/wrapped_struct.h" +#include "unrealsdk/unreal/classes/properties/attribute_property.h" #include "unrealsdk/unreal/classes/properties/copyable_property.h" #include "unrealsdk/unreal/classes/properties/uarrayproperty.h" #include "unrealsdk/unreal/classes/properties/uboolproperty.h" +#include "unrealsdk/unreal/classes/properties/ubyteproperty.h" #include "unrealsdk/unreal/classes/properties/uclassproperty.h" +#include "unrealsdk/unreal/classes/properties/ucomponentproperty.h" #include "unrealsdk/unreal/classes/properties/uenumproperty.h" #include "unrealsdk/unreal/classes/properties/uinterfaceproperty.h" #include "unrealsdk/unreal/classes/properties/uobjectproperty.h" @@ -135,7 +138,8 @@ void register_uobject_children(py::module_& mod) { PyUEClass(mod, "UBoolProperty") .def_property_readonly("FieldMask", &UBoolProperty::get_field_mask); - PyUEClass(mod, "UByteProperty"); + PyUEClass(mod, "UByteProperty") + .def_property_readonly("Enum", &UByteProperty::get_enum); PyUEClass(mod, "UClass") .def( @@ -212,9 +216,29 @@ void register_uobject_children(py::module_& mod) { PyUEClass(mod, "UBlueprintGeneratedClass"); + PyUEClass(mod, "UByteAttributeProperty") + .def_property_readonly("ModifierStackProperty", + &UByteAttributeProperty::get_modifier_stack_prop) + .def_property_readonly("OtherAttributeProperty", + &UByteAttributeProperty::get_other_attribute_property); + PyUEClass(mod, "UClassProperty") .def_property_readonly("MetaClass", &UClassProperty::get_meta_class); + PyUEClass(mod, "UComponentProperty"); + + PyUEClass(mod, "UFloatAttributeProperty") + .def_property_readonly("ModifierStackProperty", + &UFloatAttributeProperty::get_modifier_stack_prop) + .def_property_readonly("OtherAttributeProperty", + &UFloatAttributeProperty::get_other_attribute_property); + + PyUEClass(mod, "UIntAttributeProperty") + .def_property_readonly("ModifierStackProperty", + &UIntAttributeProperty::get_modifier_stack_prop) + .def_property_readonly("OtherAttributeProperty", + &UIntAttributeProperty::get_other_attribute_property); + PyUEClass(mod, "UWeakObjectProperty"); } diff --git a/src/pyunrealsdk/unreal_bindings/wrapped_array_methods.cpp b/src/pyunrealsdk/unreal_bindings/wrapped_array_methods.cpp index 1779f46..13a554e 100644 --- a/src/pyunrealsdk/unreal_bindings/wrapped_array_methods.cpp +++ b/src/pyunrealsdk/unreal_bindings/wrapped_array_methods.cpp @@ -1,4 +1,5 @@ #include "pyunrealsdk/pch.h" +#include "pyunrealsdk/static_py_object.h" #include "pyunrealsdk/unreal_bindings/wrapped_array.h" #include "unrealsdk/unreal/cast.h" #include "unrealsdk/unreal/wrappers/wrapped_array.h" @@ -141,13 +142,18 @@ void array_py_sort(WrappedArray& self, const py::object& key, bool reverse) { // Implement using the sorted builtin // It's just kind of awkward to do from C++, given half our types aren't even sortable to begin // with, and we need to be able to compare arbitrary keys anyway - py::sequence sorted = - py::module_::import("builtins").attr("sorted")(self, "key"_a = key, "reverse"_a = reverse); + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store storage; + auto& sorted = storage + .call_once_and_store_result( + []() { return py::module_::import("builtins").attr("sorted"); }) + .get_stored(); - cast(self.type, [&self, &sorted](const T* /*prop*/) { + py::sequence sorted_array = sorted(self, "key"_a = key, "reverse"_a = reverse); + + cast(self.type, [&self, &sorted_array](const T* /*prop*/) { auto size = self.size(); for (size_t i = 0; i < size; i++) { - auto val = py::cast::Value>(sorted[i]); + auto val = py::cast::Value>(sorted_array[i]); self.set_at(i, val); } }); diff --git a/src/pyunrealsdk/unreal_bindings/wrapped_struct.cpp b/src/pyunrealsdk/unreal_bindings/wrapped_struct.cpp index 21523d2..60eacfc 100644 --- a/src/pyunrealsdk/unreal_bindings/wrapped_struct.cpp +++ b/src/pyunrealsdk/unreal_bindings/wrapped_struct.cpp @@ -1,5 +1,6 @@ #include "pyunrealsdk/pch.h" #include "pyunrealsdk/unreal_bindings/wrapped_struct.h" +#include "pyunrealsdk/static_py_object.h" #include "pyunrealsdk/unreal_bindings/bindings.h" #include "pyunrealsdk/unreal_bindings/property_access.h" #include "unrealsdk/unreal/classes/uscriptstruct.h" @@ -30,7 +31,8 @@ WrappedStruct make_struct(const UScriptStruct* type, size_t arg_idx = 0; for (auto prop : type->properties()) { if (arg_idx != args.size()) { - py_setattr(prop, reinterpret_cast(new_struct.base.get()), args[arg_idx++]); + py_setattr_direct(prop, reinterpret_cast(new_struct.base.get()), + args[arg_idx++]); if (converted_kwargs.contains(prop->Name)) { throw py::type_error(unrealsdk::fmt::format( @@ -44,8 +46,8 @@ WrappedStruct make_struct(const UScriptStruct* type, auto iter = converted_kwargs.find(prop->Name); if (iter != converted_kwargs.end()) { // Use extract to also remove the value from the map, so we can ensure it's empty later - py_setattr(prop, reinterpret_cast(new_struct.base.get()), - converted_kwargs.extract(iter).mapped()); + py_setattr_direct(prop, reinterpret_cast(new_struct.base.get()), + converted_kwargs.extract(iter).mapped()); continue; } } @@ -61,10 +63,7 @@ WrappedStruct make_struct(const UScriptStruct* type, } void register_wrapped_struct(py::module_& mod) { - py::class_( - mod, "WrappedStruct", - // Need dynamic attr to create a `__dict__`, so that we can handle `__dir__` properly - py::dynamic_attr()) + py::class_(mod, "WrappedStruct") .def(py::init(&make_struct), "Creates a new wrapped struct.\n" "\n" @@ -152,9 +151,32 @@ void register_wrapped_struct(py::module_& mod) { "field"_a) .def( "__setattr__", - [](WrappedStruct& self, const FName& name, const py::object& value) { - py_setattr(py_find_field(name, self.type), - reinterpret_cast(self.base.get()), value); + [](py::object& self, const py::str& name, const py::object& value) { + // See if the standard setattr would work first, in case we're being called on an + // existing field. Getattr is only called on failure, but setattr is always called. + PYBIND11_CONSTINIT static py::gil_safe_call_once_and_store storage; + auto& setattr = storage + .call_once_and_store_result([]() { + return py::module::import("builtins") + .attr("object") + .attr("__setattr__"); + }) + .get_stored(); + + try { + setattr(self, name, value); + return; + } catch (py::error_already_set& e) { + if (!e.matches(PyExc_AttributeError)) { + throw; + } + } + + auto ue_self = py::cast(self); + auto ue_name = py::cast(name); + + py_setattr_direct(py_find_field(ue_name, ue_self.type), + reinterpret_cast(ue_self.base.get()), value); }, "Writes a value to an unreal field on the struct.\n" "\n" @@ -170,7 +192,7 @@ void register_wrapped_struct(py::module_& mod) { if (field == nullptr) { throw py::attribute_error("cannot access null attribute"); } - py_setattr(field, reinterpret_cast(self.base.get()), value); + py_setattr_direct(field, reinterpret_cast(self.base.get()), value); }, "Writes a value to an unreal field on the struct.\n" "\n" diff --git a/stubs/unrealsdk/__init__.pyi b/stubs/unrealsdk/__init__.pyi index cf3061d..a65a5bc 100644 --- a/stubs/unrealsdk/__init__.pyi +++ b/stubs/unrealsdk/__init__.pyi @@ -58,44 +58,50 @@ def find_all(cls: UClass | str, exact: bool = True) -> Iterator[UObject]: exact: If true (the default), only finds exact class matches. If false, also matches subclasses. Returns: - A list of all instances of the class. + An iterator over all instances of the class. """ def find_class(name: str, fully_qualified: None | bool = None) -> UClass: """ Finds a class by name. + Throws a ValueError if not found. + Args: name: The class name. fully_qualified: If the class name is fully qualified, or None (the default) to autodetect. Returns: - The class, or None if not found. + The unreal class. """ def find_enum(name: str, fully_qualified: None | bool = None) -> type[_GenericUnrealEnum]: """ Finds an enum by name. + Throws a ValueError if not found. + Args: name: The enum name. fully_qualified: If the enum name is fully qualified, or None (the default) to autodetect. Returns: - The enum, or None if not found. + The unreal enum. """ def find_object(cls: UClass | str, name: str) -> UObject: """ Finds an object by name. + Throws a ValueError if not found. + Args: cls: The object's class, or class name. If given as the name, always autodetects if fully qualified - call find_class() directly if you need to specify. name: The object's name. Returns: - The object, or None if not found. + The unreal object. """ def load_package(name: str, flags: int = 0) -> UObject: diff --git a/stubs/unrealsdk/logging/__init__.pyi b/stubs/unrealsdk/logging/__init__.pyi index 489d300..9cf3dc3 100644 --- a/stubs/unrealsdk/logging/__init__.pyi +++ b/stubs/unrealsdk/logging/__init__.pyi @@ -81,34 +81,49 @@ class Logger: of the string). """ -def dev_warning(*args: Any, **kwargs: Any) -> None: +# These functions do actually just forward *args/**kwargs directly, but give them the same signature +# as print for type hinting + +def dev_warning( # noqa: D417 + *objects: Any, + sep: str | None = " ", + end: str | None = "\n", + flush: bool = False, +) -> None: """ - Wrapper around print(), which temporarily changes the log level of stdout to - dev warning. + Wrapper around print(), which uses a custom file at the dev warning log level. Args: *args: Forwarded to print(). - **kwargs: Forwarded to print(). + **kwargs: Except for 'file', forwarded to print(). """ -def error(*args: Any, **kwargs: Any) -> None: +def error( # noqa: D417 + *objects: Any, + sep: str | None = " ", + end: str | None = "\n", + flush: bool = False, +) -> None: """ - Wrapper around print(), which temporarily changes the log level of stdout to - error. + Wrapper around print(), which uses a custom file at the error log level. Args: *args: Forwarded to print(). - **kwargs: Forwarded to print(). + **kwargs: Except for 'file', forwarded to print(). """ -def info(*args: Any, **kwargs: Any) -> None: +def info( # noqa: D417 + *objects: Any, + sep: str | None = " ", + end: str | None = "\n", + flush: bool = False, +) -> None: """ - Wrapper around print(), which temporarily changes the log level of stdout to - info. + Wrapper around print(), which uses a custom file at the info log level. Args: *args: Forwarded to print(). - **kwargs: Forwarded to print(). + **kwargs: Except for 'file', forwarded to print(). """ def is_console_ready() -> bool: @@ -121,14 +136,18 @@ def is_console_ready() -> bool: True if the console hook is ready, false otherwise. """ -def misc(*args: Any, **kwargs: Any) -> None: +def misc( # noqa: D417 + *objects: Any, + sep: str | None = " ", + end: str | None = "\n", + flush: bool = False, +) -> None: """ - Wrapper around print(), which temporarily changes the log level of stdout to - misc. + Wrapper around print(), which uses a custom file at the misc log level. Args: *args: Forwarded to print(). - **kwargs: Forwarded to print(). + **kwargs: Except for 'file', forwarded to print(). """ def set_console_level(level: Level) -> bool: @@ -143,12 +162,16 @@ def set_console_level(level: Level) -> bool: True if console level changed, false if an invalid value was passed in. """ -def warning(*args: Any, **kwargs: Any) -> None: +def warning( # noqa: D417 + *objects: Any, + sep: str | None = " ", + end: str | None = "\n", + flush: bool = False, +) -> None: """ - Wrapper around print(), which temporarily changes the log level of stdout to - warning. + Wrapper around print(), which uses a custom file at the warning log level. Args: *args: Forwarded to print(). - **kwargs: Forwarded to print(). + **kwargs: Except for 'file', forwarded to print(). """ diff --git a/stubs/unrealsdk/unreal/_uobject_children.pyi b/stubs/unrealsdk/unreal/_uobject_children.pyi index c42aa68..b9b85a9 100644 --- a/stubs/unrealsdk/unreal/_uobject_children.pyi +++ b/stubs/unrealsdk/unreal/_uobject_children.pyi @@ -99,7 +99,9 @@ class UBoolProperty(UProperty): @property def FieldMask(self) -> int: ... -class UByteProperty(UProperty): ... +class UByteProperty(UProperty): + @property + def Enum(self) -> UEnum | None: ... class UClass(UStruct): ClassDefaultObject: UObject @@ -170,8 +172,28 @@ class UUInt32Property(UProperty): ... class UUInt64Property(UProperty): ... class UBlueprintGeneratedClass(UClass): ... +class UByteAttributeProperty(UByteProperty): + @property + def ModifierStackProperty(self) -> UArrayProperty | None: ... + @property + def OtherAttributeProperty(self) -> UByteAttributeProperty | None: ... + class UClassProperty(UObjectProperty): @property def MetaClass(self) -> UClass: ... +class UComponentProperty(UObjectProperty): ... + +class UFloatAttributeProperty(UByteProperty): + @property + def ModifierStackProperty(self) -> UArrayProperty | None: ... + @property + def OtherAttributeProperty(self) -> UByteAttributeProperty | None: ... + +class UIntAttributeProperty(UByteProperty): + @property + def ModifierStackProperty(self) -> UArrayProperty | None: ... + @property + def OtherAttributeProperty(self) -> UByteAttributeProperty | None: ... + class UWeakObjectProperty(UObjectProperty): ...