mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-22 18:32:16 +00:00
fix: merge branch feat/plugin-feature
This commit is contained in:
@@ -8,7 +8,10 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_slicing_pipeline_params.cpp
|
||||
test_plugin_sort.cpp
|
||||
test_plugin_cloud_metadata.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
@@ -32,6 +35,19 @@ if (WIN32)
|
||||
COMMENT "Copying Python runtime for slic3rutils plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
elseif (APPLE)
|
||||
target_link_options(${_TEST_NAME}_tests PRIVATE
|
||||
"LINKER:-rpath,@executable_path/python/lib")
|
||||
|
||||
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_PREFIX_PATH}/libpython"
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMENT "Copying Python runtime for macOS plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
endif()
|
||||
|
||||
orcaslicer_discover_tests(${_TEST_NAME}_tests)
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python
|
||||
// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...).
|
||||
|
||||
#include <boost/dll/runtime_symbol_info.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <memory.h>
|
||||
#include <stdexcept>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
@@ -12,17 +15,29 @@ namespace {
|
||||
|
||||
void ensure_python_initialized()
|
||||
{
|
||||
// Deliberately a bare scoped_interpreter rather than Slic3r::PythonInterpreter:
|
||||
// `orca` is a PYBIND11_EMBEDDED_MODULE compiled into this test binary, so importing
|
||||
// it needs no bundled stdlib/sys.path, and the deterministic assertions are
|
||||
// independent of the host's Python. PythonInterpreter::initialize() expects the
|
||||
// bundled Python home laid out next to the app bundle (lib/python3.12/encodings),
|
||||
// which is not deployed beside the test binary, so using it here would fail to find
|
||||
// a home on macOS/Linux. The optional numpy-backed assertions are guarded at runtime.
|
||||
if (!Py_IsInitialized()) {
|
||||
static pybind11::scoped_interpreter interpreter;
|
||||
(void) interpreter;
|
||||
if (Py_IsInitialized())
|
||||
return;
|
||||
|
||||
static std::unique_ptr<pybind11::scoped_interpreter> interpreter;
|
||||
|
||||
PyConfig config;
|
||||
PyConfig_InitPythonConfig(&config);
|
||||
config.parse_argv = 0;
|
||||
|
||||
const auto python_home = boost::dll::program_location().parent_path() / "python";
|
||||
|
||||
if (boost::filesystem::exists(python_home)) {
|
||||
const std::string home = python_home.string();
|
||||
const PyStatus status = PyConfig_SetBytesString(&config, &config.home, home.c_str());
|
||||
|
||||
if (PyStatus_Exception(status)) {
|
||||
const char* message = status.err_msg ? status.err_msg : "Failed to set Python home";
|
||||
PyConfig_Clear(&config);
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
interpreter = std::make_unique<pybind11::scoped_interpreter>(&config);
|
||||
}
|
||||
|
||||
pybind11::module_ import_orca_module()
|
||||
|
||||
133
tests/slic3rutils/test_plugin_cloud_metadata.cpp
Normal file
133
tests/slic3rutils/test_plugin_cloud_metadata.cpp
Normal file
@@ -0,0 +1,133 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
|
||||
// value afterwards (same pattern as test_plugin_lifecycle.cpp).
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
fs::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
};
|
||||
|
||||
// Shut both singletons down while boost::log is still alive; left to their static
|
||||
// destructors, shutdown()'s logging runs after boost::log tears down its thread-local
|
||||
// storage and crashes the process on exit (same reason ScopedPluginManager exists in
|
||||
// test_plugin_lifecycle.cpp). No initialize() needed: discovery and the cloud-metadata
|
||||
// merge never touch Python, but manager shutdown instantiates the interpreter singleton.
|
||||
struct ScopedManagerShutdown
|
||||
{
|
||||
~ScopedManagerShutdown()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin whose per-plugin settings live in the PEP-723 header — the only place they exist.
|
||||
const char* const SETTINGS_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Settings Cloud Plugin"
|
||||
# type = "slicing-pipeline"
|
||||
# version = "1.0"
|
||||
#
|
||||
# [tool.orcaslicer.plugin.settings]
|
||||
# twist_deg_per_mm = "1.0"
|
||||
# taper_per_mm = "0.0"
|
||||
# ///
|
||||
print('ok')
|
||||
)PY";
|
||||
|
||||
} // namespace
|
||||
|
||||
// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud
|
||||
// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only
|
||||
// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the
|
||||
// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and
|
||||
// plugins silently fall back to their built-in defaults (found via Twistify running with its
|
||||
// demo defaults instead of the header values, 2026-07-17).
|
||||
TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]")
|
||||
{
|
||||
ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last
|
||||
ScopedDataDir data_dir_guard("cloud-meta-settings");
|
||||
|
||||
// A locally-installed cloud plugin: package .py with a settings header, plus an
|
||||
// install-state sidecar carrying the cloud identity.
|
||||
const std::string uuid = "11111111-2222-3333-4444-555555555555";
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid;
|
||||
fs::create_directories(plugin_dir);
|
||||
{
|
||||
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
|
||||
out << SETTINGS_PLUGIN_SOURCE;
|
||||
}
|
||||
PluginDescriptor sidecar;
|
||||
sidecar.name = "Settings Cloud Plugin";
|
||||
sidecar.installed_version = "1.0";
|
||||
sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
|
||||
REQUIRE(write_install_state(plugin_dir, sidecar));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
const auto find_by_uuid = [&manager, &uuid]() -> PluginDescriptor {
|
||||
for (const PluginDescriptor& d : manager.get_plugin_descriptors(/*include_invalid=*/true))
|
||||
if (d.cloud_uuid() == uuid)
|
||||
return d;
|
||||
return {};
|
||||
};
|
||||
|
||||
// Discovery parsed the header settings (premise).
|
||||
PluginDescriptor discovered = find_by_uuid();
|
||||
REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1);
|
||||
CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0");
|
||||
|
||||
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has
|
||||
// no settings, no local paths.
|
||||
PluginDescriptor cloud_record;
|
||||
cloud_record.name = "Settings Cloud Plugin";
|
||||
cloud_record.plugin_key = uuid;
|
||||
cloud_record.version = "1.1";
|
||||
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
|
||||
manager.update_cloud_metadata({cloud_record});
|
||||
|
||||
const PluginDescriptor refreshed = find_by_uuid();
|
||||
// Cloud metadata landed...
|
||||
CHECK(refreshed.version == "1.1");
|
||||
// ...and the locally-parsed settings survived the merge.
|
||||
REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1);
|
||||
CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0");
|
||||
CHECK(refreshed.settings.count("taper_per_mm") == 1);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
@@ -113,7 +113,7 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
|
||||
CHECK(state.installed_version == "1.2.0");
|
||||
|
||||
// Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still
|
||||
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_catalog compare
|
||||
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare
|
||||
// the cloud's latest version against the installed version instead of the stale header, so an
|
||||
// already-updated plugin no longer looks perpetually out of date.
|
||||
PluginDescriptor scanned;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
@@ -723,7 +723,7 @@ TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[Plugin
|
||||
installed.plugin_root = (data_dir_guard.plugins_dir() / "_subscribed" / "user" / installed.plugin_key).string();
|
||||
installed.cloud = CloudPluginState{installed.plugin_key, /*installed=*/true, false, false, false};
|
||||
|
||||
manager.update_cloud_catalog({available, installed});
|
||||
manager.update_cloud_metadata({available, installed});
|
||||
|
||||
const auto has_key = [&manager](const std::string& key) {
|
||||
PluginDescriptor descriptor;
|
||||
@@ -737,7 +737,7 @@ TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[Plugin
|
||||
// Sign out. The per-user _subscribed directory stops being scanned, so both cloud rows are now
|
||||
// stale and must go — not just the one with nothing installed behind it.
|
||||
manager.unload_cloud_plugins();
|
||||
manager.clear_cloud_plugin_catalog();
|
||||
manager.clear_cloud_plugin_metadata();
|
||||
manager.set_cloud_user("");
|
||||
|
||||
CHECK_FALSE(has_key(available.plugin_key));
|
||||
|
||||
160
tests/slic3rutils/test_slicing_pipeline_params.cpp
Normal file
160
tests/slic3rutils/test_slicing_pipeline_params.cpp
Normal file
@@ -0,0 +1,160 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include "fff_print/test_helpers.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
// End-to-end coverage of ctx.params for slicing-pipeline capabilities: discovery parses
|
||||
// [tool.orcaslicer.plugin.settings] from the PEP-723 header, and the real dispatch
|
||||
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) hands it to the plugin.
|
||||
// A break anywhere in that chain makes plugins silently run on their built-in defaults,
|
||||
// which is invisible to the plugin author (Twistify incident, 2026-07-17).
|
||||
|
||||
namespace {
|
||||
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
// canonical(): the plugin audit canonicalizes its allowed roots, so a path through
|
||||
// the macOS /var -> /private/var symlink would be rejected as "outside allowed root".
|
||||
dir = fs::canonical(fs::temp_directory_path()) / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
fs::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
};
|
||||
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
const char* const PARAM_PROBE_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Param Probe"
|
||||
# description = "Echoes ctx.params back to the test"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "slicing-pipeline"
|
||||
#
|
||||
# [tool.orcaslicer.plugin.settings]
|
||||
# alpha = "1.25"
|
||||
# beta = "hello"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class ParamEcho(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self):
|
||||
return "ParamEcho"
|
||||
|
||||
def execute(self, ctx):
|
||||
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
|
||||
return orca.ExecutionResult.success()
|
||||
try:
|
||||
text = repr(sorted(dict(ctx.params).items()))
|
||||
except Exception as e: # what plugins' defaults-fallback code swallows silently
|
||||
text = "params-error: " + repr(e)
|
||||
orca._probe_params = text # read back by the test through pybind
|
||||
return orca.ExecutionResult.success("params probed")
|
||||
|
||||
@orca.plugin
|
||||
class ParamProbePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(ParamEcho)
|
||||
)PY";
|
||||
|
||||
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("slicing-pipeline dispatch delivers PEP-723 settings as ctx.params", "[slicing_pipeline][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("pipeline-params");
|
||||
write_plugin(data_dir_guard, "ParamProbe", PARAM_PROBE_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
manager.load_plugin("ParamProbe", /*skip_deps=*/true, {});
|
||||
REQUIRE(manager.wait_for_plugin_load("ParamProbe", std::chrono::seconds(120), error));
|
||||
INFO("load error: " << error);
|
||||
REQUIRE(manager.is_plugin_loaded("ParamProbe"));
|
||||
|
||||
// The manager serves the header settings for the key the dispatch resolves by.
|
||||
const auto settings = manager.get_plugin_settings("ParamProbe");
|
||||
REQUIRE(settings.count("alpha") == 1);
|
||||
CHECK(settings.at("alpha") == "1.25");
|
||||
|
||||
// Slice with the capability selected, exactly as a preset would reference it.
|
||||
Print print;
|
||||
Model model;
|
||||
auto config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ParamEcho"}));
|
||||
config.set_key_value("plugins", new ConfigOptionStrings({"ParamProbe;;ParamEcho"}));
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
|
||||
std::string observed = "<capability never executed>";
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(static_cast<bool>(gil));
|
||||
pybind11::module_ orca = pybind11::module_::import("orca");
|
||||
if (pybind11::hasattr(orca, "_probe_params"))
|
||||
observed = orca.attr("_probe_params").cast<std::string>();
|
||||
}
|
||||
INFO("ctx.params observed by Python: " << observed);
|
||||
CHECK(observed.find("'alpha', '1.25'") != std::string::npos);
|
||||
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
|
||||
|
||||
manager.unload_plugin("ParamProbe");
|
||||
}
|
||||
Reference in New Issue
Block a user