Source code for fictive.patterns.package_override

"""
helper functions for overriding package shared resources

Frameworks, related packages, and other interoperating libraries and
modules will often rely on some shared resource(s).  E.g.:

.. code-block:: python

    ''' some_package.py '''

    class Base(object):
        ''' shared attributes '''

    class Widget(Base):
        ''' widget attributes'''

    class Sprocket(Base):
        ''' sprocket attributes'''

If another project wants to modify the shared resource (in the above
case, by subclassing `some_package.Base`), it can be difficult to
propogate those changes to the rest of the framework's resources.  For
example, because `some_package.Widget` and `some_package.Sprocket` are
a subclasss of `some_package.Base`, creating a subclass
`my_project.MyBase` will have no effect on `some_package.Widget` or
`some_package.Sprocekt`.  Obtaining `Widget` and `Sprocket` classes
that incorporate `my_project.Base` would require *also* subclassing
`Widget` and `Sprocket`.  I.e.:

.. code-block:: python

    ''' my_project.py '''

    from some_package import Base, Widget, Sprocket

    class MyBase(Base):
        ''' custom shared attributes '''

    class MyWidget(Widget):
        pass

    class MySprocket(Sprocket):
        pass

Furthermore, any additional references to the orgiinal resources might
*also* need to be overriden.  E.g.:

.. code-block:: python

    ''' my_project.py (continued) '''

    ...

    class PartFactory(object):

        part_catalog = {'widget': Widget, 'sprocket': Sprocket}

        def make_part(self, name):
            ''' creates a new part based on the order text '''
            return self.part_catalog[name]()

Here, `PartFactory` would return instances of the original classes
(i.e., `base.Widget` and `base.Sprocket`), so the project would also
likely need to subclass `PartFactory`.  For large packages, this type
of *ad hoc* subclassing can quickly become intractible.

An alternative solution is to use "moneky patch" `some_package.Base`, i.e.,
dynamically change the properties of `some_package.Base` while the
project's code is executing.  E.g.:

.. code-block:: python

    ''' my_project.py '''

    from some_package import Base

    def new_method(self):
        '''' do some common thing '''

    Base.new_method = new_method

Unfortunately, monkey patching can often fail to addesss more
complicated behaviors and interdependencies within the package.
Furthermore, the result can be sensitive to the order in which modules
are imported and loaded, which can create situations that are
difficult to diagnose and debug.  While some situations call for
monkey patching, it's an inelegant, fragile, and unidiomatic solution.

On the other hand, if a package developer foresees some package
resources as likely targets for customization, the package can be
structured to facilitate that customization.

This module provides means for exposing a package resource to being
overridden using and environment variable, a configuration file, or
other installed package.  The override can occur when the package
resource is initially imported and so will automatically propogate to
any other pakcage resources that use the overridden resource.  E.g.:

.. testsetup:: override

    with open('entry_points.txt', mode='w') as entry_points_file:
        entry_points_file.write('[some_package.base.overrides]\\n')
        entry_points_file.write('Base = my_project.base:CustomBase\\n')

.. testcode:: override

    ''' some_package/base.py '''

    class DefaultBase(object):
        ''' shared attributes '''

.. testcode:: override
    :hide:

    import sys
    import types

    some_package = types.ModuleType('some_package')
    some_package.__package__ = 'some_package'
    some_package.__path__ = ''
    some_package.base = types.ModuleType('some_package.base')
    some_package.base.__package__ = 'some_package'
    some_package.base.DefaultBase = DefaultBase
    sys.modules['some_package'] = some_package
    sys.modules['some_package.base'] = some_package.base

.. testcode:: override
    :hide:

    class CustomBase(DefaultBase):
        pass

    my_project = types.ModuleType('my_project')
    my_project.__package__ = 'my_project'
    my_project.__path__ = ''
    my_project.base = types.ModuleType('my_project.base')
    my_project.base.__package__ = 'my_project'
    my_project.base.CustomBase = CustomBase
    sys.modules['my_project'] = my_project
    sys.modules['my_project.base'] = my_project.base

.. testcode:: override

    ''' some_package/parts.py '''

    from fictive.patterns.package_override import package_override

    #: will check the default file location (entry_points.txt) and
    #: the working set for a 'Base' entry point in the
    #: 'some_package.base.overrides' group; if no such entry point is
    #: found, use the package default
    Base = package_override(
        object_reference='some_package.base:DefaultBase',
        group='some_package.base.overrides', name='Base')

    class Widget(Base):
        ''' widget attributes'''

    class Sprocket(Base):
        ''' sprocket attributes'''

    class PartFactory(object):

        part_catalog = {'widget': Widget, 'sprocket': Sprocket}

        def make_part(self, name):
            ''' creates a new part based on the order text '''
            return self.part_catalog[name]()

.. testcode:: override
    :hide:

    some_package.parts = types.ModuleType('some_package.parts')
    some_package.parts.__package__ = 'some_package'
    some_package.parts.Base = Base
    some_package.parts.Widget = Widget
    some_package.parts.Sprocket = Sprocket
    some_package.parts.PartFactory = PartFactory
    sys.modules['some_package.parts'] = some_package.parts

.. code-block:: python

    ''' my_project/base.py '''

    from some_package.base import DefaultBase

    class CustomBase(DefaultBase):
        pass

.. code-block:: ini

    # entry_points.txt

    [some_package.base.overrides]
    Base = my_project.base:CustomBase


.. testcode:: override

    '''' my_project/app.py '''

    from some_package.parts import Widget, Sprocket, PartFactory
    from my_project.base import CustomBase

.. doctest:: override

    >>> issubclass(Widget, CustomBase)
    True
    >>> isinstance(Sprocket(), CustomBase)
    True
    >>> isinstance(PartFactory().make_part('sprocket'), CustomBase)
    True

.. testcleanup:: file

    import os
    os.remove('entry_points.txt')

"""

