1# Copyright (c) 2019 Ultimaker B.V.
2# Cura is released under the terms of the LGPLv3 or higher.
3
4import os
5import re
6import configparser
7
8from typing import Any, cast, Dict, Optional, List, Union, Tuple
9from PyQt5.QtWidgets import QMessageBox
10
11from UM.Decorators import override
12from UM.Settings.ContainerFormatError import ContainerFormatError
13from UM.Settings.Interfaces import ContainerInterface
14from UM.Settings.ContainerRegistry import ContainerRegistry
15from UM.Settings.ContainerStack import ContainerStack
16from UM.Settings.InstanceContainer import InstanceContainer
17from UM.Settings.SettingInstance import SettingInstance
18from UM.Logger import Logger
19from UM.Message import Message
20from UM.Platform import Platform
21from UM.PluginRegistry import PluginRegistry  # For getting the possible profile writers to write with.
22from UM.Resources import Resources
23from UM.Util import parseBool
24from cura.ReaderWriters.ProfileWriter import ProfileWriter
25
26from . import ExtruderStack
27from . import GlobalStack
28
29import cura.CuraApplication
30from cura.Settings.cura_empty_instance_containers import empty_quality_container
31from cura.Machines.ContainerTree import ContainerTree
32from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader
33
34from UM.i18n import i18nCatalog
35catalog = i18nCatalog("cura")
36
37
38class CuraContainerRegistry(ContainerRegistry):
39    def __init__(self, *args, **kwargs):
40        super().__init__(*args, **kwargs)
41
42        # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack
43        # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack
44        # is added, we check to see if an extruder stack needs to be added.
45        self.containerAdded.connect(self._onContainerAdded)
46
47    @override(ContainerRegistry)
48    def addContainer(self, container: ContainerInterface) -> bool:
49        """Overridden from ContainerRegistry
50
51        Adds a container to the registry.
52
53        This will also try to convert a ContainerStack to either Extruder or
54        Global stack based on metadata information.
55        """
56
57        # Note: Intentional check with type() because we want to ignore subclasses
58        if type(container) == ContainerStack:
59            container = self._convertContainerStack(cast(ContainerStack, container))
60
61        if isinstance(container, InstanceContainer) and type(container) != type(self.getEmptyInstanceContainer()):
62            # Check against setting version of the definition.
63            required_setting_version = cura.CuraApplication.CuraApplication.SettingVersion
64            actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0))
65            if required_setting_version != actual_setting_version:
66                Logger.log("w", "Instance container {container_id} is outdated. Its setting version is {actual_setting_version} but it should be {required_setting_version}.".format(container_id = container.getId(), actual_setting_version = actual_setting_version, required_setting_version = required_setting_version))
67                return False  # Don't add.
68
69        return super().addContainer(container)
70
71    def createUniqueName(self, container_type: str, current_name: str, new_name: str, fallback_name: str) -> str:
72        """Create a name that is not empty and unique
73
74        :param container_type: :type{string} Type of the container (machine, quality, ...)
75        :param current_name: :type{} Current name of the container, which may be an acceptable option
76        :param new_name: :type{string} Base name, which may not be unique
77        :param fallback_name: :type{string} Name to use when (stripped) new_name is empty
78        :return: :type{string} Name that is unique for the specified type and name/id
79        """
80        new_name = new_name.strip()
81        num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name)
82        if num_check:
83            new_name = num_check.group(1)
84        if new_name == "":
85            new_name = fallback_name
86
87        unique_name = new_name
88        i = 1
89        # In case we are renaming, the current name of the container is also a valid end-result
90        while self._containerExists(container_type, unique_name) and unique_name != current_name:
91            i += 1
92            unique_name = "%s #%d" % (new_name, i)
93
94        return unique_name
95
96    def _containerExists(self, container_type: str, container_name: str):
97        """Check if a container with of a certain type and a certain name or id exists
98
99        Both the id and the name are checked, because they may not be the same and it is better if they are both unique
100        :param container_type: :type{string} Type of the container (machine, quality, ...)
101        :param container_name: :type{string} Name to check
102        """
103        container_class = ContainerStack if container_type == "machine" else InstanceContainer
104
105        return self.findContainersMetadata(container_type = container_class, id = container_name, type = container_type, ignore_case = True) or \
106                self.findContainersMetadata(container_type = container_class, name = container_name, type = container_type)
107
108    def exportQualityProfile(self, container_list: List[InstanceContainer], file_name: str, file_type: str) -> bool:
109        """Exports an profile to a file
110
111        :param container_list: :type{list} the containers to export. This is not
112        necessarily in any order!
113        :param file_name: :type{str} the full path and filename to export to.
114        :param file_type: :type{str} the file type with the format "<description> (*.<extension>)"
115        :return: True if the export succeeded, false otherwise.
116        """
117
118        # Parse the fileType to deduce what plugin can save the file format.
119        # fileType has the format "<description> (*.<extension>)"
120        split = file_type.rfind(" (*.")  # Find where the description ends and the extension starts.
121        if split < 0:  # Not found. Invalid format.
122            Logger.log("e", "Invalid file format identifier %s", file_type)
123            return False
124        description = file_type[:split]
125        extension = file_type[split + 4:-1]  # Leave out the " (*." and ")".
126        if not file_name.endswith("." + extension):  # Auto-fill the extension if the user did not provide any.
127            file_name += "." + extension
128
129        # On Windows, QML FileDialog properly asks for overwrite confirm, but not on other platforms, so handle those ourself.
130        if not Platform.isWindows():
131            if os.path.exists(file_name):
132                result = QMessageBox.question(None, catalog.i18nc("@title:window", "File Already Exists"),
133                                              catalog.i18nc("@label Don't translate the XML tag <filename>!", "The file <filename>{0}</filename> already exists. Are you sure you want to overwrite it?").format(file_name))
134                if result == QMessageBox.No:
135                    return False
136
137        profile_writer = self._findProfileWriter(extension, description)
138        try:
139            if profile_writer is None:
140                raise Exception("Unable to find a profile writer")
141            success = profile_writer.write(file_name, container_list)
142        except Exception as e:
143            Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e))
144            m = Message(catalog.i18nc("@info:status Don't translate the XML tags <filename> or <message>!", "Failed to export profile to <filename>{0}</filename>: <message>{1}</message>", file_name, str(e)),
145                        lifetime = 0,
146                        title = catalog.i18nc("@info:title", "Error"))
147            m.show()
148            return False
149        if not success:
150            Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name)
151            m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Failed to export profile to <filename>{0}</filename>: Writer plugin reported failure.", file_name),
152                        lifetime = 0,
153                        title = catalog.i18nc("@info:title", "Error"))
154            m.show()
155            return False
156        m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name),
157                    title = catalog.i18nc("@info:title", "Export succeeded"))
158        m.show()
159        return True
160
161    def _findProfileWriter(self, extension: str, description: str) -> Optional[ProfileWriter]:
162        """Gets the plugin object matching the criteria
163
164        :param extension:
165        :param description:
166        :return: The plugin object matching the given extension and description.
167        """
168        plugin_registry = PluginRegistry.getInstance()
169        for plugin_id, meta_data in self._getIOPlugins("profile_writer"):
170            for supported_type in meta_data["profile_writer"]:  # All file types this plugin can supposedly write.
171                supported_extension = supported_type.get("extension", None)
172                if supported_extension == extension:  # This plugin supports a file type with the same extension.
173                    supported_description = supported_type.get("description", None)
174                    if supported_description == description:  # The description is also identical. Assume it's the same file type.
175                        return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id))
176        return None
177
178    def importProfile(self, file_name: str) -> Dict[str, str]:
179        """Imports a profile from a file
180
181        :param file_name: The full path and filename of the profile to import.
182        :return: Dict with a 'status' key containing the string 'ok', 'warning' or 'error',
183            and a 'message' key containing a message for the user.
184        """
185
186        Logger.log("d", "Attempting to import profile %s", file_name)
187        if not file_name:
188            return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>: {1}", file_name, "Invalid path")}
189
190        global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
191        if not global_stack:
192            return {"status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Can't import profile from <filename>{0}</filename> before a printer is added.", file_name)}
193        container_tree = ContainerTree.getInstance()
194
195        machine_extruders = global_stack.extruderList
196
197        plugin_registry = PluginRegistry.getInstance()
198        extension = file_name.split(".")[-1]
199
200        for plugin_id, meta_data in self._getIOPlugins("profile_reader"):
201            if meta_data["profile_reader"][0]["extension"] != extension:
202                continue
203            profile_reader = cast(ProfileReader, plugin_registry.getPluginObject(plugin_id))
204            try:
205                profile_or_list = profile_reader.read(file_name)  # Try to open the file with the profile reader.
206            except NoProfileException:
207                return { "status": "ok", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "No custom profile to import in file <filename>{0}</filename>", file_name)}
208            except Exception as e:
209                # Note that this will fail quickly. That is, if any profile reader throws an exception, it will stop reading. It will only continue reading if the reader returned None.
210                Logger.log("e", "Failed to import profile from %s: %s while using profile reader. Got exception %s", file_name, profile_reader.getPluginId(), str(e))
211                return { "status": "error", "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "Failed to import profile from <filename>{0}</filename>:", file_name) + "\n<message>" + str(e) + "</message>"}
212
213            if profile_or_list:
214                # Ensure it is always a list of profiles
215                if not isinstance(profile_or_list, list):
216                    profile_or_list = [profile_or_list]
217
218                # First check if this profile is suitable for this machine
219                global_profile = None
220                extruder_profiles = []
221                if len(profile_or_list) == 1:
222                    global_profile = profile_or_list[0]
223                else:
224                    for profile in profile_or_list:
225                        if not profile.getMetaDataEntry("position"):
226                            global_profile = profile
227                        else:
228                            extruder_profiles.append(profile)
229                extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position", default = "0")))
230                profile_or_list = [global_profile] + extruder_profiles
231
232                if not global_profile:
233                    Logger.log("e", "Incorrect profile [%s]. Could not find global profile", file_name)
234                    return { "status": "error",
235                             "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name)}
236                profile_definition = global_profile.getMetaDataEntry("definition")
237
238                # Make sure we have a profile_definition in the file:
239                if profile_definition is None:
240                    break
241                machine_definitions = self.findContainers(id = profile_definition)
242                if not machine_definitions:
243                    Logger.log("e", "Incorrect profile [%s]. Unknown machine type [%s]", file_name, profile_definition)
244                    return {"status": "error",
245                            "message": catalog.i18nc("@info:status Don't translate the XML tags <filename>!", "This profile <filename>{0}</filename> contains incorrect data, could not import it.", file_name)
246                            }
247                machine_definition = machine_definitions[0]
248
249                # Get the expected machine definition.
250                # i.e.: We expect gcode for a UM2 Extended to be defined as normal UM2 gcode...
251                has_machine_quality = parseBool(machine_definition.getMetaDataEntry("has_machine_quality", "false"))
252                profile_definition = machine_definition.getMetaDataEntry("quality_definition", machine_definition.getId()) if has_machine_quality else "fdmprinter"
253                expected_machine_definition = container_tree.machines[global_stack.definition.getId()].quality_definition
254
255                # And check if the profile_definition matches either one (showing error if not):
256                if profile_definition != expected_machine_definition:
257                    Logger.log("d", "Profile {file_name} is for machine {profile_definition}, but the current active machine is {expected_machine_definition}. Changing profile's definition.".format(file_name = file_name, profile_definition = profile_definition, expected_machine_definition = expected_machine_definition))
258                    global_profile.setMetaDataEntry("definition", expected_machine_definition)
259                    for extruder_profile in extruder_profiles:
260                        extruder_profile.setMetaDataEntry("definition", expected_machine_definition)
261
262                quality_name = global_profile.getName()
263                quality_type = global_profile.getMetaDataEntry("quality_type")
264
265                name_seed = os.path.splitext(os.path.basename(file_name))[0]
266                new_name = self.uniqueName(name_seed)
267
268                # Ensure it is always a list of profiles
269                if type(profile_or_list) is not list:
270                    profile_or_list = [profile_or_list]
271
272                # Make sure that there are also extruder stacks' quality_changes, not just one for the global stack
273                if len(profile_or_list) == 1:
274                    global_profile = profile_or_list[0]
275                    extruder_profiles = []
276                    for idx, extruder in enumerate(global_stack.extruderList):
277                        profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1))
278                        profile = InstanceContainer(profile_id)
279                        profile.setName(quality_name)
280                        profile.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion)
281                        profile.setMetaDataEntry("type", "quality_changes")
282                        profile.setMetaDataEntry("definition", expected_machine_definition)
283                        profile.setMetaDataEntry("quality_type", quality_type)
284                        profile.setDirty(True)
285                        if idx == 0:
286                            # Move all per-extruder settings to the first extruder's quality_changes
287                            for qc_setting_key in global_profile.getAllKeys():
288                                settable_per_extruder = global_stack.getProperty(qc_setting_key, "settable_per_extruder")
289                                if settable_per_extruder:
290                                    setting_value = global_profile.getProperty(qc_setting_key, "value")
291
292                                    setting_definition = global_stack.getSettingDefinition(qc_setting_key)
293                                    if setting_definition is not None:
294                                        new_instance = SettingInstance(setting_definition, profile)
295                                        new_instance.setProperty("value", setting_value)
296                                        new_instance.resetState()  # Ensure that the state is not seen as a user state.
297                                        profile.addInstance(new_instance)
298                                        profile.setDirty(True)
299
300                                    global_profile.removeInstance(qc_setting_key, postpone_emit = True)
301                        extruder_profiles.append(profile)
302
303                    for profile in extruder_profiles:
304                        profile_or_list.append(profile)
305
306                # Import all profiles
307                profile_ids_added = []  # type: List[str]
308                additional_message = None
309                for profile_index, profile in enumerate(profile_or_list):
310                    if profile_index == 0:
311                        # This is assumed to be the global profile
312                        profile_id = (cast(ContainerInterface, global_stack.getBottom()).getId() + "_" + name_seed).lower().replace(" ", "_")
313
314                    elif profile_index < len(machine_extruders) + 1:
315                        # This is assumed to be an extruder profile
316                        extruder_id = machine_extruders[profile_index - 1].definition.getId()
317                        extruder_position = str(profile_index - 1)
318                        if not profile.getMetaDataEntry("position"):
319                            profile.setMetaDataEntry("position", extruder_position)
320                        else:
321                            profile.setMetaDataEntry("position", extruder_position)
322                        profile_id = (extruder_id + "_" + name_seed).lower().replace(" ", "_")
323
324                    else:  # More extruders in the imported file than in the machine.
325                        continue  # Delete the additional profiles.
326
327                    configuration_successful, message = self._configureProfile(profile, profile_id, new_name, expected_machine_definition)
328                    if configuration_successful:
329                        additional_message = message
330                    else:
331                        # Remove any profiles that were added.
332                        for profile_id in profile_ids_added + [profile.getId()]:
333                            self.removeContainer(profile_id)
334                        if not message:
335                            message = ""
336                        return {"status": "error", "message": catalog.i18nc(
337                                "@info:status Don't translate the XML tag <filename>!",
338                                "Failed to import profile from <filename>{0}</filename>:",
339                                file_name) + " " + message}
340                    profile_ids_added.append(profile.getId())
341                result_status = "ok"
342                success_message = catalog.i18nc("@info:status", "Successfully imported profile {0}.", profile_or_list[0].getName())
343                if additional_message:
344                    result_status = "warning"
345                    success_message += additional_message
346                return {"status": result_status, "message": success_message}
347
348            # This message is throw when the profile reader doesn't find any profile in the file
349            return {"status": "error", "message": catalog.i18nc("@info:status", "File {0} does not contain any valid profile.", file_name)}
350
351        # If it hasn't returned by now, none of the plugins loaded the profile successfully.
352        return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type or is corrupted.", file_name)}
353
354    @override(ContainerRegistry)
355    def load(self) -> None:
356        super().load()
357        self._registerSingleExtrusionMachinesExtruderStacks()
358        self._connectUpgradedExtruderStacksToMachines()
359
360    @override(ContainerRegistry)
361    def loadAllMetadata(self) -> None:
362        super().loadAllMetadata()
363        self._cleanUpInvalidQualityChanges()
364
365    def _cleanUpInvalidQualityChanges(self) -> None:
366        # We've seen cases where it was possible for quality_changes to be incorrectly added. This is to ensure that
367        # any such leftovers are purged from the registry.
368        quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type="quality_changes")
369
370        profile_count_by_name = {}  # type: Dict[str, int]
371
372        for quality_change in quality_changes:
373            name = str(quality_change.get("name", ""))
374            if name == "empty":
375                continue
376            if name not in profile_count_by_name:
377                profile_count_by_name[name] = 0
378            profile_count_by_name[name] += 1
379
380        for profile_name, profile_count in profile_count_by_name.items():
381            if profile_count > 1:
382                continue
383            # Only one profile found, this should not ever be the case, so that profile needs to be removed!
384            Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
385            invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
386            self.removeContainer(invalid_quality_changes[0]["id"])
387
388    @override(ContainerRegistry)
389    def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool:
390        """Check if the metadata for a container is okay before adding it.
391
392        This overrides the one from UM.Settings.ContainerRegistry because we
393        also require that the setting_version is correct.
394        """
395
396        if metadata is None:
397            return False
398        if "setting_version" not in metadata:
399            return False
400        try:
401            if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion:
402                return False
403        except ValueError: #Not parsable as int.
404            return False
405        return True
406
407    def _configureProfile(self, profile: InstanceContainer, id_seed: str, new_name: str, machine_definition_id: str) -> Tuple[bool, Optional[str]]:
408        """Update an imported profile to match the current machine configuration.
409
410        :param profile: The profile to configure.
411        :param id_seed: The base ID for the profile. May be changed so it does not conflict with existing containers.
412        :param new_name: The new name for the profile.
413
414        :returns: tuple (configuration_successful, message)
415                WHERE
416                bool configuration_successful: Whether the process of configuring the profile was successful
417                optional str message: A message indicating the outcome of configuring the profile. If the configuration
418                                      is successful, this message can be None or contain a warning
419        """
420
421        profile.setDirty(True)  # Ensure the profiles are correctly saved
422
423        new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile"))
424        profile.setMetaDataEntry("id", new_id)
425        profile.setName(new_name)
426
427        # Set the unique Id to the profile, so it's generating a new one even if the user imports the same profile
428        # It also solves an issue with importing profiles from G-Codes
429        profile.setMetaDataEntry("id", new_id)
430        profile.setMetaDataEntry("definition", machine_definition_id)
431
432        if "type" in profile.getMetaData():
433            profile.setMetaDataEntry("type", "quality_changes")
434        else:
435            profile.setMetaDataEntry("type", "quality_changes")
436
437        quality_type = profile.getMetaDataEntry("quality_type")
438        if not quality_type:
439            return False, catalog.i18nc("@info:status", "Profile is missing a quality type.")
440
441        global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
442        if not global_stack:
443            return False, catalog.i18nc("@info:status", "Global stack is missing.")
444
445        definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition
446        profile.setDefinition(definition_id)
447
448        if not self.addContainer(profile):
449            return False, catalog.i18nc("@info:status", "Unable to add the profile.")
450
451        # "not_supported" profiles can be imported.
452        if quality_type == empty_quality_container.getMetaDataEntry("quality_type"):
453            return True, None
454
455        # Check to make sure the imported profile actually makes sense in context of the current configuration.
456        # This prevents issues where importing a "draft" profile for a machine without "draft" qualities would report as
457        # successfully imported but then fail to show up.
458        available_quality_groups_dict = {name: quality_group for name, quality_group in ContainerTree.getInstance().getCurrentQualityGroups().items() if quality_group.is_available}
459        all_quality_groups_dict = ContainerTree.getInstance().getCurrentQualityGroups()
460
461        # If the quality type doesn't exist at all in the quality_groups of this machine, reject the profile
462        if quality_type not in all_quality_groups_dict:
463            return False, catalog.i18nc("@info:status", "Quality type '{0}' is not compatible with the current active machine definition '{1}'.", quality_type, definition_id)
464
465        # If the quality_type exists in the quality_groups of this printer but it is not available with the current
466        # machine configuration (e.g. not available for the selected nozzles), accept it with a warning
467        if quality_type not in available_quality_groups_dict:
468            return True, "\n\n" + catalog.i18nc("@info:status", "Warning: The profile is not visible because its quality type '{0}' is not available for the current configuration. "
469                                                                "Switch to a material/nozzle combination that can use this quality type.", quality_type)
470
471        return True, None
472
473    @override(ContainerRegistry)
474    def saveDirtyContainers(self) -> None:
475        # Lock file for "more" atomically loading and saving to/from config dir.
476        with self.lockFile():
477            # Save base files first
478            for instance in self.findDirtyContainers(container_type=InstanceContainer):
479                if instance.getMetaDataEntry("removed"):
480                    continue
481                if instance.getId() == instance.getMetaData().get("base_file"):
482                    self.saveContainer(instance)
483
484            for instance in self.findDirtyContainers(container_type=InstanceContainer):
485                if instance.getMetaDataEntry("removed"):
486                    continue
487                self.saveContainer(instance)
488
489            for stack in self.findContainerStacks():
490                self.saveContainer(stack)
491
492    def _getIOPlugins(self, io_type):
493        """Gets a list of profile writer plugins
494
495        :return: List of tuples of (plugin_id, meta_data).
496        """
497        plugin_registry = PluginRegistry.getInstance()
498        active_plugin_ids = plugin_registry.getActivePlugins()
499
500        result = []
501        for plugin_id in active_plugin_ids:
502            meta_data = plugin_registry.getMetaData(plugin_id)
503            if io_type in meta_data:
504                result.append( (plugin_id, meta_data) )
505        return result
506
507    def _convertContainerStack(self, container: ContainerStack) -> Union[ExtruderStack.ExtruderStack, GlobalStack.GlobalStack]:
508        """Convert an "old-style" pure ContainerStack to either an Extruder or Global stack."""
509
510        assert type(container) == ContainerStack
511
512        container_type = container.getMetaDataEntry("type")
513        if container_type not in ("extruder_train", "machine"):
514            # It is not an extruder or machine, so do nothing with the stack
515            return container
516
517        Logger.log("d", "Converting ContainerStack {stack} to {type}", stack = container.getId(), type = container_type)
518
519        if container_type == "extruder_train":
520            new_stack = ExtruderStack.ExtruderStack(container.getId())
521        else:
522            new_stack = GlobalStack.GlobalStack(container.getId())
523
524        container_contents = container.serialize()
525        new_stack.deserialize(container_contents)
526
527        # Delete the old configuration file so we do not get double stacks
528        if os.path.isfile(container.getPath()):
529            os.remove(container.getPath())
530
531        return new_stack
532
533    def _registerSingleExtrusionMachinesExtruderStacks(self) -> None:
534        machines = self.findContainerStacks(type = "machine", machine_extruder_trains = {"0": "fdmextruder"})
535        for machine in machines:
536            extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = machine.getId())
537            if not extruder_stacks:
538                self.addExtruderStackForSingleExtrusionMachine(machine, "fdmextruder")
539
540    def _onContainerAdded(self, container: ContainerInterface) -> None:
541        # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack
542        # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack
543        # is added, we check to see if an extruder stack needs to be added.
544        if not isinstance(container, ContainerStack) or container.getMetaDataEntry("type") != "machine":
545            return
546
547        machine_extruder_trains = container.getMetaDataEntry("machine_extruder_trains")
548        if machine_extruder_trains is not None and machine_extruder_trains != {"0": "fdmextruder"}:
549            return
550
551        extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = container.getId())
552        if not extruder_stacks:
553            self.addExtruderStackForSingleExtrusionMachine(container, "fdmextruder")
554
555    #
556    # new_global_quality_changes is optional. It is only used in project loading for a scenario like this:
557    #      - override the current machine
558    #      - create new for custom quality profile
559    # new_global_quality_changes is the new global quality changes container in this scenario.
560    # create_new_ids indicates if new unique ids must be created
561    #
562    def addExtruderStackForSingleExtrusionMachine(self, machine, extruder_id, new_global_quality_changes = None, create_new_ids = True):
563        new_extruder_id = extruder_id
564
565        application = cura.CuraApplication.CuraApplication.getInstance()
566
567        extruder_definitions = self.findDefinitionContainers(id = new_extruder_id)
568        if not extruder_definitions:
569            Logger.log("w", "Could not find definition containers for extruder %s", new_extruder_id)
570            return
571
572        extruder_definition = extruder_definitions[0]
573        unique_name = self.uniqueName(machine.getName() + " " + new_extruder_id) if create_new_ids else machine.getName() + " " + new_extruder_id
574
575        extruder_stack = ExtruderStack.ExtruderStack(unique_name)
576        extruder_stack.setName(extruder_definition.getName())
577        extruder_stack.setDefinition(extruder_definition)
578        extruder_stack.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
579
580        # create a new definition_changes container for the extruder stack
581        definition_changes_id = self.uniqueName(extruder_stack.getId() + "_settings") if create_new_ids else extruder_stack.getId() + "_settings"
582        definition_changes_name = definition_changes_id
583        definition_changes = InstanceContainer(definition_changes_id, parent = application)
584        definition_changes.setName(definition_changes_name)
585        definition_changes.setMetaDataEntry("setting_version", application.SettingVersion)
586        definition_changes.setMetaDataEntry("type", "definition_changes")
587        definition_changes.setMetaDataEntry("definition", extruder_definition.getId())
588
589        # move definition_changes settings if exist
590        for setting_key in definition_changes.getAllKeys():
591            if machine.definition.getProperty(setting_key, "settable_per_extruder"):
592                setting_value = machine.definitionChanges.getProperty(setting_key, "value")
593                if setting_value is not None:
594                    # move it to the extruder stack's definition_changes
595                    setting_definition = machine.getSettingDefinition(setting_key)
596                    new_instance = SettingInstance(setting_definition, definition_changes)
597                    new_instance.setProperty("value", setting_value)
598                    new_instance.resetState()  # Ensure that the state is not seen as a user state.
599                    definition_changes.addInstance(new_instance)
600                    definition_changes.setDirty(True)
601
602                    machine.definitionChanges.removeInstance(setting_key, postpone_emit = True)
603
604        self.addContainer(definition_changes)
605        extruder_stack.setDefinitionChanges(definition_changes)
606
607        # create empty user changes container otherwise
608        user_container_id = self.uniqueName(extruder_stack.getId() + "_user") if create_new_ids else extruder_stack.getId() + "_user"
609        user_container_name = user_container_id
610        user_container = InstanceContainer(user_container_id, parent = application)
611        user_container.setName(user_container_name)
612        user_container.setMetaDataEntry("type", "user")
613        user_container.setMetaDataEntry("machine", machine.getId())
614        user_container.setMetaDataEntry("setting_version", application.SettingVersion)
615        user_container.setDefinition(machine.definition.getId())
616        user_container.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position"))
617
618        if machine.userChanges:
619            # For the newly created extruder stack, we need to move all "per-extruder" settings to the user changes
620            # container to the extruder stack.
621            for user_setting_key in machine.userChanges.getAllKeys():
622                settable_per_extruder = machine.getProperty(user_setting_key, "settable_per_extruder")
623                if settable_per_extruder:
624                    setting_value = machine.getProperty(user_setting_key, "value")
625
626                    setting_definition = machine.getSettingDefinition(user_setting_key)
627                    new_instance = SettingInstance(setting_definition, definition_changes)
628                    new_instance.setProperty("value", setting_value)
629                    new_instance.resetState()  # Ensure that the state is not seen as a user state.
630                    user_container.addInstance(new_instance)
631                    user_container.setDirty(True)
632
633                    machine.userChanges.removeInstance(user_setting_key, postpone_emit = True)
634
635        self.addContainer(user_container)
636        extruder_stack.setUserChanges(user_container)
637
638        empty_variant = application.empty_variant_container
639        empty_material = application.empty_material_container
640        empty_quality = application.empty_quality_container
641
642        if machine.variant.getId() not in ("empty", "empty_variant"):
643            variant = machine.variant
644        else:
645            variant = empty_variant
646        extruder_stack.variant = variant
647
648        if machine.material.getId() not in ("empty", "empty_material"):
649            material = machine.material
650        else:
651            material = empty_material
652        extruder_stack.material = material
653
654        if machine.quality.getId() not in ("empty", "empty_quality"):
655            quality = machine.quality
656        else:
657            quality = empty_quality
658        extruder_stack.quality = quality
659
660        machine_quality_changes = machine.qualityChanges
661        if new_global_quality_changes is not None:
662            machine_quality_changes = new_global_quality_changes
663
664        if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"):
665            extruder_quality_changes_container = self.findInstanceContainers(name = machine_quality_changes.getName(), extruder = extruder_id)
666            if extruder_quality_changes_container:
667                extruder_quality_changes_container = extruder_quality_changes_container[0]
668
669                quality_changes_id = extruder_quality_changes_container.getId()
670                extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0]
671            else:
672                # Some extruder quality_changes containers can be created at runtime as files in the qualities
673                # folder. Those files won't be loaded in the registry immediately. So we also need to search
674                # the folder to see if the quality_changes exists.
675                extruder_quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName())
676                if extruder_quality_changes_container:
677                    quality_changes_id = extruder_quality_changes_container.getId()
678                    extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
679                    extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0]
680                else:
681                    # If we still cannot find a quality changes container for the extruder, create a new one
682                    container_name = machine_quality_changes.getName()
683                    container_id = self.uniqueName(extruder_stack.getId() + "_qc_" + container_name)
684                    extruder_quality_changes_container = InstanceContainer(container_id, parent = application)
685                    extruder_quality_changes_container.setName(container_name)
686                    extruder_quality_changes_container.setMetaDataEntry("type", "quality_changes")
687                    extruder_quality_changes_container.setMetaDataEntry("setting_version", application.SettingVersion)
688                    extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
689                    extruder_quality_changes_container.setMetaDataEntry("quality_type", machine_quality_changes.getMetaDataEntry("quality_type"))
690                    extruder_quality_changes_container.setMetaDataEntry("intent_category", "default")  # Intent categories weren't a thing back then.
691                    extruder_quality_changes_container.setDefinition(machine_quality_changes.getDefinition().getId())
692
693                    self.addContainer(extruder_quality_changes_container)
694                    extruder_stack.qualityChanges = extruder_quality_changes_container
695
696            if not extruder_quality_changes_container:
697                Logger.log("w", "Could not find quality_changes named [%s] for extruder [%s]",
698                           machine_quality_changes.getName(), extruder_stack.getId())
699            else:
700                # Move all per-extruder settings to the extruder's quality changes
701                for qc_setting_key in machine_quality_changes.getAllKeys():
702                    settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder")
703                    if settable_per_extruder:
704                        setting_value = machine_quality_changes.getProperty(qc_setting_key, "value")
705
706                        setting_definition = machine.getSettingDefinition(qc_setting_key)
707                        new_instance = SettingInstance(setting_definition, definition_changes)
708                        new_instance.setProperty("value", setting_value)
709                        new_instance.resetState()  # Ensure that the state is not seen as a user state.
710                        extruder_quality_changes_container.addInstance(new_instance)
711                        extruder_quality_changes_container.setDirty(True)
712
713                        machine_quality_changes.removeInstance(qc_setting_key, postpone_emit=True)
714        else:
715            extruder_stack.qualityChanges = self.findInstanceContainers(id = "empty_quality_changes")[0]
716
717        self.addContainer(extruder_stack)
718
719        # Also need to fix the other qualities that are suitable for this machine. Those quality changes may still have
720        # per-extruder settings in the container for the machine instead of the extruder.
721        if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"):
722            quality_changes_machine_definition_id = machine_quality_changes.getDefinition().getId()
723        else:
724            whole_machine_definition = machine.definition
725            machine_entry = machine.definition.getMetaDataEntry("machine")
726            if machine_entry is not None:
727                container_registry = ContainerRegistry.getInstance()
728                whole_machine_definition = container_registry.findDefinitionContainers(id = machine_entry)[0]
729
730            quality_changes_machine_definition_id = "fdmprinter"
731            if whole_machine_definition.getMetaDataEntry("has_machine_quality"):
732                quality_changes_machine_definition_id = machine.definition.getMetaDataEntry("quality_definition",
733                                                                                            whole_machine_definition.getId())
734        qcs = self.findInstanceContainers(type = "quality_changes", definition = quality_changes_machine_definition_id)
735        qc_groups = {}  # map of qc names -> qc containers
736        for qc in qcs:
737            qc_name = qc.getName()
738            if qc_name not in qc_groups:
739                qc_groups[qc_name] = []
740            qc_groups[qc_name].append(qc)
741            # Try to find from the quality changes cura directory too
742            quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName())
743            if quality_changes_container:
744                qc_groups[qc_name].append(quality_changes_container)
745
746        for qc_name, qc_list in qc_groups.items():
747            qc_dict = {"global": None, "extruders": []}
748            for qc in qc_list:
749                extruder_position = qc.getMetaDataEntry("position")
750                if extruder_position is not None:
751                    qc_dict["extruders"].append(qc)
752                else:
753                    qc_dict["global"] = qc
754            if qc_dict["global"] is not None and len(qc_dict["extruders"]) == 1:
755                # Move per-extruder settings
756                for qc_setting_key in qc_dict["global"].getAllKeys():
757                    settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder")
758                    if settable_per_extruder:
759                        setting_value = qc_dict["global"].getProperty(qc_setting_key, "value")
760
761                        setting_definition = machine.getSettingDefinition(qc_setting_key)
762                        new_instance = SettingInstance(setting_definition, definition_changes)
763                        new_instance.setProperty("value", setting_value)
764                        new_instance.resetState()  # Ensure that the state is not seen as a user state.
765                        qc_dict["extruders"][0].addInstance(new_instance)
766                        qc_dict["extruders"][0].setDirty(True)
767
768                        qc_dict["global"].removeInstance(qc_setting_key, postpone_emit=True)
769
770        # Set next stack at the end
771        extruder_stack.setNextStack(machine)
772
773        return extruder_stack
774
775    def _findQualityChangesContainerInCuraFolder(self, name: str) -> Optional[InstanceContainer]:
776        quality_changes_dir = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QualityChangesInstanceContainer)
777
778        instance_container = None
779
780        for item in os.listdir(quality_changes_dir):
781            file_path = os.path.join(quality_changes_dir, item)
782            if not os.path.isfile(file_path):
783                continue
784
785            parser = configparser.ConfigParser(interpolation = None)
786            try:
787                parser.read([file_path])
788            except Exception:
789                # Skip, it is not a valid stack file
790                continue
791
792            if not parser.has_option("general", "name"):
793                continue
794
795            if parser["general"]["name"] == name:
796                # Load the container
797                container_id = os.path.basename(file_path).replace(".inst.cfg", "")
798                if self.findInstanceContainers(id = container_id):
799                    # This container is already in the registry, skip it
800                    continue
801
802                instance_container = InstanceContainer(container_id)
803                with open(file_path, "r", encoding = "utf-8") as f:
804                    serialized = f.read()
805                try:
806                    instance_container.deserialize(serialized, file_path)
807                except ContainerFormatError:
808                    Logger.logException("e", "Unable to deserialize InstanceContainer %s", file_path)
809                    continue
810                self.addContainer(instance_container)
811                break
812
813        return instance_container
814
815    # Fix the extruders that were upgraded to ExtruderStack instances during addContainer.
816    # The stacks are now responsible for setting the next stack on deserialize. However,
817    # due to problems with loading order, some stacks may not have the proper next stack
818    # set after upgrading, because the proper global stack was not yet loaded. This method
819    # makes sure those extruders also get the right stack set.
820    def _connectUpgradedExtruderStacksToMachines(self) -> None:
821        extruder_stacks = self.findContainers(container_type = ExtruderStack.ExtruderStack)
822        for extruder_stack in extruder_stacks:
823            if extruder_stack.getNextStack():
824                # Has the right next stack, so ignore it.
825                continue
826
827            machines = ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack.getMetaDataEntry("machine", ""))
828            if machines:
829                extruder_stack.setNextStack(machines[0])
830            else:
831                Logger.log("w", "Could not find machine {machine} for extruder {extruder}", machine = extruder_stack.getMetaDataEntry("machine"), extruder = extruder_stack.getId())
832
833    # Override just for the type.
834    @classmethod
835    @override(ContainerRegistry)
836    def getInstance(cls, *args, **kwargs) -> "CuraContainerRegistry":
837        return cast(CuraContainerRegistry, super().getInstance(*args, **kwargs))
838