From c2f88b17be2e89c26b9cebec99023136b802bfac Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 17 Jul 2026 16:09:19 +0800 Subject: [PATCH] Complete the plugin settings removal after the feat/plugin-feature merge The merge kept this branch's PluginConfig design, which deletes PluginDescriptor::settings, get_plugin_settings() and ctx.params, but left references to them behind: the slic3rutils target did not build, and the bindings test still asserted the removed ctx.params attribute. Port the two settings tests onto PluginConfig instead of dropping them. They guard a field bug where a cloud-metadata refresh wiped a plugin's settings and it silently ran on its own defaults, so the equivalent properties are still worth pinning: that a stored config survives the refresh, and that an edited config reaches the plugin through a real dispatch. Also defer PluginsConfigDialog's web commands off the webview script-message callback, as PluginsDialog already does. Its remove_preset_override handler put a modal wxMessageBox on that stack, which is the GTK crash class fixed in b779a7bfed/f2ccbfc8b5 for the sibling dialog. --- sandboxes/orca_twistify_plugin_example_any.py | 6 +- src/slic3r/GUI/PluginsConfigDialog.cpp | 14 +- src/slic3r/GUI/PluginsConfigDialog.hpp | 6 + src/slic3r/GUI/PluginsDialog.cpp | 5 +- src/slic3r/GUI/PostProcessor.cpp | 3 - src/slic3r/plugin/PluginFsUtils.cpp | 5 +- .../SlicingPipelinePluginCapability.cpp | 3 +- tests/slic3rutils/CMakeLists.txt | 2 +- .../test_plugin_cloud_metadata.cpp | 108 ++++++------ .../test_slicing_pipeline_bindings.cpp | 2 +- .../test_slicing_pipeline_config.cpp | 153 +++++++++++++++++ .../test_slicing_pipeline_params.cpp | 160 ------------------ 12 files changed, 236 insertions(+), 231 deletions(-) create mode 100644 tests/slic3rutils/test_slicing_pipeline_config.cpp delete mode 100644 tests/slic3rutils/test_slicing_pipeline_params.cpp diff --git a/sandboxes/orca_twistify_plugin_example_any.py b/sandboxes/orca_twistify_plugin_example_any.py index 5c43e3a147..4714af536b 100644 --- a/sandboxes/orca_twistify_plugin_example_any.py +++ b/sandboxes/orca_twistify_plugin_example_any.py @@ -24,9 +24,9 @@ preview corkscrews and the print keeps correct walls/infill/flow. Because we edit geometry in place, surface types are preserved automatically (no per-surface type carry needed), and no numpy is required -- -rotate/scale/translate are host methods. Parameters come from ctx.params (the -settings table above). The first object layer is untouched (z_rel = 0), so bed -adhesion is unaffected. +rotate/scale/translate are host methods. Parameters come from self.get_config() +(see _params below), which the host seeds from get_default_config(). The first +object layer is untouched (z_rel = 0), so bed adhesion is unaffected. """ import math import json diff --git a/src/slic3r/GUI/PluginsConfigDialog.cpp b/src/slic3r/GUI/PluginsConfigDialog.cpp index 2f36e192e9..0241b47b8c 100644 --- a/src/slic3r/GUI/PluginsConfigDialog.cpp +++ b/src/slic3r/GUI/PluginsConfigDialog.cpp @@ -42,7 +42,7 @@ PluginsConfigDialog::PluginsConfigDialog(wxWindow* parent, Preset::Type type, co wxSize(640, 520)); } -PluginsConfigDialog::~PluginsConfigDialog() = default; +PluginsConfigDialog::~PluginsConfigDialog() { m_alive->store(false, std::memory_order_release); } const Preset* PluginsConfigDialog::current_preset() const { @@ -70,6 +70,18 @@ void PluginsConfigDialog::on_script_message(const nlohmann::json& payload) if (handle_common_script_command(payload)) return; + // Defer command handling out of the webview script-message callback, exactly as PluginsDialog + // does: GTK and macOS deliver it synchronously inside the native webview callback, and window + // work on that stack is the crash class fixed in b779a7bfed/f2ccbfc8b5. remove_preset_override + // puts a modal message box on that stack, which is the same bug. + wxGetApp().CallAfter([this, alive = m_alive, payload]() { + if (alive->load(std::memory_order_acquire)) + handle_web_command(payload); + }); +} + +void PluginsConfigDialog::handle_web_command(const nlohmann::json& payload) +{ const std::string command = payload.value("command", ""); if (command == "request_capabilities") { send_capabilities(); diff --git a/src/slic3r/GUI/PluginsConfigDialog.hpp b/src/slic3r/GUI/PluginsConfigDialog.hpp index 08407faa9a..616fde17ca 100644 --- a/src/slic3r/GUI/PluginsConfigDialog.hpp +++ b/src/slic3r/GUI/PluginsConfigDialog.hpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include namespace Slic3r { namespace GUI { @@ -25,6 +27,8 @@ public: private: void on_script_message(const nlohmann::json& payload) override; + // Runs one web command on a clean main-loop stack; see on_script_message. + void handle_web_command(const nlohmann::json& payload); const Preset* current_preset() const; void send_capabilities(); @@ -41,6 +45,8 @@ private: // Set when the preset's stored text could not be parsed: the rows are shown read-only rather // than silently replacing data we did not understand. std::string m_parse_error; + // Guards the deferred command handlers against the dialog being destroyed while one is queued. + std::shared_ptr> m_alive = std::make_shared>(true); }; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 74911a38b9..9a82791652 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -483,8 +483,9 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) // Defer command handling out of the webview script-message callback: GTK and macOS // deliver it synchronously inside the native webview callback (see ui_create_window // in PluginHostUi.cpp), and window work on that stack is the crash class fixed in - // b779a7bfed/f2ccbfc8b5. Deferring at this single entry point keeps every command - // handler, current and future, off that stack by construction. + // b779a7bfed/f2ccbfc8b5. Deferring here keeps every command handler in THIS dialog off + // that stack; it is not a guarantee for other WebViewHostDialog subclasses, which each + // have to defer for themselves (PluginsConfigDialog does; others still do not). wxGetApp().CallAfter([this, alive = m_alive, payload]() { if (alive->load(std::memory_order_acquire)) handle_web_command(payload); diff --git a/src/slic3r/GUI/PostProcessor.cpp b/src/slic3r/GUI/PostProcessor.cpp index 71363740aa..cd8592c813 100644 --- a/src/slic3r/GUI/PostProcessor.cpp +++ b/src/slic3r/GUI/PostProcessor.cpp @@ -270,9 +270,6 @@ static void run_post_process_plugins(const ConfigOptionStrings& capabilities, ctx.host = host; ctx.output_name = output_name; ctx.full_config = &config; // no live Print here; config_value() reads this - // Hand the plugin its own [tool.orcaslicer.plugin.settings] as ctx.params (same plugin_key the - // capability was resolved by), mirroring the in-pipeline dispatcher in GUI_App.cpp. - const std::string plugin_key = ref.uuid.empty() ? ref.name : ref.uuid; ExecutionResult exec_result; try { diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 5da2707fa8..b0867ef28f 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -467,7 +467,10 @@ bool parse_pep723_toml(const std::string& toml_content, if (trimmed == "[tool.orcaslicer.plugin]") { section = TomlSection::OrcaPlugin; } else if (trimmed == "[tool.orcaslicer.plugin.settings]") { - section = TomlSection::OrcaPluginSettings; // per-plugin params table + // Legacy table, superseded by PluginConfig. Recognized so its keys are ignored + // rather than falling through to Root, where a stray dependencies/requires-python + // key inside it would be parsed as package metadata. + section = TomlSection::OrcaPluginSettings; } else { section = TomlSection::Root; // Unknown section — skip. } diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp index 61c1daae4f..f4569aebba 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp @@ -2,7 +2,6 @@ #include "SlicingPipelinePluginCapabilityTrampoline.hpp" #include "slic3r/plugin/PluginBindingUtils.hpp" // config_value_or_none #include "libslic3r/libslic3r.h" // unscale<>, live SCALING_FACTOR -#include // std::map -> dict for ctx.params namespace py = pybind11; namespace Slic3r { @@ -31,7 +30,7 @@ void SlicingPipelinePluginCapability::RegisterBindings(py::module_& module, py:: // ctx.object are None; instead ctx.gcode_path / ctx.host / ctx.output_name are set and the plugin // edits the file at ctx.gcode_path IN PLACE. May fire more than once per slice (file export and/or // upload each fire once, on separate working copies) and its output is not reflected in the G-code - // preview (the viewer maps the pre-post-process file). ctx.config_value()/ctx.params still work. + // preview (the viewer maps the pre-post-process file). ctx.config_value() still works. .value("psGCodePostProcess", SlicingPipelineStepPlugin::psGCodePostProcess) .export_values(); diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index e185219e48..b4d2dc7745 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -8,7 +8,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_install.cpp test_plugin_lifecycle.cpp test_slicing_pipeline_bindings.cpp - test_slicing_pipeline_params.cpp + test_slicing_pipeline_config.cpp test_plugin_sort.cpp test_plugin_cloud_metadata.cpp ../fff_print/test_helpers.cpp diff --git a/tests/slic3rutils/test_plugin_cloud_metadata.cpp b/tests/slic3rutils/test_plugin_cloud_metadata.cpp index 2fac559c1d..af503afa69 100644 --- a/tests/slic3rutils/test_plugin_cloud_metadata.cpp +++ b/tests/slic3rutils/test_plugin_cloud_metadata.cpp @@ -1,46 +1,27 @@ #include #include +#include #include #include #include #include +#include + +#include "plugin_test_utils.hpp" #include +#include #include #include using namespace Slic3r; namespace fs = boost::filesystem; +using json = nlohmann::json; 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 @@ -55,46 +36,46 @@ struct ScopedManagerShutdown } }; -// 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 +const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script # requires-python = ">=3.12" # # [tool.orcaslicer.plugin] -# name = "Settings Cloud Plugin" +# name = "Config 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]") +// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the +// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so +// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via +// Twistify running with its demo defaults instead of the configured values, 2026-07-17). +// Configuration now lives in PluginConfig, keyed by the capability identity and kept off the +// descriptor entirely, so the merge cannot reach it. This asserts that end to end: a stored +// config survives the same refresh path, while the descriptor fields the refresh owns do update. +// +// The capability need not exist for this to be meaningful: what is pinned is the architectural +// invariant that config never rides on the descriptor again. Anyone reintroducing it there, or +// adding a cloud-refresh path that prunes config, fails here. +TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginCloudMetadata]") { ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last - ScopedDataDir data_dir_guard("cloud-meta-settings"); + ScopedDataDir data_dir_guard("cloud-meta-config"); - // A locally-installed cloud plugin: package .py with a settings header, plus an - // install-state sidecar carrying the cloud identity. + // A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the + // cloud identity. Discovery derives plugin_key from the cloud UUID. const std::string uuid = "11111111-2222-3333-4444-555555555555"; - const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid; + const fs::path plugin_dir = fs::path(get_orca_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; + out << CLOUD_PLUGIN_SOURCE; } PluginDescriptor sidecar; - sidecar.name = "Settings Cloud Plugin"; + sidecar.name = "Config Cloud Plugin"; sidecar.installed_version = "1.0"; sidecar.cloud = CloudPluginState{uuid, true, false, false, false}; REQUIRE(write_install_state(plugin_dir, sidecar)); @@ -109,25 +90,38 @@ TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[P 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"); + const PluginDescriptor discovered = find_by_uuid(); + REQUIRE(discovered.plugin_key == uuid); + REQUIRE(discovered.version == "1.0"); - // A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has - // no settings, no local paths. + // The user has configured the plugin's capability (premise). + const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "Twist", uuid}; + const json configured{{"twist_deg_per_mm", 1.0}, {"taper_per_mm", 0.0}}; + REQUIRE(manager.get_config().store_capability_config(id, configured)); + + // A cloud catalog refresh for the same plugin: the record knows name/version/uuid and knows + // nothing about local config or local paths. PluginDescriptor cloud_record; - cloud_record.name = "Settings Cloud Plugin"; + cloud_record.name = "Config 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}); + // Cloud metadata landed on the descriptor... 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); + CHECK(refreshed.plugin_key == uuid); + CHECK(refreshed.installed_version == "1.0"); + + // ...and the stored config is untouched, both in the live store... + const auto stored = manager.get_config().get_config(id); + REQUIRE(stored); + CHECK(stored->config == configured); + + // ...and on disk, which is what the next run reads back. + PluginConfig reloaded; + reloaded.load(); + REQUIRE(reloaded.has_config(id)); + CHECK(reloaded.get_config(id)->config == configured); } diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index 893e00ab04..8f00b6f31b 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -106,7 +106,7 @@ TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view py::object slicing = orca.attr("slicing"); // Context surface: raw graph entry points + workflow accessors. - for (const char* name : { "print", "object", "params", "config_value", "cancelled", + for (const char* name : { "print", "object", "config_value", "cancelled", "orca_version", "step" }) CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name)); diff --git a/tests/slic3rutils/test_slicing_pipeline_config.cpp b/tests/slic3rutils/test_slicing_pipeline_config.cpp new file mode 100644 index 0000000000..ff8cddcba6 --- /dev/null +++ b/tests/slic3rutils/test_slicing_pipeline_config.cpp @@ -0,0 +1,153 @@ +#include + +#include +#include +#include +#include +#include + +#include "fff_print/test_helpers.hpp" +#include "plugin_test_utils.hpp" + +#include +#include +#include + +#include +#include + +using namespace Slic3r; +using namespace Slic3r::Test; +namespace fs = boost::filesystem; +using json = nlohmann::json; + +// End-to-end coverage of a slicing-pipeline capability reading its own config: the loader seeds the +// store from the capability's get_default_config() hook, and the real dispatch +// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) lets the plugin read back whatever +// the host has stored, through self.get_config(). 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). +// +// Note this is deliberately NOT ctx.config_value(): that reads the slicer's print config, not the +// plugin's own config. + +namespace { + +struct ScopedPluginManager +{ + bool initialized = false; + + ScopedPluginManager() { initialized = PluginManager::instance().initialize(); } + ~ScopedPluginManager() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; + +const char* const CONFIG_PROBE_SOURCE = R"PY(# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Config Probe" +# description = "Echoes its own config back to the test" +# author = "OrcaSlicer" +# version = "1.0" +# type = "slicing-pipeline" +# /// +import json + +import orca + +class ConfigEcho(orca.slicing.SlicingPipelineCapabilityBase): + def get_name(self): + return "ConfigEcho" + + def get_default_config(self): + return {"alpha": "1.25", "beta": "hello"} + + def execute(self, ctx): + if ctx.step != orca.slicing.Step.posSlice or ctx.object is None: + return orca.ExecutionResult.success() + try: + text = repr(sorted(json.loads(self.get_config()).items())) + except Exception as e: # what plugins' defaults-fallback code swallows silently + text = "config-error: " + repr(e) + orca._probe_config = text # read back by the test through pybind + return orca.ExecutionResult.success("config probed") + +@orca.plugin +class ConfigProbePackage(orca.base): + def register_capabilities(self): + orca.register_capability(ConfigEcho) +)PY"; + +fs::path write_plugin(const std::string& stem, const std::string& source) +{ + const fs::path plugin_dir = fs::path(get_orca_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 the stored config to self.get_config()", "[slicing_pipeline][PluginConfig][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("pipeline-config"); + write_plugin("ConfigProbe", CONFIG_PROBE_SOURCE); + + PluginManager& manager = PluginManager::instance(); + manager.get_config().load(); // reset the singleton's store against the empty temp data dir + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + manager.load_plugin("ConfigProbe", /*skip_deps=*/true, {}); + REQUIRE(manager.wait_for_plugin_load("ConfigProbe", std::chrono::seconds(120), error)); + INFO("load error: " << error); + REQUIRE(manager.is_plugin_loaded("ConfigProbe")); + + const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "ConfigEcho", "ConfigProbe"}; + + // Loading seeds the store from the capability's get_default_config() hook, so a plugin has a + // config before anyone has opened the Config tab. + const auto seeded = manager.get_config().get_config(id); + REQUIRE(seeded); + CHECK(seeded->config == json({{"alpha", "1.25"}, {"beta", "hello"}})); + + // What editing the config in the Config tab does: the value the plugin must actually run on. + REQUIRE(manager.get_config().store_capability_config(id, json({{"alpha", "9.5"}, {"beta", "hello"}}))); + + // 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({"ConfigEcho"})); + config.set_key_value("plugins", new ConfigOptionStrings({"ConfigProbe;;ConfigEcho"})); + init_print({cube(20)}, print, model, config); + print.process(); + + std::string observed = ""; + { + PythonGILState gil; + REQUIRE(static_cast(gil)); + pybind11::module_ orca = pybind11::module_::import("orca"); + if (pybind11::hasattr(orca, "_probe_config")) + observed = orca.attr("_probe_config").cast(); + } + INFO("config observed by Python: " << observed); + // The edited value arrived, not the seeded default: the host's store is what reaches the plugin. + CHECK(observed.find("'alpha', '9.5'") != std::string::npos); + CHECK(observed.find("'beta', 'hello'") != std::string::npos); + CHECK(observed.find("1.25") == std::string::npos); + + manager.unload_plugin("ConfigProbe"); +} diff --git a/tests/slic3rutils/test_slicing_pipeline_params.cpp b/tests/slic3rutils/test_slicing_pipeline_params.cpp deleted file mode 100644 index 808baba48d..0000000000 --- a/tests/slic3rutils/test_slicing_pipeline_params.cpp +++ /dev/null @@ -1,160 +0,0 @@ -#include - -#include -#include -#include -#include - -#include "fff_print/test_helpers.hpp" - -#include -#include - -#include -#include - -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 = ""; - { - PythonGILState gil; - REQUIRE(static_cast(gil)); - pybind11::module_ orca = pybind11::module_::import("orca"); - if (pybind11::hasattr(orca, "_probe_params")) - observed = orca.attr("_probe_params").cast(); - } - 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"); -}