summaryrefslogtreecommitdiff
path: root/src/mailman/utilities/modules.py
blob: 155a34ed256e291c629eaf3fe1b481392d253309 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# Copyright (C) 2009-2017 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman.  If not, see <http://www.gnu.org/licenses/>.

"""Package and module utilities."""

import os
import sys

from contextlib import contextmanager
from importlib import import_module
from pkg_resources import resource_filename, resource_listdir
from public import public


@public
def abstract_component(cls):
    """Decorator preventing `find_components()` from instantiating the class.

    Normally, `find_components()` instantiates any component class that
    it finds matching the given interface.  Some component classes must not be
    instantiated though, because they act as base classes.  Put this decorator
    on the class definition to prevent instantiation.
    """
    cls.__abstract_component__ = True
    return cls


@public
def find_name(dotted_name):
    """Import and return the named object in package space.

    :param dotted_name: The dotted module path name to the object.
    :type dotted_name: string
    :return: The object.
    :rtype: object
    """
    module_path, dot, object_name = dotted_name.rpartition('.')
    module = import_module(module_path)
    return getattr(module, object_name)


@public
def call_name(dotted_name, *args, **kws):
    """Imports and calls the named object in package space.

    :param dotted_name: The dotted module path name to the object.
    :type dotted_name: string
    :param args: The positional arguments.
    :type args: tuple
    :param kws: The keyword arguments.
    :type kws: dict
    :return: The object.
    :rtype: object
    """
    named_callable = find_name(dotted_name)
    return named_callable(*args, **kws)


def scan_module(module, interface):
    """Return all the object in a module that conform to an interface.

    Scan every item named in the module's `__all__`.  If that item conforms to
    the given interface, *and* the item is not declared as an
    `@abstract_component`, then instantiate the item and return the resulting
    instance.

    :param module: A module object.
    :type module: module
    :param interface: The interface that returned objects must conform to.
    :type interface: `Interface`
    :return: The sequence of instantiated matching components.
    :rtype: instantiated objects implementing `interface`
    """
    missing = object()
    for name in module.__all__:
        component = getattr(module, name, missing)
        assert component is not missing, (
            '%s has bad __all__: %s' % (module, name))   # pragma: nocover
        if (interface.implementedBy(component)
                # We cannot use getattr() here because that will return True
                # for all subclasses.  __abstract_component__ should *not* be
                # inherited, meaning subclasses must declare themselves to be
                # abstract if they also don't want to be instantiated.  Only
                # by looking at the component's __dict__ can we know for sure
                # where the marker has been placed.  The value of
                # __abstract_component__ doesn't matter, only its presence.
                and '__abstract_component__' not in component.__dict__):
            yield component()


@public
def find_components(package, interface):
    """Find components which conform to a given interface.

    Search all the modules in a given package, returning an iterator over all
    objects found that conform to the given interface, unless that object is
    decorated with `@abstract_component`.

    :param package: The package path to search.
    :type package: string
    :param interface: The interface that returned objects must conform to.
    :type interface: `Interface`
    :return: The sequence of instantiated matching components.
    :rtype: instantiated objects implementing `interface`
    """
    for filename in resource_listdir(package, ''):
        basename, extension = os.path.splitext(filename)
        if extension != '.py' or basename.startswith('.'):
            continue
        module_name = '{}.{}'.format(package, basename)
        __import__(module_name, fromlist='*')
        module = sys.modules[module_name]
        if not hasattr(module, '__all__'):
            continue
        yield from scan_module(module, interface)


@public
def add_components(package, interface, mapping):
    """Add components to a given mapping.

    Similarly to `find_components()` this inspects all modules in a given
    package looking for objects that conform to a given interface.  All such
    found objects (unless decorated with `@abstract_component`) are added to
    the given mapping, keyed by the object's `.name` attribute, which is
    required.  It is a fatal error if that key already exists in the mapping.

    :param package: The package path to search.
    :type package: string
    :param interface: The interface that returned objects must conform to.
        Objects found must have a `.name` attribute containing a unique
        string.
    :type interface: `Interface`
    :param mapping: The mapping to add the found components to.
    :type mapping: A dict-like mapping.  This only needs to support
        containment tests (e.g. `in` and `not in`) and `__setitem__()`.
    :raises RuntimeError: when a duplicate key is found.
    """
    for component in find_components(package, interface):
        if component.name in mapping:
            raise RuntimeError(
                'Duplicate key "{}" found in {}; previously {}'.format(
                    component.name, component, mapping[component.name]))
        mapping[component.name] = component


@public
def expand_path(url):
    """Expand a python: path, returning the absolute file system path."""
    # Is the context coming from a file system or Python path?
    if url.startswith('python:'):
        resource_path = url[7:]
        package, dot, resource = resource_path.rpartition('.')
        return resource_filename(package, resource + '.cfg')
    else:
        return url


@public
@contextmanager
def hacked_sys_modules(name, module):
    old_module = sys.modules.get(name)
    sys.modules[name] = module
    try:
        yield
    finally:
        if old_module is None:
            del sys.modules[name]
        else:
            sys.modules[name] = old_module