mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-18 00:12:09 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
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<std::atomic<bool>> m_alive = std::make_shared<std::atomic<bool>>(true);
|
||||
};
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 <pybind11/stl.h> // std::map<std::string,std::string> -> 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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,46 +1,27 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
153
tests/slic3rutils/test_slicing_pipeline_config.cpp
Normal file
153
tests/slic3rutils/test_slicing_pipeline_config.cpp
Normal file
@@ -0,0 +1,153 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "fff_print/test_helpers.hpp"
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
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 = "<capability never executed>";
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(static_cast<bool>(gil));
|
||||
pybind11::module_ orca = pybind11::module_::import("orca");
|
||||
if (pybind11::hasattr(orca, "_probe_config"))
|
||||
observed = orca.attr("_probe_config").cast<std::string>();
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
#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