Welcome to mirror list, hosted at ThFree Co, Russian Federation.

CuraContainerRegistry.py « Settings « cura - github.com/Ultimaker/Cura.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 24b7436bad1ffadb37c63b80ff0a5caaf16332e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
# Copyright (c) 2019 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.

import os
import re
import configparser

from typing import Any, cast, Dict, Optional, List, Union, Tuple
from PyQt5.QtWidgets import QMessageBox

from UM.Decorators import override
from UM.Settings.ContainerFormatError import ContainerFormatError
from UM.Settings.Interfaces import ContainerInterface
from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.ContainerStack import ContainerStack
from UM.Settings.InstanceContainer import InstanceContainer
from UM.Settings.SettingInstance import SettingInstance
from UM.Logger import Logger
from UM.Message import Message
from UM.Platform import Platform
from UM.PluginRegistry import PluginRegistry  # For getting the possible profile writers to write with.
from UM.Resources import Resources
from UM.Util import parseBool
from cura.ReaderWriters.ProfileWriter import ProfileWriter

from . import ExtruderStack
from . import GlobalStack

import cura.CuraApplication
from cura.Settings.cura_empty_instance_containers import empty_quality_container
from cura.Machines.ContainerTree import ContainerTree
from cura.ReaderWriters.ProfileReader import NoProfileException, ProfileReader

from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")


