OmniGraph Code Deprecation

This is a guide to the deprecation of Python classes, functions, and objects to support backward compatibility. C++ deprecation is left to the ABI functions.

Before You Deprecate

Unlike C++, Python is totally transparent to the user. At least it is in the way in which we are currently using it. That means there is no fixed ABI that you can safely code to, knowing that all incompatible changes will be localized to that set of functions.

In order to mitigate this, it is a best practice to provide a published Python API through explicit imports in your main module’s __init__.py file. Then you can make a statement that only things that appear at the top level of that module are guaranteed to remain compatible through minor and patch version changes.

Here’s a sample directory structure that allows hiding of the implementation details in a Pythonic way, using a leading underscore to hint that the contents are not meant to be visible.

omni.my.extension/
    python/
        __init__.py
        _impl/
            MyClass.py
            my_function.py

Then inside the __init__.py file you might see something like this:

"""Interface for the omni.my.extension module.

Only components explicitly imported through this file are to be considered part of the API, guaranteed to remain
backwardly compatible through all minor and patch version changes.

Recommended import usage is as follows:

.. code-block:: python

    import omni.my.extension as ome
    ome.my_function()
"""
from ._impl/my_function import my_function
from ._impl/MyClass import MyClass

A good documentation approach is still being worked out. For now using docstrings in your functions, modules, and classes will suffice.

Warning

By default Git will ignore anything starting with an underscore, so you might have to add a .gitignore file in the python/ directory containing something like this: # Although directories starting with _ are globally ignored, this one is intentional, # to follow the Python convention of internal implementation details starting with underscore !_impl/

When To Deprecate

Now that an API is established the deprecation choice can be limited to times when that API must be changed. Python code is very flexible so very large changes can be made without losing existing functionality. Examples of such changes are:

  • Adding new methods to classes

  • Adding new parameters to existing class methods, if they have default values and appear last in the parameter list

  • Adding new functions

  • Adding new parameters to existing class methods, if they have default values and appear last in the parameter list

  • Adding new constant objects

Each of these changes would be considered additional functionality, requiring a bump to the minor version of the extension.

Each of these changes could also be done by wholesale deprecation of the entity in question, with a replacement that uses a new name using the Deprecation Process below.

Some changes require full deprecation of the existing functionality due to potential incompatibility. Examples of such changes are:

  • Removing a parameter from a class method or function

  • Deleting a class method, function, or object

  • Renaming an object

  • Changing an implementation

  • An ABI function with a Python binding has been deprecated

Deprecation Process

Deprecation In Place

The first line of defence for easy deprecation when adding new features to existing code is to provide defaults that replicate existing functionality. For example, let’s say you have this function that adds two integers together:

def add(value_1: int, value_2: int) -> int:
    return value1 + value_2

New functionality is added that lets you add three numbers. Rather than creating a new function you can use the existing one, adding a default third parameter:

def add(value_1: int, value_2: int, value_3: int = 0) -> int:
    return value1 + value_2 + value_3

The key feature here is that all code written against the original API will continue to function without changes.

You can be creative about how you do this as well. You can instead use flexible arguments to add an arbitrary number of values:

def add(value_1: int, value_2: int, *args) -> int:
    return value1 + value_2 + sum(args)

Or you can use the typing system to generalize the function (assuming you are not using static type checkers):

def add(value_1: Union[int, float], value_2: Union[int, float]) -> Union[int, float]:
    return value1 + value_2

Or even allow different object types to use the same pattern:

Add_t = Union[int, Tuple[float, float]]
def add(value_1: Add_t, value_2: Add_t) -> Add_t:
    if isinstance(value_1, tuple):
        if isinstance(value_2, tuple):
            return value_1 + value_2
        else:
            return value_1 + tuple([value_2] * len(value_1))
    else:
        if isinstance(value_2, tuple):
            return value_2 + tuple([value_1] * len(value_2)
        else:
            return value_1 + value_2

Tip

A good deprecation strategy prevents the remaining code from becoming overly complex. If you start to see the parameter type checking code outweigh the actual functioning code it’s time to think about deprecating the original function and introducing a new one.

Deprecation By Renaming

If you really do need incompatible features then you can make your new function the primary interface and relegate the old one to the deprecated section. One easy way to do this is to create a new subdirectory that contains all of the deprecated functionality. This makes it both easy to find and easy to eliminate once a few releases have passed and you can no longer support it.

For example if you want to create completely new versions of your class and function you can modify the directory structure to look like this:

omni.my.extension/
    python/
        __init__.py
        _impl/
            MyNewClass.py
            my_new_function.py
            v_1/
                MyClass.py
                my_function.py

Then inside the __init__.py file you might see something like this:

"""Interface for the omni.my.extension module.

Only components explicitly imported through this file are to be considered part of the API, guaranteed to remain
backwardly compatible through all minor and patch version changes.

Recommended import usage is as follows:

.. code-block:: python

    import omni.my.extension as ome
    ome.my_new_function()
"""
from ._impl/my_new_function import my_new_function
from ._impl/MyNewClass import MyNewClass

r"""Deprecated - everything below here is deprecated as of version 1.1.
 _____   ______  _____   _____   ______  _____         _______  ______  _____
|  __ \ |  ____||  __ \ |  __ \ |  ____|/ ____|    /\ |__   __||  ____||  __ \
| |  | || |__   | |__) || |__) || |__  | |        /  \   | |   | |__   | |  | |
| |  | ||  __|  |  ___/ |  _  / |  __| | |       / /\ \  | |   |  __|  | |  | |
| |__| || |____ | |     | | \ \ | |____| |____  / ____ \ | |   | |____ | |__| |
|_____/ |______||_|     |_|  \_\|______|\_____|/_/    \_\|_|   |______||_____/
"""
from .impl.v_1.my_functon import my_function
from .impl.v_1.MyClass import MyClass

Now, as before, existing code continues to work as it is still calling the old code which it accesses with the same imported module, however the new versions are clearly marked as the main API.

So what happens if the user is using the deprecated versions? With just this change they remain blissfuly unaware that progress has happened. Instead we would prefer if they were notified so that they have a chance to upgrade their code to take advantage of new features and avoid the shortcomings of the old ones. To this end some decorators can be used to provide some messaging to the user when they are using deprecated features.

Deprecation Messaging

Messaging can be added in deprecation situations by using one of the functions and decorators that support it. All deprecation functions can be accessed from the top omni.graph.tools module level.

The omni.graph.tools.DeprecateMessage class provides a simple way of logging a message that will only show up once per session.

class DeprecateMessage:
    """Manager for deprecation messages, to make it efficient to prevent multiple logging of the same
    deprecation messages.

    The default settings for output is usually enough to help you find where deprecated code is referenced.
    If more information is desired these per-class variables can be set to reduce the filtering being done. The
    message should contains an action item for the user to upgrade from the deprecated functionality:

    .. code-block:: python

        DeprecateMessage.deprecated("Install the latest version instead")

        # Although it's not usually necessary the class can be tuned using these class variable

        SILENCE_LOG = False  # When set the output does not go to the console log; useful to disable for testing
        SHOW_STACK = True  # Report stack trace in the deprecation message - can be turned off if it is too verbose
        MAX_STACK_LEVELS = 3  # Maximum number of stack levels to report, after filtering
        RE_IGNORE = re.compile("deprecate.py|bindings-python|importlib")  # Ignore stack levels matching these patterns
    """

The omni.graph.tools.DeprecateClass decorator provides a method to emit a deprecation message when the deprecated class is accessed.

class DeprecatedClass:
    """Decorator to deprecate a class. Takes one argument that is a string to describe the action the user is to
    take to avoid the deprecated class. A deprecation message will be shown once, the first time the deprecated
    class is instantiated.

    .. code-block:: python

        @DeprecatedClass("After version 1.5.0 use og.NewerClass instead")
        class OlderClass:
            pass
    """

The omni.graph.tools.RenamedClass decorator is a slightly more sophisticated method of deprecating a class when the deprecation is simply a name change.

def RenamedClass(cls, old_class_name: str, rename_message: Optional[str] = None) -> object:
    """Syntactic sugar to provide a class deprecation that is a simple renaming, where all of the functions in
    the old class are still present in backwards compatible form in the new class.

    Args:
        old_class_name: The name of the class that was renamed
        rename_message: If not None, what to use instead of the old class. If None then assume the new class is used.

    Usage:

    .. code-block:: python

        MyDeprecatedClass = RenamedClass(MyNewClass, "MyDeprecatedClass")
    """

The omni.graph.tools.deprecated_function() decorator provides a method to emit a deprecation message when the old function is called.

def deprecated_function(deprecation_message: str):
    """Decorator to deprecate a function.

    Args:
        deprecation_message: A description of the action the user is to take to avoid the deprecated function.

    A deprecation message will only be shown once, the first time the deprecated function is called.

    .. code-block:: python

        @deprecated_function("After version 1.5.0 use og.newer_function() instead")
        def older_function():
            pass
    """

The omni.graph.tools.DeprecatedImport() decorator provides a method to emit a deprecation message when an entire deprecated file is imported for use. This should not be used for imports that will be included in the API for backward compatibility, nor should these files be moved as they must continue to exist at the same import location in order to remain compatible.

def DeprecatedImport(deprecation_message: str):
    """Decorator to deprecate a specific file or module import. Usually the functionality has been deprecated and
    moved to a different file.

    Args:
        deprecation_message: String with the action the user is to perform to avoid the deprecated import

    Usage:

    .. code-block:: python

        '''This is the top line of the imported file'''
        import omni.graph.tools as og
        og.DeprecatedImport("Import 'omni.graph.tools as og' and use og.new_function() instead")

        # The rest of the file can be left as-is for best backward compatibility, or import non-deprecated versions
        # of objects from their new location to avoid duplication.
    """

For The Future

If deprecations become too difficult to manage a more structured approach can be implemented. This would involve using a namespaced versioning for a module so that you can import a more precise version to maintain compatibility.

For example, the directory structure might be arranged as follows to support version 1 and version 2 of a module:

omni.my.extension/
    python/
        __init__.py
        v1.py
        v2.py
        _impl/
            v1/
                MyClass.py
                my_function.py
            v1/
                MyClass.py
                my_function.py

With this structure the imports can be selectively added to the top level files to make explicit versioning decisions for the available APIs.

import omni.graph.tools as og  # Always use the latest version of the interfaces, potentially breaking on upgrade
import omni.graph.tools.v1 as og  # Lock yourself to version 1, requiring explicit change to upgrade
import omni.graph.tools.v2 as og  # Lock yourself to version 2, requiring explicit change to upgrade

The main files might contain something like this to support that import structure:

# __init__.py
from .v1 import *

# v1.py
from ._impl/v1/MyClass import MyClass
from ._impl/v1/my_function import my_function

# v2.py
from ._impl/v2/MyClass import MyClass
from ._impl/v2/my_function import my_function

You can see how version selection redirects you to the matching versions of the classes and functions without any renaming necessary. The deprecation messaging can then be applied to older versions as before.

It might also be desirable to apply versioning information to class implementations so that their version can be checked in a standard way where it is important.

@version(1)
@deprecated("Use omni.graph.tools.v2.MyClass instead")
class MyClass:
    pass

More sophisticated mechanisms could provide version conversions as well, so that you can always get a certain version of a class if you require it, even if it was created using the old API by providing a function with a standard name:

@version(1)
@deprecated("Use omni.graph.tools.v2.MyClass instead")
class MyClass:
    @classmethod
    @version_compatibility(1)
    def upgrade_version(cls, old_version) -> MyClass:
        return MyClass(old_version.parameters)