ADDED   LICENSE
Index: LICENSE
==================================================================
--- LICENSE
+++ LICENSE
@@ -0,0 +1,1 @@
+Publid Domain

ADDED   README.md
Index: README.md
==================================================================
--- README.md
+++ README.md
@@ -0,0 +1,189 @@
+Provides meta data extraction and plugin basename lookup. And it’s meant for
+in-application feature and option management.
+The [descriptor format](https://fossil.include-once.org/pluginspec/index)
+(*self-contained* atop each script) is basically:
+
+    # encoding: utf-8
+    # api: python
+    # type: handler
+    # category: io
+    # title: Plugin configuration
+    # description: Read meta data, pyz/package contents, module locating
+    # version: 0.5
+    # priority: core
+    # docs: http://fossil.include-once.org/streamtuner2/wiki/plugin+meta+data
+    # config:
+    #    { name: xyz, value: 1, type: bool, description: "Sets..." }
+    #    { name: var, value: "init", description: "Another..." }
+    # license: MITL
+    #
+    # Documentation goes here...
+
+The `key: value` format is language-agnostic.  It’s basically YAML in the
+topmost script comment.  For Python only # hash comments are used.  Defaults
+to rather common field names, encourages a documentation block, and an
+obvious [config: { ..  } scheme](https://fossil.include-once.org/pluginspec/wiki/config)
+for options and defaults.
+
+How it's used:
+
+    import pluginconf
+    meta = pluginconf.plugin_meta(fn="./plugin/func.py")
+    print(meta)
+
+What it’s not:
+
+>  * This is not another config reader/parser/storage class.
+>  * Doesn’t impose a specific plugin API.
+>  * Neither concerns itself with module/package loading. (See [pluginbase](https://pypi.org/project/pluginbase/) or just `__import__`.)
+
+What for then?
+
+>  * Separates code from meta data. Avoids keeping seldomly used descriptors in variables.
+>  * Does away with externalized ini/json files for modules, yet simplifies use of external tooling.
+>  * Minimizes premature module loading just to inspect meta information.
+
+pluginconf is less about a concrete implementation/code, but pushing a universal meta data format.
+
+
+# API
+
+Lookup configuration is currently just done through injection:
+
+    plugin_base = [__package__, "myapp.plugins", "/usr/share/app/extensions"]
+    module_base = "pluginconf"  # or any top-level app module
+
+Which declares module and plugin basenames, which get used for lookups by
+just module= names in e.g. `module_list()`. (Works for literal setups
+and within PYZ bundles).   
+This is unnecessary for plain `plugin_meta(fn=)` extraction.
+
+#### plugin_meta( module= | fn= | src= | frame= )
+
+Returns a meta data dictionary for the given module name, file, source code, or caller frame:
+
+    {
+      "title": "Compound★",
+      "description": "...",
+      "version": "0.1",
+      "type": "channel",
+      "category": "virtual",
+      "config": […],
+      "doc": "The multiline comment \n following meta fields..."
+      …
+    }
+
+And that’s already all it does.  
+All other methods in pluginconf are mostly just for module lookup or data
+retrieval.
+
+#### module_list()
+
+Returns basenames of available/installed plugins (from possible sources
+in `plugin_base`).
+
+#### add_plugin_defaults()
+
+Populates your config_options{} and plugin_states{} list. Can be a classic dict,
+or one of the hundreds of config parser/managers. You might want to combine
+config options and plugin states in a single dict even:
+
+    import pluginconf
+    pluginconf.module_base = __name__
+    pluginconf.plugin_base = [__package__]
+
+    conf = {
+        "defaults": "123",
+	"plugins": {}       # ← stores the activation states
+    }
+
+    for module,meta in pluginconf.all_plugin_meta().items():
+        pluginconf.add_plugin_defaults(conf, conf["plugins"], meta, module)
+        #      share the same dict      ↑        ↑
+
+#### get_data( fn= )
+
+Is mostly an alias for pkgutil.get_data(). Abstracts usage within PYZ packages a little.
+
+#### argparse_map()
+
+Provides a simpler way to specify ugly argparse definitions. And allows to amass options from plugins.
+
+# GUI
+
+There's a Tkinter/PySimpleGUI variant of the option dialog from
+[streamtuner2](https://fossil.include-once.org/streamtuner2/) included:
+![](https://fossil.include-once.org/streamtuner2/raw/ba3d43061948b97087a38b45f015c7736843a631?m=image/png)
+
+The `pluginconf.gui.window()` implementation has a few less features, but
+might suffice for common applications. It just lists a single pane of
+settings, and doesn't even attempt to group by categories.
+
+Its main function performs the plugin lookup (`*.py` meta reading) and
+displays an editing window:
+
+     import pluginconf.gui
+     config = {
+         "debug": 0, "verbose": 1, "temp_dir": "/tmp"
+     }
+     plugin_states = {
+         "core": 1, "printing_ui": 0
+     }
+     pluginconf.gui.window(config, plugin_states, files=["./library/*.py"])
+
+Where both `config` and `plugin_states` get updated after invocation. The
+function return value indicates whether save or cancel was pressed.
+
+  * `plugin_states={}` can be omitted/empty, but the GUI will still display
+    checkboxes for plugin files, even if they go unused.
+  * Supports only basic option types (bool, str, int, select), no table/dict.
+  * Type casting is somewhat basic (no integer regex).
+  * And doesn't support nested config names=`app[module][var]` yet.
+  * The config dict might be prefilled from either in-app defaults,
+    or `json.load()`, and/or per `pluginconf.add_plugin_defaults()`.
+  * It's still up to the application how/where to store the config{} dict (e.g. `json.dumps()`).
+  * And alternatively to the *.py glob list, you could inject a prepared
+    dictionary as `plugins={}` list (keys are unused) and leave `files=None`.
+  * Any PySimpleGUI options (title=, theme=, resizable=) are passed through to
+    the config window.
+ 
+Overall it's surprisingly short given the PySimpleGUI result set. It would
+likely behave as well, if e.g. additional tabs or input widgets were added.
+
+
+# setup.py wrapper
+
+Another obvious use case for PMD is simplifying packaging. A `setup()`
+script can become as short as:
+
+     from pluginconf.setup import setup
+     setup(
+         fn="main/pkg.py"
+     )
+
+Which will reuse version: and descriptors from the meta comment. For simple
+one-module packages, you might get away with just `setup()` and an all
+automatic lookup. The non-standard PMD field `# classifiers: x11, python`
+can be used to lookup trove categories (crude search on select topics).
+All other `setup(fields=…)` are passed on to distutils/setuptools as is.
+-- Btw, [setupmeta](https://pypi.org/project/setupmeta/) is an even more
+versatile wrapper with sensible defaults and source scanning.
+
+
+#### Caveats
+
+ * It’s mostly just an excerpt from streamtuner2.
+ * Might need customization prior use.
+ * The GUI implmentation is fairly simplistic.
+ * Doesn't bundle any plugin repo loader logic.
+ * So doesn't make use of the dependency class.
+ * The description fields can double as packaging source (setup.py). There's also a
+   [# pack: specifier](https://fossil.include-once.org/pluginspec/wiki/References)
+   for fpm (deb/rpm/arch/exe/pyzw/pip generation), unused in the `setup.py`
+   wrapper here however.
+ * In Python `# type:` might neede doubled commenting (## pylint), or alternating to 
+   other descriptors like`class:` or `slot:`. (The whole scheme is agnostic to
+   designators. Common keys are just recommended for potential interoparability.)
+ * The format incidentally mixes well with established comment markers like
+   `# format: off` or `# pylint: disable=…` for instance.
+

ADDED   TODO
Index: TODO
==================================================================
--- TODO
+++ TODO
@@ -0,0 +1,2 @@
+ - argparse
+ - pmd2inks

ADDED   g.py
Index: g.py
==================================================================
--- g.py
+++ g.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+import pluginconf.gui
+pluginconf.gui.window({}, {}, ["../streamtuner2/channels/*.py"])

ADDED   pluginconf/__init__.py
Index: pluginconf/__init__.py
==================================================================
--- pluginconf/__init__.py
+++ pluginconf/__init__.py
@@ -0,0 +1,594 @@
+# encoding: utf-8
+# api: python
+# type: extract
+# category: config
+# title: Plugin configuration
+# description: Read meta data, pyz/package contents, module locating
+# version: 0.7.7
+# state: stable
+# classifiers: documentation
+# license: PD
+# priority: core
+# docs: https://fossil.include-once.org/pluginspec/
+# url: http://fossil.include-once.org/streamtuner2/wiki/plugin+meta+data
+# config: -
+#
+# Provides plugin lookup and meta data extraction utility functions.
+# It's used to abstract module+option management in applications.
+# For consolidating internal use and external/tool accessibility.
+#
+# The key:value format is language-agnostic. It's basically YAML in
+# a topmost script comment. For Python only # hash comments though.
+# Uses common field names, a documentation block, and an obvious
+# `config: { .. }` spec for options and defaults.
+#
+# It neither imposes a specific module/plugin API, nor config storage,
+# and doesn't fixate module loading. It's really just meant to look
+# up meta infos.
+# This approach avoids in-code values/inspection, externalized meta
+# descriptors, and any hodgepodge or premature module loading just to
+# uncover module description fields.
+#
+# plugin_meta()
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾
+#  Is the primary function to extract a meta dictionary from files.
+#  It either reads from a given module= name, a literal fn=, or just
+#  src= code, and as fallback inspects the last stack frame= else.
+#
+# module_list()
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾
+#  Returns basenames of available/installed plugins. It uses the
+#  plugin_base=[] list for module relation. Which needs to be set up
+#  beforehand, or injected.
+#
+# add_plugin_defaults()
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+#  Populates a config_options{} and plugin_states{} list. Used for
+#  initial setup, or when adding new plugins, etc. Both dicts might
+#  also be ConfigParser stores, or implement magic __set__ handling
+#  to act on state changes.
+#
+# get_data()
+# ‾‾‾‾‾‾‾‾‾‾
+#  Is mostly an alias for pkgutil.get_data(). It abstracts the main
+#  base path, allows PYZ usage, and adds some convenience flags.‾
+#  It's somewhat off-scope for plugin management, but used internally.
+#
+# argparse_map()
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+#  Converts a list of config: options with arg: attribute for use as
+#  argparser parameters.
+#
+# dependency().depends()/.valid()
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+#  Probes a new plugins` depends: list against installed base modules.
+#  Utilizes each version: fields and allows for virtual modules, or
+#  alternatives and honors alias: names.
+#
+#
+# Generally this scheme concerns itself more with plugin basenames.
+# That is: module scripts in a package like `ext.plg1` and `ext.plg2`.
+# It can be initialized by injecting the plugin-package basename into
+# plugin_base = []. The associated paths will be used for module
+# lookup via pkgutil.iter_modules().
+#
+# And a central module can be extended with new lookup locations best
+# by attaching new locations itself via module.__path__ + ["./local"]
+# for example.
+#
+# Plugin loading thus becomes as simple as __import__("ext.local").
+# The attached plugin_state config dictionary in most cases can just
+# list module basenames, if there's only one set to manage.
+
+
+import sys
+import os
+import re
+import pkgutil
+import inspect
+try:
+    from distutils.spawn import find_executable
+except:
+    try: 
+        from compat2and3 import find_executable
+    except:
+        def find_executable(str):
+            pass
+try:
+    from gzip import decompress as gzip_decode  # Py3 only
+except:
+    try:
+        from compat2and3 import gzip_decode   # st2 stub
+    except:
+        def gzip_decode(bytes):
+            import zlib
+            return zlib.decompress(bytes, 16 + zlib.MAX_WBITS)    # not fully compatible
+import zipfile
+import argparse
+
+__all__ = [
+    "get_data", "module_list", "plugin_meta",
+    "dependency", "add_plugin_defaults"
+]
+
+
+# Injectables
+# ‾‾‾‾‾‾‾‾‾‾‾
+log_ERR = lambda *x: None
+
+# File lookup relation for get_data(), should name a top-level package.
+module_base = "config"         # equivalent PluginBase(package=…)
+
+# Package/module names for module_list() and plugin_meta() lookups.
+# All associated paths will be scanned for module/plugin basenames.
+plugin_base = ["channels"]     # equivalent to `searchpath` in PluginBase
+
+
+# Resource retrieval
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Fetches file content from install path or from within PYZ
+# archive. This is just an alias and convenience wrapper for
+# pkgutil.get_data().
+# Utilizes the module_base / file_base as top-level reference.
+#
+def get_data(fn, decode=False, gz=False, file_base=None):
+    try:
+        bin = pkgutil.get_data(file_base or module_base, fn)
+        if gz:
+            bin = gzip_decode(bin)
+        if decode:
+            return bin.decode("utf-8", errors='ignore')
+        else:
+            return str(bin)
+    except:
+        # log_ERR("get_data() didn't find:", fn, "in", file_base)
+        pass
+
+
+# Plugin name lookup
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Search through ./plugins/ (and other configured plugin_base
+# names → paths) and get module basenames.
+#
+def module_list(extra_paths=[]):
+
+    # Convert plugin_base package names into paths for iter_modules
+    paths = []
+    for mp in plugin_base:
+        if sys.modules.get(mp):
+            paths += sys.modules[mp].__path__
+        elif os.path.exists(mp):
+            paths.append(mp)
+
+    # Should list plugins within zips as well as local paths
+    ls = pkgutil.iter_modules(paths + extra_paths)
+    return [name for loader, name, ispkg in ls]
+
+
+# Plugin => meta dict
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# This is a trivial wrapper to assemble a complete dictionary
+# of available/installed plugins. It associates each plugin name
+# with a its meta{} fields.
+#
+def all_plugin_meta():
+    return {
+        name: plugin_meta(module=name) for name in module_list()
+    }
+
+
+# Plugin meta data extraction
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Can fetch infos from different sources:
+#
+#   fn=      read literal files, or .pyz contents
+#
+#   src=     from already uncovered script code
+#
+#   module=  lookup per pkgutil, from plugin bases
+#            or top-level modules
+#
+#   frame=   extract comment header of caller
+#            (default)
+#
+def plugin_meta(fn=None, src=None, module=None, frame=1, extra_base=[], max_length=6144):
+
+    # Try via pkgutil first,
+    # find any plugins.* modules, or main packages
+    if module:
+        fn = module
+        for base in plugin_base + extra_base:
+            try:
+                src = get_data(fn=fn+".py", decode=True, file_base=base)
+                if src:
+                    break
+            except:
+                continue  # plugin_meta_extract() will print a notice later
+
+    # Real filename/path
+    elif fn and os.path.exists(fn):
+        src = open(fn).read(max_length)
+
+    # Else get source directly from caller
+    elif not src and not fn:
+        module = inspect.getmodule(sys._getframe(frame))
+        fn = inspect.getsourcefile(module)
+        src = inspect.getcomments(module)
+
+    # Assume it's a filename within a zip
+    elif fn:
+        intfn = ""
+        while fn and len(fn) and not os.path.exists(fn):
+            fn, add = os.path.split(fn)
+            intfn = add + "/" + intfn
+        if len(fn) >= 3 and intfn and zipfile.is_zipfile(fn):
+            src = zipfile.ZipFile(fn, "r").read(intfn.strip("/"))
+
+    # Extract source comment into meta dict
+    if not src:
+        src = ""
+    if not isinstance(src, str):
+        src = src.decode("utf-8", errors='replace')
+    return plugin_meta_extract(src, fn)
+
+
+# Comment and field extraction logic
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Finds the first comment block. Splits key:value header
+# fields from comment. Turns everything into an dict, with
+# some stub fields if absent.
+#
+def plugin_meta_extract(src="", fn=None, literal=False):
+
+    # Defaults
+    meta = {
+        "id": os.path.splitext(os.path.basename(fn or ""))[0],
+        "fn": fn,
+        "api": "python",
+        "type": "module",
+        "category": None,
+        "priority": None,
+        "version": "0",
+        "title": fn,
+        "description": "no description",
+        "config": [],
+        "doc": ""
+    }
+
+    # Extract coherent comment block
+    src = src.replace("\r", "")
+    if not literal:
+        src = rx.comment.search(src)
+        if not src:
+            log_ERR("Couldn't read source meta information:", fn)
+            return meta
+        src = src.group(0)
+        src = rx.hash.sub("", src).strip()
+
+    # Split comment block
+    if src.find("\n\n") > 0:
+        src, meta["doc"] = src.split("\n\n", 1)
+
+    # Turn key:value lines into dictionary
+    for field in rx.keyval.findall(src):
+        meta[field[0].replace("-", "_")] = field[1].strip()
+    meta["config"] = plugin_meta_config(meta.get("config") or "")
+
+    return meta
+
+
+# Unpack config: structures
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Further breaks up the meta['config'] descriptor.
+# Creates an array from JSON/YAML option lists.
+#
+# config:
+#   { name: 'var1', type: text, value: "default, ..." }
+#   { name=option2, type=boolean, $value=1, etc. }
+#
+# Stubs out name, value, type, description if absent.
+#
+def plugin_meta_config(str):
+    config = []
+    for entry in rx.config.findall(str):
+        entry = entry[0] or entry[1]
+        opt = {
+            "type": None,
+            "name": None,
+            "description": "",
+            "value": None
+        }
+        for field in rx.options.findall(entry):
+            opt[field[0]] = (field[1] or field[2] or field[3] or "").strip()
+        # normalize type
+        opt["type"] = config_opt_type_map.get(opt["type"], opt["type"] or "str")
+        # preparse select:
+        if opt.get("select"):
+            opt["select"] = config_opt_parse_select(opt.get("select", ""))
+        config.append(opt)
+    return config
+
+# split up `select: 1=on|2=more|3=title` or `select: foo|bar|lists`
+def config_opt_parse_select(s):
+    if re.search("([=:])", s):
+        return dict(rx.select_dict.findall(s))
+    else:
+        return dict([(v, v) for v in rx.select_list.findall(s)])
+
+# normalize type:names to `str`, `text`, `bool`, `int`, `select`, `dict`
+config_opt_type_map = dict(
+    longstr="text", string="str", boolean="bool", checkbox="bool", integer="int", number="int",
+    choice="select", options="select", table="dict", array="dict"
+)
+
+
+# Comment extraction regexps
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Pretty crude comment splitting approach. But works
+# well enough already. Technically a YAML parser would
+# do better; but is likely overkill.
+#
+class rx:
+    comment = re.compile(r"""(^ {0,4}#.*\n)+""", re.M)
+    hash = re.compile(r"""(^ {0,4}# {0,3}\r*)""", re.M)
+    keyval = re.compile(r"""
+        ^([\w-]+):(.*$(?:\n(?![\w-]+:).+$)*)   # plain key:value lines
+    """, re.M | re.X)
+    config = re.compile(r"""
+        \{ ((?: [^\{\}]+ | \{[^\}]*\} )+) \}   # JSOL/YAML scheme {...} dicts
+        | \< (.+?) \>                          # old <input> HTML style
+    """, re.X)
+    options = re.compile(r"""
+        ["':$]?   (\w*)  ["']?                 # key or ":key" or '$key'
+        \s* [:=] \s*                           # "=" or ":"
+     (?:  "  ([^"]*)  "
+       |  '  ([^']*)  '                        #  "quoted" or 'singl' values
+       |     ([^,]*)                           #  or unquoted literals
+     )
+    """, re.X)
+    select_dict = re.compile(r"(\w+)\s*[=:>]+\s*([^=,|:]+)")
+    select_list = re.compile(r"\s*([^,|;]+)\s*")
+
+
+
+# ArgumentParser options conversion
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# As variation of in-application config: options, this method converts
+# cmdline argument specifiers.
+#
+#  config:
+#    { arg: -i, name: input[], type: str, description: input files }
+#
+# Which allows to collect argumentparser options from different plugins.
+# The only difference to normal config entries is the `arg:` attribute.
+#
+#  · It adds array arguments with a [] name suffix, or a `*` type suffix.
+#    Else even a `?` or `+` and numeric counts after the type flag.
+#
+#  · Understands the types `str`, `int` and `bool`.
+#
+#  · Entries may carry a `hidden: 1` or `required: 1` attribute.
+#
+#  · And `help:` is an alias to `description:`
+#    And `default:` an alias for `value:`
+#
+#  · While `type: select` utilizes the `select: a|b|c` format as usual.
+#
+# ArgParsers const=, metavar= flag, or type=file are not aliased here.
+#
+# Basically returns a dictionary that can be fed per **kwargs directly
+# to an ArgumentParsers add_argument(). Iterate over each plugins
+# meta['config'][] options to convert them.
+#
+def argparse_map(opt):
+    if not ("arg" in opt and opt["name"] and opt["type"]):
+        return {}
+
+    # Extract --flag names
+    args = opt["arg"].split() + re.findall(r"-+\w+", opt["name"])
+
+    # Prepare mapping options
+    typing = re.findall(r"bool|str|\[\]|const|false|true", opt["type"])
+    naming = re.findall(r"\[\]", opt["name"])
+    name = re.findall(r"(?<!-)\b\w+", opt["name"])
+    nargs = re.findall(r"\b\d+\b|[\?\*\+]", opt["type"]) or [None]
+    is_arr = "[]" in (naming + typing) and nargs == [None]
+    is_bool = "bool" in typing
+    false_b = "false" in typing or opt["value"] in ("0", "false")
+    # print("\nname=", name, "is_arr=", is_arr, "is_bool=", is_bool,
+    # "bool_d=", false_b, "naming=", naming, "typing=", typing)
+
+    # Populate combination as far as ArgumentParser permits
+    kwargs = dict(
+        args     = args,
+        dest     = name[0] if not name[0] in args else None,
+        action   = is_arr and "append"
+                   or  is_bool and false_b and "store_false"
+                   or  is_bool and "store_true"  or  "store",
+        nargs    = nargs[0],
+        default  = opt.get("default") or opt["value"],
+        type     = None if is_bool  else  ("int" in typing and int
+                   or  "bool" in typing and bool  or  str),
+        choices  = opt["select"].split("|") if "select" in opt else None,
+        required = "required" in opt or None,
+        help     = opt["description"] if not "hidden" in opt
+                   else argparse.SUPPRESS
+    )
+    return {k: w for k, w in kwargs.items() if w is not None}
+
+
+# Minimal depends: probing
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Now this definitely requires customization. Each plugin can carry
+# a list of (soft-) dependency names.
+#
+#   depends: config, appcore >= 2.0, bin:wkhtmltoimage, python < 3.5
+#
+# Here only in-application modules are honored, system references
+# ignored. Unknown plugin names are also skipped. A real install
+# helper might want to auto-tick them on, etc. This example is just
+# meant for probing downloadable plugins.
+#
+# The .valid() helper only asserts the api: string, or skips existing
+# modules, and if they're more recent.
+# While .depends() compares minimum versions against existing modules.
+#
+# In practice there's little need for full-blown dependency resolving
+# for application-level modules.
+#
+class dependency(object):
+
+    # prepare list of known plugins and versions
+    def __init__(self, add={}, core=["st2", "uikit", "config", "action"]):
+        self.have = {
+            "python": { "version": sys.version }
+        }
+        # inject virtual modules
+        for name, meta in add.items():
+            if isinstance(meta, bool): meta = 1 if meta else -1
+            if isinstance(meta, tuple): meta = ".".join(str(n) for n in meta)
+            if isinstance(meta, (int, float, str)): meta = {"version": str(meta)}
+            self.have[name] = meta
+        # read plugins/*
+        self.have.update(all_plugin_meta())
+        # add core modules
+        for name in core:
+            self.have[name] = plugin_meta(module=name, extra_base=["config"])
+        # aliases
+        for name, meta in self.have.copy().items():
+            if meta.get("alias"):
+                for alias in re.split(r"\s*[,;]\s*", meta["alias"]):
+                    self.have[alias] = self.have[name]
+
+    # basic plugin pre-screening (skip __init__, filter by api:,
+    # exclude installed & same-version plugins)
+    def valid(self, newpl, _log=lambda *x:0):
+        id = newpl.get("$name", "__invalid")
+        have_ver = self.have.get(id, {}).get("version", "0")
+        if id.find("__") == 0:
+            _log("wrong id")
+            pass
+        elif newpl.get("api") not in ("python", "streamtuner2"):
+            _log("wrong api")
+            pass
+        elif set((newpl.get("status"), newpl.get("priority"))).intersection(set(("obsolete", "broken"))):
+            _log("wrong status")
+            pass
+        elif have_ver >= newpl.get("version", "0.0"):
+            _log("newer version already installed")
+            pass
+        else:
+            return True
+
+    # Verify depends: and breaks: against existing plugins/modules
+    def depends(self, plugin, _log=lambda *x:0):
+        r = True
+        if plugin.get("depends"):
+            r &= self.and_or(self.split(plugin["depends"]), self.have)
+        if plugin.get("breaks"):
+            r &= self.neither(self.split(plugin["breaks"]), self.have)
+        _log(r)
+        return r
+
+    # Split trivial "pkg | alt, mod >= 1, uikit < 4.0" string into nested list [[dep],[alt,alt],[dep]]
+    def split(self, dep_str):
+        dep_cmp = []
+        for alt_str in re.split(r"\s*[,;]+\s*", dep_str):
+            alt_cmp = []
+            # split alternatives |
+            for part in re.split(r"\s*\|+\s*", alt_str):
+                # skip deb:pkg-name, rpm:name, bin:name etc.
+                if not len(part):
+                    continue
+                if part.find(":") >= 0:
+                    self.have[part] = { "version": self.module_test(*part.split(":")) }
+                # find comparison and version num
+                part += " >= 0"
+                m = re.search(r"([\w.:-]+)\s*\(?\s*([>=<!~]+)\s*([\d.]+([-~.]\w+)*)", part)
+                if m and m.group(2):
+                    alt_cmp.append([m.group(i) for i in (1, 2, 3)])
+            if alt_cmp:
+                dep_cmp.append(alt_cmp)
+        return dep_cmp
+
+    # Single comparison
+    def cmp(self, d, have, absent=True):
+        name, op, ver = d
+        # absent=True is the relaxed check, will ignore unknown plugins // set absent=False or None for strict check (as in breaks: rule e.g.)
+        if not have.get(name, {}).get("version"):
+            return absent
+        # curr = installed version
+        curr = have[name]["version"]
+        tbl = {
+            ">=": curr >= ver,
+            "<=": curr <= ver,
+            "==": curr == ver,
+            ">":  curr > ver,
+            "<":  curr < ver,
+            "!=": curr != ver,
+        }
+        r = tbl.get(op, True)
+        #print "log.VERSION_COMPARE: ", name, " → (", curr, op, ver, ") == ", r
+        return r
+
+    # Compare nested structure of [[dep],[alt,alt]]
+    def and_or(self, deps, have, r = True):
+        #print deps
+        return not False in [True in [self.cmp(d, have) for d in alternatives] for alternatives in deps]
+
+    # Breaks/Conflicts: check [[or],[or]]
+    def neither(self, deps, have):
+        return not True in [self.cmp(d, have, absent=None) for cnd in deps for d in cnd]
+
+    # Resolves/injects complex "bin:name" or "python:name" dependency URNs
+    def module_test(self, type, name):
+        return "1"  # disabled for now
+        if "_" + type in dir(self):
+            return "1" if bool(getattr(self, "_" + type)(name)) else "-1"
+    # `bin:name` lookup
+    def _bin(self, name):
+        return find_executable(name)
+    # `python:module` test
+    def _python(self, name):
+        return __import__("imp").find_module(name) is not None
+
+
+# Add plugin defaults to conf.* store
+# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
+# Utility function which applies plugin meta data to a config
+# store. Which in the case of streamtuner2 is really just a
+# dictionary `conf{}` and a plugin list in `conf.plugins{}`.
+#
+# Adds each default option value: to conf_options{}. And sets
+# first plugin state (enabled/disabled) in conf_plugins{} list,
+# depending on priority: classifier.
+#
+def add_plugin_defaults(conf_options, conf_plugins, meta={}, module=""):
+
+    # Option defaults, if not yet defined
+    for opt in meta.get("config", []):
+        if "name" in opt and "value" in opt:
+            _value = opt.get("value", "")
+            _name = opt.get("name")
+            _type = opt.get("type")
+            if _name not in conf_options:
+                # typemap
+                if _type == "bool":
+                    val = _value.lower() in ("1", "true", "yes", "on")
+                elif _type == "int":
+                    val = int(_value)
+                elif _type in ("table", "list"):
+                    val = [ re.split(r"\s*[,;]\s*", s.strip()) for s in re.split(r"\s*[|]\s*", _value) ]
+                elif _type == "dict":
+                    val = dict([ re.split(r"\s*(?:=>+|==*|-+>|:=+)\s*", s.strip(), 1) for s in re.split(r"\s*[|;,]\s*", _value) ])
+                else:
+                    val = str(_value)
+                conf_options[_name] = val
+
+    # Initial plugin activation status
+    if module and module not in conf_plugins:
+        conf_plugins[module] = meta.get("priority") in (
+            "core", "builtin", "always", "default", "standard"
+        )
+
+

ADDED   pluginconf/gui.py
Index: pluginconf/gui.py
==================================================================
--- pluginconf/gui.py
+++ pluginconf/gui.py
@@ -0,0 +1,202 @@
+# encoding: UTF-8
+# api: python
+# type: ui
+# category: io
+# title: Config GUI
+# description: Display plugins + options in setup window
+# version: 0.8
+# depends: python:pysimplegui (>= 4.0)
+# priority: optional
+# config: -
+#
+# Creates a PySimpleGUI options list. Scans a given list of *.py files
+# for meta data, then populates a config{} dict and (optionally) a state
+# map for plugins themselves.
+#
+#    jsoncfg = {}
+#    pluginconf.gui.window(jsoncfg, {}, ["plugins/*.py"])
+#
+# Very crude, and not as many widgets as the Gtk/St2 implementation.
+# Supports type: str, bool, select, int, dict, text config: options.
+#
+
+
+import PySimpleGUI as sg
+import pluginconf
+import glob, json, os, re, textwrap
+
+
+# temporarily store collected plugin config: dicts
+options = {}
+
+
+#-- show configuation window
+def window(config={}, plugin_states={}, files=["*/*.py"], plugins={}, opt_label=False, theme="DefaultNoMoreNagging", **kwargs):
+    """
+    Reads *.py files and crafts a settings dialog from meta data.
+    
+    Parameters
+    ----------
+    config : dict
+        Config settings, updated after dialog completion
+    plugin_states : dict
+        Plugin activation states, also input/output
+    files : list
+        Glob list of *.py files to extract meta definitions from
+    plugins : dict
+        Alternatively to files=[] list, a preparsed list of pluginmeta+config dicts can be injected
+    opt_label : bool
+        Show config name= as label
+    **kwargs : dict
+        Other options are passed on to PySimpleGUI
+    """
+    
+    if theme:
+        sg.theme(theme)
+    if files:
+        plugins = read_options(files)
+    layout = plugin_layout(plugins.values(), config, plugin_states, opt_label=opt_label)
+    layout.append([sg.T(" ")])
+    #print(repr(layout))
+    
+    # pack window
+    layout = [
+        [sg.Column(layout, expand_x=1, expand_y=0, size=(575,680), scrollable="vertically", element_justification='left')],
+        [sg.Column([[sg.Button("Cancel"), sg.Button("Save")]], element_justification='right')]
+    ]
+    if not "title" in kwargs:
+        kwargs["title"] = "Options"
+    if not "font" in kwargs:
+        kwargs["font"] = "Sans 11"
+    win = sg.Window(layout=layout, resizable=1, **kwargs)
+
+    # wait for save/exit        
+    event,data = win.read()
+    win.close()
+    if event=="Save":
+        for k,v in data.items():
+            if options.get(k):
+                #@ToDo: handle array[key] names
+                config[k] = cast.fromtype(data[k], options[k])
+            elif type(k) is str and k.startswith('p:'):
+                k = k.replace('p:', '')
+                if plugins.get(k):
+                    plugin_states[k] = v
+        return True
+    #print(config, plugin_states)
+    
+    
+# craft list of widgets for each read plugin
+def plugin_layout(ls, config, plugin_states, opt_label=False):
+    layout = []
+    for plg in ls:
+        #print(plg.get("id"))
+        layout = layout + plugin_entry(plg, plugin_states)
+        for opt in plg["config"]:
+            if opt.get("name"):
+                if opt_label:
+                    layout.append([sg.T(opt["name"], font=("Sans",11,"bold"), pad=((50,0),(7,0)))])
+                layout.append(option_entry(opt, config))
+    return layout
+    
+# checkbox for plugin name
+def plugin_entry(e, plugin_states):
+    id = e["id"]
+    return [
+         [
+             sg.Checkbox(
+                  e.get("title", id), key='p:'+id, default=plugin_states.get(id, 0), tooltip=e.get("doc"), metadata="plugin",
+                  font="bold", pad=(0,(8,0))
+             ),
+             sg.Text("({}/{})".format(e.get("type"), e.get("category")), text_color="#005", pad=(0,(8,0))),
+             sg.Text(e.get("version"), text_color="#a72", pad=(0,(8,0)))
+         ],
+         [
+             sg.Text(e.get("description", ""), tooltip=e.get("doc"), font=("sans", 10), pad=(26,(0,10)))
+         ]
+    ]
+
+# widgets for single config option
+def option_entry(o, config):
+    #print(o)
+    name = o.get("name", "")
+    desc = wrap(o.get("description", name), 60)
+    type = o.get("type", "str")
+    help = o.get("help", None)
+    if help:
+        help = wrap(help, 60)
+    options[name] = o
+    val = config.get(name, o.get("value", ""))
+    if o.get("hidden"):
+        pass
+    elif type == "str":
+        return [
+            sg.InputText(key=name, default_text=str(val), size=(20,1), pad=((50,0),3)),
+            sg.Text(wrap(desc, 50), pad=(5,2), tooltip=help or name, justification='left', auto_size_text=1)
+        ]
+    elif type == "text":
+        return [
+            sg.Multiline(key=name, default_text=str(val), size=(45,4), pad=((40,0),3)),
+            sg.Text(wrap(desc, 20), pad=(5,2), tooltip=help or name, justification='left', auto_size_text=1)
+        ]
+    elif type == "bool":
+        return [
+            sg.Checkbox(wrap(desc, 70), key=name, default=cast.bool(val), tooltip=help or name, pad=((40,0),2), auto_size_text=1)
+        ]
+    elif type == "int":
+        return [
+            sg.InputText(key=name, default_text=str(val), size=(6,1), pad=((50,0),3)),
+            sg.Text(wrap(desc, 60), pad=(5,2), tooltip=help or name, auto_size_text=1)
+        ]
+    elif type == "select":
+        #o["select"] = parse_select(o.get("select", ""))
+        values = [v for v in o["select"].values()]
+        return [
+            sg.Combo(key=name, default_value=o["select"].get(val, val), values=values, size=(15,1), pad=((50,0),0), font="Sans 11"),
+            sg.Text(wrap(desc, 47), pad=(5,2), tooltip=help or name, auto_size_text=1)
+        ]
+    elif type == "dict":  # or "table" rather ?
+        return [
+            sg.Table(values=config.get(name, ["", ""]), headings=o.get("columns", "Key,Value").split(","),
+            num_rows=5, col_widths=30, def_col_width=30, auto_size_columns=False, max_col_width=150, key=name, tooltip=help or desc)
+        ]
+    return []
+
+
+#-- read files, return dict of {id:pmd} for all plugins
+def read_options(files):
+    ls = [pluginconf.plugin_meta(fn=fn) for pattern in files for fn in glob.glob(pattern)]
+    return dict(
+        (meta["id"], meta) for meta in ls
+    )
+
+
+#-- map option types (from strings)
+class cast:
+    @staticmethod
+    def bool(v):
+        if v in ("1", 1, True, "true", "TRUE", "yes", "YES", "on", "ON"):
+            return True
+        return False
+    @staticmethod
+    def int(v):        
+        return int(v) if re.match("-?\d+", v) else 0
+    @staticmethod
+    def fromtype(v, opt):
+        if not opt.get("type"):
+            return str(v)
+        elif opt["type"] == "int":
+            return cast.int(v)
+        elif opt["type"] == "bool":
+            return cast.bool(v)
+        elif opt["type"] == "select":
+            inverse = dict((v,k) for k,v in opt["select"].items())
+            return inverse.get(v, v)
+        elif opt["type"] == "text":
+            return str(v).rstrip()
+        else:
+            return v
+
+#-- textwrap for `description` and `help` option fields
+def wrap(s, w=50):
+    return "\n".join(textwrap.wrap(s, w)) if s else ""

ADDED   pluginconf/setup.py
Index: pluginconf/setup.py
==================================================================
--- pluginconf/setup.py
+++ pluginconf/setup.py
@@ -0,0 +1,473 @@
+# encoding: utf-8
+# api: setuptools
+# type: functions
+# title: setup() wrapper
+# description: utilizes PMD to populate some package fields
+# version: 0.3
+# license: PD
+#
+# Utilizes plugin meta data to cut down setup.py incantations
+# to basically just:
+#
+#     from pluginconf.setup import setup
+#     setup(
+#         fn="pluginconf/__init__.py"
+#     )
+#
+# Where the fn= either pinpoints the main invocation point,
+# when name= or packages= (implicitly from `find_packages()`)
+# don't specify a locatable script already.
+#
+# Mapped meta fields include:
+#
+#     # description: …
+#     # version: …
+#     # url: …
+#     # depends: python (>= 2.7), python:pkg-name (>= 1.0)
+#     # suggests: python:extras_require (>= 1.0)
+#     # author: …
+#     # license: spdx
+#     # state: stable
+#     # type: classifier
+#     # category: classifier
+#     # keywords: tag, tag
+#     # classifiers: tag, trove, shortcuts
+#     # doc-format: text/markdown
+#     #
+#     # Long description used in lieu of README...
+#    
+# A README.* will be read if present, else PMD comment used.
+# Classifiers and license matching is very crude, just for
+# the most common cases. Type:, Category: and Classifiers:
+# or Keywords: are also scanned for trove classifers.
+#
+
+
+import os, re, glob
+import setuptools
+import pluginconf
+
+
+def _name_to_fn(name):
+    """ find primary entry point.py from package name """
+    for pfx in "", "src/", "src/"+name+"/":
+        for sfx in ".py", "/__init__.py":
+            if os.path.exists(pfx+name+sfx):
+                return pfx+name+sfx
+
+def _get_readme():
+    """ get README.md contents """
+    for fn,mime in ("README.md", "text/markdown"), ("README.rst", "text/x-rst"), ("README.txt", "text/plain"):
+        if os.path.exists(fn):
+            with open(fn, "r") as f:
+                return f.read(), mime
+    #system("pandoc -f markdown -t rst README.md  > README.rst")
+    return "", "text/plain"
+
+def setup(debug=0, **kwargs):
+    """
+        Wrapper around `setuptools.setup()` which adds some defaults
+        and plugin meta data import, with two shortcut params:
+        
+          fn="pkg/main.py",
+          long_description="@README.md"
+
+        Other setup() params work as usual.
+    """
+
+    # stub values
+    stub = {
+        "classifiers": [],
+        "project_urls": {},
+        "python_requires": ">= 2.7",
+        "install_requires": [],
+        "extras_require": {},
+        #"package_dir": {"": "."},
+        #"package_data": {},
+        #"data_files": [],
+        "entry_points": {},
+        "packages": setuptools.find_packages()
+    }
+    for k,v in stub.items():
+        if not k in kwargs:
+            kwargs[k] = v
+
+    # package name
+    if not "name" in kwargs:
+        kwargs["name"] = kwargs["packages"][0]
+
+    # read README
+    if not "long_description" in kwargs or re.match("^@?([.\w]+/)*README.(\w+)$", kwargs.get("long_description", "-")):
+        kwargs["long_description"], kwargs["long_description_content_type"] = _get_readme()
+
+    # search name= package if no fn= given
+    if not kwargs.get("fn") and kwargs.get("name"):
+        kwargs["fn"] = _name_to_fn(kwargs["name"])
+
+    # read plugin meta data (PMD)  
+    pmd = {}
+    pmd = pluginconf.plugin_meta(fn=kwargs["fn"])
+    
+    # id: if no name= still
+    if pmd.get("id") and not kwargs.get("name"):
+        if pmd["id"] == "__init__":
+            pmd["id"] = re.findall("([\w\.\-]+)/__init__.+$", kwargs["fn"])[0]
+        kwargs["name"] = pmd["id"]
+    # cleanup
+    if "fn" in kwargs:
+        del kwargs["fn"]
+
+    # version:, description:, author:
+    for field in "version", "description", "license", "author", "url":
+        if field in pmd and not field in kwargs:
+            kwargs[field] = pmd[field]
+    # other urls:
+    for k,url in pmd.items():
+        if type(url) is str and k != "url" and re.match("https?://\S+", url):
+            kwargs["project_urls"][k.title()] = url
+    # depends:
+    if "depends" in pmd:
+        deps = re.findall("python\s*\(?(>=?\s?[\d.]+)", pmd["depends"])
+        if deps:
+            kwargs["python_requires"] = deps[0]
+    if "depends" in pmd and not kwargs["install_requires"]:
+        deps = re.findall("(?:python|pip):([\w\-]+)\s*(\(?[<=>\s\d.\-]+)?", pmd["depends"])
+        if deps:
+            kwargs["install_requires"] = [name+re.sub("[^<=>\d.\-]", "", ver) for name,ver in deps]
+    # suggests:
+    if "suggests" in pmd and not kwargs["extras_require"]:
+        deps = re.findall("(?:python|pip):([\w\-]+)\s*\(?\s*([>=<]+\s*[\d.\-]+)", pmd["suggests"])
+        if deps:
+            kwargs["extras_require"].update(dict(deps))
+    # doc:
+    if not kwargs.get("long_description"):
+        kwargs["long_description"] = pmd["doc"]
+        kwargs["long_description_content_type"] = pmd.get("doc_format", "text/plain")
+    # keywords=
+    if not "keywords" in kwargs:
+        if "keywords" in pmd:
+            kwargs["keywords"] = pmd["keywords"]
+        elif "category" in pmd:
+            kwargs["keywords"] = pmd["category"]
+    
+    # automatic inclusions
+    if not "data_files" in kwargs:
+        kwargs["data_files"] = []
+    for man in glob.glob("man*/*.[12345678]"):
+        section = man[-1]
+        kwargs["data_files"].append(("man/man"+section, [man],))
+
+    # classifiers=
+    trove_map = {
+        "license": {
+            "MITL?": "License :: OSI Approved :: MIT License",
+            "PD|Public Domain": "License :: Public Domain",
+            "ASL": "License :: OSI Approved :: Apache Software License",
+            "art": "License :: OSI Approved :: Artistic License",
+            "BSDL?": "License :: OSI Approved :: BSD License",
+            "CPL": "License :: OSI Approved :: Common Public License",
+            "AGPL.*3": "License :: OSI Approved :: GNU Affero General Public License v3",
+            "AGPLv*3\+": "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
+            "GPL": "License :: OSI Approved :: GNU General Public License (GPL)",
+            "GPL.*3": "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+            "LGPL": "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
+            "MPL": "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
+            "Pyth": "License :: OSI Approved :: Python Software Foundation License"
+        },
+        "state": {
+            "pre|release|cand": "Development Status :: 2 - Pre-Alpha",
+            "alpha": "Development Status :: 3 - Alpha",
+            "beta": "Development Status :: 4 - Beta",
+            "stable": "Development Status :: 5 - Production/Stable",
+            "mature": "Development Status :: 6 - Mature"
+        }
+    }
+    # license:
+    if pmd.get("license") and not any(re.match("License ::", l) for l in kwargs["classifiers"]):
+        for rx,trove in trove_map["license"].items():
+            if re.search(rx, pmd["license"], re.I):
+                kwargs["classifiers"].append(trove)
+    # state:
+    if pmd.get("state", pmd.get("status")) and not any(re.match("Development Status ::", l) for l in kwargs["classifiers"]):
+        for rx,trove in trove_map["state"].items():
+            if re.search(rx, pmd.get("state", pmd.get("status", "stable")), re.I):
+                kwargs["classifiers"].append(trove)
+    # topics::
+    rx = "|".join(re.findall("(\w{4,})", " | ".join([pmd.get(f, "---") for f in ("api", "category", "type", "keywords", "classifiers")])))
+    for line in topic_trove:
+        if re.search("::[^:]*("+rx+")[^:]*$", line, re.I):
+            if line not in kwargs["classifiers"]:
+                kwargs["classifiers"].append(line)
+
+    # handover
+    if debug:
+        import pprint
+        pprint.pprint(kwargs)
+    setuptools.setup(**kwargs)
+
+
+
+topic_trove="""Topic :: Adaptive Technologies
+Topic :: Artistic Software
+Topic :: Communications
+Topic :: Communications :: BBS
+Topic :: Communications :: Chat
+Topic :: Communications :: Chat :: ICQ
+Topic :: Communications :: Chat :: Internet Relay Chat
+Topic :: Communications :: Chat :: Unix Talk
+Topic :: Communications :: Conferencing
+Topic :: Communications :: Email
+Topic :: Communications :: Email :: Address Book
+Topic :: Communications :: Email :: Email Clients (MUA)
+Topic :: Communications :: Email :: Filters
+Topic :: Communications :: Email :: Mail Transport Agents
+Topic :: Communications :: Email :: Mailing List Servers
+Topic :: Communications :: Email :: Post-Office
+Topic :: Communications :: Email :: Post-Office :: IMAP
+Topic :: Communications :: Email :: Post-Office :: POP3
+Topic :: Communications :: FIDO
+Topic :: Communications :: Fax
+Topic :: Communications :: File Sharing
+Topic :: Communications :: File Sharing :: Gnutella
+Topic :: Communications :: File Sharing :: Napster
+Topic :: Communications :: Ham Radio
+Topic :: Communications :: Internet Phone
+Topic :: Communications :: Telephony
+Topic :: Communications :: Usenet News
+Topic :: Database :: Database Engines/Servers
+Topic :: Database :: Front-Ends
+Topic :: Desktop Environment :: File Managers
+Topic :: Desktop Environment :: GNUstep
+Topic :: Desktop Environment :: Gnome
+Topic :: Desktop Environment :: K Desktop Environment (KDE)
+Topic :: Desktop Environment :: K Desktop Environment (KDE) :: Themes
+Topic :: Desktop Environment :: PicoGUI
+Topic :: Desktop Environment :: PicoGUI :: Applications
+Topic :: Desktop Environment :: PicoGUI :: Themes
+Topic :: Desktop Environment :: Screen Savers
+Topic :: Documentation :: Sphinx
+Topic :: Education
+Topic :: Education :: Computer Aided Instruction (CAI)
+Topic :: Education :: Testing
+Topic :: Games/Entertainment
+Topic :: Games/Entertainment :: Arcade
+Topic :: Games/Entertainment :: Board Games
+Topic :: Games/Entertainment :: First Person Shooters
+Topic :: Games/Entertainment :: Fortune Cookies
+Topic :: Games/Entertainment :: Multi-User Dungeons (MUD)
+Topic :: Games/Entertainment :: Puzzle Games
+Topic :: Games/Entertainment :: Real Time Strategy
+Topic :: Games/Entertainment :: Role-Playing
+Topic :: Games/Entertainment :: Side-Scrolling/Arcade Games
+Topic :: Games/Entertainment :: Simulation
+Topic :: Games/Entertainment :: Turn Based Strategy
+Topic :: Home Automation
+Topic :: Internet :: File Transfer Protocol (FTP)
+Topic :: Internet :: Finger
+Topic :: Internet :: Log Analysis
+Topic :: Internet :: Name Service (DNS)
+Topic :: Internet :: Proxy Servers
+Topic :: Internet :: WAP
+Topic :: Internet :: WWW/HTTP
+Topic :: Internet :: WWW/HTTP :: Browsers
+Topic :: Internet :: WWW/HTTP :: Dynamic Content
+Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries
+Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System
+Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards
+Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
+Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Page Counters
+Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Wiki
+Topic :: Internet :: WWW/HTTP :: HTTP Servers
+Topic :: Internet :: WWW/HTTP :: Indexing/Search
+Topic :: Internet :: WWW/HTTP :: Session
+Topic :: Internet :: WWW/HTTP :: Site Management
+Topic :: Internet :: WWW/HTTP :: Site Management :: Link Checking
+Topic :: Internet :: WWW/HTTP :: WSGI
+Topic :: Internet :: WWW/HTTP :: WSGI :: Application
+Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
+Topic :: Internet :: WWW/HTTP :: WSGI :: Server
+Topic :: Internet :: XMPP
+Topic :: Internet :: Z39.50
+Topic :: Multimedia
+Topic :: Multimedia :: Graphics
+Topic :: Multimedia :: Graphics :: 3D Modeling
+Topic :: Multimedia :: Graphics :: 3D Rendering
+Topic :: Multimedia :: Graphics :: Capture
+Topic :: Multimedia :: Graphics :: Capture :: Digital Camera
+Topic :: Multimedia :: Graphics :: Capture :: Scanners
+Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
+Topic :: Multimedia :: Graphics :: Editors
+Topic :: Multimedia :: Graphics :: Editors :: Raster-Based
+Topic :: Multimedia :: Graphics :: Editors :: Vector-Based
+Topic :: Multimedia :: Graphics :: Graphics Conversion
+Topic :: Multimedia :: Graphics :: Presentation
+Topic :: Multimedia :: Graphics :: Viewers
+Topic :: Multimedia :: Sound/Audio
+Topic :: Multimedia :: Sound/Audio :: Analysis
+Topic :: Multimedia :: Sound/Audio :: CD Audio
+Topic :: Multimedia :: Sound/Audio :: CD Audio :: CD Playing
+Topic :: Multimedia :: Sound/Audio :: CD Audio :: CD Ripping
+Topic :: Multimedia :: Sound/Audio :: CD Audio :: CD Writing
+Topic :: Multimedia :: Sound/Audio :: Capture/Recording
+Topic :: Multimedia :: Sound/Audio :: Conversion
+Topic :: Multimedia :: Sound/Audio :: Editors
+Topic :: Multimedia :: Sound/Audio :: MIDI
+Topic :: Multimedia :: Sound/Audio :: Mixers
+Topic :: Multimedia :: Sound/Audio :: Players
+Topic :: Multimedia :: Sound/Audio :: Players :: MP3
+Topic :: Multimedia :: Sound/Audio :: Sound Synthesis
+Topic :: Multimedia :: Sound/Audio :: Speech
+Topic :: Multimedia :: Video
+Topic :: Multimedia :: Video :: Capture
+Topic :: Multimedia :: Video :: Conversion
+Topic :: Multimedia :: Video :: Display
+Topic :: Multimedia :: Video :: Non-Linear Editor
+Topic :: Office/Business
+Topic :: Office/Business :: Financial
+Topic :: Office/Business :: Financial :: Accounting
+Topic :: Office/Business :: Financial :: Investment
+Topic :: Office/Business :: Financial :: Point-Of-Sale
+Topic :: Office/Business :: Financial :: Spreadsheet
+Topic :: Office/Business :: Groupware
+Topic :: Office/Business :: News/Diary
+Topic :: Office/Business :: Office Suites
+Topic :: Office/Business :: Scheduling
+Topic :: Other/Nonlisted Topic
+Topic :: Printing
+Topic :: Religion
+Topic :: Scientific/Engineering
+Topic :: Scientific/Engineering :: Artificial Intelligence
+Topic :: Scientific/Engineering :: Artificial Life
+Topic :: Scientific/Engineering :: Astronomy
+Topic :: Scientific/Engineering :: Atmospheric Science
+Topic :: Scientific/Engineering :: Bio-Informatics
+Topic :: Scientific/Engineering :: Chemistry
+Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
+Topic :: Scientific/Engineering :: GIS
+Topic :: Scientific/Engineering :: Human Machine Interfaces
+Topic :: Scientific/Engineering :: Hydrology
+Topic :: Scientific/Engineering :: Image Processing
+Topic :: Scientific/Engineering :: Image Recognition
+Topic :: Scientific/Engineering :: Information Analysis
+Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
+Topic :: Scientific/Engineering :: Mathematics
+Topic :: Scientific/Engineering :: Medical Science Apps.
+Topic :: Scientific/Engineering :: Physics
+Topic :: Scientific/Engineering :: Visualization
+Topic :: Security
+Topic :: Security :: Cryptography
+Topic :: Sociology
+Topic :: Sociology :: Genealogy
+Topic :: Sociology :: History
+Topic :: Software Development
+Topic :: Software Development :: Assemblers
+Topic :: Software Development :: Bug Tracking
+Topic :: Software Development :: Build Tools
+Topic :: Software Development :: Code Generators
+Topic :: Software Development :: Compilers
+Topic :: Software Development :: Debuggers
+Topic :: Software Development :: Disassemblers
+Topic :: Software Development :: Documentation
+Topic :: Software Development :: Embedded Systems
+Topic :: Software Development :: Internationalization
+Topic :: Software Development :: Interpreters
+Topic :: Software Development :: Libraries
+Topic :: Software Development :: Libraries :: Application Frameworks
+Topic :: Software Development :: Libraries :: Java Libraries
+Topic :: Software Development :: Libraries :: PHP Classes
+Topic :: Software Development :: Libraries :: Perl Modules
+Topic :: Software Development :: Libraries :: Pike Modules
+Topic :: Software Development :: Libraries :: Python Modules
+Topic :: Software Development :: Libraries :: Ruby Modules
+Topic :: Software Development :: Libraries :: Tcl Extensions
+Topic :: Software Development :: Libraries :: pygame
+Topic :: Software Development :: Localization
+Topic :: Software Development :: Object Brokering
+Topic :: Software Development :: Object Brokering :: CORBA
+Topic :: Software Development :: Pre-processors
+Topic :: Software Development :: Quality Assurance
+Topic :: Software Development :: Testing
+Topic :: Software Development :: Testing :: Acceptance
+Topic :: Software Development :: Testing :: BDD
+Topic :: Software Development :: Testing :: Mocking
+Topic :: Software Development :: Testing :: Traffic Generation
+Topic :: Software Development :: Testing :: Unit
+Topic :: Software Development :: User Interfaces
+Topic :: Software Development :: Version Control
+Topic :: Software Development :: Version Control :: Bazaar
+Topic :: Software Development :: Version Control :: CVS
+Topic :: Software Development :: Version Control :: Git
+Topic :: Software Development :: Version Control :: Mercurial
+Topic :: Software Development :: Version Control :: RCS
+Topic :: Software Development :: Version Control :: SCCS
+Topic :: Software Development :: Widget Sets
+Topic :: System
+Topic :: System :: Archiving
+Topic :: System :: Archiving :: Backup
+Topic :: System :: Archiving :: Compression
+Topic :: System :: Archiving :: Mirroring
+Topic :: System :: Archiving :: Packaging
+Topic :: System :: Benchmark
+Topic :: System :: Boot
+Topic :: System :: Boot :: Init
+Topic :: System :: Clustering
+Topic :: System :: Console Fonts
+Topic :: System :: Distributed Computing
+Topic :: System :: Emulators
+Topic :: System :: Filesystems
+Topic :: System :: Hardware
+Topic :: System :: Hardware :: Hardware Drivers
+Topic :: System :: Hardware :: Mainframes
+Topic :: System :: Hardware :: Symmetric Multi-processing
+Topic :: System :: Installation/Setup
+Topic :: System :: Logging
+Topic :: System :: Monitoring
+Topic :: System :: Networking
+Topic :: System :: Networking :: Firewalls
+Topic :: System :: Networking :: Monitoring
+Topic :: System :: Networking :: Monitoring :: Hardware Watchdog
+Topic :: System :: Networking :: Time Synchronization
+Topic :: System :: Operating System
+Topic :: System :: Operating System Kernels
+Topic :: System :: Operating System Kernels :: BSD
+Topic :: System :: Operating System Kernels :: GNU Hurd
+Topic :: System :: Operating System Kernels :: Linux
+Topic :: System :: Power (UPS)
+Topic :: System :: Recovery Tools
+Topic :: System :: Shells
+Topic :: System :: Software Distribution
+Topic :: System :: System Shells
+Topic :: System :: Systems Administration
+Topic :: System :: Systems Administration :: Authentication/Directory
+Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP
+Topic :: System :: Systems Administration :: Authentication/Directory :: NIS
+Topic :: Terminals
+Topic :: Terminals :: Serial
+Topic :: Terminals :: Telnet
+Topic :: Terminals :: Terminal Emulators/X Terminals
+Topic :: Text Editors
+Topic :: Text Editors :: Emacs
+Topic :: Text Editors :: Integrated Development Environments (IDE)
+Topic :: Text Editors :: Text Processing
+Topic :: Text Editors :: Word Processors
+Topic :: Text Processing
+Topic :: Text Processing :: Filters
+Topic :: Text Processing :: Fonts
+Topic :: Text Processing :: General
+Topic :: Text Processing :: Indexing
+Topic :: Text Processing :: Linguistic
+Topic :: Text Processing :: Markup
+Topic :: Text Processing :: Markup :: HTML
+Topic :: Text Processing :: Markup :: LaTeX
+Topic :: Text Processing :: Markup :: Markdown
+Topic :: Text Processing :: Markup :: SGML
+Topic :: Text Processing :: Markup :: VRML
+Topic :: Text Processing :: Markup :: XML
+Topic :: Text Processing :: Markup :: reStructuredText
+Topic :: Utilities""".split("\n")
+
+try:
+    from trove_classifiers import classifiers
+    topic_trove = [line for line in classifiers if re.match("Topic ::", line)]
+except:
+    pass

ADDED   pytest.ini
Index: pytest.ini
==================================================================
--- pytest.ini
+++ pytest.ini
@@ -0,0 +1,7 @@
+[pytest]
+minversion = 1.0
+addopts = -vv -ra -q --ignore=test/_*.py --ignore=test/__*.py  -p no:warnings
+testpaths = test/
+python_files = *.py
+# Yes sure pytest, why not do the obvious thing anyway
+python_functions = !_* [a-z]* [A-Z]* !_*

ADDED   setup.cfg
Index: setup.cfg
==================================================================
--- setup.cfg
+++ setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1

ADDED   setup.py
Index: setup.py
==================================================================
--- setup.py
+++ setup.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# encoding: utf-8
+# api: pip
+# type: build
+# title: config for setuptools
+#
+# Always prefer setuptools over distutils
+#
+
+import sys
+sys.path.insert(0, ".")  # bootstrap local ./pluginconf module
+
+import pluginconf.setup
+
+#from os import system
+#system("pandoc -f markdown -t rst README.md  > README.rst")
+
+
+pluginconf.setup.setup(
+    fn="pluginconf/__init__.py",
+    long_description="README.md"
+    #author_email="m..@include-once.org",
+)
+

ADDED   test/.pyz.d/__init__.py
Index: test/.pyz.d/__init__.py
==================================================================
--- test/.pyz.d/__init__.py
+++ test/.pyz.d/__init__.py
@@ -0,0 +1,6 @@
+# type: init
+
+""" set up loading mechanism? """
+
+import os.path
+__path__ = [os.path.dirname(__file__)]

ADDED   test/.pyz.d/__main__.py
Index: test/.pyz.d/__main__.py
==================================================================
--- test/.pyz.d/__main__.py
+++ test/.pyz.d/__main__.py

ADDED   test/.pyz.d/config.py
Index: test/.pyz.d/config.py
==================================================================
--- test/.pyz.d/config.py
+++ test/.pyz.d/config.py

ADDED   test/.pyz.d/inner.py
Index: test/.pyz.d/inner.py
==================================================================
--- test/.pyz.d/inner.py
+++ test/.pyz.d/inner.py
@@ -0,0 +1,15 @@
+# type: pyz
+# category: complex
+# title: pyz module
+# desription: lookup within zipped packages/apps
+# config:
+#   { name: relation, type: bool, val: 1, description: none }
+# state: alpha
+#
+# doc here
+
+
+import os.path
+__path__ = [os.path.dirname(__file__)]
+
+print("inner module invoked")

ADDED   test/.pyz.pyz
Index: test/.pyz.pyz
==================================================================
--- test/.pyz.pyz
+++ test/.pyz.pyz
cannot compute difference between binary files

ADDED   test/Makefile
Index: test/Makefile
==================================================================
--- test/Makefile
+++ test/Makefile
@@ -0,0 +1,4 @@
+pyz:	.pyz.pyz
+
+.pyz.pyz: .pyz.d/*
+	cd .pyz.d ; zip ../.pyz.pyz *

ADDED   test/__init__.py
Index: test/__init__.py
==================================================================
--- test/__init__.py
+++ test/__init__.py
@@ -0,0 +1,1 @@
+""" stub, to recognize test/ as module= """

ADDED   test/basic.py
Index: test/basic.py
==================================================================
--- test/basic.py
+++ test/basic.py
@@ -0,0 +1,25 @@
+# type: test
+# title: basic PMD
+# description: check for some fields
+# version: 0.1-rc1
+# 
+# This the doc.
+
+import pytest
+import pluginconf
+
+@pytest.fixture
+def pmd():
+    return pluginconf.plugin_meta(fn=__file__)
+
+def type_(pmd):
+    assert pmd["type"] == "test"
+
+def version_(pmd):
+    assert pmd["version"] == "0.1-rc1"
+
+def title_(pmd):
+    assert pmd["title"] == "basic PMD"
+
+def doc_(pmd):
+    assert pmd["doc"] == "This the doc."

ADDED   test/config.py
Index: test/config.py
==================================================================
--- test/config.py
+++ test/config.py
@@ -0,0 +1,36 @@
+# type: test
+# title: config struct
+# description: probe for some fields
+# config:
+#   { name: id, value: 1, description: identifier }
+#   { name: string, value: string, description: should be a str }
+#   { name: desc, value: -, description: be descriptive here }
+#   { name: select1, type: select, select: 1|2|3, description: alternatives }
+#   { name: select2, type: select, select: a=1|b=2|c=3, description: key-val }
+# version: 0.1
+# 
+# Do all the settings!
+
+import pytest
+import pluginconf
+
+@pytest.fixture
+def config():
+    return pluginconf.plugin_meta(fn=__file__)["config"]
+
+def name(config):
+    assert config[0]["name"] == "id"
+
+def string(config):
+    assert config[1]["value"] == "string"
+    assert config[1]["type"] == "str"
+
+def desc(config):
+    assert config[2]["description"] == "be descriptive here"
+
+def select1(config):
+    assert config[3]["select"] == { "1":"1", "2":"2", "3":"3" }
+
+def select2(config):
+    assert config[4]["select"] == { "a":"1", "b":"2", "c":"3" }
+

ADDED   test/config_complex.py
Index: test/config_complex.py
==================================================================
--- test/config_complex.py
+++ test/config_complex.py
@@ -0,0 +1,20 @@
+# type: test
+# title: config edge cases
+# description: some less stable options
+# config:
+#   { name: nested, value: "{var}", description: "should be able to understand {enclosed} braces" }
+# version: 0.1
+# 
+# Do all the settings!
+
+import pytest
+import pluginconf
+
+@pytest.fixture
+def config():
+    return pluginconf.plugin_meta(fn=__file__)["config"]
+
+def name(config):
+    print(config)
+    assert config[0]["value"] == "{var}"
+

ADDED   test/conftest.py
Index: test/conftest.py
==================================================================
--- test/conftest.py
+++ test/conftest.py
@@ -0,0 +1,2 @@
+import pytest
+import pluginconf as pc

ADDED   test/pyz.py
Index: test/pyz.py
==================================================================
--- test/pyz.py
+++ test/pyz.py
@@ -0,0 +1,37 @@
+# type: test
+# version: 0.2
+# title: pyz lookups
+#
+# This is genuinely the most fragile part.
+# Not the least testing it from outside the pyz package.
+# Here `app.pyz/inner.py` must override its lookup __path__=[…]
+# Fails from within test/xyz/ subdir
+
+import sys
+import os.path
+import pytest
+import pluginconf
+
+sys.path.insert(0, f"{os.path.dirname(__file__)}/.pyz.pyz")
+os.chdir(os.path.dirname(__file__))
+
+pluginconf.module_base = ["config"]    # must be one of the .pyz-contained modules (should be a dir/submodule for real uses)
+pluginconf.plugin_base = ["inner"]     # relative, must declare __path__, can't be __main__.py
+
+@pytest.fixture
+def pmd():
+    return pluginconf.plugin_meta(module="inner")
+
+def importy():
+    import inner as pyz_inner
+    #print(pyz_inner.__file__)
+
+def module_list():
+    assert set(pluginconf.module_list()) & {'__main__', 'config', 'inner'}
+
+def inner_props(pmd):
+    assert pmd["type"] == "pyz"
+    assert pmd["category"] == "complex"
+    assert pmd["title"] == "pyz module"
+    assert pmd["config"][0]["name"] == "relation"
+    assert pmd["state"] == "alpha"

ADDED   test/sources.py
Index: test/sources.py
==================================================================
--- test/sources.py
+++ test/sources.py
@@ -0,0 +1,32 @@
+# type: test
+# title: frame=
+# description: test alternative source aqusition
+# ok: 1
+
+import pytest
+import pluginconf
+import sys
+
+def _pmd(**kwargs):
+    return pluginconf.plugin_meta(**kwargs)
+
+def frame_1():
+    assert _pmd(frame=1)["ok"]
+
+def frame_0():
+    # should resolve to pluginconf/__init__.py
+    assert _pmd(frame=0)["title"] == "Plugin configuration"
+
+def src():
+    # requires trailing newline
+    assert _pmd(src="# id: empty\n")["id"] == "empty"
+
+def module_pc_init():
+    pluginconf.plugin_base = ["pluginconf", "."]
+    assert _pmd(module="__init__")["category"] == "config"
+
+def module_test():
+    # requires __init__.py in test/
+    pluginconf.plugin_base = ["test"]
+    assert _pmd(module="sources")["ok"]
+