class CuraContainerRegistry(ContainerRegistry):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack
        # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack
        # is added, we check to see if an extruder stack needs to be added.
        self.containerAdded.connect(self._onContainerAdded)

    @override(ContainerRegistry)
    def addContainer(self, container: ContainerInterface) -> bool:
        """Overridden from ContainerRegistry

        Adds a container to the registry.

        This will also try to convert a ContainerStack to either Extruder or
        Global stack based on metadata information.
        """

        # Note: Intentional check with type() because we want to ignore subclasses
        if type(container) == ContainerStack:
            container = self._convertContainerStack(cast(ContainerStack, container))

        if isinstance(container, InstanceContainer) and type(container) != type(self.getEmptyInstanceContainer()):
            # Check against setting version of the definition.
            required_setting_version = cura.CuraApplication.CuraApplication.SettingVersion
            actual_setting_version = int(container.getMetaDataEntry("setting_version", default = 0))
            if required_setting_version != actual_setting_version:
                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))
                return False  # Don't add.

        return super().addContainer(container)

    def createUniqueName(self, container_type: str, current_name: str, new_name: str, fallback_name: str) -> str:
        """Create a name that is not empty and unique

        :param container_type: :type{string} Type of the container (machine, quality, ...)
        :param current_name: :type{} Current name of the container, which may be an acceptable option
        :param new_name: :type{string} Base name, which may not be unique
        :param fallback_name: :type{string} Name to use when (stripped) new_name is empty
        :return: :type{string} Name that is unique for the specified type and name/id
        """
        new_name = new_name.strip()
        num_check = re.compile(r"(.*?)\s*#\d+$").match(new_name)
        if num_check:
            new_name = num_check.group(1)
        if new_name == "":
            new_name = fallback_name

        unique_name = new_name
        i = 1
        # In case we are renaming, the current name of the container is also a valid end-result
        while self._containerExists(container_type, unique_name) and unique_name != current_name:
            i += 1
            unique_name = "%s #%d" % (new_name, i)

        return unique_name

    def _containerExists(self, container_type: str, container_name: str):
        """Check if a container with of a certain type and a certain name or id exists

        Both the id and the name are checked, because they may not be the same and it is better if they are both unique
        :param container_type: :type{string} Type of the container (machine, quality, ...)
        :param container_name: :type{string} Name to check
        """
        container_class = ContainerStack if container_type == "machine" else InstanceContainer

        return self.findContainersMetadata(container_type = container_class, id = container_name, type = container_type, ignore_case = True) or \
                self.findContainersMetadata(container_type = container_class, name = container_name, type = container_type)

    def exportQualityProfile(self, container_list: List[InstanceContainer], file_name: str, file_type: str) -> bool:
        """Exports an profile to a file

        :param container_list: :type{list} the containers to export. This is not
        necessarily in any order!
        :param file_name: :type{str} the full path and filename to export to.
        :param file_type: :type{str} the file type with the format "<description> (*.<extension>)"
        :return: True if the export succeeded, false otherwise.
        """

        # Parse the fileType to deduce what plugin can save the file format.
        # fileType has the format "<description> (*.<extension>)"
        split = file_type.rfind(" (*.")  # Find where the description ends and the extension starts.
        if split < 0:  # Not found. Invalid format.
            Logger.log("e", "Invalid file format identifier %s", file_type)
            return False
        description = file_type[:split]
        extension = file_type[split + 4:-1]  # Leave out the " (*." and ")".
        if not file_name.endswith("." + extension):  # Auto-fill the extension if the user did not provide any.
            file_name += "." + extension

        # On Windows, QML FileDialog properly asks for overwrite confirm, but not on other platforms, so handle those ourself.
        if not Platform.isWindows():
            if os.path.exists(file_name):
                result = QMessageBox.question(None, catalog.i18nc("@title:window", "File Already Exists"),
                                              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))
                if result == QMessageBox.No:
                    return False

        profile_writer = self._findProfileWriter(extension, description)
        try:
            if profile_writer is None:
                raise Exception("Unable to find a profile writer")
            success = profile_writer.write(file_name, container_list)
        except Exception as e:
            Logger.log("e", "Failed to export profile to %s: %s", file_name, str(e))
            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)),
                        lifetime = 0,
                        title = catalog.i18nc("@info:title", "Error"))
            m.show()
            return False
        if not success:
            Logger.log("w", "Failed to export profile to %s: Writer plugin reported failure.", file_name)
            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),
                        lifetime = 0,
                        title = catalog.i18nc("@info:title", "Error"))
            m.show()
            return False
        m = Message(catalog.i18nc("@info:status Don't translate the XML tag <filename>!", "Exported profile to <filename>{0}</filename>", file_name),
                    title = catalog.i18nc("@info:title", "Export succeeded"))
        m.show()
        return True

    def _findProfileWriter(self, extension: str, description: str) -> Optional[ProfileWriter]:
        """Gets the plugin object matching the criteria

        :param extension:
        :param description:
        :return: The plugin object matching the given extension and description.
        """
        plugin_registry = PluginRegistry.getInstance()
        for plugin_id, meta_data in self._getIOPlugins("profile_writer"):
            for supported_type in meta_data["profile_writer"]:  # All file types this plugin can supposedly write.
                supported_extension = supported_type.get("extension", None)
                if supported_extension == extension:  # This plugin supports a file type with the same extension.
                    supported_description = supported_type.get("description", None)
                    if supported_description == description:  # The description is also identical. Assume it's the same file type.
                        return cast(ProfileWriter, plugin_registry.getPluginObject(plugin_id))
        return None

    def importProfile(self, file_name: str) -> Dict[str, str]:
        """Imports a profile from a file

        :param file_name: The full path and filename of the profile to import.
        :return: Dict with a 'status' key containing the string 'ok', 'warning' or 'error',
            and a 'message' key containing a message for the user.
        """

        Logger.log("d", "Attempting to import profile %s", file_name)
        if not file_name:
            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")}

        global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
        if not global_stack:
            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)}
        container_tree = ContainerTree.getInstance()

        machine_extruders = global_stack.extruderList

        plugin_registry = PluginRegistry.getInstance()
        extension = file_name.split(".")[-1]

        for plugin_id, meta_data in self._getIOPlugins("profile_reader"):
            if meta_data["profile_reader"][0]["extension"] != extension:
                continue
            profile_reader = cast(ProfileReader, plugin_registry.getPluginObject(plugin_id))
            try:
                profile_or_list = profile_reader.read(file_name)  # Try to open the file with the profile reader.
            except NoProfileException:
                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)}
            except Exception as e:
                # 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.
                Logger.log("e", "Failed to import profile from %s: %s while using profile reader. Got exception %s", file_name, profile_reader.getPluginId(), str(e))
                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>"}

            if profile_or_list:
                # Ensure it is always a list of profiles
                if not isinstance(profile_or_list, list):
                    profile_or_list = [profile_or_list]

                # First check if this profile is suitable for this machine
                global_profile = None
                extruder_profiles = []
                if len(profile_or_list) == 1:
                    global_profile = profile_or_list[0]
                else:
                    for profile in profile_or_list:
                        if not profile.getMetaDataEntry("position"):
                            global_profile = profile
                        else:
                            extruder_profiles.append(profile)
                extruder_profiles = sorted(extruder_profiles, key = lambda x: int(x.getMetaDataEntry("position", default = "0")))
                profile_or_list = [global_profile] + extruder_profiles

                if not global_profile:
                    Logger.log("e", "Incorrect profile [%s]. Could not find global profile", file_name)
                    return { "status": "error",
                             "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)}
                profile_definition = global_profile.getMetaDataEntry("definition")

                # Make sure we have a profile_definition in the file:
                if profile_definition is None:
                    break
                machine_definitions = self.findContainers(id = profile_definition)
                if not machine_definitions:
                    Logger.log("e", "Incorrect profile [%s]. Unknown machine type [%s]", file_name, profile_definition)
                    return {"status": "error",
                            "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)
                            }
                machine_definition = machine_definitions[0]

                # Get the expected machine definition.
                # i.e.: We expect gcode for a UM2 Extended to be defined as normal UM2 gcode...
                has_machine_quality = parseBool(machine_definition.getMetaDataEntry("has_machine_quality", "false"))
                profile_definition = machine_definition.getMetaDataEntry("quality_definition", machine_definition.getId()) if has_machine_quality else "fdmprinter"
                expected_machine_definition = container_tree.machines[global_stack.definition.getId()].quality_definition

                # And check if the profile_definition matches either one (showing error if not):
                if profile_definition != expected_machine_definition:
                    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))
                    global_profile.setMetaDataEntry("definition", expected_machine_definition)
                    for extruder_profile in extruder_profiles:
                        extruder_profile.setMetaDataEntry("definition", expected_machine_definition)

                quality_name = global_profile.getName()
                quality_type = global_profile.getMetaDataEntry("quality_type")

                name_seed = os.path.splitext(os.path.basename(file_name))[0]
                new_name = self.uniqueName(name_seed)

                # Ensure it is always a list of profiles
                if type(profile_or_list) is not list:
                    profile_or_list = [profile_or_list]

                # Make sure that there are also extruder stacks' quality_changes, not just one for the global stack
                if len(profile_or_list) == 1:
                    global_profile = profile_or_list[0]
                    extruder_profiles = []
                    for idx, extruder in enumerate(global_stack.extruderList):
                        profile_id = ContainerRegistry.getInstance().uniqueName(global_stack.getId() + "_extruder_" + str(idx + 1))
                        profile = InstanceContainer(profile_id)
                        profile.setName(quality_name)
                        profile.setMetaDataEntry("setting_version", cura.CuraApplication.CuraApplication.SettingVersion)
                        profile.setMetaDataEntry("type", "quality_changes")
                        profile.setMetaDataEntry("definition", expected_machine_definition)
                        profile.setMetaDataEntry("quality_type", quality_type)
                        profile.setDirty(True)
                        if idx == 0:
                            # Move all per-extruder settings to the first extruder's quality_changes
                            for qc_setting_key in global_profile.getAllKeys():
                                settable_per_extruder = global_stack.getProperty(qc_setting_key, "settable_per_extruder")
                                if settable_per_extruder:
                                    setting_value = global_profile.getProperty(qc_setting_key, "value")

                                    setting_definition = global_stack.getSettingDefinition(qc_setting_key)
                                    if setting_definition is not None:
                                        new_instance = SettingInstance(setting_definition, profile)
                                        new_instance.setProperty("value", setting_value)
                                        new_instance.resetState()  # Ensure that the state is not seen as a user state.
                                        profile.addInstance(new_instance)
                                        profile.setDirty(True)

                                    global_profile.removeInstance(qc_setting_key, postpone_emit = True)
                        extruder_profiles.append(profile)

                    for profile in extruder_profiles:
                        profile_or_list.append(profile)

                # Import all profiles
                profile_ids_added = []  # type: List[str]
                additional_message = None
                for profile_index, profile in enumerate(profile_or_list):
                    if profile_index == 0:
                        # This is assumed to be the global profile
                        profile_id = (cast(ContainerInterface, global_stack.getBottom()).getId() + "_" + name_seed).lower().replace(" ", "_")

                    elif profile_index < len(machine_extruders) + 1:
                        # This is assumed to be an extruder profile
                        extruder_id = machine_extruders[profile_index - 1].definition.getId()
                        extruder_position = str(profile_index - 1)
                        if not profile.getMetaDataEntry("position"):
                            profile.setMetaDataEntry("position", extruder_position)
                        else:
                            profile.setMetaDataEntry("position", extruder_position)
                        profile_id = (extruder_id + "_" + name_seed).lower().replace(" ", "_")

                    else:  # More extruders in the imported file than in the machine.
                        continue  # Delete the additional profiles.

                    configuration_successful, message = self._configureProfile(profile, profile_id, new_name, expected_machine_definition)
                    if configuration_successful:
                        additional_message = message
                    else:
                        # Remove any profiles that were added.
                        for profile_id in profile_ids_added + [profile.getId()]:
                            self.removeContainer(profile_id)
                        if not message:
                            message = ""
                        return {"status": "error", "message": catalog.i18nc(
                                "@info:status Don't translate the XML tag <filename>!",
                                "Failed to import profile from <filename>{0}</filename>:",
                                file_name) + " " + message}
                    profile_ids_added.append(profile.getId())
                result_status = "ok"
                success_message = catalog.i18nc("@info:status", "Successfully imported profile {0}.", profile_or_list[0].getName())
                if additional_message:
                    result_status = "warning"
                    success_message += additional_message
                return {"status": result_status, "message": success_message}

            # This message is throw when the profile reader doesn't find any profile in the file
            return {"status": "error", "message": catalog.i18nc("@info:status", "File {0} does not contain any valid profile.", file_name)}

        # If it hasn't returned by now, none of the plugins loaded the profile successfully.
        return {"status": "error", "message": catalog.i18nc("@info:status", "Profile {0} has an unknown file type or is corrupted.", file_name)}

    @override(ContainerRegistry)
    def load(self) -> None:
        super().load()
        self._registerSingleExtrusionMachinesExtruderStacks()
        self._connectUpgradedExtruderStacksToMachines()

    @override(ContainerRegistry)
    def loadAllMetadata(self) -> None:
        super().loadAllMetadata()
        self._cleanUpInvalidQualityChanges()

    def _cleanUpInvalidQualityChanges(self) -> None:
        # We've seen cases where it was possible for quality_changes to be incorrectly added. This is to ensure that
        # any such leftovers are purged from the registry.
        quality_changes = ContainerRegistry.getInstance().findContainersMetadata(type="quality_changes")

        profile_count_by_name = {}  # type: Dict[str, int]

        for quality_change in quality_changes:
            name = str(quality_change.get("name", ""))
            if name == "empty":
                continue
            if name not in profile_count_by_name:
                profile_count_by_name[name] = 0
            profile_count_by_name[name] += 1

        for profile_name, profile_count in profile_count_by_name.items():
            if profile_count > 1:
                continue
            # Only one profile found, this should not ever be the case, so that profile needs to be removed!
            Logger.log("d", "Found an invalid quality_changes profile with the name %s. Going to remove that now", profile_name)
            invalid_quality_changes = ContainerRegistry.getInstance().findContainersMetadata(name=profile_name)
            self.removeContainer(invalid_quality_changes[0]["id"])

    @override(ContainerRegistry)
    def _isMetadataValid(self, metadata: Optional[Dict[str, Any]]) -> bool:
        """Check if the metadata for a container is okay before adding it.

        This overrides the one from UM.Settings.ContainerRegistry because we
        also require that the setting_version is correct.
        """

        if metadata is None:
            return False
        if "setting_version" not in metadata:
            return False
        try:
            if int(metadata["setting_version"]) != cura.CuraApplication.CuraApplication.SettingVersion:
                return False
        except ValueError: #Not parsable as int.
            return False
        return True

    def _configureProfile(self, profile: InstanceContainer, id_seed: str, new_name: str, machine_definition_id: str) -> Tuple[bool, Optional[str]]:
        """Update an imported profile to match the current machine configuration.

        :param profile: The profile to configure.
        :param id_seed: The base ID for the profile. May be changed so it does not conflict with existing containers.
        :param new_name: The new name for the profile.

        :returns: tuple (configuration_successful, message)
                WHERE
                bool configuration_successful: Whether the process of configuring the profile was successful
                optional str message: A message indicating the outcome of configuring the profile. If the configuration
                                      is successful, this message can be None or contain a warning
        """

        profile.setDirty(True)  # Ensure the profiles are correctly saved

        new_id = self.createUniqueName("quality_changes", "", id_seed, catalog.i18nc("@label", "Custom profile"))
        profile.setMetaDataEntry("id", new_id)
        profile.setName(new_name)

        # Set the unique Id to the profile, so it's generating a new one even if the user imports the same profile
        # It also solves an issue with importing profiles from G-Codes
        profile.setMetaDataEntry("id", new_id)
        profile.setMetaDataEntry("definition", machine_definition_id)

        if "type" in profile.getMetaData():
            profile.setMetaDataEntry("type", "quality_changes")
        else:
            profile.setMetaDataEntry("type", "quality_changes")

        quality_type = profile.getMetaDataEntry("quality_type")
        if not quality_type:
            return False, catalog.i18nc("@info:status", "Profile is missing a quality type.")

        global_stack = cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack()
        if not global_stack:
            return False, catalog.i18nc("@info:status", "There is no active printer yet.")

        definition_id = ContainerTree.getInstance().machines[global_stack.definition.getId()].quality_definition
        profile.setDefinition(definition_id)

        if not self.addContainer(profile):
            return False, catalog.i18nc("@info:status", "Unable to add the profile.")

        # "not_supported" profiles can be imported.
        if quality_type == empty_quality_container.getMetaDataEntry("quality_type"):
            return True, None

        # Check to make sure the imported profile actually makes sense in context of the current configuration.
        # This prevents issues where importing a "draft" profile for a machine without "draft" qualities would report as
        # successfully imported but then fail to show up.
        available_quality_groups_dict = {name: quality_group for name, quality_group in ContainerTree.getInstance().getCurrentQualityGroups().items() if quality_group.is_available}
        all_quality_groups_dict = ContainerTree.getInstance().getCurrentQualityGroups()

        # If the quality type doesn't exist at all in the quality_groups of this machine, reject the profile
        if quality_type not in all_quality_groups_dict:
            return False, catalog.i18nc("@info:status", "Quality type '{0}' is not compatible with the current active machine definition '{1}'.", quality_type, definition_id)

        # If the quality_type exists in the quality_groups of this printer but it is not available with the current
        # machine configuration (e.g. not available for the selected nozzles), accept it with a warning
        if quality_type not in available_quality_groups_dict:
            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. "
                                                                "Switch to a material/nozzle combination that can use this quality type.", quality_type)

        return True, None

    @override(ContainerRegistry)
    def saveDirtyContainers(self) -> None:
        # Lock file for "more" atomically loading and saving to/from config dir.
        with self.lockFile():
            # Save base files first
            for instance in self.findDirtyContainers(container_type=InstanceContainer):
                if instance.getMetaDataEntry("removed"):
                    continue
                if instance.getId() == instance.getMetaData().get("base_file"):
                    self.saveContainer(instance)

            for instance in self.findDirtyContainers(container_type=InstanceContainer):
                if instance.getMetaDataEntry("removed"):
                    continue
                self.saveContainer(instance)

            for stack in self.findContainerStacks():
                self.saveContainer(stack)

    def _getIOPlugins(self, io_type):
        """Gets a list of profile writer plugins

        :return: List of tuples of (plugin_id, meta_data).
        """
        plugin_registry = PluginRegistry.getInstance()
        active_plugin_ids = plugin_registry.getActivePlugins()

        result = []
        for plugin_id in active_plugin_ids:
            meta_data = plugin_registry.getMetaData(plugin_id)
            if io_type in meta_data:
                result.append( (plugin_id, meta_data) )
        return result

    def _convertContainerStack(self, container: ContainerStack) -> Union[ExtruderStack.ExtruderStack, GlobalStack.GlobalStack]:
        """Convert an "old-style" pure ContainerStack to either an Extruder or Global stack."""

        assert type(container) == ContainerStack

        container_type = container.getMetaDataEntry("type")
        if container_type not in ("extruder_train", "machine"):
            # It is not an extruder or machine, so do nothing with the stack
            return container

        Logger.log("d", "Converting ContainerStack {stack} to {type}", stack = container.getId(), type = container_type)

        if container_type == "extruder_train":
            new_stack = ExtruderStack.ExtruderStack(container.getId())
        else:
            new_stack = GlobalStack.GlobalStack(container.getId())

        container_contents = container.serialize()
        new_stack.deserialize(container_contents)

        # Delete the old configuration file so we do not get double stacks
        if os.path.isfile(container.getPath()):
            os.remove(container.getPath())

        return new_stack

    def _registerSingleExtrusionMachinesExtruderStacks(self) -> None:
        machines = self.findContainerStacks(type = "machine", machine_extruder_trains = {"0": "fdmextruder"})
        for machine in machines:
            extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = machine.getId())
            if not extruder_stacks:
                self.addExtruderStackForSingleExtrusionMachine(machine, "fdmextruder")

    def _onContainerAdded(self, container: ContainerInterface) -> None:
        # We don't have all the machines loaded in the beginning, so in order to add the missing extruder stack
        # for single extrusion machines, we subscribe to the containerAdded signal, and whenever a global stack
        # is added, we check to see if an extruder stack needs to be added.
        if not isinstance(container, ContainerStack) or container.getMetaDataEntry("type") != "machine":
            return

        machine_extruder_trains = container.getMetaDataEntry("machine_extruder_trains")
        if machine_extruder_trains is not None and machine_extruder_trains != {"0": "fdmextruder"}:
            return

        extruder_stacks = self.findContainerStacks(type = "extruder_train", machine = container.getId())
        if not extruder_stacks:
            self.addExtruderStackForSingleExtrusionMachine(container, "fdmextruder")

    #
    # new_global_quality_changes is optional. It is only used in project loading for a scenario like this:
    #      - override the current machine
    #      - create new for custom quality profile
    # new_global_quality_changes is the new global quality changes container in this scenario.
    # create_new_ids indicates if new unique ids must be created
    #
    def addExtruderStackForSingleExtrusionMachine(self, machine, extruder_id, new_global_quality_changes = None, create_new_ids = True):
        new_extruder_id = extruder_id

        application = cura.CuraApplication.CuraApplication.getInstance()

        extruder_definitions = self.findDefinitionContainers(id = new_extruder_id)
        if not extruder_definitions:
            Logger.log("w", "Could not find definition containers for extruder %s", new_extruder_id)
            return

        extruder_definition = extruder_definitions[0]
        unique_name = self.uniqueName(machine.getName() + " " + new_extruder_id) if create_new_ids else machine.getName() + " " + new_extruder_id

        extruder_stack = ExtruderStack.ExtruderStack(unique_name)
        extruder_stack.setName(extruder_definition.getName())
        extruder_stack.setDefinition(extruder_definition)
        extruder_stack.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))

        # create a new definition_changes container for the extruder stack
        definition_changes_id = self.uniqueName(extruder_stack.getId() + "_settings") if create_new_ids else extruder_stack.getId() + "_settings"
        definition_changes_name = definition_changes_id
        definition_changes = InstanceContainer(definition_changes_id, parent = application)
        definition_changes.setName(definition_changes_name)
        definition_changes.setMetaDataEntry("setting_version", application.SettingVersion)
        definition_changes.setMetaDataEntry("type", "definition_changes")
        definition_changes.setMetaDataEntry("definition", extruder_definition.getId())

        # move definition_changes settings if exist
        for setting_key in definition_changes.getAllKeys():
            if machine.definition.getProperty(setting_key, "settable_per_extruder"):
                setting_value = machine.definitionChanges.getProperty(setting_key, "value")
                if setting_value is not None:
                    # move it to the extruder stack's definition_changes
                    setting_definition = machine.getSettingDefinition(setting_key)
                    new_instance = SettingInstance(setting_definition, definition_changes)
                    new_instance.setProperty("value", setting_value)
                    new_instance.resetState()  # Ensure that the state is not seen as a user state.
                    definition_changes.addInstance(new_instance)
                    definition_changes.setDirty(True)

                    machine.definitionChanges.removeInstance(setting_key, postpone_emit = True)

        self.addContainer(definition_changes)
        extruder_stack.setDefinitionChanges(definition_changes)

        # create empty user changes container otherwise
        user_container_id = self.uniqueName(extruder_stack.getId() + "_user") if create_new_ids else extruder_stack.getId() + "_user"
        user_container_name = user_container_id
        user_container = InstanceContainer(user_container_id, parent = application)
        user_container.setName(user_container_name)
        user_container.setMetaDataEntry("type", "user")
        user_container.setMetaDataEntry("machine", machine.getId())
        user_container.setMetaDataEntry("setting_version", application.SettingVersion)
        user_container.setDefinition(machine.definition.getId())
        user_container.setMetaDataEntry("position", extruder_stack.getMetaDataEntry("position"))

        if machine.userChanges:
            # For the newly created extruder stack, we need to move all "per-extruder" settings to the user changes
            # container to the extruder stack.
            for user_setting_key in machine.userChanges.getAllKeys():
                settable_per_extruder = machine.getProperty(user_setting_key, "settable_per_extruder")
                if settable_per_extruder:
                    setting_value = machine.getProperty(user_setting_key, "value")

                    setting_definition = machine.getSettingDefinition(user_setting_key)
                    new_instance = SettingInstance(setting_definition, definition_changes)
                    new_instance.setProperty("value", setting_value)
                    new_instance.resetState()  # Ensure that the state is not seen as a user state.
                    user_container.addInstance(new_instance)
                    user_container.setDirty(True)

                    machine.userChanges.removeInstance(user_setting_key, postpone_emit = True)

        self.addContainer(user_container)
        extruder_stack.setUserChanges(user_container)

        empty_variant = application.empty_variant_container
        empty_material = application.empty_material_container
        empty_quality = application.empty_quality_container

        if machine.variant.getId() not in ("empty", "empty_variant"):
            variant = machine.variant
        else:
            variant = empty_variant
        extruder_stack.variant = variant

        if machine.material.getId() not in ("empty", "empty_material"):
            material = machine.material
        else:
            material = empty_material
        extruder_stack.material = material

        if machine.quality.getId() not in ("empty", "empty_quality"):
            quality = machine.quality
        else:
            quality = empty_quality
        extruder_stack.quality = quality

        machine_quality_changes = machine.qualityChanges
        if new_global_quality_changes is not None:
            machine_quality_changes = new_global_quality_changes

        if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"):
            extruder_quality_changes_container = self.findInstanceContainers(name = machine_quality_changes.getName(), extruder = extruder_id)
            if extruder_quality_changes_container:
                extruder_quality_changes_container = extruder_quality_changes_container[0]

                quality_changes_id = extruder_quality_changes_container.getId()
                extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0]
            else:
                # Some extruder quality_changes containers can be created at runtime as files in the qualities
                # folder. Those files won't be loaded in the registry immediately. So we also need to search
                # the folder to see if the quality_changes exists.
                extruder_quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName())
                if extruder_quality_changes_container:
                    quality_changes_id = extruder_quality_changes_container.getId()
                    extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
                    extruder_stack.qualityChanges = self.findInstanceContainers(id = quality_changes_id)[0]
                else:
                    # If we still cannot find a quality changes container for the extruder, create a new one
                    container_name = machine_quality_changes.getName()
                    container_id = self.uniqueName(extruder_stack.getId() + "_qc_" + container_name)
                    extruder_quality_changes_container = InstanceContainer(container_id, parent = application)
                    extruder_quality_changes_container.setName(container_name)
                    extruder_quality_changes_container.setMetaDataEntry("type", "quality_changes")
                    extruder_quality_changes_container.setMetaDataEntry("setting_version", application.SettingVersion)
                    extruder_quality_changes_container.setMetaDataEntry("position", extruder_definition.getMetaDataEntry("position"))
                    extruder_quality_changes_container.setMetaDataEntry("quality_type", machine_quality_changes.getMetaDataEntry("quality_type"))
                    extruder_quality_changes_container.setMetaDataEntry("intent_category", "default")  # Intent categories weren't a thing back then.
                    extruder_quality_changes_container.setDefinition(machine_quality_changes.getDefinition().getId())

                    self.addContainer(extruder_quality_changes_container)
                    extruder_stack.qualityChanges = extruder_quality_changes_container

            if not extruder_quality_changes_container:
                Logger.log("w", "Could not find quality_changes named [%s] for extruder [%s]",
                           machine_quality_changes.getName(), extruder_stack.getId())
            else:
                # Move all per-extruder settings to the extruder's quality changes
                for qc_setting_key in machine_quality_changes.getAllKeys():
                    settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder")
                    if settable_per_extruder:
                        setting_value = machine_quality_changes.getProperty(qc_setting_key, "value")

                        setting_definition = machine.getSettingDefinition(qc_setting_key)
                        new_instance = SettingInstance(setting_definition, definition_changes)
                        new_instance.setProperty("value", setting_value)
                        new_instance.resetState()  # Ensure that the state is not seen as a user state.
                        extruder_quality_changes_container.addInstance(new_instance)
                        extruder_quality_changes_container.setDirty(True)

                        machine_quality_changes.removeInstance(qc_setting_key, postpone_emit=True)
        else:
            extruder_stack.qualityChanges = self.findInstanceContainers(id = "empty_quality_changes")[0]

        self.addContainer(extruder_stack)

        # Also need to fix the other qualities that are suitable for this machine. Those quality changes may still have
        # per-extruder settings in the container for the machine instead of the extruder.
        if machine_quality_changes.getId() not in ("empty", "empty_quality_changes"):
            quality_changes_machine_definition_id = machine_quality_changes.getDefinition().getId()
        else:
            whole_machine_definition = machine.definition
            machine_entry = machine.definition.getMetaDataEntry("machine")
            if machine_entry is not None:
                container_registry = ContainerRegistry.getInstance()
                whole_machine_definition = container_registry.findDefinitionContainers(id = machine_entry)[0]

            quality_changes_machine_definition_id = "fdmprinter"
            if whole_machine_definition.getMetaDataEntry("has_machine_quality"):
                quality_changes_machine_definition_id = machine.definition.getMetaDataEntry("quality_definition",
                                                                                            whole_machine_definition.getId())
        qcs = self.findInstanceContainers(type = "quality_changes", definition = quality_changes_machine_definition_id)
        qc_groups = {}  # map of qc names -> qc containers
        for qc in qcs:
            qc_name = qc.getName()
            if qc_name not in qc_groups:
                qc_groups[qc_name] = []
            qc_groups[qc_name].append(qc)
            # Try to find from the quality changes cura directory too
            quality_changes_container = self._findQualityChangesContainerInCuraFolder(machine_quality_changes.getName())
            if quality_changes_container:
                qc_groups[qc_name].append(quality_changes_container)

        for qc_name, qc_list in qc_groups.items():
            qc_dict = {"global": None, "extruders": []}
            for qc in qc_list:
                extruder_position = qc.getMetaDataEntry("position")
                if extruder_position is not None:
                    qc_dict["extruders"].append(qc)
                else:
                    qc_dict["global"] = qc
            if qc_dict["global"] is not None and len(qc_dict["extruders"]) == 1:
                # Move per-extruder settings
                for qc_setting_key in qc_dict["global"].getAllKeys():
                    settable_per_extruder = machine.getProperty(qc_setting_key, "settable_per_extruder")
                    if settable_per_extruder:
                        setting_value = qc_dict["global"].getProperty(qc_setting_key, "value")

                        setting_definition = machine.getSettingDefinition(qc_setting_key)
                        new_instance = SettingInstance(setting_definition, definition_changes)
                        new_instance.setProperty("value", setting_value)
                        new_instance.resetState()  # Ensure that the state is not seen as a user state.
                        qc_dict["extruders"][0].addInstance(new_instance)
                        qc_dict["extruders"][0].setDirty(True)

                        qc_dict["global"].removeInstance(qc_setting_key, postpone_emit=True)

        # Set next stack at the end
        extruder_stack.setNextStack(machine)

        return extruder_stack

    def _findQualityChangesContainerInCuraFolder(self, name: str) -> Optional[InstanceContainer]:
        quality_changes_dir = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QualityChangesInstanceContainer)

        instance_container = None

        for item in os.listdir(quality_changes_dir):
            file_path = os.path.join(quality_changes_dir, item)
            if not os.path.isfile(file_path):
                continue

            parser = configparser.ConfigParser(interpolation = None)
            try:
                parser.read([file_path])
            except Exception:
                # Skip, it is not a valid stack file
                continue

            if not parser.has_option("general", "name"):
                continue

            if parser["general"]["name"] == name:
                # Load the container
                container_id = os.path.basename(file_path).replace(".inst.cfg", "")
                if self.findInstanceContainers(id = container_id):
                    # This container is already in the registry, skip it
                    continue

                instance_container = InstanceContainer(container_id)
                with open(file_path, "r", encoding = "utf-8") as f:
                    serialized = f.read()
                try:
                    instance_container.deserialize(serialized, file_path)
                except ContainerFormatError:
                    Logger.logException("e", "Unable to deserialize InstanceContainer %s", file_path)
                    continue
                self.addContainer(instance_container)
                break

        return instance_container

    # Fix the extruders that were upgraded to ExtruderStack instances during addContainer.
    # The stacks are now responsible for setting the next stack on deserialize. However,
    # due to problems with loading order, some stacks may not have the proper next stack
    # set after upgrading, because the proper global stack was not yet loaded. This method
    # makes sure those extruders also get the right stack set.
    def _connectUpgradedExtruderStacksToMachines(self) -> None:
        extruder_stacks = self.findContainers(container_type = ExtruderStack.ExtruderStack)
        for extruder_stack in extruder_stacks:
            if extruder_stack.getNextStack():
                # Has the right next stack, so ignore it.
                continue

            machines = ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack.getMetaDataEntry("machine", ""))
            if machines:
                extruder_stack.setNextStack(machines[0])
            else:
                Logger.log("w", "Could not find machine {machine} for extruder {extruder}", machine = extruder_stack.getMetaDataEntry("machine"), extruder = extruder_stack.getId())

    # Override just for the type.
    @classmethod
    @override(ContainerRegistry)
    def getInstance(cls, *args, **kwargs) -> "CuraContainerRegistry":
        return cast(CuraContainerRegistry, super().getInstance(*args, **kwargs))