diff --git a/docs/configuration.rst b/docs/configuration.rst index 2aa6416..be4bcf4 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -122,3 +122,23 @@ Also change your i3wm config to the following: position top workspace_buttons yes } + +Settings that require credentials can utilize the keyring module to keep sensitive information out of config files. +To take advantage of this feature, simply use the setting_util.py script to set the credentials for a module. Once this +is done you can add the module to your config without specifying the credentials, eg: + +:: + + # Use the default keyring to retrieve credentials. To determine which backend is the default on your system, run + # python -c 'import keyring; print(keyring.get_keyring())' + status.register('github') + +If you don't want to use the default you can set a specific keyring like so: + +:: + + from keyring.backends.file import PlaintextKeyring + status.register('github', keyring_backend=PlaintextKeyring()) + + +i3pystatus will locate and set the credentials during the module loading process. Currently supported credentals are "password", "email" and "username". \ No newline at end of file diff --git a/docs/module.rst b/docs/module.rst index 6b195f3..31be7be 100644 --- a/docs/module.rst +++ b/docs/module.rst @@ -14,6 +14,9 @@ tools for this which make this even easier: periodically. - Settings (already built into above classes) allow you to easily specify user-modifiable attributes of your class for configuration. +- For modules that require credentials, it is recommended to add a + keyring_backend setting to allow users to specify their own backends + for retrieving sensitive credentials. Required settings and default values are also handled. diff --git a/i3pystatus/clock.py b/i3pystatus/clock.py index 67a8fa7..61c9de4 100644 --- a/i3pystatus/clock.py +++ b/i3pystatus/clock.py @@ -32,13 +32,24 @@ class Clock(IntervalModule): on_downscroll = ["scroll_format", -1] def init(self): - lang, enc = os.environ.get('LANG', None).split('.', 1) - if lang != locale.getlocale(locale.LC_TIME)[0]: + env_lang = os.environ.get('LC_TIME', None) + if env_lang is None: + env_lang = os.environ.get('LANG', None) + + if env_lang is not None: + if env_lang.find('.') != -1: + lang = tuple(env_lang.split('.', 1)) + else: + lang = (env_lang, None) + else: + lang = (None, None) + + if lang != locale.getlocale(locale.LC_TIME): # affects datetime.time.strftime() in whole program - locale.setlocale(locale.LC_TIME, (lang, enc)) + locale.setlocale(locale.LC_TIME, lang) if self.format is None: - if lang == 'en_US': + if lang[0] == 'en_US': # MDY format - United States of America self.format = ["%a %b %-d %X"] else: diff --git a/i3pystatus/core/settings.py b/i3pystatus/core/settings.py index 6d67ae6..9406b82 100644 --- a/i3pystatus/core/settings.py +++ b/i3pystatus/core/settings.py @@ -2,10 +2,10 @@ from i3pystatus.core.util import KeyConstraintDict from i3pystatus.core.exceptions import ConfigKeyError, ConfigMissingError import inspect import logging +import getpass class SettingsBase: - """ Support class for providing a nice and flexible settings interface @@ -18,6 +18,8 @@ class SettingsBase: Settings are stored as attributes of self. """ + __PROTECTED_SETTINGS = ["password", "email", "username"] + settings = ( ("log_level", "Set to true to log error to .i3pystatus- file"), ) @@ -44,21 +46,25 @@ class SettingsBase: return kwargs def merge_with_parents_settings(): - settings = tuple() - # getmro returns base classes according to Method Resolution Order for cls in inspect.getmro(self.__class__): if hasattr(cls, "settings"): settings = settings + cls.settings return settings + self.__name__ = "{}.{}".format( + self.__module__, self.__class__.__name__) + settings = merge_with_parents_settings() settings = self.flatten_settings(settings) sm = KeyConstraintDict(settings, self.required) settings_source = get_argument_dict(args, kwargs) + protected = self.get_protected_settings(settings_source) + settings_source.update(protected) + try: sm.update(settings_source) except KeyError as exc: @@ -70,13 +76,48 @@ class SettingsBase: raise ConfigMissingError( type(self).__name__, missing=exc.keys) from exc - self.__name__ = "{}.{}".format( - self.__module__, self.__class__.__name__) - self.logger = logging.getLogger(self.__name__) self.logger.setLevel(self.log_level) self.init() + def get_protected_settings(self, settings_source): + """ + Attempt to retrieve protected settings from keyring if they are not already set. + """ + user_backend = settings_source.get('keyring_backend') + found_settings = dict() + for setting_name in self.__PROTECTED_SETTINGS: + # Nothing to do if the setting is already defined. + if settings_source.get(setting_name): + continue + + setting = None + identifier = "%s.%s" % (self.__name__, setting_name) + if hasattr(self, 'required') and setting_name in getattr(self, 'required'): + setting = self.get_setting_from_keyring(identifier, user_backend) + elif hasattr(self, setting_name): + setting = self.get_setting_from_keyring(identifier, user_backend) + if setting: + found_settings.update({setting_name: setting}) + return found_settings + + def get_setting_from_keyring(self, setting_identifier, keyring_backend=None): + """ + Retrieves a protected setting from keyring + :param setting_identifier: must be in the format package.module.Class.setting + """ + # If a custom keyring backend has been defined, use it. + if keyring_backend: + return keyring_backend.get_password(setting_identifier, getpass.getuser()) + + # Otherwise try and use default keyring. + try: + import keyring + except ImportError: + pass + else: + return keyring.get_password(setting_identifier, getpass.getuser()) + def init(self): """Convenience method which is called after all settings are set diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index 57e9467..9bc11fd 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -383,7 +383,7 @@ def make_graph(values, lower_limit=0.0, upper_limit=100.0, style="blocks"): extent = mx - mn if style == 'blocks': - bar = u'_▁▂▃▄▅▆▇█' + bar = '_▁▂▃▄▅▆▇█' bar_count = len(bar) - 1 if extent == 0: graph = '_' * len(values) @@ -436,7 +436,7 @@ def make_vertical_bar(percentage, width=1): :param width: How many characters wide the bar should be. :returns: Bar as a String """ - bar = u' _▁▂▃▄▅▆▇█' + bar = ' _▁▂▃▄▅▆▇█' percentage //= 10 if percentage < 0: output = bar[0] diff --git a/i3pystatus/cpu_usage.py b/i3pystatus/cpu_usage.py index 8897c22..a131850 100644 --- a/i3pystatus/cpu_usage.py +++ b/i3pystatus/cpu_usage.py @@ -29,6 +29,7 @@ class CpuUsage(IntervalModule): format = "{usage:02}%" format_all = "{core}:{usage:02}%" exclude_average = False + interval = 1 settings = ( ("format", "format string."), ("format_all", ("format string used for {usage_all} per core. " @@ -40,7 +41,6 @@ class CpuUsage(IntervalModule): def init(self): self.prev_total = defaultdict(int) self.prev_busy = defaultdict(int) - self.interval = 1 self.formatter = Formatter() def get_cpu_timings(self): diff --git a/i3pystatus/disk.py b/i3pystatus/disk.py index 90a6000..b67d176 100644 --- a/i3pystatus/disk.py +++ b/i3pystatus/disk.py @@ -38,6 +38,8 @@ class Disk(IntervalModule): self.output = {} return + critical = available < self.critical_limit + cdict = { "total": (stat.f_bsize * stat.f_blocks) / self.divisor, "free": (stat.f_bsize * stat.f_bfree) / self.divisor, @@ -51,6 +53,6 @@ class Disk(IntervalModule): self.output = { "full_text": self.format.format(**cdict), - "color": self.color if available > self.critical_limit else self.critical_color, - "urgent": available > self.critical_limit + "color": self.critical_color if critical else self.color, + "urgent": critical } diff --git a/i3pystatus/github.py b/i3pystatus/github.py index a40c306..d4dbeba 100644 --- a/i3pystatus/github.py +++ b/i3pystatus/github.py @@ -16,18 +16,20 @@ class Github(IntervalModule): * `{unread_count}` - number of unread notifications, empty if 0 """ - unread_marker = u"●" + unread_marker = "●" unread = '' color = '#78EAF2' username = '' password = '' format = '{unread}' interval = 600 + keyring_backend = None on_leftclick = 'open_github' settings = ( ('format', 'format string'), + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ('unread_marker', 'sets the string that the "unread" formatter shows when there are pending notifications'), ("username", ""), ("password", ""), diff --git a/i3pystatus/mail/imap.py b/i3pystatus/mail/imap.py index 94f3b20..6f91937 100644 --- a/i3pystatus/mail/imap.py +++ b/i3pystatus/mail/imap.py @@ -16,10 +16,12 @@ class IMAP(Backend): settings = ( "host", "port", "username", "password", + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), "ssl", "mailbox", ) required = ("host", "username", "password") + keyring_backend = None port = 993 ssl = True diff --git a/i3pystatus/modsde.py b/i3pystatus/modsde.py index ff5f9b0..acb3a2d 100644 --- a/i3pystatus/modsde.py +++ b/i3pystatus/modsde.py @@ -20,10 +20,12 @@ class ModsDeChecker(IntervalModule): settings = ( ("format", """Use {unread} as the formatter for number of unread posts"""), + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ("offset", """subtract number of posts before output"""), "color", "username", "password" ) required = ("username", "password") + keyring_backend = None color = "#7181fe" offset = 0 diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 8946f19..95d5bab 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -1,5 +1,6 @@ import socket from os.path import basename +from math import floor from i3pystatus import IntervalModule, formatp from i3pystatus.core.util import TimeWrapper @@ -34,8 +35,10 @@ class MPD(IntervalModule): ("format", "formatp string"), ("status", "Dictionary mapping pause, play and stop to output"), ("color", "The color of the text"), - ("text_len", "Defines max length for title, album and artist, if truncated ellipsis are appended as indicator"), - ("truncate_fields", "fileds that will be truncated if exceeding text_len"), + ("max_field_len", "Defines max length for in truncate_fields defined fields, if truncated, ellipsis are appended as indicator. It's applied *before* max_len. Value of 0 disables this."), + ("max_len", "Defines max length for the hole string, if exceeding fields specefied in truncate_fields are truncated equaly. If truncated, ellipsis are appended as indicator. It's applied *after* max_field_len. Value of 0 disables this."), + ("truncate_fields", "fields that will be truncated if exceeding max_field_len or max_len."), + ) host = "localhost" @@ -48,7 +51,8 @@ class MPD(IntervalModule): "stop": "◾", } color = "#FFFFFF" - text_len = 25 + max_field_len = 25 + max_len = 100 truncate_fields = ("title", "album", "artist") on_leftclick = "switch_playpause" on_rightclick = "next_song" @@ -91,17 +95,30 @@ class MPD(IntervalModule): } - for key in self.truncate_fields: - if len(fdict[key]) > self.text_len: - fdict[key] = fdict[key][:self.text_len - 1] + "…" - if not fdict["title"] and "filename" in fdict: fdict["filename"] = '.'.join( basename(currentsong["file"]).split('.')[:-1]) else: fdict["filename"] = "" + + if self.max_field_len > 0: + for key in self.truncate_fields: + if len(fdict[key]) > self.max_field_len: + fdict[key] = fdict[key][:self.max_field_len - 1] + "…" + + full_text = formatp(self.format, **fdict).strip() + full_text_len = len(full_text) + if full_text_len > self.max_len and self.max_len > 0: + shrink = floor((self.max_len - full_text_len) / + len(self.truncate_fields)) - 1 + + for key in self.truncate_fields: + fdict[key] = fdict[key][:shrink] + "…" + + full_text = formatp(self.format, **fdict).strip() + self.output = { - "full_text": formatp(self.format, **fdict).strip(), + "full_text": full_text, "color": self.color, } diff --git a/i3pystatus/network.py b/i3pystatus/network.py index 8eb8c80..a70c7b9 100644 --- a/i3pystatus/network.py +++ b/i3pystatus/network.py @@ -338,7 +338,6 @@ class Network(IntervalModule, ColorRangeModule): format_values = dict(kbs="", network_graph="", bytes_sent="", bytes_recv="", packets_sent="", packets_recv="", interface="", v4="", v4mask="", v4cidr="", v6="", v6mask="", v6cidr="", mac="", essid="", freq="", quality="", quality_bar="") - color = None if self.network_traffic: network_usage = self.network_traffic.get_usage(self.interface) format_values.update(network_usage) @@ -352,22 +351,22 @@ class Network(IntervalModule, ColorRangeModule): format_values['network_graph'] = self.get_network_graph(kbs) format_values['kbs'] = "{0:.1f}".format(round(kbs, 2)).rjust(6) color = self.get_gradient(kbs, self.colors, self.upper_limit) + else: + color = None + + if sysfs_interface_up(self.interface, self.unknown_up): + if not color: + color = self.color_up + format_str = self.format_up + else: + color = self.color_down + format_str = self.format_down network_info = self.network_info.get_info(self.interface) format_values.update(network_info) - format_values['interface'] = self.interface - if sysfs_interface_up(self.interface, self.unknown_up): - if not self.dynamic_color: - color = self.color_up - self.output = { - "full_text": self.format_up.format(**format_values), - 'color': color, - } - else: - color = self.color_down - self.output = { - "full_text": self.format_down.format(**format_values), - 'color': color, - } + self.output = { + "full_text": format_str.format(**format_values), + 'color': color, + } diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index b75104c..acb0784 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -80,9 +80,13 @@ class NowPlaying(IntervalModule): def get_player(self): if self.player: player = "org.mpris.MediaPlayer2." + self.player + try: + return dbus.SessionBus().get_object(player, "/org/mpris/MediaPlayer2") + except dbus.exceptions.DBusException: + raise NoPlayerException() else: player = self.find_player() - return dbus.SessionBus().get_object(player, "/org/mpris/MediaPlayer2") + return dbus.SessionBus().get_object(player, "/org/mpris/MediaPlayer2") def run(self): try: diff --git a/i3pystatus/pyload.py b/i3pystatus/pyload.py index f960114..a528eab 100644 --- a/i3pystatus/pyload.py +++ b/i3pystatus/pyload.py @@ -29,9 +29,11 @@ class pyLoad(IntervalModule): "format", "captcha_true", "captcha_false", "download_true", "download_false", - "username", "password" + "username", "password", + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ) required = ("username", "password") + keyring_backend = None address = "http://127.0.0.1:8000" format = "{captcha} {progress_all:.1f}% {speed:.1f} kb/s" diff --git a/i3pystatus/reddit.py b/i3pystatus/reddit.py index f448ecd..3535fbd 100644 --- a/i3pystatus/reddit.py +++ b/i3pystatus/reddit.py @@ -35,6 +35,7 @@ class Reddit(IntervalModule): ("format", "Format string used for output."), ("username", "Reddit username."), ("password", "Reddit password."), + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), ("subreddit", "Subreddit to monitor. Uses frontpage if unspecified."), ("sort_by", "'hot', 'new', 'rising', 'controversial', or 'top'."), ("color", "Standard color."), @@ -48,6 +49,7 @@ class Reddit(IntervalModule): format = "[{submission_subreddit}] {submission_title} ({submission_domain})" username = "" password = "" + keyring_backend = None subreddit = "" sort_by = "hot" color = "#FFFFFF" diff --git a/i3pystatus/temp.py b/i3pystatus/temp.py index 4639ccf..9e37ade 100644 --- a/i3pystatus/temp.py +++ b/i3pystatus/temp.py @@ -1,6 +1,3 @@ -import re -import glob - from i3pystatus import IntervalModule @@ -16,10 +13,14 @@ class Temperature(IntervalModule): "format string used for output. {temp} is the temperature in degrees celsius"), "color", "file", + "alert_temp", + "alert_color", ) format = "{temp} °C" color = "#FFFFFF" file = "/sys/class/thermal/thermal_zone0/temp" + alert_temp = 90 + alert_color = "#FF0000" def run(self): with open(self.file, "r") as f: @@ -27,5 +28,5 @@ class Temperature(IntervalModule): self.output = { "full_text": self.format.format(temp=temp), - "color": self.color, + "color": self.color if temp < self.alert_temp else self.alert_color, } diff --git a/i3pystatus/whosonlocation.py b/i3pystatus/whosonlocation.py index dad46dc..e866fc3 100644 --- a/i3pystatus/whosonlocation.py +++ b/i3pystatus/whosonlocation.py @@ -5,7 +5,6 @@ from bs4 import BeautifulSoup class WhosOnLocation(): - email = None password = None session = None @@ -68,9 +67,11 @@ class WOL(IntervalModule): password = None settings = ( + ('keyring_backend', 'alternative keyring backend for retrieving credentials'), 'email', 'password' ) + keyring_backend = None color_on_site = '#00FF00' color_off_site = '#ff0000' diff --git a/setting_util.py b/setting_util.py new file mode 100755 index 0000000..97cf663 --- /dev/null +++ b/setting_util.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +import glob +import inspect +import os +import keyring +import getpass +import sys +import signal +from i3pystatus import Module, SettingsBase +from i3pystatus.core import ClassFinder +from collections import defaultdict, OrderedDict + +def signal_handler(signal, frame): + sys.exit(0) +signal.signal(signal.SIGINT, signal_handler) + + +def get_int_in_range(prompt, _range): + while True: + answer = input(prompt) + try: + n = int(answer.strip()) + if n in _range: + return n + else: + print("Value out of range!") + except ValueError: + print("Invalid input!") + +modules = [os.path.basename(m.replace('.py', '')) + for m in glob.glob(os.path.join(os.path.dirname(__file__), "i3pystatus", "*.py")) + if not os.path.basename(m).startswith('_')] + +protected_settings = SettingsBase._SettingsBase__PROTECTED_SETTINGS +class_finder = ClassFinder(Module) +credential_modules = defaultdict(dict) +for module_name in modules: + try: + module = class_finder.get_module(module_name) + clazz = class_finder.get_class(module) + members = [m[0] for m in inspect.getmembers(clazz) if not m[0].startswith('_')] + if any([hasattr(clazz, setting) for setting in protected_settings]): + credential_modules[clazz.__name__]['credentials'] = list(set(protected_settings) & set(members)) + credential_modules[clazz.__name__]['key'] = "%s.%s" % (clazz.__module__, clazz.__name__) + elif hasattr(clazz, 'required'): + protected = [] + required = getattr(clazz, 'required') + for setting in protected_settings: + if setting in required: + protected.append(setting) + if protected: + credential_modules[clazz.__name__]['credentials'] = protected + credential_modules[clazz.__name__]['key'] = "%s.%s" % (clazz.__module__, clazz.__name__) + + except ImportError: + continue + +choices = [k for k in credential_modules.keys()] +for idx, module in enumerate(choices, start=1): + print("%s - %s" % (idx, module)) + +index = get_int_in_range("Choose module:\n> ", range(1, len(choices) + 1)) +module_name = choices[index - 1] +module = credential_modules[module_name] + +for idx, setting in enumerate(module['credentials'], start=1): + print("%s - %s" % (idx, setting)) + +choices = module['credentials'] +index = get_int_in_range("Choose setting for %s:\n> " % module_name, range(1, len(choices) + 1)) +setting = choices[index - 1] + +answer = getpass.getpass("Enter value for %s:\n> " % setting) +answer2 = getpass.getpass("Re-enter value\n> ") +if answer == answer2: + key = "%s.%s" % (module['key'], setting) + keyring.set_password(key, getpass.getuser(), answer) + print("%s set!" % setting) +else: + print("Values don't match - nothing set.")