[core] restructure to allow PIP packaging

OK - so I have to admit I *hate* the fact that PIP seems to require a
subdirectory named like the library.

But since the PIP package is something really nifty to have (thanks to
@tony again!!!), I updated the codebase to hopefully conform with what
PIP expects. Testruns so far look promising...
This commit is contained in:
tobi-wan-kenobi 2020-05-09 21:22:00 +02:00
parent 1d25be2059
commit 320827d577
146 changed files with 2509 additions and 2 deletions

View file

View file

@ -0,0 +1,240 @@
import os
import ast
from configparser import RawConfigParser
import sys
import glob
import textwrap
import argparse
import logging
import core.theme
import util.store
import util.format
import modules.core
import modules.contrib
log = logging.getLogger(__name__)
MODULE_HELP = "Specify a space-separated list of modules to load. The order of the list determines their order in the i3bar (from left to right). Use <module>:<alias> to provide an alias in case you want to load the same module multiple times, but specify different parameters."
PARAMETER_HELP = (
"Provide configuration parameters in the form of <module>.<key>=<value>"
)
THEME_HELP = "Specify the theme to use for drawing modules"
def all_modules():
"""Return a list of available modules"""
result = {}
for path in [modules.core.__file__, modules.contrib.__file__]:
path = os.path.dirname(path)
for mod in glob.iglob("{}/*.py".format(path)):
result[os.path.basename(mod).replace(".py", "")] = 1
res = list(result.keys())
res.sort()
return res
class print_usage(argparse.Action):
def __init__(self, option_strings, dest, nargs=None, **kwargs):
argparse.Action.__init__(self, option_strings, dest, nargs, **kwargs)
self._indent = " " * 2
def __call__(self, parser, namespace, value, option_string=None):
if value == "modules":
self._args = namespace
self._format = "plain"
self.print_modules()
elif value == "modules-rst":
self._args = namespace
self._format = "rst"
self.print_modules()
elif value == "themes":
self.print_themes()
sys.exit(0)
def print_themes(self):
print(", ".join(core.theme.themes()))
def print_modules(self):
basepath = os.path.abspath(
os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")
)
rst = {}
for m in all_modules():
try:
module_type = "core"
filename = os.path.join(basepath, "modules", "core", "{}.py".format(m))
if not os.path.exists(filename):
filename = os.path.join(
basepath, "modules", "contrib", "{}.py".format(m)
)
module_type = "contrib"
if not os.path.exists(filename):
log.warning("module {} not found".format(m))
continue
doc = None
with open(filename) as f:
tree = ast.parse(f.read())
doc = ast.get_docstring(tree)
if not doc:
log.warning("failed to find docstring for {}".format(m))
continue
if self._format == "rst":
if os.path.exists(
os.path.join(basepath, "screenshots", "{}.png".format(m))
):
doc = "{}\n\n.. image:: ../screenshots/{}.png".format(doc, m)
rst[module_type] = rst.get(module_type, [])
rst[module_type].append({ "module": m, "content": doc })
else:
print(
textwrap.fill(
"{}:".format(m),
80,
initial_indent=self._indent * 2,
subsequent_indent=self._indent * 2,
)
)
for line in doc.split("\n"):
print(
textwrap.fill(
line,
80,
initial_indent=self._indent * 3,
subsequent_indent=self._indent * 6,
)
)
except Exception as e:
log.warning(e)
if self._format == "rst":
print("List of modules\n===============")
for k in [ "core", "contrib" ]:
print("\n{}\n{}\n".format(k, "-"*len(k)))
for mod in rst[k]:
print("\n{}\n{}\n".format(mod["module"], "~"*len(mod["module"])))
print(mod["content"])
class Config(util.store.Store):
def __init__(self, args):
super(Config, self).__init__()
parser = argparse.ArgumentParser(
description="bumblebee-status is a modular, theme-able status line generator for the i3 window manager. https://github.com/tobi-wan-kenobi/bumblebee-status/wiki"
)
parser.add_argument(
"-m", "--modules", nargs="+", action="append", default=[], help=MODULE_HELP
)
parser.add_argument(
"-p",
"--parameters",
nargs="+",
action="append",
default=[],
help=PARAMETER_HELP,
)
parser.add_argument("-t", "--theme", default="default", help=THEME_HELP)
parser.add_argument(
"-i",
"--iconset",
default="auto",
help="Specify the name of an iconset to use (overrides theme default)",
)
parser.add_argument(
"-a",
"--autohide",
nargs="+",
default=[],
help="Specify a list of modules to hide when not in warning/error state",
)
parser.add_argument(
"-d", "--debug", action="store_true", help="Add debug fields to i3 output"
)
parser.add_argument(
"-f",
"--logfile",
help="destination for the debug log file, if -d|--debug is specified; defaults to stderr",
)
parser.add_argument(
"-r",
"--right-to-left",
action="store_true",
help="Draw widgets from right to left, rather than left to right (which is the default)",
)
parser.add_argument(
"-l",
"--list",
choices=["modules", "themes", "modules-rst"],
help="Display a list of available themes or available modules, along with their parameters",
action=print_usage,
)
self.__args = parser.parse_args(args)
for cfg in [
"~/.bumblebee-status.conf",
"~/.config/bumblebee-status.conf",
"~/.config/bumblebee-status/config",
]:
cfg = os.path.expanduser(cfg)
self.load_config(cfg)
parameters = [item for sub in self.__args.parameters for item in sub]
for param in parameters:
if not "=" in param:
log.error(
'missing value for parameter "{}" - ignoring this parameter'.format(
param
)
)
continue
key, value = param.split("=", 1)
self.set(key, value)
def load_config(self, filename):
if os.path.exists(filename):
log.info("loading {}".format(filename))
tmp = RawConfigParser()
tmp.read(filename)
if tmp.has_section("module-parameters"):
for key, value in tmp.items("module-parameters"):
self.set(key, value)
def modules(self):
return [item for sub in self.__args.modules for item in sub]
def interval(self, default=1):
return util.format.seconds(self.get("interval", default))
def debug(self):
return self.__args.debug
def reverse(self):
return self.__args.right_to_left
def logfile(self):
return self.__args.logfile
def theme(self):
return self.__args.theme
def iconset(self):
return self.__args.iconset
def autohide(self, name):
return name in self.__args.autohide
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,67 @@
import util.format
def never(init):
def call_init(obj, *args, **kwargs):
init(obj, *args, **kwargs)
if obj.parameter("interval") is None:
obj.set("interval", "never")
return call_init
def every(hours=0, minutes=0, seconds=0):
def decorator_init(init):
def call_init(obj, *args, **kwargs):
init(obj, *args, **kwargs)
if obj.parameter("interval") is None:
obj.set("interval", hours * 3600 + minutes * 60 + seconds)
return call_init
return decorator_init
def scrollable(func):
def wrapper(module, widget):
text = func(module, widget)
if not text:
return text
if text != widget.get("__content__", text):
widget.set("scrolling.start", 0)
widget.set("scrolling.direction", "right")
widget.set("__content__", text)
width = util.format.asint(module.parameter("scrolling.width", 30))
if util.format.asbool(module.parameter("scrolling.makewide", True)):
widget.set("theme.minwidth", "A" * width)
if width < 0 or len(text) <= width:
return text
start = widget.get("scrolling.start", 0)
bounce = util.format.asbool(module.parameter("scrolling.bounce", True))
scroll_speed = util.format.asint(module.parameter("scrolling.speed", 1))
direction = widget.get("scrolling.direction", "right")
if direction == "left":
scroll_speed = -scroll_speed
if start + scroll_speed <= 0: # bounce back
widget.set("scrolling.direction", "right")
next_start = start + scroll_speed
if next_start + width > len(text):
if not bounce:
next_start = 0
else:
next_start = start - scroll_speed
widget.set("scrolling.direction", "left")
widget.set("scrolling.start", next_start)
return text[start : start + width]
return wrapper
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,29 @@
__callbacks = {}
def register(event, callback, *args, **kwargs):
cb = callback
if len(args) + len(kwargs) > 0:
cb = lambda: callback(*args, **kwargs)
__callbacks.setdefault(event, []).append(cb)
def clear():
__callbacks.clear()
def trigger(event, *args, **kwargs):
cb = __callbacks.get(event, [])
if len(cb) == 0:
return False
for callback in cb:
if len(args) + len(kwargs) == 0:
callback()
else:
callback(*args, **kwargs)
return True
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,69 @@
import uuid
import logging
import core.event
import util.cli
LEFT_MOUSE = 1
MIDDLE_MOUSE = 2
RIGHT_MOUSE = 3
WHEEL_UP = 4
WHEEL_DOWN = 5
def button_name(button):
if button == LEFT_MOUSE:
return "left-mouse"
if button == RIGHT_MOUSE:
return "right-mouse"
if button == MIDDLE_MOUSE:
return "middle-mouse"
if button == WHEEL_UP:
return "wheel-up"
if button == WHEEL_DOWN:
return "wheel-down"
return "n/a"
class Object(object):
def __init__(self):
super(Object, self).__init__()
self.id = str(uuid.uuid4())
def __event_id(obj_id, button):
return "{}::{}".format(obj_id, button_name(button))
def __execute(cmd):
try:
util.cli.execute(cmd, wait=False)
except Exception as e:
logging.error("failed to invoke callback: {}".format(e))
def register(obj, button=None, cmd=None):
event_id = __event_id(obj.id if obj is not None else "", button)
logging.debug("registering callback {}".format(event_id))
if callable(cmd):
core.event.register(event_id, cmd)
else:
core.event.register(event_id, lambda _: __execute(cmd))
def trigger(event):
if not "button" in event:
return
triggered = False
for field in ["instance", "name"]:
if not field in event:
continue
if core.event.trigger(__event_id(event[field], event["button"]), event):
triggered = True
if not triggered:
core.event.trigger(__event_id("", event["button"]), event)
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,124 @@
import os
import importlib
import logging
import core.input
import core.widget
import core.decorators
try:
error = ModuleNotFoundError("")
except Exception as e:
ModuleNotFoundError = Exception
log = logging.getLogger(__name__)
def load(module_name, config=core.config.Config([]), theme=None):
error = None
module_short, alias = (module_name.split(":") + [module_name])[0:2]
config.set("__alias__", alias)
for namespace in ["core", "contrib"]:
try:
mod = importlib.import_module(
"modules.{}.{}".format(namespace, module_short)
)
log.debug(
"importing {} from {}.{}".format(module_short, namespace, module_short)
)
return getattr(mod, "Module")(config, theme)
except ImportError as e:
log.debug("failed to import {}: {}".format(module_name, e))
error = e
log.fatal("failed to import {}: {}".format(module_name, error))
return Error(config=config, module=module_name, error=error)
class Module(core.input.Object):
def __init__(self, config=core.config.Config([]), theme=None, widgets=[]):
super().__init__()
self.__config = config
self.__widgets = widgets if isinstance(widgets, list) else [widgets]
self.module_name = self.__module__.split(".")[-1]
self.name = self.module_name
self.alias = self.__config.get("__alias__", None)
self.id = self.alias if self.alias else self.name
self.next_update = None
self.theme = theme
for widget in self.__widgets:
widget.module = self
def hidden(self):
return False
def parameter(self, key, default=None):
value = default
for prefix in [self.name, self.module_name, self.alias]:
value = self.__config.get("{}.{}".format(prefix, key), value)
# TODO retrieve from config file
return value
def set(self, key, value):
self.__config.set("{}.{}".format(self.name, key), value)
def update(self):
pass
def update_wrapper(self):
try:
self.update()
except Exception as e:
self.set("interval", 1)
module = Error(config=self.__config, module="error", error=str(e))
self.__widgets = [module.widget()]
self.update = module.update
def widgets(self):
return self.__widgets
def clear_widgets(self):
del self.__widgets[:]
def add_widget(self, full_text="", name=None):
widget = core.widget.Widget(full_text=full_text, name=name, module=self)
self.widgets().append(widget)
return widget
def widget(self, name=None):
if not name:
return self.widgets()[0]
for w in self.widgets():
if w.name == name:
return w
return None
def state(self, widget):
return []
def threshold_state(self, value, warn, crit):
if value > float(self.parameter("critical", crit)):
return "critical"
if value > float(self.parameter("warning", warn)):
return "warning"
return None
class Error(Module):
def __init__(self, module, error, config=core.config.Config([]), theme=None):
super().__init__(config, theme, core.widget.Widget(self.full_text))
self.__module = module
self.__error = error
def full_text(self, widget):
return "{}: {}".format(self.__module, self.__error)
def state(self, widget):
return ["critical"]
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,245 @@
import sys
import json
import time
import core.theme
import core.event
import util.format
def dump_json(obj):
return obj.dict()
def assign(src, dst, key, src_key=None, default=None):
if not src_key:
if key.startswith("_"):
src_key = key
else:
src_key = key.replace("_", "-") # automagically replace _ with -
for k in src_key if isinstance(src_key, list) else [src_key]:
if k in src:
dst[key] = src[k]
return
if default is not None:
dst[key] = default
class block(object):
__COMMON_THEME_FIELDS = [
"separator",
"separator-block-width",
"default-separators",
"border-top",
"border-left",
"border-right",
"border-bottom",
"fg",
"bg",
"padding",
"prefix",
"suffix",
]
def __init__(self, theme, module, widget):
self.__attributes = {}
for key in self.__COMMON_THEME_FIELDS:
tmp = theme.get(key, widget)
if tmp is not None:
self.__attributes[key] = tmp
self.__attributes["name"] = module.id
self.__attributes["instance"] = widget.id
self.__attributes["prev-bg"] = theme.get("bg", "previous")
def set(self, key, value):
self.__attributes[key] = value
def is_pango(self, attr):
if isinstance(attr, dict) and "pango" in attr:
return True
return False
def pangoize(self, text):
if not self.is_pango(text):
return text
self.__attributes["markup"] = "pango"
attr = dict(text["pango"])
text = attr.get("full_text", "")
if "full_text" in attr:
del attr["full_text"]
result = "<span"
for key, value in attr.items():
result = '{} {}="{}"'.format(result, key, value)
result = "{}>{}</span>".format(result, text)
return result
def dict(self):
result = {}
assign(self.__attributes, result, "full_text", ["full_text", "separator"])
assign(self.__attributes, result, "separator", "default-separators")
if "_decorator" in self.__attributes:
assign(self.__attributes, result, "color", "bg")
assign(self.__attributes, result, "background", "prev-bg")
result["_decorator"] = True
else:
assign(self.__attributes, result, "color", "fg")
assign(self.__attributes, result, "background", "bg")
if "full_text" in self.__attributes:
result["full_text"] = self.pangoize(result["full_text"])
result["full_text"] = self.__format(self.__attributes["full_text"])
for k in [
"name",
"instance",
"separator_block_width",
"border",
"border_top",
"border_bottom",
"border_left",
"border_right",
"markup",
"_raw",
"_suffix",
"_prefix",
"min_width",
"align",
]:
assign(self.__attributes, result, k)
return result
def __pad(self, text):
padding = self.__attributes.get("padding", "")
if not text:
return padding
return "{}{}{}".format(padding, text, padding)
def __format(self, text):
if text is None:
return None
prefix = self.__pad(self.pangoize(self.__attributes.get("prefix")))
suffix = self.__pad(self.pangoize(self.__attributes.get("suffix")))
self.set("_prefix", prefix)
self.set("_suffix", suffix)
self.set("_raw", text)
return "{}{}{}".format(prefix, text, suffix)
class i3(object):
def __init__(self, theme=core.theme.Theme(), config=core.config.Config([])):
self.__modules = []
self.__content = {}
self.__theme = theme
self.__config = config
core.event.register("update", self.update)
core.event.register("start", self.draw, "start")
core.event.register("draw", self.draw, "statusline")
core.event.register("stop", self.draw, "stop")
def theme(self, new_theme=None):
if new_theme:
self.__theme = new_theme
return self.__theme
def modules(self, modules=None):
if not modules:
return self.__modules
self.__modules = modules if isinstance(modules, list) else [modules]
def draw(self, what, args=None):
cb = getattr(self, what)
data = cb(args) if args else cb()
if "blocks" in data:
sys.stdout.write(json.dumps(data["blocks"], default=dump_json))
if "suffix" in data:
sys.stdout.write(data["suffix"])
sys.stdout.write("\n")
sys.stdout.flush()
def start(self):
return {
"blocks": {"version": 1, "click_events": True},
"suffix": "\n[",
}
def stop(self):
return {"suffix": "\n]"}
def __separator_block(self, module, widget):
if not self.__theme.get("separator"):
return []
blk = block(self.__theme, module, widget)
blk.set("_decorator", True)
return [blk]
def __content_block(self, module, widget):
blk = block(self.__theme, module, widget)
minwidth = widget.theme("minwidth")
if minwidth is not None:
try:
blk.set("min-width", "-" * int(minwidth))
except:
blk.set("min-width", minwidth)
blk.set("align", widget.theme("align"))
blk.set("full_text", self.__content[widget])
if widget.get("pango", False):
blk.set("markup", "pango")
if self.__config.debug():
state = module.state(widget)
if isinstance(state, list):
state = ", ".join(state)
blk.set("__state", state)
return blk
def blocks(self, module):
blocks = []
for widget in module.widgets():
if widget.module and self.__config.autohide(widget.module.name):
if not any(
state in widget.state() for state in ["warning", "critical"]
):
continue
if module.hidden():
continue
blocks.extend(self.__separator_block(module, widget))
blocks.append(self.__content_block(module, widget))
core.event.trigger("next-widget")
return blocks
# TODO: only updates full text, not the state!?
def update(self, affected_modules=None, redraw_only=False):
now = time.time()
for module in self.__modules:
if affected_modules and not module.id in affected_modules:
continue
if not affected_modules and module.next_update:
if module.parameter("interval", "") == "never":
continue
if now < module.next_update:
continue
if not redraw_only:
module.update_wrapper()
if module.parameter("interval", "") != "never":
module.next_update = now + util.format.seconds(
module.parameter("interval", self.__config.interval())
)
for widget in module.widgets():
self.__content[widget] = widget.full_text()
def statusline(self):
blocks = []
for module in self.__modules:
blocks.extend(self.blocks(module))
return {"blocks": blocks, "suffix": ","}
def wait(self, interval):
time.sleep(interval)
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,158 @@
import os
import io
import json
import logging
import copy
import glob
import core.event
import util.algorithm
log = logging.getLogger(__name__)
THEME_BASE_DIR = os.path.dirname(os.path.realpath(__file__))
PATHS = [
".",
os.path.join(THEME_BASE_DIR, "../../themes"),
os.path.expanduser("~/.config/bumblebee-status/themes"),
os.path.expanduser("~/.local/share/bumblebee-status/themes"), # PIP
]
def themes():
themes_dict = {}
for path in PATHS:
for filename in glob.iglob("{}/*.json".format(path)):
if "test" not in filename:
themes_dict[os.path.basename(filename).replace(".json", "")] = 1
result = list(themes_dict.keys())
result.sort()
return result
def merge_replace(value, new_value, key):
if not isinstance(value, dict):
return new_value
if isinstance(new_value, dict):
util.algorithm.merge(value, new_value)
return value
# right now, merging needs explicit pango support :(
if "pango" in value:
value["pango"]["full_text"] = new_value
return value
class Theme(object):
def __init__(self, name="default", iconset="auto", raw_data=None):
self.name = name
self.__widget_count = 0
self.__previous = {}
self.__current = {}
self.__keywords = {}
self.__value_idx = {}
self.__data = raw_data if raw_data else self.load(name)
for icons in self.__data.get("icons", []):
util.algorithm.merge(self.__data, self.load(icons, "icons"))
if iconset != "auto":
util.algorithm.merge(self.__data, self.load(iconset, "icons"))
for colors in self.__data.get("colors", []):
util.algorithm.merge(self.__keywords, self.load_keywords(colors))
core.event.register("draw", self.__start)
core.event.register("next-widget", self.__next_widget)
def keywords(self):
return self.__keywords
def color(self, name, default=None):
return self.keywords().get(name, default)
def load(self, name, subdir=""):
if isinstance(name, dict):
return name # support plain data
for path in PATHS:
theme_file = os.path.join(path, subdir, "{}.json".format(name))
result = self.__load_json(theme_file)
if result != {}:
return result
raise RuntimeError("unable to find theme {}".format(name))
def __load_json(self, filename):
filename = os.path.expanduser(filename)
if not os.path.isfile(filename):
return {}
with io.open(filename) as data:
return json.load(data)
def load_keywords(self, name):
try:
if isinstance(name, dict):
return name
if name.lower() == "wal":
wal = self.__load_json("~/.cache/wal/colors.json")
result = {}
for field in ["special", "colors"]:
for key in wal.get(field, {}):
result[key] = wal[field][key]
return result
except Exception as e:
log.error("failed to load colors: {}", e)
def __start(self):
self.__widget_count = 0
self.__current.clear()
self.__previous.clear()
for key, value in self.__value_idx.items():
self.__value_idx[key] = value + 1
def __next_widget(self):
self.__widget_count = self.__widget_count + 1
self.__previous = dict(self.__current)
self.__current.clear()
def get(self, key, widget=None, default=None):
if not widget:
widget = core.widget.Widget("")
# special handling
if widget == "previous":
return self.__previous.get(key, None)
value = default
for option in ["defaults", "cycle"]:
if option in self.__data:
tmp = self.__data[option]
if isinstance(tmp, list):
tmp = tmp[self.__widget_count % len(tmp)]
value = merge_replace(value, tmp.get(key, value), key)
if isinstance(value, dict):
value = copy.deepcopy(value)
value = merge_replace(value, self.__data.get(key, value), key)
if widget.module:
value = merge_replace(
value, self.get(widget.module.name, None, {}).get(key, value), key
)
if not key in widget.state():
for state in widget.state():
theme = self.get(state, widget, {})
value = merge_replace(value, theme.get(key, value), key)
if not type(value) in (list, dict):
value = self.__keywords.get(value, value)
if isinstance(value, list):
idx = self.__value_idx.get("{}::{}".format(widget.id, key), 0) % len(value)
self.__value_idx["{}::{}".format(widget.id, key)] = idx
widget.set(key, idx)
value = value[idx]
self.__current[key] = value
return value
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -0,0 +1,69 @@
import core.input
import core.decorators
import util.store
import util.format
class Widget(util.store.Store, core.input.Object):
def __init__(self, full_text="", name=None, module=None):
super(Widget, self).__init__()
self.__full_text = full_text
self.module = module
self.name = name
@property
def module(self):
return self.__module
@module.setter
def module(self, module):
self.__module = module
if self.index() < 0:
return
if module:
custom_ids = util.format.aslist(module.parameter("id"))
if len(custom_ids) > self.index():
self.id = custom_ids[self.index()]
def index(self):
if not self.module:
return 0
idx = 0
for w in self.module.widgets():
if w.id == self.id:
return idx
idx = idx + 1
return -1 # not found
def theme(self, attribute):
attr = "theme.{}".format(attribute)
if self.module:
param = util.format.aslist(self.module.parameter(attr))
if param and len(param) > self.index():
return param[self.index()]
return self.get(attr)
def full_text(self, value=None):
if value:
self.__full_text = value
else:
if callable(self.__full_text):
return self.__full_text(self)
return self.__full_text
def state(self):
rv = []
if self.get("state", None):
tmp = self.get("state")
rv = tmp[:] if isinstance(tmp, list) else [tmp]
if self.module:
tmp = self.module.state(self)
rv.extend(tmp if isinstance(tmp, list) else [tmp])
return rv
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4