import os
import typing

import pkg_resources

from fictive.patterns.dynamic_load import (
    from_string,
    from_environment,
    from_file,
    from_working_set,
)


[docs]def package_override( object_reference: str = None, env_key: str = None, filename: str = 'entry_points.txt', working_set: pkg_resources.WorkingSet = None, group: str = None, name: str = None, multiple: typing.Literal['raise', 'warn', 'silent', 'iter'] = 'raise'): # pylint: disable=too-many-arguments """ dynamically load resource from the specified source(s) :param str object_reference: an object reference (see https://packaging.python.org/specifications/entry-points/#data-model) indicating the default object to load if no other overrides are found :param str env_key: an environment key to check for an object reference (see https://packaging.python.org/specifications/entry-points/#data-model). If the key is set, will attempt to `pkg_resources.EntryPoint.resolve()` the value :param str filename: the file name containing an entry points file (see https://packaging.python.org/specifications/entry-points/#file-format). :param pkg_resources.WorkingSet working_set: teh working set to search for the specified entry point :param str group: the entry point group to search for an entry point record to resolve. **REQUIRED** if loading from a **file** or **working set** :param str name: the name of the entry point to resolve :param multiple: `pkg_resources` permits a working set to have multiple entry points with the same group and name. In the event that multiple entry points match the `group` and `name` values provided, the value of the `multiple` parameter controls the result: - 'raise' (default): raises a `pkg_resources.ResolutionError` - 'warn': a warning is issued and one of the matching entry points is arbitrarily chosen and loaded - 'silent': one of the matching entry points is arbitrarily chosen and loaded without any warning :raises ValueError: the object reference contains one or more syntax errors :raises pkg_resources.ResolutionError: there are multiple entry points in the specified group with the specified name and `multiple` is :code:`'raise'` :raises ImportError: the entry point could not be loaded """ if isinstance(env_key, str) and env_key in os.environ: return from_environment(env_key) try: return from_file(group, name, filename) except (OSError, KeyError): pass try: return from_working_set(group, name, working_set, multiple) except IndexError: return from_string(object_reference)