Scripted objects migration

From FreeCAD Documentation
Revision as of 03:50, 9 May 2020 by Vocx (talk | contribs) (→‎Migration when restoring the document: We will migrate the older object by modifying the old class. The majority of the original class is deleted, and instead the onDocumentRestored method is implemented. When this method exists, it will run when the document tries to restore an object that uses the old class, so this is the chance we have to assign the new classes.)
This page is a work in progress. Please do not edit or translate until this message is removed.

Introduction

Scripted objects are rebuilt every time a FCStd document is opened. To do this the document keeps a reference to the module and Python class that were used to create the object, along with its properties.

<Document SchemaVersion="4" ProgramVersion="0.19R20959 (Git)" FileVersion="1">
    ...
    <Properties Count="15" TransientCount="3">
    ...
    </Properties>
    <Objects Count="1" Dependencies="1">
        <ObjectDeps Name="Custom" Count="0"/>
        <Object type="Part::FeaturePython" name="Custom" id="2715" Touched="1" />
    </Objects>
    <ObjectData Count="1">
        <Object name="Custom">
            <Properties Count="9" TransientCount="0">
                ...
                <Property name="Proxy" type="App::PropertyPythonObject" status="1">
                    <Python value="eyJUeXBlIjogIkN1c3RvbSJ9" encoded="yes" module="old_module" class="OldObject"/>
                </Property>
                ...
            </Properties>
        </Object>
    </ObjectData>
</Document>

Particularly focus on this part:

...
                <Property name="Proxy" type="App::PropertyPythonObject" status="1">
                    <Python value="eyJUeXBlIjogIkN1c3RvbSJ9" encoded="yes" module="old_module" class="OldObject"/>
                </Property>
                ...

If the module or class is no longer present, the object will fail to load correctly. This means that once an object is created using a particular class, the module should no longer be moved or renamed because if this is done, previously saved objects will break.

However, a valid reason for moving or renaming the class is to improve the structure and maintainability of the original code, for example, when restructuring an entire workbench. In this case there are various strategies to migrate old objects to using a new class. This is done in order to retain backwards compatibility, when outright breaking of old documents must be avoided.

Old object and new object

Old object

An old object is defined in a module which is at the root of the workbench.

# old_module.py
class OldObject:
    def __init__(self, obj):
        obj.addProperty("App::PropertyLength", "Length")
        obj.addProperty("App::PropertyArea", "Area")
        obj.Length = 15
        obj.Area = 300
        obj.Proxy = self
        self.Type = "Custom"

    def execute(self, obj):
        pass

An object can be created using this class, and it can be saved to my_document.FCstd. If no particular viewprovider is assigned to the new object, its proxy class is simply set to a value different from None, in this case, to 1.

import FreeCAD as App
import old_module

doc = App.newDocument()
doc.FileName = "my_document.FCStd"

obj = doc.addObject("Part::FeaturePython", "Custom")
old_module.OldObject(obj)
if App.GuiUp:
    obj.ViewObject.Proxy=1
doc.save()

Python console session with the basic properties omitted.

>>> obj = App.ActiveDocument.Custom
>>> print(obj.PropertiesList)
['Area', ..., ..., ..., 'Length', ..., ..., ..., ...]
>>> print(obj.Proxy)
<old_module.OldObject object at 0x7efc3c51c390>

New object

Now we consider that the workbench is restructured, so that classes aren't just at the root directory, but instead are inside an objects directory. Complex workbenches that have many different types of objects should be structured in directories including objects, viewproviders, Gui Commands, task panel interfaces, etc.

# objects/new_module.py
class NewObject:
    def __init__(self, obj):
        obj.addProperty("App::PropertyLength", "Length")
        obj.addProperty("App::PropertyArea", "GeneralArea")
        obj.addProperty("App::PropertyInteger", "Divisions")
        obj.Length = 30
        obj.GeneralArea = 600
        obj.Divisions = 4
        obj.Proxy = self
        self.Type = "Custom"

    def execute(self, obj):
        pass

This new class will refer to the same type of object, but both the module name as well as the class name have been renamed. Moreover, the properties also have changed; one property has been renamed, and a completely new property has been added.

If we create a new object with this new module we will have the following console session.

>>> obj2 = App.ActiveDocument.Custom2
>>> print(obj2.PropertiesList)
['Divisions', ..., 'GeneralArea', ..., ..., 'Length', ..., ..., ..., ...]
>>> print(obj2.Proxy)
<objects.new_module.NewObject object at 0x7efc1cf68c50>

Migration by redirecting the class

We will migrate the older object by redirecting the old class. The original class is deleted, and the name of the class is simply redirected to point to the new class.

# old_module.py
import objects.new_module as new_module

OldObject = new_module.NewObject

Any document that tries to load old_module.OldObject will be redirected to load objects.new_module.NewObject instead.

If we open the document, and inspect the properties of the object in the Python console we will see that the older properties are conserved, but the object has a new Proxy class.

>>> obj = App.ActiveDocument.Custom
>>> print(obj.PropertiesList)
['Area', ..., ..., ..., 'Length', ..., ..., ..., ...]
>>> print(obj.Proxy)
<objects.new_module.NewObject object at 0x7f099700b2b0>

However, in this case we don't see the new properties of the new class. The reason is simply that the older object didn't have these properties. When old_module.OldObject was redirected to objects.new_module.NewObject, only the proxy class changed, but previous information was retained.

Now, if the document is saved and opened again, it will automatically look for objects.new_module.NewObject, and it will not require old_module.OldObject any more. The old_module.py file may be removed permanently from the system as long as all older objects have been migrated to the new module. If the old module is removed but an object hasn't been migrated, the report view will show a message like this when opening a document containing such object.

<class 'ModuleNotFoundError'>: No module named 'old_module'

If it is not realistically possible to migrate all older objects, say, because the old module was used in a workbench for many years, then old_module.py must be kept as long as it's deemed necessary to give users the opportunity to migrate their objects.

Advantages and disadvantages

Advantages

  • This is a simple method that just requires redirecting an old class to a new class.
  • Old properties are conserved as long as the new class doesn't override them.
  • This is good if the old class and the new class have the same properties (handle the same type of data) but only their module or class name is different.

Disadvantages

  • The new class keeps the old properties of the object, which is not always desired.
  • The new properties or renamed properties aren't taken into account, so the object will load but it may not use the new behavior of the new class.
  • The old module may have to be kept indefinitely to migrate all old objects created in the past.

Migration when restoring the document

We will migrate the older object by modifying the old class. The majority of the original class is deleted, and instead the onDocumentRestored method is implemented. When this method exists, it will run when the document tries to restore an object that uses the old class, so this is the chance we have to assign the new classes.

# old_module.py
import FreeCAD as App
import objects.new_module as new_module
import viewp.new_view as new_view

class OldObject:
    def onDocumentRestored(self, obj):
        if hasattr(obj, "Proxy") and obj.Proxy.Type == "Custom":
            _module = str(obj.Proxy.__class__)
            _module = _module.lstrip("<class '").rstrip("'>")

            if _module == "old_module.OldObject":
                self._migrate(obj, _module)

    def _migrate(self, obj, _module):
        new_module.NewObject(obj)

        if App.GuiUp:
            new_view.ViewProviderNew(obj.ViewObject)

Links