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