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