diff --git a/examples/resources/working_with_trait_versions/openassetio_example/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/__init__.py new file mode 100644 index 0000000..0b1f0d8 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/__init__.py @@ -0,0 +1,7 @@ +""" +An example traits schema +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from .v2 import * diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v1/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/v1/__init__.py new file mode 100644 index 0000000..a755489 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v1/__init__.py @@ -0,0 +1,8 @@ +""" +An example traits schema +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from . import traits +from . import specifications diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v1/specifications/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/v1/specifications/__init__.py new file mode 100644 index 0000000..06aaee0 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v1/specifications/__init__.py @@ -0,0 +1,7 @@ +""" +Specifications defined in the 'openassetio-example' package. +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from . import example diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v1/specifications/example.py b/examples/resources/working_with_trait_versions/openassetio_example/v1/specifications/example.py new file mode 100644 index 0000000..17c4409 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v1/specifications/example.py @@ -0,0 +1,77 @@ + +""" +Specification definitions in the 'example' namespace. + +Test specifications. +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from openassetio.trait import TraitsData + + +from .. import traits + + + +class ExampleSpecification: + """ + An example. + Usage: entity + """ + kTraitSet = { + # 'openassetio-example:example.Unchanged' + traits.example.UnchangedTrait.kId, + # 'openassetio-example:example.Updated' + traits.example.UpdatedTrait.kId, + + } + + def __init__(self, traitsData): + """ + Constructs the specification as a view on the supplied + shared @fqref{TraitsData} "TraitsData" instance. + + @param traitsData @fqref{TraitsData} "TraitsData" + + @warning Specifications are always a view on the supplied data, + which is held by reference. Any changes made to the data will be + visible to any other specifications or @ref trait "traits" that + wrap the same TraitsData instance. + """ + if not isinstance(traitsData, TraitsData): + raise TypeError("Specifications must be constructed with a TraitsData instance") + self.__data = traitsData + + def traitsData(self): + """ + Returns the underlying (shared) @fqref{TraitsData} "TraitsData" + instance held by this specification. + """ + return self.__data + + @classmethod + def create(cls): + """ + Returns a new instance of the Specification, holding a new + @fqref{TraitsData} "TraitsData" instance, pre-populated with all + of the specifications traits. + """ + data = TraitsData(cls.kTraitSet) + return cls(data) + + + def unchangedTrait(self): + """ + Returns the view for the 'openassetio-example:example.Unchanged' trait wrapped around + the data held in this instance. + """ + return traits.example.UnchangedTrait(self.traitsData()) + + def updatedTrait(self): + """ + Returns the view for the 'openassetio-example:example.Updated' trait wrapped around + the data held in this instance. + """ + return traits.example.UpdatedTrait(self.traitsData()) + \ No newline at end of file diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v1/traits/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/v1/traits/__init__.py new file mode 100644 index 0000000..190df00 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v1/traits/__init__.py @@ -0,0 +1,7 @@ +""" +Traits defined in the 'openassetio-example' package. +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from . import example diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v1/traits/example.py b/examples/resources/working_with_trait_versions/openassetio_example/v1/traits/example.py new file mode 100644 index 0000000..d0ae54e --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v1/traits/example.py @@ -0,0 +1,259 @@ + +""" +Trait definitions in the 'example' namespace. + +Example namespace +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from typing import Union + +from openassetio.trait import TraitsData + + +class RemovedTrait: + """ + An example. + Usage: entity, locale, relationship + """ + kId = "openassetio-example:example.Removed.v1" + + def __init__(self, traitsData): + """ + Construct this trait view, wrapping the given data. + + @param traitsData @fqref{TraitsData}} "TraitsData" The target + data that holds/will hold the traits properties. + """ + self.__data = traitsData + + def isImbued(self): + """ + Checks whether the data this trait has been applied to + actually has this trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return self.isImbuedTo(self.__data) + + @classmethod + def isImbuedTo(cls, traitsData): + """ + Checks whether the given data actually has this trait. + @param traitsData: Data to check for trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return traitsData.hasTrait(cls.kId) + + def imbue(self): + """ + Adds this trait to the held data. + + If the data already has this trait, it is a no-op. + """ + self.__data.addTrait(self.kId) + + @classmethod + def imbueTo(cls, traitsData): + """ + Adds this trait to the provided data. + + If the data already has this trait, it is a no-op. + """ + traitsData.addTrait(cls.kId) + + + + +class UnchangedTrait: + """ + An example. + Usage: entity, locale, relationship + """ + kId = "openassetio-example:example.Unchanged.v1" + + def __init__(self, traitsData): + """ + Construct this trait view, wrapping the given data. + + @param traitsData @fqref{TraitsData}} "TraitsData" The target + data that holds/will hold the traits properties. + """ + self.__data = traitsData + + def isImbued(self): + """ + Checks whether the data this trait has been applied to + actually has this trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return self.isImbuedTo(self.__data) + + @classmethod + def isImbuedTo(cls, traitsData): + """ + Checks whether the given data actually has this trait. + @param traitsData: Data to check for trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return traitsData.hasTrait(cls.kId) + + def imbue(self): + """ + Adds this trait to the held data. + + If the data already has this trait, it is a no-op. + """ + self.__data.addTrait(self.kId) + + @classmethod + def imbueTo(cls, traitsData): + """ + Adds this trait to the provided data. + + If the data already has this trait, it is a no-op. + """ + traitsData.addTrait(cls.kId) + + + + +class UpdatedTrait: + """ + An example. + Usage: entity, locale, relationship + """ + kId = "openassetio-example:example.Updated.v1" + + def __init__(self, traitsData): + """ + Construct this trait view, wrapping the given data. + + @param traitsData @fqref{TraitsData}} "TraitsData" The target + data that holds/will hold the traits properties. + """ + self.__data = traitsData + + def isImbued(self): + """ + Checks whether the data this trait has been applied to + actually has this trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return self.isImbuedTo(self.__data) + + @classmethod + def isImbuedTo(cls, traitsData): + """ + Checks whether the given data actually has this trait. + @param traitsData: Data to check for trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return traitsData.hasTrait(cls.kId) + + def imbue(self): + """ + Adds this trait to the held data. + + If the data already has this trait, it is a no-op. + """ + self.__data.addTrait(self.kId) + + @classmethod + def imbueTo(cls, traitsData): + """ + Adds this trait to the provided data. + + If the data already has this trait, it is a no-op. + """ + traitsData.addTrait(cls.kId) + + + def setPropertyToKeep(self, propertyToKeep: str): + """ + Sets the propertyToKeep property. + + A property that is unchanged between versions. + """ + if not isinstance(propertyToKeep, str): + raise TypeError("propertyToKeep must be a 'str'.") + self.__data.setTraitProperty(self.kId, "propertyToKeep", propertyToKeep) + + def getPropertyToKeep(self, defaultValue: str=None) -> Union[str, None]: + """ + Gets the value of the propertyToKeep property or the supplied default. + + A property that is unchanged between versions. + """ + value = self.__data.getTraitProperty(self.kId, "propertyToKeep") + if value is None: + return defaultValue + + if not isinstance(value, str): + if defaultValue is None: + raise TypeError(f"Invalid stored value type: '{type(value).__name__}' should be 'str'.") + return defaultValue + return value + + def setPropertyToRemove(self, propertyToRemove: bool): + """ + Sets the propertyToRemove property. + + A defunct property that should be removed in the next version. + """ + if not isinstance(propertyToRemove, bool): + raise TypeError("propertyToRemove must be a 'bool'.") + self.__data.setTraitProperty(self.kId, "propertyToRemove", propertyToRemove) + + def getPropertyToRemove(self, defaultValue: bool=None) -> Union[bool, None]: + """ + Gets the value of the propertyToRemove property or the supplied default. + + A defunct property that should be removed in the next version. + """ + value = self.__data.getTraitProperty(self.kId, "propertyToRemove") + if value is None: + return defaultValue + + if not isinstance(value, bool): + if defaultValue is None: + raise TypeError(f"Invalid stored value type: '{type(value).__name__}' should be 'bool'.") + return defaultValue + return value + + def setPropertyToRename(self, propertyToRename: bool): + """ + Sets the propertyToRename property. + + A property that has an inappropriate name and should be renamed + in the next version. + """ + if not isinstance(propertyToRename, bool): + raise TypeError("propertyToRename must be a 'bool'.") + self.__data.setTraitProperty(self.kId, "propertyToRename", propertyToRename) + + def getPropertyToRename(self, defaultValue: bool=None) -> Union[bool, None]: + """ + Gets the value of the propertyToRename property or the supplied default. + + A property that has an inappropriate name and should be renamed + in the next version. + """ + value = self.__data.getTraitProperty(self.kId, "propertyToRename") + if value is None: + return defaultValue + + if not isinstance(value, bool): + if defaultValue is None: + raise TypeError(f"Invalid stored value type: '{type(value).__name__}' should be 'bool'.") + return defaultValue + return value + + + diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v2/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/v2/__init__.py new file mode 100644 index 0000000..a755489 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v2/__init__.py @@ -0,0 +1,8 @@ +""" +An example traits schema +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from . import traits +from . import specifications diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v2/specifications/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/v2/specifications/__init__.py new file mode 100644 index 0000000..06aaee0 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v2/specifications/__init__.py @@ -0,0 +1,7 @@ +""" +Specifications defined in the 'openassetio-example' package. +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from . import example diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v2/specifications/example.py b/examples/resources/working_with_trait_versions/openassetio_example/v2/specifications/example.py new file mode 100644 index 0000000..4fee4bf --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v2/specifications/example.py @@ -0,0 +1,77 @@ + +""" +Specification definitions in the 'example' namespace. + +Test specifications. +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from openassetio.trait import TraitsData + + +from .. import traits + + + +class ExampleSpecification: + """ + An example specification. + Usage: entity + """ + kTraitSet = { + # 'openassetio-example:example.Unchanged' + traits.example.UnchangedTrait.kId, + # 'openassetio-example:example.Updated' + traits.example.UpdatedTrait.kId, + + } + + def __init__(self, traitsData): + """ + Constructs the specification as a view on the supplied + shared @fqref{TraitsData} "TraitsData" instance. + + @param traitsData @fqref{TraitsData} "TraitsData" + + @warning Specifications are always a view on the supplied data, + which is held by reference. Any changes made to the data will be + visible to any other specifications or @ref trait "traits" that + wrap the same TraitsData instance. + """ + if not isinstance(traitsData, TraitsData): + raise TypeError("Specifications must be constructed with a TraitsData instance") + self.__data = traitsData + + def traitsData(self): + """ + Returns the underlying (shared) @fqref{TraitsData} "TraitsData" + instance held by this specification. + """ + return self.__data + + @classmethod + def create(cls): + """ + Returns a new instance of the Specification, holding a new + @fqref{TraitsData} "TraitsData" instance, pre-populated with all + of the specifications traits. + """ + data = TraitsData(cls.kTraitSet) + return cls(data) + + + def unchangedTrait(self): + """ + Returns the view for the 'openassetio-example:example.Unchanged' trait wrapped around + the data held in this instance. + """ + return traits.example.UnchangedTrait(self.traitsData()) + + def updatedTrait(self): + """ + Returns the view for the 'openassetio-example:example.Updated' trait wrapped around + the data held in this instance. + """ + return traits.example.UpdatedTrait(self.traitsData()) + \ No newline at end of file diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v2/traits/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/v2/traits/__init__.py new file mode 100644 index 0000000..190df00 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v2/traits/__init__.py @@ -0,0 +1,7 @@ +""" +Traits defined in the 'openassetio-example' package. +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from . import example diff --git a/examples/resources/working_with_trait_versions/openassetio_example/v2/traits/example.py b/examples/resources/working_with_trait_versions/openassetio_example/v2/traits/example.py new file mode 100644 index 0000000..9463e35 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/v2/traits/example.py @@ -0,0 +1,257 @@ + +""" +Trait definitions in the 'example' namespace. + +Example namespace +""" + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from typing import Union + +from openassetio.trait import TraitsData + + +class AddedTrait: + """ + An example. + Usage: entity, locale, relationship + """ + kId = "openassetio-example:example.Added.v1" + + def __init__(self, traitsData): + """ + Construct this trait view, wrapping the given data. + + @param traitsData @fqref{TraitsData}} "TraitsData" The target + data that holds/will hold the traits properties. + """ + self.__data = traitsData + + def isImbued(self): + """ + Checks whether the data this trait has been applied to + actually has this trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return self.isImbuedTo(self.__data) + + @classmethod + def isImbuedTo(cls, traitsData): + """ + Checks whether the given data actually has this trait. + @param traitsData: Data to check for trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return traitsData.hasTrait(cls.kId) + + def imbue(self): + """ + Adds this trait to the held data. + + If the data already has this trait, it is a no-op. + """ + self.__data.addTrait(self.kId) + + @classmethod + def imbueTo(cls, traitsData): + """ + Adds this trait to the provided data. + + If the data already has this trait, it is a no-op. + """ + traitsData.addTrait(cls.kId) + + + + +class UnchangedTrait: + """ + An example. + Usage: entity, locale, relationship + """ + kId = "openassetio-example:example.Unchanged.v1" + + def __init__(self, traitsData): + """ + Construct this trait view, wrapping the given data. + + @param traitsData @fqref{TraitsData}} "TraitsData" The target + data that holds/will hold the traits properties. + """ + self.__data = traitsData + + def isImbued(self): + """ + Checks whether the data this trait has been applied to + actually has this trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return self.isImbuedTo(self.__data) + + @classmethod + def isImbuedTo(cls, traitsData): + """ + Checks whether the given data actually has this trait. + @param traitsData: Data to check for trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return traitsData.hasTrait(cls.kId) + + def imbue(self): + """ + Adds this trait to the held data. + + If the data already has this trait, it is a no-op. + """ + self.__data.addTrait(self.kId) + + @classmethod + def imbueTo(cls, traitsData): + """ + Adds this trait to the provided data. + + If the data already has this trait, it is a no-op. + """ + traitsData.addTrait(cls.kId) + + + + +class UpdatedTrait: + """ + An example. + Usage: entity, locale, relationship + """ + kId = "openassetio-example:example.Updated.v2" + + def __init__(self, traitsData): + """ + Construct this trait view, wrapping the given data. + + @param traitsData @fqref{TraitsData}} "TraitsData" The target + data that holds/will hold the traits properties. + """ + self.__data = traitsData + + def isImbued(self): + """ + Checks whether the data this trait has been applied to + actually has this trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return self.isImbuedTo(self.__data) + + @classmethod + def isImbuedTo(cls, traitsData): + """ + Checks whether the given data actually has this trait. + @param traitsData: Data to check for trait. + @return `True` if the underlying data has this trait, `False` + otherwise. + """ + return traitsData.hasTrait(cls.kId) + + def imbue(self): + """ + Adds this trait to the held data. + + If the data already has this trait, it is a no-op. + """ + self.__data.addTrait(self.kId) + + @classmethod + def imbueTo(cls, traitsData): + """ + Adds this trait to the provided data. + + If the data already has this trait, it is a no-op. + """ + traitsData.addTrait(cls.kId) + + + def setPropertyThatWasAdded(self, propertyThatWasAdded: float): + """ + Sets the propertyThatWasAdded property. + + A new property added in the latest version. + """ + if not isinstance(propertyThatWasAdded, float): + raise TypeError("propertyThatWasAdded must be a 'float'.") + self.__data.setTraitProperty(self.kId, "propertyThatWasAdded", propertyThatWasAdded) + + def getPropertyThatWasAdded(self, defaultValue: float=None) -> Union[float, None]: + """ + Gets the value of the propertyThatWasAdded property or the supplied default. + + A new property added in the latest version. + """ + value = self.__data.getTraitProperty(self.kId, "propertyThatWasAdded") + if value is None: + return defaultValue + + if not isinstance(value, float): + if defaultValue is None: + raise TypeError(f"Invalid stored value type: '{type(value).__name__}' should be 'float'.") + return defaultValue + return value + + def setPropertyThatWasRenamed(self, propertyThatWasRenamed: bool): + """ + Sets the propertyThatWasRenamed property. + + A property that has been renamed. + """ + if not isinstance(propertyThatWasRenamed, bool): + raise TypeError("propertyThatWasRenamed must be a 'bool'.") + self.__data.setTraitProperty(self.kId, "propertyThatWasRenamed", propertyThatWasRenamed) + + def getPropertyThatWasRenamed(self, defaultValue: bool=None) -> Union[bool, None]: + """ + Gets the value of the propertyThatWasRenamed property or the supplied default. + + A property that has been renamed. + """ + value = self.__data.getTraitProperty(self.kId, "propertyThatWasRenamed") + if value is None: + return defaultValue + + if not isinstance(value, bool): + if defaultValue is None: + raise TypeError(f"Invalid stored value type: '{type(value).__name__}' should be 'bool'.") + return defaultValue + return value + + def setPropertyToKeep(self, propertyToKeep: str): + """ + Sets the propertyToKeep property. + + A property that is unchanged between versions. + """ + if not isinstance(propertyToKeep, str): + raise TypeError("propertyToKeep must be a 'str'.") + self.__data.setTraitProperty(self.kId, "propertyToKeep", propertyToKeep) + + def getPropertyToKeep(self, defaultValue: str=None) -> Union[str, None]: + """ + Gets the value of the propertyToKeep property or the supplied default. + + A property that is unchanged between versions. + """ + value = self.__data.getTraitProperty(self.kId, "propertyToKeep") + if value is None: + return defaultValue + + if not isinstance(value, str): + if defaultValue is None: + raise TypeError(f"Invalid stored value type: '{type(value).__name__}' should be 'str'.") + return defaultValue + return value + + + diff --git a/examples/resources/working_with_trait_versions/v1.yml b/examples/resources/working_with_trait_versions/v1.yml new file mode 100644 index 0000000..51b38cd --- /dev/null +++ b/examples/resources/working_with_trait_versions/v1.yml @@ -0,0 +1,62 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/OpenAssetIO/OpenAssetIO-TraitGen/main/python/openassetio_traitgen/schema.json +# yamllint disable-line rule:document-start +package: openassetio-example +version: 1 +description: An example traits schema + +traits: + example: + description: Example namespace + members: + Unchanged: + version: 1 + description: An example. + usage: + - entity + - locale + - relationship + + Removed: + version: 1 + description: An example. + usage: + - entity + - locale + - relationship + + Updated: + version: 1 + description: An example. + usage: + - entity + - locale + - relationship + properties: + propertyToKeep: + type: string + description: A property that is unchanged between versions. + propertyToRename: + type: boolean + description: > + A property that has an inappropriate name and should be renamed in the + next version. + propertyToRemove: + type: boolean + description: A defunct property that should be removed in the next version. + +specifications: + example: + description: Test specifications. + members: + + Example: + description: An example. + usage: + - entity + traitSet: + - namespace: example + name: Unchanged + version: 1 + - namespace: example + name: Updated + version: 1 diff --git a/examples/resources/working_with_trait_versions/v2.yml b/examples/resources/working_with_trait_versions/v2.yml new file mode 100644 index 0000000..c1f7b1b --- /dev/null +++ b/examples/resources/working_with_trait_versions/v2.yml @@ -0,0 +1,61 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/OpenAssetIO/OpenAssetIO-TraitGen/main/python/openassetio_traitgen/schema.json +# yamllint disable-line rule:document-start +package: openassetio-example +version: 2 +description: An example traits schema + +traits: + example: + description: Example namespace + members: + Unchanged: + version: 1 + description: An example. + usage: + - entity + - locale + - relationship + + Added: + version: 1 + description: An example. + usage: + - entity + - locale + - relationship + + Updated: + version: 2 + description: An example. + usage: + - entity + - locale + - relationship + properties: + propertyToKeep: + type: string + description: A property that is unchanged between versions. + propertyThatWasRenamed: + type: boolean + description: A property that has been renamed. + propertyThatWasAdded: + type: float + description: A new property added in the latest version. + +specifications: + example: + description: Test specifications. + members: + + Example: + description: An example specification. + usage: + - entity + traitSet: + - namespace: example + name: Unchanged + version: 1 + - namespace: example + name: Updated + version: 2 + diff --git a/examples/working_with_trait_versions.ipynb b/examples/working_with_trait_versions.ipynb new file mode 100644 index 0000000..8bef35d --- /dev/null +++ b/examples/working_with_trait_versions.ipynb @@ -0,0 +1,972 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "source": [ + "# Hosts/Managers: Working with trait versions\n", + "\n", + "This notebook illustrates how a manager and host can work together despite using different trait versions.\n" + ], + "metadata": { + "collapsed": false + }, + "id": "07297a3d-048b-496b-adb0-8fdd67316021" + }, + { + "cell_type": "markdown", + "source": [ + "## Versioned traits" + ], + "metadata": { + "collapsed": false + }, + "id": "dbc81a5ffa923a5" + }, + { + "cell_type": "markdown", + "source": [ + "### Schema subpackages\n", + "\n", + "Trait packages generated by `traitgen` can include subpackages for all available schema versions, with the top-level namespace containing aliases to the most-recent available version.\n", + "\n", + "To illustrate this, we use the versioned trait mockups under `resources/working_with_trait_versions`. \n" + ], + "metadata": { + "collapsed": false + }, + "id": "c8f9847f13e25fa1" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "from resources.working_with_trait_versions.openassetio_example import traits, specifications\n", + "from resources.working_with_trait_versions.openassetio_example import v1\n", + "from resources.working_with_trait_versions.openassetio_example import v2\n", + "\n", + "\n", + "assert traits is v2.traits\n", + "assert specifications is v2.specifications\n", + "\n", + "assert v1.traits is not v2.traits\n", + "assert v1.traits is not v2.specifications" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.501325Z", + "start_time": "2024-04-17T13:35:09.489263Z" + } + }, + "id": "ed3207872eaa89b0", + "execution_count": 1 + }, + { + "cell_type": "markdown", + "source": [ + "Host applications that bundle the trait package may use the non-versioned top-level package by default. \n", + "\n", + "For Python manager plugins, if \n", + "\n", + "* the host application's largest (most-recent) schema version does not match the schema version the Python plugin was developed against; and\n", + "* the Python plugin uses the default (top-level) traits/specifications packages\n", + "\n", + "the application may hit unexpected runtime errors. This is because the structure of trait/specification view classes may be different, or simply not exist.\n", + "\n", + "A Python manager plugin should therefore use a versioned namespace. This solves the problem when the plugin is loaded into an application that defaults to a _higher_ schema version than the plugin was developed for. \n", + "\n", + "However, if the host application's largest available schema version is too _low_ for the manager plugin, then using a versioned namespace would suffer an `ImportError` when attempting to `import` the (too new) versioned namespace. This can either be left to fail (better to fail early), or be tolerated within the plugin by catching the exception and falling back to an older version.\n", + "\n", + "C++ plugins have the trait view classes (privately) compiled into them, so do not depend on the schema version that the host application was built against. Incompatibilities only become apparent at runtime, when incoming trait data is found to be of a version unsupported by the manager or host." + ], + "metadata": { + "collapsed": false + }, + "id": "e96f0b057e4fd49c" + }, + { + "cell_type": "markdown", + "source": [ + "### Trait views within subpackages\n", + "\n", + "We imagine an industry where there are only 4 traits, `AddedTrait`, `RemovedTrait`, `UnchangedTrait` and `UpdatedTrait`, which are used across entities, relationships, policies and locales. The traits themselves have no meaning, they are named purely to give a hint as to how they change in subsequent trait schema versions.\n", + "\n", + "Within a subpackage, all traits within that subpackage's schema version are included, with unchanged traits being duplicated. Duplication may be replaced with aliasing in a future update of `traitgen`." + ], + "metadata": { + "collapsed": false + }, + "id": "26381c27fe243e62" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "assert v2.traits.example.UnchangedTrait is not v1.traits.example.UnchangedTrait\n", + "assert v2.traits.example.UpdatedTrait is not v1.traits.example.UpdatedTrait\n", + "\n", + "assert \"RemovedTrait\" not in v2.traits.example.__dict__\n", + "assert \"RemovedTrait\" in v1.traits.example.__dict__\n", + "\n", + "assert \"AddedTrait\" in v2.traits.example.__dict__\n", + "assert \"AddedTrait\" not in v1.traits.example.__dict__" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:54:03.525882Z", + "start_time": "2024-04-17T13:54:03.513393Z" + } + }, + "id": "1470d16817a82421", + "execution_count": 10 + }, + { + "cell_type": "markdown", + "source": [ + "Each trait encodes its version in its unique ID. \n", + "\n", + "The maximum possible version of a trait is bounded by the top-level schema version (inclusive). That is, whenever a trait's version is incremented, the schema version is also incremented.\n", + "\n", + "The schema version may exceed the largest trait version, however. Adding a new trait will result in a schema version bump, but the new trait itself will start at version 1. Changes to specifications (see below) will also result in a schema bump." + ], + "metadata": { + "collapsed": false + }, + "id": "75a1877c0c9c3786" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "# An updated trait has a different ID in each subpackage.\n", + "assert v1.traits.example.UpdatedTrait.kId == \"openassetio-example:example.Updated.v1\"\n", + "assert v2.traits.example.UpdatedTrait.kId == \"openassetio-example:example.Updated.v2\"\n", + "\n", + "# A newly added trait starts at version 1, despite the schema\n", + "# (subpackage) version being greater than 1.\n", + "assert v2.traits.example.AddedTrait.kId == \"openassetio-example:example.Added.v1\"" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.531776Z", + "start_time": "2024-04-17T13:35:09.516191Z" + } + }, + "id": "a31f8a7263370714", + "execution_count": 3 + }, + { + "cell_type": "markdown", + "source": [ + "This means trait view classes from one schema version cannot be used to read traits of another version, unless the trait is unchanged between schema versions." + ], + "metadata": { + "collapsed": false + }, + "id": "6e6d3357806e5f67" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "from openassetio.trait import TraitsData\n", + "\n", + "\n", + "v1_data, v2_data, unchanged_data = TraitsData(), TraitsData(), TraitsData()\n", + "v1.traits.example.UpdatedTrait.imbueTo(v1_data)\n", + "v2.traits.example.UpdatedTrait.imbueTo(v2_data)\n", + "v1.traits.example.UnchangedTrait.imbueTo(unchanged_data)\n", + "\n", + "assert v1.traits.example.UpdatedTrait.isImbuedTo(v1_data) is True\n", + "assert v1.traits.example.UpdatedTrait.isImbuedTo(v2_data) is False\n", + "\n", + "assert v2.traits.example.UpdatedTrait.isImbuedTo(v1_data) is False\n", + "assert v2.traits.example.UpdatedTrait.isImbuedTo(v2_data) is True\n", + "\n", + "assert v1.traits.example.UnchangedTrait.isImbuedTo(unchanged_data) is True\n", + "assert v2.traits.example.UnchangedTrait.isImbuedTo(unchanged_data) is True" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.544703Z", + "start_time": "2024-04-17T13:35:09.533132Z" + } + }, + "id": "a147006dd4b9b453", + "execution_count": 4 + }, + { + "cell_type": "markdown", + "source": [ + "### Specifications\n", + "\n", + "Specifications are a way to document well-known sets of traits that categorize entities, relationships, locales, or policies. Agreement on these as an industry is crucial for effective interop.\n", + "\n", + "However, they do not have an independent version - their version is implicit in the (versioned) traits that they compose, and in the overall schema version where they are defined. A major consequence of this is that no specification version is embedded in the data itself.\n", + "\n", + "If the raison d'ĂȘtre of specifications is a way to have an industry-standard collection of trait sets, then is the exact version of those traits really important? On the assumption that a new version of a trait doesn't fundamentally change its meaning (otherwise it would be an entirely new trait), then it's reasonable to say that specifications are trait version agnostic.\n", + "\n", + "So specifications are invaluable as documentation of common trait sets. However, the auto-generateed `Specification` view classes should be used with caution: using a specification view class to categorize an entity may lead to unexpected false negatives when the trait versions do not line up.\n" + ], + "metadata": { + "collapsed": false + }, + "id": "ab4dde470a3808d4" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "entity_data = TraitsData(\n", + " {v1.traits.example.UnchangedTrait.kId, v1.traits.example.UpdatedTrait.kId})\n", + "\n", + "# As long as the trait set of the specification lines up with the\n", + "# incoming data, we can use it to categorize an entity.\n", + "is_an_example_entity = v1.specifications.example.ExampleSpecification.kTraitSet.issubset(\n", + " entity_data.traitSet())\n", + "assert is_an_example_entity is True\n", + "\n", + "# Conceptually, the entity is still an Example, but subsequent updates\n", + "# to the specification mean the version suffix on some trait IDs have\n", + "# updated and no longer match the incoming data, so we get a false\n", + "# negative.\n", + "is_an_example_entity = v2.specifications.example.ExampleSpecification.kTraitSet.issubset(\n", + " entity_data.traitSet())\n", + "assert is_an_example_entity is False\n" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.557999Z", + "start_time": "2024-04-17T13:35:09.545693Z" + } + }, + "id": "a94f4bcd14862e3a", + "execution_count": 5 + }, + { + "cell_type": "markdown", + "source": [ + "## Example \n", + "\n", + "The following sections will define a manager and a host and explore how they can communicate despite no prior knowledge of what trait versions each side will send.\n", + "\n", + "### Prerequisites\n", + "\n", + "First we must define some boilerplate." + ], + "metadata": { + "collapsed": false + }, + "id": "a06b8108bf6b24f1" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "from openassetio.hostApi import HostInterface\n", + "from openassetio.managerApi import Host, HostSession\n", + "from openassetio.log import ConsoleLogger, SeverityFilter\n", + "\n", + "\n", + "class NotebookHostInterface(HostInterface):\n", + " def identifier(self):\n", + " return \"org.jupyter.notebook\"\n", + "\n", + " def displayName(self):\n", + " return \"Jupyter Notebook\"\n", + "\n", + "\n", + "host_session = HostSession(Host(NotebookHostInterface()), SeverityFilter(ConsoleLogger()))" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.575258Z", + "start_time": "2024-04-17T13:35:09.558968Z" + } + }, + "id": "592a08fb134655a", + "execution_count": 6 + }, + { + "cell_type": "markdown", + "source": [ + "### Manager \n", + "\n", + "In the following a manager implementation is defined that relies solely on the versioned trait mockups under `resources/working_with_trait_versions`. \n", + "\n", + "The idea is simply to tease out possible patterns of versioned trait combinations and access patterns that might cause problems for implementors. As such, the semantics are nonsense, but hopefully the branching logic is roughly representative." + ], + "metadata": { + "collapsed": false + }, + "id": "6a4b40c3f5f306e" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "from openassetio.managerApi import ManagerInterface, EntityReferencePagerInterface\n", + "from openassetio import errors, access, EntityReference\n", + "\n", + "\n", + "an_entity_ref_str = \"example://entity\"\n", + "\n", + "\n", + "class ExampleManagerInterface(ManagerInterface):\n", + "\n", + " def identifier(self):\n", + " return \"org.openassetio.example.manager\"\n", + "\n", + " def displayName(self):\n", + " return \"Example Manager\"\n", + "\n", + " def hasCapability(self, capability):\n", + " return capability in (\n", + " ManagerInterface.Capability.kEntityReferenceIdentification,\n", + " ManagerInterface.Capability.kManagementPolicyQueries,\n", + " ManagerInterface.Capability.kEntityTraitIntrospection,\n", + " ManagerInterface.Capability.kResolution,\n", + " ManagerInterface.Capability.kPublishing,\n", + " ManagerInterface.Capability.kRelationshipQueries,\n", + " ManagerInterface.Capability.kDefaultEntityReferences,\n", + " )\n", + "\n", + " def isEntityReferenceString(self, someString, _hostSession):\n", + " return someString.startswith(\"example://\")\n", + "\n", + " def managementPolicy(self, traitSets, policyAccess, context, _hostSession):\n", + " # Initialise default empty response, to be filled in below.\n", + " policy_datas = [TraitsData() for _ in traitSets]\n", + "\n", + " # We care about the specific Context locale under which this\n", + " # query was made.\n", + " is_locale_special = False\n", + " special_locale_value = False\n", + "\n", + " # Assume we know from reading release notes that UnchangedTrait\n", + " # hasn't changed, so we can use v2 knowing that v1 is equivalent.\n", + " if v2.traits.example.UnchangedTrait.isImbuedTo(context.locale):\n", + " # UpdatedTrait changes between versions, but we know from\n", + " # release notes that the property we're interested in still\n", + " # exists semantically, it's just the name has changed.\n", + "\n", + " # Check if v2 of UpdatedTrait is imbued, and if so extract\n", + " # the property via its new name.\n", + " if v2.traits.example.UpdatedTrait.isImbuedTo(context.locale):\n", + " is_locale_special = True\n", + " special_locale_value = v2.traits.example.UpdatedTrait(\n", + " context.locale).getPropertyThatWasRenamed(defaultValue=special_locale_value)\n", + "\n", + " # If v2 of UpdatedTrait was not imbued, fall back to v1. If\n", + " # imbued, extract the property via its old name. If both v2\n", + " # and v1 were imbued for some reason, prefer v2.\n", + " elif v1.traits.example.UpdatedTrait.isImbuedTo(context.locale):\n", + " is_locale_special = True\n", + " special_locale_value = v1.traits.example.UpdatedTrait(\n", + " context.locale).getPropertyToRename(defaultValue=special_locale_value)\n", + "\n", + " for trait_set, policy_data in zip(traitSets, policy_datas):\n", + " if policyAccess is access.PolicyAccess.kRead:\n", + "\n", + " # Read is only supported when the locale is \"special\".\n", + " if not is_locale_special:\n", + " continue\n", + "\n", + " # Only sets with the v2 Added or v1 Removed trait are\n", + " # supported.\n", + " if not (v1.traits.example.RemovedTrait.kId in trait_set or\n", + " v2.traits.example.AddedTrait.kId in trait_set):\n", + " continue\n", + "\n", + " if special_locale_value is True:\n", + " # Since the locale's \"special\" value is set, we are\n", + " # capable of providing property values for either or\n", + " # both v1 and v2 of UpdatedTrait simultaneously.\n", + "\n", + " if v2.traits.example.UpdatedTrait.kId in trait_set:\n", + " v2.traits.example.UpdatedTrait.imbueTo(policy_data)\n", + "\n", + " if v1.traits.example.UpdatedTrait.kId in trait_set:\n", + " v1.traits.example.UpdatedTrait.imbueTo(policy_data)\n", + " else:\n", + " # Since the locale's \"special\" value is not set, we\n", + " # can only provide property values for either v1 or\n", + " # v2, but not both, of UpdatedTrait. We prefer v2.\n", + "\n", + " if (v2.traits.example.UpdatedTrait.kId in trait_set\n", + " and not v1.traits.example.UpdatedTrait.kId in trait_set):\n", + " v2.traits.example.UpdatedTrait.imbueTo(policy_data)\n", + " elif v1.traits.example.UpdatedTrait.kId in trait_set:\n", + " v1.traits.example.UpdatedTrait.imbueTo(policy_data)\n", + "\n", + " continue\n", + " else:\n", + " # Other access modes all the same.\n", + " if not is_locale_special:\n", + " continue\n", + "\n", + " # Prefer v2, ignoring v1 if it is set.\n", + "\n", + " if v2.traits.example.UpdatedTrait.kId in trait_set:\n", + " v2.traits.example.UpdatedTrait.imbueTo(policy_data)\n", + " elif v1.traits.example.UpdatedTrait.kId in trait_set:\n", + " v1.traits.example.UpdatedTrait.imbueTo(policy_data)\n", + " \n", + "\n", + " return policy_datas\n", + "\n", + " def defaultEntityReference(\n", + " self, traitSets, defaultEntityAccess, context, hostSession, successCallback,\n", + " errorCallback):\n", + " for idx, trait_set in enumerate(traitSets):\n", + " is_an_unchanged = v2.traits.example.UnchangedTrait.kId in trait_set\n", + " is_an_updated = (v2.traits.example.UpdatedTrait.kId in trait_set or\n", + " v1.traits.example.UpdatedTrait.kId in trait_set)\n", + " is_a_removed = v1.traits.example.RemovedTrait.kId in trait_set\n", + " is_an_added = v2.traits.example.AddedTrait.kId in trait_set\n", + "\n", + " if is_an_unchanged and is_an_updated and is_a_removed:\n", + " # Only possible with v1 schema/trait\n", + " entity_ref = \"example://default/removed\"\n", + " elif is_an_unchanged and is_an_updated and is_an_added:\n", + " # Only possible with v2 schema/trait\n", + " entity_ref = \"example://default/added\"\n", + " elif is_an_unchanged and is_an_updated:\n", + " # v1 or v2 schemas\n", + " entity_ref = \"example://default\"\n", + " else:\n", + " # Any other unrecognized trait set\n", + " errorCallback(\n", + " idx, errors.BatchElementError(\n", + " errors.BatchElementError.ErrorCode.kInvalidTraitSet,\n", + " \"Entity trait set unrecognised\"))\n", + " continue\n", + "\n", + " # Not really important here, but for completeness handle all\n", + " # access modes.\n", + " if defaultEntityAccess is access.DefaultEntityAccess.kWrite:\n", + " entity_ref += \"/new\"\n", + " elif defaultEntityAccess is access.DefaultEntityAccess.kCreateRelated:\n", + " entity_ref += \"/child/new\"\n", + "\n", + " successCallback(idx, entity_ref)\n", + "\n", + " def entityTraits(\n", + " self,\n", + " entityRefs,\n", + " entityTraitsAccess,\n", + " context,\n", + " hostSession,\n", + " successCallback,\n", + " errorCallback):\n", + " for idx, entity_ref in enumerate(entityRefs):\n", + " # This manager only supports one entity ref.\n", + " if str(entity_ref) == an_entity_ref_str:\n", + " # Read access\n", + " if entityTraitsAccess == access.EntityTraitsAccess.kRead:\n", + " # We use the ExampleSpecification - a well-known\n", + " # trait set with all traits required to categorize\n", + " # an entity as an Example. No way to know what\n", + " # version the host would prefer. So prefer latest,\n", + " # v2.\n", + " successCallback(idx, v2.specifications.example.ExampleSpecification.kTraitSet)\n", + " else:\n", + " # Minimum required for publishing is a reduced set.\n", + " successCallback(\n", + " idx, v2.specifications.example.ExampleSpecification.kTraitSet - {\n", + " v2.traits.example.AddedTrait.kId})\n", + "\n", + " else:\n", + " errorCallback(\n", + " idx, errors.BatchElementError(\n", + " errors.BatchElementError.ErrorCode.kEntityResolutionError,\n", + " \"Entity doesn't exist\"))\n", + "\n", + " def resolve(\n", + " self,\n", + " entityRefs,\n", + " traitSet,\n", + " resolveAccess,\n", + " context,\n", + " hostSession,\n", + " successCallback,\n", + " errorCallback):\n", + " traits_datas = [TraitsData() for _ in entityRefs]\n", + "\n", + " for idx, (entity_ref, traits_data) in enumerate(zip(entityRefs, traits_datas)):\n", + " if str(entity_ref) == an_entity_ref_str:\n", + " if resolveAccess == access.ResolveAccess.kRead:\n", + " # Support either v1 or v2 (or both) for read.\n", + " if v2.traits.example.UpdatedTrait.kId in traitSet:\n", + " trait = v2.traits.example.UpdatedTrait(traits_data)\n", + " trait.setPropertyToKeep(\"value\")\n", + " trait.setPropertyThatWasRenamed(True)\n", + " trait.setPropertyThatWasAdded(123.456)\n", + " if v1.traits.example.UpdatedTrait.kId in traitSet:\n", + " trait = v1.traits.example.UpdatedTrait(traits_data)\n", + " trait.setPropertyToKeep(\"value\")\n", + " trait.setPropertyToRename(True)\n", + " trait.setPropertyToRemove(False)\n", + "\n", + " elif resolveAccess == access.ResolveAccess.kManagerDriven:\n", + " # Only support v2 for publishing workflows.\n", + " if v2.traits.example.UpdatedTrait.kId in traitSet:\n", + " trait = v2.traits.example.UpdatedTrait(traits_data)\n", + " trait.setPropertyThatWasAdded(456.789)\n", + " else:\n", + " # Similar for other entities.\n", + " if resolveAccess == access.ResolveAccess.kManagerDriven:\n", + " # Only support v2 for publishing workflows.\n", + " if v2.traits.example.UpdatedTrait.kId in traitSet:\n", + " trait = v2.traits.example.UpdatedTrait(traits_data)\n", + " trait.setPropertyThatWasAdded(789.123)\n", + " trait.setPropertyThatWasRenamed(True)\n", + "\n", + " successCallback(idx, traits_data)\n", + "\n", + " def getWithRelationship(\n", + " self,\n", + " entityRefs,\n", + " relationshipTraitsData,\n", + " resultTraitSet,\n", + " pageSize,\n", + " relationsAccess,\n", + " context,\n", + " hostSession,\n", + " successCallback,\n", + " errorCallback):\n", + "\n", + " # Parse out important aspects of the type of relationship.\n", + "\n", + " is_rel_unchanged = v2.traits.example.UnchangedTrait.isimbuedTo(\n", + " relationshipTraitsData)\n", + "\n", + " rel_v2_updated = v2.traits.example.UpdatedTrait(relationshipTraitsData)\n", + " rel_v1_updated = v1.traits.example.UpdatedTrait(relationshipTraitsData)\n", + " is_rel_updated = rel_v2_updated.isImbued() or rel_v1_updated.isImbued()\n", + "\n", + " important_property = rel_v2_updated.getPropertyThatWasRenamed(\n", + " defaultValue=rel_v1_updated.getPropertyToRename(defaultValue=False))\n", + "\n", + " is_a_child_relationship = is_rel_unchanged and is_rel_updated\n", + "\n", + " # Parse out important aspects of the type of expected result entity.\n", + "\n", + " result_type = \"none\"\n", + " if {v2.traits.example.AddedTrait.kId,\n", + " v2.traits.example.UnchangedTrait.kId}.issubset(resultTraitSet):\n", + " result_type = \"component\"\n", + " elif {v1.traits.example.RemovedTrait.kId,\n", + " v2.traits.example.UnchangedTrait.kId}.issubset(resultTraitSet):\n", + " result_type = \"element\"\n", + "\n", + " # Loop through input entities.\n", + "\n", + " for idx, entity_ref in enumerate(entityRefs):\n", + "\n", + " rels = []\n", + "\n", + " if relationsAccess is access.RelationsAccess.kRead:\n", + " if str(entity_ref) == an_entity_ref_str:\n", + " if is_a_child_relationship:\n", + " if result_type == \"component\":\n", + " rels.append(EntityReference(\"example://entity/component/1\"))\n", + " rels.append(EntityReference(\"example://entity/component/2\"))\n", + "\n", + " elif result_type == \"element\":\n", + " rels.append(EntityReference(\"example://entity/element/a\"))\n", + "\n", + " if result_type != \"none\" and important_property is True:\n", + " rels.append(EntityReference(\"example://entity/component/3/element/b\"))\n", + " else:\n", + " # Similar for other entity refs.\n", + " ...\n", + "\n", + " elif relationsAccess is access.RelationsAccess.kWrite:\n", + " if str(entity_ref) == an_entity_ref_str:\n", + " if is_a_child_relationship:\n", + " # Only respond for \"component\" (i.e. v2) relationships.\n", + " if result_type == \"component\":\n", + " rels.append(EntityReference(\"example://entity/component/1/edit\"))\n", + " rels.append(EntityReference(\"example://entity/component/2/edit\"))\n", + " else:\n", + " # Similar for other entity refs.\n", + " ...\n", + "\n", + " elif relationsAccess is access.RelationsAccess.kCreateRelated:\n", + " if str(entity_ref) == an_entity_ref_str:\n", + " if is_a_child_relationship:\n", + " # Only respond for \"component\" (i.e. v2) relationships.\n", + " if result_type == \"component\":\n", + " rels.append(EntityReference(\"example://entity/component/new\"))\n", + " else:\n", + " # Similar for other entity refs.\n", + " ...\n", + "\n", + " successCallback(idx, ExampleEntityReferencePagerInterface(pageSize, rels))\n", + "\n", + " def getWithRelationships(\n", + " self,\n", + " entityReference,\n", + " relationshipTraitsDatas,\n", + " resultTraitSet,\n", + " pageSize,\n", + " relationsAccess,\n", + " context,\n", + " hostSession,\n", + " successCallback,\n", + " errorCallback):\n", + " # Largely same as getWithRelationship, with outer loop changed.\n", + " ...\n", + "\n", + " def preflight(\n", + " self,\n", + " targetEntityRefs,\n", + " traitsDatas,\n", + " publishingAccess,\n", + " context,\n", + " hostSession,\n", + " successCallback,\n", + " errorCallback):\n", + "\n", + " for idx, (entity_ref, traits_data) in enumerate(zip(targetEntityRefs, traitsDatas)):\n", + " if str(entity_ref) != an_entity_ref_str:\n", + " errorCallback(\n", + " idx, errors.BatchElementError(\n", + " errors.BatchElementError.ErrorCode.kEntityAccessError,\n", + " \"Cannot publish to this entity\"))\n", + " continue\n", + "\n", + " # Categorise the data to publish\n", + " is_an_unchanged = v2.traits.example.UnchangedTrait.isImbuedTo(traits_data)\n", + " is_an_updated = (v2.traits.example.UpdatedTrait.isImbuedTo(traits_data) or\n", + " v1.traits.example.UpdatedTrait.isImbuedTo(traits_data))\n", + " is_a_removed = v1.traits.example.RemovedTrait.isImbuedTo(traits_data)\n", + " is_an_added = v2.traits.example.AddedTrait.isImbuedTo(traits_data)\n", + "\n", + " # Based on the given traits, categorize to pipeline-specific, \"type\"\n", + " is_an_item = is_an_added and is_an_updated\n", + " is_a_unit = is_a_removed and is_an_updated\n", + " is_an_ingredient = is_an_unchanged and is_an_updated\n", + "\n", + " # At least and only one, i.e. n-ary xor.\n", + " if int(is_an_item) + int(is_a_unit) + int(is_an_ingredient) != 1:\n", + " errorCallback(\n", + " idx, errors.BatchElementError(\n", + " errors.BatchElementError.ErrorCode.kInvalidPreflightHint,\n", + " \"Unsupported traits for publishing to this entity\"))\n", + " continue\n", + "\n", + " if is_an_item:\n", + " v2_updated_trait = v2.traits.example.UpdatedTrait(traits_data)\n", + " v1_updated_trait = v1.traits.example.UpdatedTrait(traits_data)\n", + "\n", + " if v2_updated_trait.isImbued():\n", + " specialisation = v2_updated_trait.getPropertyToKeep()\n", + " else: # at this point guaranteed that v1 is imbued.\n", + " specialisation = v1_updated_trait.getPropertyToKeep()\n", + "\n", + " successCallback(\n", + " idx, EntityReference(\n", + " f\"example://working_ref/item/{specialisation}/new\"))\n", + "\n", + " elif is_a_unit:\n", + " successCallback(idx, EntityReference(f\"example://working_ref/unit/new\"))\n", + "\n", + " elif is_an_ingredient:\n", + " successCallback(idx, EntityReference(f\"example://working_ref/ingredient/new\"))\n", + "\n", + " def register(\n", + " self,\n", + " targetEntityRefs,\n", + " entityTraitsDatas,\n", + " publishingAccess,\n", + " context,\n", + " hostSession,\n", + " successCallback,\n", + " errorCallback):\n", + " for idx, (entity_ref, traits_data) in enumerate(zip(targetEntityRefs, entityTraitsDatas)):\n", + " # Must provide a reference returned from `preflight`\n", + " if not str(entity_ref).startswith(\"example://working_ref/\"):\n", + " errorCallback(\n", + " idx, errors.BatchElementError(\n", + " errors.BatchElementError.ErrorCode.kEntityAccessError,\n", + " \"Cannot publish to this entity\"))\n", + " continue\n", + "\n", + " # Categorise the data to publish\n", + " is_an_unchanged = v2.traits.example.UnchangedTrait.isImbuedTo(traits_data)\n", + " is_an_updated = (v2.traits.example.UpdatedTrait.isImbuedTo(traits_data) or\n", + " v1.traits.example.UpdatedTrait.isImbuedTo(traits_data))\n", + " is_a_removed = v1.traits.example.RemovedTrait.isImbuedTo(traits_data)\n", + " is_an_added = v2.traits.example.AddedTrait.isImbuedTo(traits_data)\n", + "\n", + " # Based on the given traits, categorize to pipeline-specific, \"type\"\n", + " is_an_item = is_an_added and is_an_updated\n", + " is_a_unit = is_a_removed and is_an_updated\n", + " is_an_ingredient = is_an_unchanged and is_an_updated\n", + "\n", + " # At least and only one, i.e. n-ary xor.\n", + " if int(is_an_item) + int(is_a_unit) + int(is_an_ingredient) != 1:\n", + " errorCallback(\n", + " idx, errors.BatchElementError(\n", + " errors.BatchElementError.ErrorCode.kPreflightHintError,\n", + " \"Unsupported traits for publishing to this entity\"))\n", + " continue\n", + "\n", + " v2_updated_trait = v2.traits.example.UpdatedTrait(traits_data)\n", + " v1_updated_trait = v1.traits.example.UpdatedTrait(traits_data)\n", + " if is_an_item:\n", + " if v2_updated_trait.isImbued():\n", + " foo = v2_updated_trait.getPropertyThatWasRenamed()\n", + " bar = v2_updated_trait.getPropertyThatWasAdded()\n", + " do_backend_operation(ref=entity_ref, foo=foo, bar=bar, baz=None)\n", + " else: # at this point guaranteed that v1 is imbued.\n", + " foo = v1_updated_trait.getPropertyToRename()\n", + " baz = v1_updated_trait.getPropertyToRemove()\n", + " do_backend_operation(ref=entity_ref, foo=foo, bar=None, baz=baz)\n", + "\n", + " successCallback(idx, EntityReference(f\"example://item\"))\n", + "\n", + " elif is_a_unit:\n", + " if v2_updated_trait.isImbued():\n", + " foo = v2_updated_trait.getPropertyToKeep()\n", + " do_backend_operation(ref=entity_ref, foo=foo)\n", + " else: # at this point guaranteed that v1 is imbued.\n", + " foo = v1_updated_trait.getPropertyToKeep()\n", + " do_backend_operation(ref=entity_ref, foo=foo)\n", + "\n", + " successCallback(idx, EntityReference(f\"example://unit\"))\n", + "\n", + " elif is_an_ingredient:\n", + " if v2_updated_trait.isImbued():\n", + " baz = v2_updated_trait.getPropertyThatWasAdded()\n", + " do_backend_operation(ref=entity_ref, foo=None, baz=baz)\n", + " else: # at this point guaranteed that v1 is imbued.\n", + " foo = v1_updated_trait.getPropertyToRemove()\n", + " do_backend_operation(ref=entity_ref, foo=foo, baz=None)\n", + "\n", + " successCallback(idx, EntityReference(f\"example://ingredient\"))\n", + "\n", + "\n", + "class ExampleEntityReferencePagerInterface(EntityReferencePagerInterface):\n", + " def __init__(self, page_size, results):\n", + " self.__results = results\n", + " self.__idx = 0\n", + " self.__page_size = page_size\n", + "\n", + " def hasNext(self, _hostSession):\n", + " return self.__idx < len(self.__results)\n", + "\n", + " def get(self, _hostSession):\n", + " return self.__results[self.__idx:self.__idx + self.__page_size]\n", + "\n", + " def next(self, _hostSession):\n", + " self.__idx += self.__page_size\n", + "\n", + " def close(self, _hostSession):\n", + " pass\n", + "\n", + "\n", + "def do_backend_operation(**kwargs):\n", + " # Do some pipeline-specific backend operation.\n", + " pass\n" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.623548Z", + "start_time": "2024-04-17T13:35:09.576565Z" + } + }, + "id": "5bb5454ca1ae8b06", + "execution_count": 7 + }, + { + "cell_type": "markdown", + "source": [ + "### Host\n", + "\n", + "Next we investigate how a host application might interact with this manager. The following is modified from the generic republish workflow in `generic_republish.ipynb`. Once again, the semantics are nonsense, but hopefully the branching logic is instructive." + ], + "metadata": { + "collapsed": false + }, + "id": "864fe9a610fff83" + }, + { + "cell_type": "code", + "outputs": [], + "source": [ + "from openassetio.hostApi import Manager\n", + "\n", + "\n", + "# Boilerplate preamble.\n", + "manager = Manager(ExampleManagerInterface(), host_session)\n", + "context = manager.createContext()\n", + "an_entity_ref = manager.createEntityReference(an_entity_ref_str)\n", + "\n", + "# Configure the locale\n", + "\n", + "context.locale.addTraits(v2.specifications.example.ExampleSpecification.kTraitSet)\n", + "\n", + "# The minimum set of traits required to publish to this entity\n", + "# reference.\n", + "minimum_trait_set = manager.entityTraits(an_entity_ref, access.EntityTraitsAccess.kWrite, context)\n", + "\n", + "is_using_v2_traits = True # Track whether we detect v2 isn't supported.\n", + "\n", + "# Whatever the minimum trait set is, we know we're going to publish an\n", + "# Example.\n", + "desired_trait_set = minimum_trait_set | v2.specifications.example.ExampleSpecification.kTraitSet\n", + "\n", + "# Get the set of traits that have properties the manager can persist.\n", + "[policy_for_desired_traits] = manager.managementPolicy(\n", + " [desired_trait_set], access.PolicyAccess.kWrite, context)\n", + "\n", + "# Check if the policy contains a trait that we expect to persist, and\n", + "# attempt to fall back to v1 if the trait is not supported. This\n", + "# requires us to know that the UpdatedTrait is part of the\n", + "# ExampleSpecification (and it is the only trait that carries `resolve`able\n", + "# properties).\n", + "if not v2.traits.example.UpdatedTrait.isImbuedTo(policy_for_desired_traits):\n", + " # v2 didn't work, try v1.\n", + " desired_trait_set = minimum_trait_set | v1.specifications.example.ExampleSpecification.kTraitSet\n", + "\n", + " # Get the set of traits that have properties the manager can\n", + " # persist.\n", + " [policy_for_desired_traits] = manager.managementPolicy(\n", + " [desired_trait_set], access.PolicyAccess.kWrite, context)\n", + "\n", + " if not v1.traits.example.UpdatedTrait.isImbuedTo(policy_for_desired_traits):\n", + " # v1 didn't work either, bail.\n", + " raise Exception(f\"Cannot publish an Example to ref {an_entity_ref}\")\n", + "\n", + " is_using_v2_traits = False\n", + "\n", + "# Filter down the desired traits to only those that are supported.\n", + "trait_set_to_publish = desired_trait_set & policy_for_desired_traits.traitSet()\n", + "\n", + "# We want to keep (the minimum amount of) data from the previous\n", + "# version, except for the values we're going to provide.\n", + "if is_using_v2_traits:\n", + " trait_set_to_keep = trait_set_to_publish - v2.specifications.example.ExampleSpecification.kTraitSet\n", + "else:\n", + " trait_set_to_keep = trait_set_to_publish - v1.specifications.example.ExampleSpecification.kTraitSet\n", + "\n", + "# Get the properties that we wish to keep from the current version.\n", + "data_to_publish = manager.resolve(\n", + " an_entity_ref, trait_set_to_keep, access.ResolveAccess.kRead, context)\n", + "\n", + "# Any traits without properties, or where the manager cannot provide\n", + "# them, will be missing from the data. We still need to imbue those\n", + "# traits, so that manager knows what kind of entity we are publishing.\n", + "data_to_publish.addTraits(minimum_trait_set)\n", + "\n", + "# Get the manager's policy for dictating trait properties, i.e. which\n", + "# traits the manager can \"drive\" for us.\n", + "[policy_for_derived_traits] = manager.managementPolicy(\n", + " [trait_set_to_publish], access.PolicyAccess.kManagerDriven, context)\n", + "\n", + "# Check if the manager can derive a value for us.\n", + "if v2.traits.example.UpdatedTrait.isImbuedTo(policy_for_derived_traits):\n", + " # Imbue an empty trait, so that the manager is aware in `preflight`\n", + " # that we intend to publish this trait. We will ask the manager to\n", + " # fill in the value for us before calling `register`.\n", + " v2.traits.example.UpdatedTrait.imbueTo(data_to_publish)\n", + "elif v1.traits.example.UpdatedTrait.isImbuedTo(policy_for_derived_traits):\n", + " # Fall back to v1.\n", + " v1.traits.example.UpdatedTrait.imbueTo(data_to_publish)\n", + "else:\n", + " # If the manager doesn't want to provide a value for entities of\n", + " # this type, use a default.\n", + " if is_using_v2_traits:\n", + " v2.traits.example.UpdatedTrait(data_to_publish).setPropertyThatWasRenamed(True)\n", + " else:\n", + " v1.traits.example.UpdatedTrait(data_to_publish).setPropertyToRename(True)\n", + "\n", + "# We can now successfully begin the publishing process.\n", + "working_ref = manager.preflight(\n", + " an_entity_ref, data_to_publish, access.PublishingAccess.kWrite, context)\n", + "\n", + "# Check if the manager can provide a value to us.\n", + "# First try v2.\n", + "if v2.traits.example.UpdatedTrait.kId in policy_for_derived_traits.traitSet():\n", + " derived_data = manager.resolve(\n", + " working_ref, {v2.traits.example.UpdatedTrait.kId}, access.ResolveAccess.kManagerDriven,\n", + " context)\n", + "\n", + " v2.traits.example.UpdatedTrait(data_to_publish).setPropertyThatWasRenamed(\n", + " v2.traits.example.UpdatedTrait(derived_data).getPropertyThatWasRenamed())\n", + "\n", + "# Fall back to v1.\n", + "elif v1.traits.example.UpdatedTrait.kId in policy_for_derived_traits.traitSet():\n", + " derived_data = manager.resolve(\n", + " working_ref, {v1.traits.example.UpdatedTrait.kId}, access.ResolveAccess.kManagerDriven,\n", + " context)\n", + "\n", + " v1.traits.example.UpdatedTrait(data_to_publish).setPropertyToRename(\n", + " v1.traits.example.UpdatedTrait(derived_data).getPropertyToRename())\n", + "\n", + "# [Do some work to write the new file...]\n", + "\n", + "# We can now finally publish\n", + "updated_ref = manager.register(\n", + " working_ref, data_to_publish, access.PublishingAccess.kWrite, context)" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-04-17T13:35:09.638280Z", + "start_time": "2024-04-17T13:35:09.624587Z" + } + }, + "id": "b8e12c55d88bda27", + "execution_count": 8 + }, + { + "cell_type": "markdown", + "source": [ + "## Conclusion\n", + "\n", + "The above explorations have shown that working with versioned traits is entirely possible using the existing API.\n", + "\n", + "There are a few cases where the version of a trait is unimportant, i.e. where the properties are not required since we only wish to use the traits to categorize an entity/relationship/policy/locale. Those cases may warrant API changes to reduce boilerplate. In particular, Specification view classes are currently unsuitable for this use-case. However, this boilerplate does not _prevent_ workflows. Specifications can be used as documentation to help authors construct their own trait set detection logic. So the addition of utility functions is not critical to working with versioned traits.\n", + "\n", + "Conversely, when constructing a trait set or data, a schema version must be chosen by the host/manager based on their preference (theoretically, a host/manager could choose to mix and match trait versions from multiple schema versions when constructing data, but we assume that is an unusual pattern). It is in these circumstances that Specification view classes are particularly useful.\n", + "\n", + "It seems clear that dealing with mixed trait versions adds a lot of branching logic that could be hard to follow and maintain. A tempting solution is to expose the version of the schema, which will be used by a host/manager when constructing trait sets/data, as a queryable value, so that branching can be performed at a higher level. However, as discussed in [DR023](https://github.com/OpenAssetIO/OpenAssetIO/blob/main/doc/decisions/DR023-Versioning-traits-and-specifications-method.md), this precludes many important workflows, since the ultimate provenance of trait data is unknown in the general case. For example, the manager may combine old data from a database with newly generated data; or the host may load an old project file holding trait data of a previous schema version; or multiple components of a system, each working with a different schema version, may collaborate to produce a trait set/data." + ], + "metadata": { + "collapsed": false + }, + "id": "257d865837cecdad" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}