ADDED pluginconf/bind.py Index: pluginconf/bind.py ================================================================== --- pluginconf/bind.py +++ pluginconf/bind.py @@ -0,0 +1,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 Index: test/bind.py ================================================================== --- test/bind.py +++ test/bind.py @@ -0,0 +1,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 Index: test/conftest.py ================================================================== --- test/conftest.py +++ test/conftest.py @@ -1,2 +1,8 @@ import pytest import pluginconf as pc + + +@pytest.fixture +def reset(): + pc.plugin_base = ["plugins"] + pc.data_root = "config" ADDED test/plugins/__init__.py Index: test/plugins/__init__.py ================================================================== --- test/plugins/__init__.py +++ test/plugins/__init__.py @@ -0,0 +1,4 @@ +# type: virtual +# title: plugins/* dir for bind test + +init = 1 ADDED test/plugins/core.py Index: test/plugins/core.py ================================================================== --- test/plugins/core.py +++ test/plugins/core.py @@ -0,0 +1,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 Index: test/plugins/first.py ================================================================== --- test/plugins/first.py +++ test/plugins/first.py @@ -0,0 +1,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 Index: test/plugins/run.py ================================================================== --- test/plugins/run.py +++ test/plugins/run.py @@ -0,0 +1,11 @@ +# type: stub +# title: runnable +# description: run some code on import +# priority: optional +# version: 1 +# category: code +# config: - +# + +def init(): + return 4