diff --git a/examples/resources/working_with_trait_versions/example.yml b/examples/resources/working_with_trait_versions/example.yml new file mode 100644 index 0000000..0ea2e5a --- /dev/null +++ b/examples/resources/working_with_trait_versions/example.yml @@ -0,0 +1,95 @@ +# 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 +description: An example traits schema + +traits: + example: + description: Example namespace + members: + + Deprecated: + deprecated: true + versions: + 1: + description: An example. + usage: + - entity + - locale + - relationship + + Added: + versions: + 1: + description: An example. + usage: + - entity + - locale + - relationship + + Updated: + versions: + 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. + 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: + versions: + 1: + description: An example. + usage: + - entity + traitSet: + - namespace: example + name: Added + version: 1 + - namespace: example + name: Updated + version: 1 + 2: + description: An example. + usage: + - entity + traitSet: + - namespace: example + name: Added + version: 1 + - namespace: example + name: Updated + version: 2 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..a755489 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/__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/specifications/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/specifications/__init__.py new file mode 100644 index 0000000..06aaee0 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/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/specifications/example.py b/examples/resources/working_with_trait_versions/openassetio_example/specifications/example.py new file mode 100644 index 0000000..ef36ffe --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/specifications/example.py @@ -0,0 +1,139 @@ + +""" +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_v2: + """ + An example. + Usage: entity + """ + kTraitSet = { + # 'openassetio-example:example.Deprecated' + traits.example.AddedTrait_v1.kId, + # 'openassetio-example:example.Updated' + traits.example.UpdatedTrait_v2.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 addedTrait(self): + """ + Returns the view for the 'openassetio-example:example.Unchanged' trait wrapped around + the data held in this instance. + """ + return traits.example.AddedTrait_v1(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_v2(self.traitsData()) + + +class ExampleSpecification_v1: + """ + An example. + Usage: entity + """ + kTraitSet = { + # 'openassetio-example:example.Deprecated' + traits.example.AddedTrait_v1.kId, + # 'openassetio-example:example.Updated' + traits.example.UpdatedTrait_v1.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 addedTrait(self): + """ + Returns the view for the 'openassetio-example:example.Unchanged' trait wrapped around + the data held in this instance. + """ + return traits.example.AddedTrait_v1(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_v2(self.traitsData()) + + +# Alias for first version. +ExampleSpecification = ExampleSpecification_v1 diff --git a/examples/resources/working_with_trait_versions/openassetio_example/traits/__init__.py b/examples/resources/working_with_trait_versions/openassetio_example/traits/__init__.py new file mode 100644 index 0000000..190df00 --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/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/traits/example.py b/examples/resources/working_with_trait_versions/openassetio_example/traits/example.py new file mode 100644 index 0000000..ce96f6a --- /dev/null +++ b/examples/resources/working_with_trait_versions/openassetio_example/traits/example.py @@ -0,0 +1,431 @@ +""" +Trait definitions in the 'example' namespace. + +Example namespace +""" +import warnings + +# WARNING: This file is auto-generated by openassetio-traitgen, do not edit. + +from typing import Union + +from openassetio.trait import TraitsData + + +class DeprecatedTrait_v1: + """ + An example. + Usage: entity, locale, relationship + """ + + kId = "openassetio-example:example.Deprecated" + + __deprecated__ = True # (Eventually use PEP 702) + + 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) + + +# Alias for first version. +class DeprecatedTrait(DeprecatedTrait_v1): + def __init__(self, traitsData): + super().__init__(traitsData) + warnings.warn( + "Use of unversioned trait view classes is deprecated", + category=DeprecationWarning, + stacklevel=2, + ) + +class UpdatedTrait_v2: + """ + 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 + + +class UpdatedTrait_v1: + """ + An example. + Usage: entity, locale, relationship + """ + + kId = "openassetio-example:example.Updated" + + 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 + + +# Alias to first version +class UpdatedTrait(UpdatedTrait_v1): + def __init__(self, traitsData): + super().__init__(traitsData) + warnings.warn( + "Use of unversioned trait view classes is deprecated", + category=DeprecationWarning, + stacklevel=2, + ) + +class AddedTrait_v1: + """ + An example. + Usage: entity, locale, relationship + """ + + kId = "openassetio-example:example.Added" + + 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) + + +# Alias to first version +class AddedTrait(AddedTrait_v1): + def __init__(self, traitsData): + super().__init__(traitsData) + warnings.warn( + "Use of unversioned trait view classes is deprecated", + category=DeprecationWarning, + stacklevel=2, + ) diff --git a/examples/working_with_trait_versions.ipynb b/examples/working_with_trait_versions.ipynb new file mode 100644 index 0000000..1bf6c11 --- /dev/null +++ b/examples/working_with_trait_versions.ipynb @@ -0,0 +1,992 @@ +{ + "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": [ + "\n", + "For illustration, we use the versioned trait mockups under `resources/working_with_trait_versions`. \n" + ], + "metadata": { + "collapsed": false + }, + "id": "c8f9847f13e25fa1" + }, + { + "cell_type": "code", + "source": "from resources.working_with_trait_versions.openassetio_example import traits, specifications", + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-05-16T13:20:48.919285Z", + "start_time": "2024-05-16T13:20:48.906980Z" + } + }, + "id": "ed3207872eaa89b0", + "outputs": [], + "execution_count": 1 + }, + { + "cell_type": "markdown", + "source": [ + "### Versioned trait views\n", + "\n", + "We imagine an industry where there are only 3 traits, `AddedTrait`, `DeprecatedTrait`, 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 are versioned.\n", + "\n", + "Within the package, trait views for all previous versions are included, with a version suffix on the class name. As a special case for backward compatibility, the class name without a version suffix is an alias to version 1.\n", + "\n", + "The unique ID of a trait will compare non-equal between different versions of the same trait.\n", + "\n", + "Traits can be marked as deprecated to signal that they will eventually be removed from the schema altogether." + ], + "metadata": { + "collapsed": false + }, + "id": "26381c27fe243e62" + }, + { + "cell_type": "code", + "source": [ + "assert traits.example.UpdatedTrait_v1.kId == traits.example.UpdatedTrait.kId\n", + "assert traits.example.UpdatedTrait_v2.kId != traits.example.UpdatedTrait.kId\n", + "\n", + "assert traits.example.DeprecatedTrait_v1.__deprecated__\n" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-05-16T13:20:49.017184Z", + "start_time": "2024-05-16T13:20:49.014169Z" + } + }, + "id": "1470d16817a82421", + "outputs": [], + "execution_count": 2 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "The non-suffixed classes are deprecated and should not be used. Using these will give a warning.", + "id": "574a603365c048c0" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-05-16T13:20:49.039103Z", + "start_time": "2024-05-16T13:20:49.036043Z" + } + }, + "cell_type": "code", + "source": [ + "import warnings\n", + "warnings.simplefilter(\"default\")\n", + "\n", + "from openassetio.trait import TraitsData\n", + "\n", + "_ = traits.example.AddedTrait(TraitsData())" + ], + "id": "8a77383d0cd2a877", + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_298107/3386987461.py:6: DeprecationWarning: Use of unversioned trait view classes is deprecated\n", + " _ = traits.example.AddedTrait(TraitsData())\n" + ] + } + ], + "execution_count": 3 + }, + { + "cell_type": "markdown", + "source": [ + "In a Python environment, if the host application's largest available versions are too _low_ for the manager plugin, then using a version-suffixed class would suffer an `ImportError` when attempting to `import` the (too new) versioned class. 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 versions that the host application was built against/ships with. Incompatibilities only become apparent at runtime in the data layer, when incoming trait data is found to be of a version unsupported by the manager or host." + ], + "metadata": { + "collapsed": false + }, + "id": "75a1877c0c9c3786" + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "### Trait IDs\n", + "\n", + "Each trait at version 2 or above encodes its version in its unique ID. As a special case, version 1 has no version tag." + ], + "id": "59b7afb066b5bcc9" + }, + { + "cell_type": "code", + "source": [ + "# An updated trait has a different ID for each version.\n", + "assert traits.example.UpdatedTrait_v1.kId == \"openassetio-example:example.Updated\"\n", + "assert traits.example.UpdatedTrait_v2.kId == \"openassetio-example:example.Updated.v2\"\n", + "\n", + "# A newly added trait starts at version 1, which as no version tag in the ID.\n", + "assert traits.example.AddedTrait.kId == \"openassetio-example:example.Added\"" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-05-16T13:20:49.057577Z", + "start_time": "2024-05-16T13:20:49.054304Z" + } + }, + "id": "a31f8a7263370714", + "outputs": [], + "execution_count": 4 + }, + { + "cell_type": "markdown", + "source": "This means trait view classes from one version cannot be used to read another version.", + "metadata": { + "collapsed": false + }, + "id": "6e6d3357806e5f67" + }, + { + "cell_type": "code", + "source": [ + "from openassetio.trait import TraitsData\n", + "\n", + "\n", + "v1_data = TraitsData()\n", + "\n", + "traits.example.UpdatedTrait_v1.imbueTo(v1_data)\n", + "\n", + "assert traits.example.UpdatedTrait.isImbuedTo(v1_data) is True\n", + "assert traits.example.UpdatedTrait_v1.isImbuedTo(v1_data) is True\n", + "assert traits.example.UpdatedTrait_v2.isImbuedTo(v1_data) is False\n" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-05-16T13:20:49.085065Z", + "start_time": "2024-05-16T13:20:49.081220Z" + } + }, + "id": "a147006dd4b9b453", + "outputs": [], + "execution_count": 5 + }, + { + "metadata": {}, + "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. View classes are generated for these, which compose the trait views of their trait set.\n", + "\n", + "Similar to traits, the class names have a version tag suffixed to them, and the untagged class is a deprecated alias to version 1. \n", + "\n" + ], + "id": "ab4dde470a3808d4" + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-05-16T13:20:49.124614Z", + "start_time": "2024-05-16T13:20:49.121773Z" + } + }, + "cell_type": "code", + "source": [ + "assert specifications.example.ExampleSpecification_v1.kTraitSet == specifications.example.ExampleSpecification.kTraitSet\n", + "assert specifications.example.ExampleSpecification_v2.kTraitSet != specifications.example.ExampleSpecification.kTraitSet" + ], + "id": "220587e7fdcc6e1f", + "outputs": [], + "execution_count": 6 + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "However, no specification version is embedded in the data itself. The data sent between host and manager only contains the versions of the composite traits. Detecting that some trait data satisfies a specification therefore requires a try-and-see approach, checking the data against the specification's trait set.\n", + "\n", + "If specifications are 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" + ], + "id": "5a8eabe3cd9e7a3a" + }, + { + "cell_type": "code", + "source": [ + "entity_data = TraitsData(\n", + " {traits.example.AddedTrait_v1.kId, traits.example.UpdatedTrait_v1.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 = specifications.example.ExampleSpecification_v1.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 = specifications.example.ExampleSpecification_v2.kTraitSet.issubset(\n", + " entity_data.traitSet())\n", + "assert is_an_example_entity is False\n" + ], + "metadata": { + "collapsed": false, + "ExecuteTime": { + "end_time": "2024-05-16T13:20:49.150228Z", + "start_time": "2024-05-16T13:20:49.146420Z" + } + }, + "id": "a94f4bcd14862e3a", + "outputs": [], + "execution_count": 7 + }, + { + "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", + "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-05-16T13:20:49.229661Z", + "start_time": "2024-05-16T13:20:49.225468Z" + } + }, + "id": "592a08fb134655a", + "outputs": [], + "execution_count": 8 + }, + { + "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", + "source": [ + "from openassetio.managerApi import ManagerInterface, EntityReferencePagerInterface\n", + "from openassetio import errors, access, EntityReference\n", + "\n", + "from resources.working_with_trait_versions.openassetio_example import traits\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 traits.example.AddedTrait_v1.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 traits.example.UpdatedTrait_v2.isImbuedTo(context.locale):\n", + " is_locale_special = True\n", + " special_locale_value = traits.example.UpdatedTrait_v2(\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 traits.example.UpdatedTrait_v1.isImbuedTo(context.locale):\n", + " is_locale_special = True\n", + " special_locale_value = traits.example.UpdatedTrait_v1(\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 Example entities are supported, though we support both v1 and v2.\n", + " if (not specifications.example.ExampleSpecification_v2.kTraitSet.issubset(\n", + " trait_set)\n", + " and not specifications.example.ExampleSpecification_v1.kTraitSet.issubset(\n", + " 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 traits.example.UpdatedTrait_v2.kId in trait_set:\n", + " traits.example.UpdatedTrait_v2.imbueTo(policy_data)\n", + "\n", + " if traits.example.UpdatedTrait_v1.kId in trait_set:\n", + " traits.example.UpdatedTrait_v1.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 (traits.example.UpdatedTrait_v2.kId in trait_set\n", + " and not traits.example.UpdatedTrait_v1.kId in trait_set):\n", + " traits.example.UpdatedTrait_v2.imbueTo(policy_data)\n", + " elif traits.example.UpdatedTrait_v1.kId in trait_set:\n", + " traits.example.UpdatedTrait_v1.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 traits.example.UpdatedTrait_v2.kId in trait_set:\n", + " traits.example.UpdatedTrait_v2.imbueTo(policy_data)\n", + " elif traits.example.UpdatedTrait_v1.kId in trait_set:\n", + " traits.example.UpdatedTrait_v1.imbueTo(policy_data)\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_updated = (traits.example.UpdatedTrait_v2.kId in trait_set or\n", + " traits.example.UpdatedTrait_v1.kId in trait_set)\n", + " is_a_deprecated = traits.example.DeprecatedTrait_v1.kId in trait_set\n", + " is_an_added = traits.example.AddedTrait_v1.kId in trait_set\n", + "\n", + " if is_an_updated and is_a_deprecated:\n", + " entity_ref = \"example://default/deprecated\"\n", + " elif is_an_updated and is_an_added:\n", + " entity_ref = \"example://default/added\"\n", + " elif is_an_updated:\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 default,\n", + " # which will be the latest, v2.\n", + " successCallback(idx, specifications.example.ExampleSpecification_v2.kTraitSet)\n", + " else:\n", + " # Minimum required for publishing is a reduced set.\n", + " successCallback(\n", + " idx, specifications.example.ExampleSpecification_v2.kTraitSet - {\n", + " 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 traits.example.UpdatedTrait_v2.kId in traitSet:\n", + " trait = traits.example.UpdatedTrait_v2(traits_data)\n", + " trait.setPropertyToKeep(\"value\")\n", + " trait.setPropertyThatWasRenamed(True)\n", + " trait.setPropertyThatWasAdded(123.456)\n", + " if traits.example.UpdatedTrait_v1.kId in traitSet:\n", + " trait = traits.example.UpdatedTrait_v1(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 traits.example.UpdatedTrait_v2.kId in traitSet:\n", + " trait = traits.example.UpdatedTrait_v2(traits_data)\n", + " trait.setPropertyThatWasAdded(456.789)\n", + " else:\n", + " # Similar for other entities.\n", + " if resolveAccess == access.ResolveAccess.kManagerDriven:\n", + " # Only support latest version (v2) for publishing workflows.\n", + " if traits.example.UpdatedTrait_v2.kId in traitSet:\n", + " trait = traits.example.UpdatedTrait_v2(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_added = traits.example.AddedTrait_v1.isimbuedTo(\n", + " relationshipTraitsData)\n", + " is_rel_deprecated = traits.example.DeprecatedTrait_v1.isimbuedTo(\n", + " relationshipTraitsData)\n", + "\n", + " rel_v2_updated = traits.example.UpdatedTrait_v2(relationshipTraitsData)\n", + " rel_v1_updated = traits.example.UpdatedTrait_v1(relationshipTraitsData)\n", + " is_rel_updated = rel_v2_updated.isImbued() or rel_v1_updated.isImbued()\n", + "\n", + " # Some important property used to filter the list of related entities.\n", + " important_property = rel_v2_updated.getPropertyThatWasRenamed(\n", + " defaultValue=rel_v1_updated.getPropertyToRename(defaultValue=False))\n", + "\n", + " is_a_child_relationship = (is_rel_added or is_rel_deprecated) and is_rel_updated\n", + "\n", + " # Parse out important aspects of the type of expected result entity.\n", + "\n", + " result_type = \"none\"\n", + " if {traits.example.AddedTrait.kId}.issubset(resultTraitSet):\n", + " result_type = \"component\"\n", + " elif {traits.example.DeprecatedTrait.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_updated = (traits.example.UpdatedTrait_v2.isImbuedTo(traits_data) or\n", + " traits.example.UpdatedTrait_v1.isImbuedTo(traits_data))\n", + " is_a_deprecated = traits.example.DeprecatedTrait_v1.isImbuedTo(traits_data)\n", + " is_an_added = traits.example.AddedTrait_v1.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_deprecated and is_an_updated\n", + " is_an_ingredient = not is_an_added and not is_a_deprecated 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 = traits.example.UpdatedTrait_v2(traits_data)\n", + " v1_updated_trait = traits.example.UpdatedTrait_v1(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_updated = (traits.example.UpdatedTrait_v2.isImbuedTo(traits_data) or\n", + " traits.example.UpdatedTrait_v1.isImbuedTo(traits_data))\n", + " is_a_deprecated = traits.example.DeprecatedTrait.isImbuedTo(traits_data)\n", + " is_an_added = traits.example.AddedTrait_v1.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_deprecated and is_an_updated\n", + " is_an_ingredient = not is_an_added and not is_a_deprecated 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 = traits.example.UpdatedTrait_v2(traits_data)\n", + " v1_updated_trait = traits.example.UpdatedTrait_v1(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", + " EntityReferencePagerInterface.__init__(self)\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-05-16T13:20:49.302536Z", + "start_time": "2024-05-16T13:20:49.248027Z" + } + }, + "id": "5bb5454ca1ae8b06", + "outputs": [], + "execution_count": 9 + }, + { + "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", + "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(specifications.example.ExampleSpecification_v2.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", + "# Whatever the minimum trait set is, we know we're going to publish an\n", + "# Example.\n", + "desired_trait_set = minimum_trait_set | specifications.example.ExampleSpecification_v2.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 traits.example.UpdatedTrait_v2.isImbuedTo(policy_for_desired_traits):\n", + " # v2 didn't work, try v1.\n", + " desired_trait_set = minimum_trait_set | specifications.example.ExampleSpecification_v1.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 traits.example.UpdatedTrait_v1.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", + "# 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 specifications.example.ExampleSpecification_v2.kTraitSet.issubset(desired_trait_set):\n", + " trait_set_to_keep = trait_set_to_publish - specifications.example.ExampleSpecification_v2.kTraitSet\n", + "else:\n", + " trait_set_to_keep = trait_set_to_publish - specifications.example.ExampleSpecification_v1.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 traits.example.UpdatedTrait_v2.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", + " traits.example.UpdatedTrait_v2.imbueTo(data_to_publish)\n", + "elif traits.example.UpdatedTrait_v1.isImbuedTo(policy_for_derived_traits):\n", + " # Fall back to v1.\n", + " traits.example.UpdatedTrait_v1.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", + " # Here we use the Specification view class, rather than the trait\n", + " # view class. We could equivalently use `UpdatedTrait` directly. By\n", + " # using the Specification view class we can be certain of the\n", + " # compatibility of the trait view class version.\n", + " if specifications.example.ExampleSpecification_v2.kTraitSet.issubset(desired_trait_set):\n", + " specifications.example.ExampleSpecification_v2(\n", + " data_to_publish).updatedTrait().setPropertyThatWasRenamed(True)\n", + " else:\n", + " specifications.example.ExampleSpecification_v1(\n", + " data_to_publish).updatedTrait().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 traits.example.UpdatedTrait_v2.kId in policy_for_derived_traits.traitSet():\n", + " derived_data = manager.resolve(\n", + " working_ref, {traits.example.UpdatedTrait_v2.kId}, access.ResolveAccess.kManagerDriven,\n", + " context)\n", + "\n", + " traits.example.UpdatedTrait_v2(data_to_publish).setPropertyThatWasRenamed(\n", + " traits.example.UpdatedTrait_v2(derived_data).getPropertyThatWasRenamed())\n", + "\n", + "# Fall back to v1.\n", + "elif traits.example.UpdatedTrait_v1.kId in policy_for_derived_traits.traitSet():\n", + " derived_data = manager.resolve(\n", + " working_ref, {traits.example.UpdatedTrait_v1.kId}, access.ResolveAccess.kManagerDriven,\n", + " context)\n", + "\n", + " traits.example.UpdatedTrait_v1(data_to_publish).setPropertyToRename(\n", + " traits.example.UpdatedTrait_v1(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-05-16T13:20:49.321308Z", + "start_time": "2024-05-16T13:20:49.309670Z" + } + }, + "id": "b8e12c55d88bda27", + "outputs": [], + "execution_count": 10 + }, + { + "cell_type": "markdown", + "source": [ + "## Future work\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 trait/specification version must be chosen by the host/manager. In the case of hosts adapting to managers, the `managementPolicy` API method provides a negotiation mechanism. Otherwise, the host/manager must simply choose a preferred version. It is in these circumstances, where new trait data is being created, 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. However, as discussed in [DR023](https://github.com/OpenAssetIO/OpenAssetIO/blob/main/doc/decisions/DR023-Versioning-traits-and-specifications-method.md), branching at a higher level precludes many important workflows, so there seems no way around this." + ], + "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 +}