-
-
Notifications
You must be signed in to change notification settings - Fork 185
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #890 from Ultimaker/CURA-10722_plugins_can_has_set…
…tings [CURA-10722] Add 'additional settings providers' (Engine Plugins)
- Loading branch information
Showing
6 changed files
with
296 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
# Copyright (c) 2023 UltiMaker. | ||
# Uranium is released under the terms of the LGPLv3 or higher. | ||
|
||
import json | ||
from pathlib import Path | ||
import os.path | ||
from typing import Any, Dict, List | ||
|
||
from UM.Logger import Logger | ||
from UM.PluginObject import PluginObject | ||
from UM.Settings.SettingDefinition import DefinitionPropertyType, SettingDefinition | ||
|
||
|
||
class AdditionalSettingDefinitionsAppender(PluginObject): | ||
""" | ||
This class is a way for plugins to append additional settings, not defined by/for the main program itself. | ||
Each plugin needs to register as either a 'setting_definitions_appender' or 'backend_plugin'. | ||
Any implementation also needs to fill 'self.definition_file_paths' with a list of files with setting definitions. | ||
Each file should be a json list of setting categories, either matching existing names, or be a new category. | ||
Each category and setting has the same json structure as the main settings otherwise. | ||
It's also possible to set the 'self.appender_type', if there are many kinds of plugins to implement this, | ||
in order to prevent name-clashes. | ||
Lastly, if setting-definitions are to be made on the fly by the plugin, override 'getAdditionalSettingDefinitions', | ||
instead of providing the files. This should then return a dict, as if parsed by json. | ||
""" | ||
|
||
def __init__(self) -> None: | ||
super().__init__() | ||
self.appender_type = "PLUGIN" | ||
self.definition_file_paths: List[Path] = [] | ||
|
||
def getAppenderType(self) -> str: | ||
""" | ||
Return an extra identifier prepended to the setting internal id, to prevent name-clashes with other plugins. | ||
""" | ||
return self.appender_type | ||
|
||
def getAdditionalSettingDefinitions(self) -> Dict[str, Dict[str, Any]]: | ||
""" | ||
Return the settings added by this plugin in json format. | ||
Put values in self.definition_file_paths if you wish to load from files, or override this function otherwise. | ||
The settings should be divided by category (either existing or new ones). | ||
Settings in existing categories will be appended, new categories will be created. | ||
Setting names (not labels) will be post-processed ('mangled') internally to prevent name-clashes. | ||
NOTE: The 'mangled' names are also the ones send out to any backend! | ||
(See the _prependIdToSettings function below for a more precise explanation.) | ||
:return: Dictionary of settings-categories, containing settings-definitions (with post-processed names). | ||
""" | ||
result = {} | ||
for path in self.definition_file_paths: | ||
if not os.path.exists(path): | ||
Logger.error(f"File {path} with additional settings for '{self.getId()}' doesn't exist.") | ||
continue | ||
try: | ||
with open(path, "r", encoding = "utf-8") as definitions_file: | ||
result.update(json.load(definitions_file)) | ||
except OSError as oex: | ||
Logger.error(f"Could not read additional settings file for '{self.getId()}' because: {str(oex)}") | ||
continue | ||
except json.JSONDecodeError as jex: | ||
Logger.error(f"Could not parse additional settings provided by '{self.getId()}' because: {str(jex)}") | ||
continue | ||
return self._prependIdToSettings(result) | ||
|
||
def _prependIdToSettings(self, settings: Dict[str, Any]) -> Dict[str, Any]: | ||
""" This takes the whole (extra) settings-map as defined by the provider, and returns a tag-renamed version. | ||
Additional (appended) settings will need to be prepended with (an) extra identifier(s)/namespaces to not collide. | ||
This is done for when there are multiple additional settings appenders that might not know about each other. | ||
This includes any formulas, which will also be included in the renaming process. | ||
Appended settings may not be the same as 'baseline' (so any 'non-appended' settings) settings. | ||
(But may of course clash between different providers and versions, that's the whole point of this function...) | ||
Furthermore, it's assumed that formulas within the appended settings will only use settings either; | ||
- as defined within the baseline, or; | ||
- any other settings defined _by the provider itself_. | ||
For each key that is renamed, this results in a mapping <key> -> _<provider_type>__<id*>__<version>__<key> | ||
where '<id*>' is the version of the provider, but converted from using points to using underscores. | ||
Example: 'tapdance_factor' might become '_plugin__dancingprinter__1_2_99__tapdance_factor' | ||
Also note that all the tag_... parameters will be forced to lower-case. | ||
:param tag_type: Type of the additional settings appender, for example; "PLUGIN". | ||
:param tag_id: ID of the provider. Should be unique. | ||
:param tag_version: Version of the provider. Points will be replaced by underscores. | ||
:param settings: The settings as originally provided. | ||
:returns: Remapped settings, where each settings-name is properly tagged/'namespaced'. | ||
""" | ||
tag_type = self.getAppenderType().lower() | ||
tag_id = self.getId().lower() | ||
tag_version = self.getVersion().lower().replace(".", "_") | ||
|
||
# First get the mapping, so that both the 'headings' and formula's can be renamed at the same time later. | ||
def _getMapping(values: Dict[str, Any]) -> Dict[str, str]: | ||
result = {} | ||
for key, value in values.items(): | ||
mapped_key = key | ||
if isinstance(value, dict): | ||
if "type" in value and value["type"] != "category": | ||
mapped_key = f"_{tag_type}__{tag_id}__{tag_version}__{key}" | ||
result.update(_getMapping(value)) | ||
result[key] = mapped_key | ||
return result | ||
|
||
key_map = _getMapping(settings) | ||
|
||
# Get all values that can be functions, so it's known where to replace. | ||
function_type_names = set(SettingDefinition.getPropertyNames(DefinitionPropertyType.Function)) | ||
|
||
# Replace all, both as key-names and their use in formulas. | ||
def _doReplace(values: Dict[str, Any]) -> Dict[str, str]: | ||
result = {} | ||
for key, value in values.items(): | ||
if key in function_type_names and isinstance(value, str): | ||
# Replace key-names in the specified settings-function. | ||
for original, mapped in key_map.items(): | ||
value = value.replace(original, mapped) | ||
elif isinstance(value, dict): | ||
# Replace key-name 'heading'. | ||
key = key_map.get(key, key) | ||
value = _doReplace(value) | ||
result[key] = value | ||
return result | ||
|
||
return _doReplace(settings) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
# Copyright (c) 2023 UltiMaker | ||
# Uranium is released under the terms of the LGPLv3 or higher. | ||
|
||
import os | ||
import pytest | ||
from unittest.mock import MagicMock, patch | ||
|
||
from UM.Settings.AdditionalSettingDefinitionAppender import AdditionalSettingDefinitionsAppender | ||
from UM.Settings.DefinitionContainer import DefinitionContainer | ||
from UM.VersionUpgradeManager import VersionUpgradeManager | ||
|
||
|
||
class PluginTestClass(AdditionalSettingDefinitionsAppender): | ||
def __init__(self) -> None: | ||
super().__init__() | ||
self._plugin_id = "RealityPerforator" | ||
self._version = "7.8.9" | ||
self.appender_type = "CLOWNS" | ||
self.definition_file_paths = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "additional_settings", "append_extra_settings.def.json")] | ||
|
||
|
||
def test_AdditionalSettingNames(): | ||
plugin = PluginTestClass() | ||
settings = plugin.getAdditionalSettingDefinitions() | ||
|
||
assert "test_setting" in settings | ||
assert "category_too" in settings | ||
assert "children" in settings["test_setting"] | ||
assert "children" in settings["category_too"] | ||
|
||
assert "_clowns__realityperforator__7_8_9__glombump" in settings["test_setting"]["children"] | ||
assert "_clowns__realityperforator__7_8_9__zharbler" in settings["category_too"]["children"] | ||
|
||
|
||
def test_AdditionalSettingContainer(upgrade_manager: VersionUpgradeManager): | ||
plugin = PluginTestClass() | ||
settings = plugin.getAdditionalSettingDefinitions() | ||
|
||
definition_container = DefinitionContainer("TheSunIsADeadlyLazer") | ||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "definitions", "children.def.json"), encoding = "utf-8") as data: | ||
definition_container.deserialize(data.read()) | ||
definition_container.appendAdditionalSettingDefinitions(settings) | ||
|
||
# 'merged' setting-categories should definitely be in the relevant container (as well as the original ones): | ||
assert len(definition_container.findDefinitions(key="test_setting")) == 1 | ||
kid_keys = [x.key for x in definition_container.findDefinitions(key="test_setting")[0].children] | ||
assert "test_child_0" in kid_keys | ||
assert "test_child_1" in kid_keys | ||
assert "_clowns__realityperforator__7_8_9__glombump" in kid_keys | ||
|
||
# other settings (from new categories) are added 'dry' to the container: | ||
assert "_clowns__realityperforator__7_8_9__zharbler" in definition_container.getAllKeys() |
44 changes: 44 additions & 0 deletions
44
tests/Settings/additional_settings/append_extra_settings.def.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
{ | ||
"test_setting": | ||
{ | ||
"children": | ||
{ | ||
"glombump": | ||
{ | ||
"label": "Snosmozea", | ||
"description": "Sudoriferous.", | ||
"unit": "mm/s", | ||
"type": "float", | ||
"minimum_value": "0.1", | ||
"maximum_value_warning": "150", | ||
"maximum_value": "500", | ||
"default_value": 60, | ||
"settable_per_mesh": true | ||
} | ||
} | ||
}, | ||
"category_too": | ||
{ | ||
"label": "Warble!", | ||
"type": "category", | ||
"description": "Blomblimg", | ||
"icon": "Printer", | ||
"children": | ||
{ | ||
"zharbler": | ||
{ | ||
"label": "Frumg", | ||
"description": "Pleonastic Pellerator.", | ||
"unit": "mm/s", | ||
"type": "float", | ||
"minimum_value": "0.1", | ||
"maximum_value_warning": "150", | ||
"maximum_value": "500", | ||
"default_value": 60, | ||
"value": "glombump * 1.2", | ||
"settable_per_mesh": false | ||
} | ||
} | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters