.. _omnigraph_deprecation:

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.

.. code-block:: text

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

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

.. code-block:: python

    """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:

.. code-block:: python

    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:

.. code-block:: python

    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:

.. code-block:: python

    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):

.. code-block:: python

    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:

.. code-block:: python

    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:

.. code-block:: text

    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:

.. code-block:: python

    """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 :py:class:`omni.graph.tools.DeprecateMessage` class provides a simple way of logging a message that will only
show up once per session.

.. literalinclude:: ../../../../../source/extensions/omni.graph.tools/python/_impl/deprecate.py
    :language: python
    :start-after: begin-deprecate-message
    :end-before: end-deprecate-message

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

.. literalinclude:: ../../../../../source/extensions/omni.graph.tools/python/_impl/deprecate.py
    :language: python
    :start-after: begin-deprecated-class
    :end-before: end-deprecated-class

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

.. literalinclude:: ../../../../../source/extensions/omni.graph.tools/python/_impl/deprecate.py
    :language: python
    :start-after: begin-renamed-class
    :end-before: end-renamed-class

The :py:func:`omni.graph.tools.deprecated_function` decorator provides a method to emit a deprecation message
when the old function is called.

.. literalinclude:: ../../../../../source/extensions/omni.graph.tools/python/_impl/deprecate.py
    :language: python
    :start-after: begin-deprecated-function
    :end-before: end-deprecated-function

The :py:func:`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.

.. literalinclude:: ../../../../../source/extensions/omni.graph.tools/python/_impl/deprecate.py
    :language: python
    :start-after: begin-deprecated-import
    :end-before: end-deprecated-import

The :py:func:`omni.graph.core.Attribute.is_deprecated` method provides a way to determine if an attribute has been
deprecated, and :py:func:`omni.graph.core.Attribute.deprecation_message` returns the associated text from
the .ogn file.

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:

.. code-block:: text

    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.

.. code-block:: python

    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:

.. code-block:: python

    # __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.

.. code-block:: python

    @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:

.. code-block:: python

    @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)
