Overview
Comment:introduce .bind module
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: d5d59c76fd526556529a02ed98570381a4c0fba45f59b06b3c638d8937d8b1bb
User & Date: mario on 2022-10-30 09:08:39
Other Links: manifest | tags
Context
2022-10-30
09:11
rename module_base to data_root, change bytes.decode (probably should be a try/except), add dirname when module.__path__ missing, doc updates check-in: 8b8e58b0e7 user: mario tags: trunk
09:08
introduce .bind module check-in: d5d59c76fd user: mario tags: trunk
2022-10-29
03:11
table intro check-in: b2227cd919 user: mario tags: trunk
Changes

Added pluginconf/bind.py version [8927125c8f].







































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# encoding: utf-8
# api: pluginconf
##type: loader
# title: plugin loader
# description: implements a trivial unified namespace importer
# version: 0.1
# state: alpha
# priority: optional
#
# Most basic plugin management/loader. It unifies meta data retrieval
# and instantiation. It still doesn't dictate a plugin API or config
# storage (using json in examples). And can be a simple setup:
#
# Create an empty plugins/__init__.py to use as package and for
# plugin discovery.
#
# Designate it as such:
#
#      import pluginconf.bind
#      pluginconf.bind.base("plugins")
#
# Set up a default conf={} in your application, with some presets,
# or updating it from a stored config file:
#
#      conf = {
#          "first_run": 1,
#          "debug": 0,
#          "plugins": {},
#      }
#      conf.update(
#          json.load(open("app.json", "r"))
#      )
#
# Then update defaults from plugins:
#
#      if conf["first_run"]:
#          pluginconf.bind.defaults(conf)
#
# Instantiate plugin modules based on load conf[plugins] state:
#
#      for module in pluginconf.bind.load_enabled(conf):
#          module.register(main_window)
#
# Using a simple init function often suffices, if plugins don't
# register themselves. Alternatively use a class name aligned with
# the plugin basename to disover it, or dir(), or similar such.
#
# If you want users to update settings or plugin states, use the
# .window module:
#
#      pluginconf.gui.window(conf, conf["plugins"], files=["plugins/*.py"])
#      json.dump(conf, open("app.json", "w"))
#
# Alternatively there's the load() for known plugin names, or find()
# to uncover them based on descriptors. Or isolated() to instantiate
# from a different set.
#
# Notably the whole setup makes most sense if you have user-supplied
# plugin and some repository to fetch/update new ones from. (Out of
# scope here, but a zip/pyz download and extract might suffice). If
# so, entangle the alternative directory or pyz to be scanned:
#
#      pluginconf.bind.base("plugins", dir="~/.config/app/usrplugs/")
#      pluginconf.bind.defaults(conf)  # update
#
# With PySimpleGUI, `conf` could be a psg.UserSettings("app.json") for
# automatic settings+update storage, btw.
#

#-- bit briefer for API docs --
"""
Basic plugin loader implementation for flat namespaces. Ties together
pluginconf lookups and config management for plugin loading in apps.
It's rather basic, and subject to change. Does impose a config dict
format, but no storage still.

### Usage example

    # designate a plugins/*.py package as plugin_base
    import plugins
    import pluginconf.bind
    pluginconf.bind.base(plugins)

    # preset core app settings / load from json, add plugin options
    conf = {
        "plugins": {
        }
    }
    pluginconf.bind.defaults(conf)

    # load conf-enabled plugins, and register modules somehow
    for mod in pluginconf.bind.load_enabled(conf):
        mod.init()

### Find by type

    for name, pmd in pluginconf.bind.find(type="effect").items():
        mod = pluginconf.bind.load(name)
        if pmd.category == "menu":
            main_win.add_menu(mod.menu) 

Note that this uses meta data extraction, so should best be confined
for app setup/initialization, not used recurringly. The config state
usage is the preferred method. (Only requires property loading
once, for installation or with `pluginconf.gui.window()` usage.)

----
Method interactions: ๐Ÿš = import, ๐Ÿงฉ = meta, ๐Ÿงพ = config, ๐Ÿ›   = setup
"""

import os
import sys
import importlib
import pluginconf

#-- reset pluginconf if .bind is used
pluginconf.plugin_base = []


def load(name):
    """
    Import individual plugin from any of the base paths ๐Ÿš

    Parameters
    ----------
    name : str
        Plugin name in any of the registered plugin_baseยดs. (The whole
        namespace is assumed to be flat, and identifiers to be unique.)
    """

    for package in pluginconf.plugin_base:
        if package in ("", "."):
            continue
        module_name = package + "." + name
        if module_name in sys.modules:
            return sys.modules[module_name]
        try:
            return importlib.import_module(module_name)
        except ImportError:
            pass


def base(module, path=None):
    """
    Register module as package/plugin_base. Or expand its search path ๐Ÿ›  .

    Parameters
    ----------
    module : module/str
        The package basename to later load plugins from (must be a package,
        like `plugins/__init__.py`, or be tied to a path= or zip). Ideally
        this module was already imported in main. But parameter may be a string.
    path : str
        Add a directory or pyz/zip bundle to registered plugin_base. Could
        be invoked multiple times =./contrib/, =/usr/share/app/extenstions/,
        =~/.config/app/userplug/ (same as declaring the `__path__` in the
        base `package/__init__.py`.)
    """

    # if supplied as string, instead of active module
    if isinstance(module, str):
        module = sys.modules.get(module) or __import__(module)

    # add to search list
    if module.__name__ not in pluginconf.plugin_base:
        pluginconf.plugin_base.append(module.__name__)

    # enjoin dir or pyz
    if not hasattr(module, "__path__"):
        module.__path__ = [_dirname(module.__file__)]
    if path:
        module.__path__.append(_dirname(path))


def find(**kwargs):
    """
    Find plugins by e.g. type= or category= ๐Ÿงฉ

    Parameters
    ----------
    type : str
        Usually you'd search on a designated plugin categorization, like type=
        and api=, or slot=, or class= or whatever is most consistent. Multiple
        attributes can be filtered on. (Version/title probably not useful here.)

    Returns
    ----------
    dict : basename โ†’ PluginMeta dict
    """

    def has_all(pmd):
        for key, dep in kwargs.items():
            if not pmd.get(key) == dep:
                break
        else:
            return True

    return pluginconf.PluginMeta({
        name: pmd for name, pmd in pluginconf.all_plugin_meta().items() if has_all(pmd)
    })


def load_enabled(conf):
    """
    Import modules that are enabled in conf[plugins]={name:True,โ€ฆ} ๐Ÿงพ ๐Ÿš

    Parameters
    ----------
    conf : dict
        Simple options-value dictionary, but with one conf["plugins"] = {} subdict,
        which holds plugin activation states. The config dict doesn't have to match
        the available plugin options (defaults can be added), but should focus on
        essential presets. Differentiation only might become relevant for storage.
    """
    for name, state in conf.get("plugins", conf).items():
        if not state:
            continue
        if name.startswith("_"):
            continue
        yield load(name)


def defaults(conf):
    """
    Traverse installed plugins and expand config dict with presets ๐Ÿงฉ ๐Ÿงพ

    Parameters
    ----------
    conf : dict ๐Ÿ”
        Expands the top-level config dict with preset values from any plugins.
    """
    for name, pmd in pluginconf.all_plugin_meta().items():
        pluginconf.add_plugin_defaults(conf, conf["plugins"], pmd, name)


# pylint: disable=invalid-name
class isolated():
    """
    Context manager for isolated plugin structures.  ๐Ÿ› 
    This is a shallow alternative to pluginbase and library-level plugins.
    Temporarily swaps global settings, thus maps most static functions.

        with pluginconf.bind.isolated("libplugins") as bound:
            bound.modules2.init()
            print(
                bound.find(api="library")
            )
    """
    def __init__(self, package):
        self.package = package if isinstance(package, str) else package.__name__
        self.reset = []
        #mod = sys.modules.get(self.package) or __import__(self.package)  # must be a real package
        #if not hasattr(mod, "__path__"):
        #    mod.__path__ = [_dirname(mod.__file__)]

    def __enter__(self):
        """ just swap out global config """
        self.reset, pluginconf.plugin_base = pluginconf.plugin_base, []
        base(self.package)
        return self

    def __exit__(self, *exc):
        pluginconf.plugin_base = self.reset

    @staticmethod
    def load(name):
        """ load module from wrapped package ๐Ÿš """
        return load(name)

    def __getattr__(self, name):
        return self.load(name)

    def get_data(self, *args, **kwargs):
        """ get file relative to encapsulated plugins ๐Ÿš """
        pluginconf.get_data(*args, **kwargs, file_root=self.package)

    @staticmethod
    def find(**kwargs):
        """ find by meta attributes ๐Ÿงฉ """
        return find(**kwargs)

    @staticmethod
    def defaults():
        """ *return* defaults for isolated plugin structure ๐Ÿงฉ ๐Ÿงพ """
        conf = {"plugins": {}}
        defaults(conf)
        return conf


def _dirname(file):
    return os.path.dirname(os.path.realpath(file))

Added test/bind.py version [bdb0475b8c].















































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
# type: test
# title: bind loader
# description: simple loader mechanism
# version: 0.1
# 
# Utilize plugins/


import pytest
import logging
logging.basicConfig(level=logging.DEBUG)
import pluginconf
print(pluginconf.plugin_base)
import pluginconf.bind


def init(reset):
#    pluginconf.plugin_base = []
    import test.plugins
    pluginconf.bind.base(test.plugins)
    assert "test.plugins" in pluginconf.plugin_base
    assert test.plugins.__path__ != []

def test_first():
    assert pluginconf.plugin_meta(module="first").title == "first plugin"

def find_plugins():
    assert len(pluginconf.bind.find(type="stub")) == 3

def config_set():
    conf = {
        "first_run": 1,
        "plugins": {
        },
    }
    pluginconf.bind.defaults(conf)
    assert conf["injected"] is True  # converted from "1"
    assert conf["plugins"]["core"] == True  # priority core

def load_first():
    conf = {
        "plugins": {
            "first": True,
        },
    }
    for mod in pluginconf.bind.load_enabled(conf):
        assert mod.init

def context():
    with pluginconf.bind.isolated("test.plugins") as ext:
        assert len(ext.find(type="stub")) == 3
        assert ext.run.init() == 4

def tearDown(reset):
    pass

Modified test/conftest.py from [b78d8789f6] to [0ca86310bb].

1
2






import pytest
import pluginconf as pc








>
>
>
>
>
>
1
2
3
4
5
6
7
8
import pytest
import pluginconf as pc


@pytest.fixture
def reset():
    pc.plugin_base = ["plugins"]
    pc.data_root = "config"

Added test/plugins/__init__.py version [2b400b71e1].









>
>
>
>
1
2
3
4
# type: virtual
# title: plugins/* dir for bind test

init = 1

Added test/plugins/core.py version [a74fa3ed69].























>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
# type: stub
# title: core
# description: virtual main app
# priority: core
# version: 1
# category: value
# config:
#    { name: core_opt, value: 123, type: int }
#

init = 3

Added test/plugins/first.py version [169f4b96c9].



























>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
# type: stub
# title: first plugin
# description: does nothing
# version: 1
# category: value
# config:
#    { name: injected, value: 1, type: bool }
#

init = 2



Added test/plugins/run.py version [115b4c2666].























>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
# type: stub
# title: runnable
# description: run some code on import
# priority: optional
# version: 1
# category: code
# config: -
#

def init():
    return 4