From b9cc06e3106b6d825fb58ed3182d2a518461e74a Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 8 Nov 2015 09:59:10 +0800 Subject: [PATCH 001/168] Refactor reddit module to be more efficient. --- i3pystatus/reddit.py | 165 ++++++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 64 deletions(-) diff --git a/i3pystatus/reddit.py b/i3pystatus/reddit.py index 402e676..07eceba 100644 --- a/i3pystatus/reddit.py +++ b/i3pystatus/reddit.py @@ -1,10 +1,12 @@ #!/usr/bin/env python +import re + +import praw + from i3pystatus import IntervalModule from i3pystatus.core.util import internet, require, user_open -import praw - class Reddit(IntervalModule): """ @@ -70,83 +72,118 @@ class Reddit(IntervalModule): _permalink = "" _url = "" + subreddit_pattern = re.compile("\{submission_\w+\}") + message_pattern = re.compile("\{message_\w+\}") + user_pattern = re.compile("\{comment_karma\}|\{link_karma\}") + + reddit_session = None + @require(internet) def run(self): - r = praw.Reddit(user_agent='i3pystatus') + reddit = self.connect() + fdict = {} - if self.password: - r.login(self.username, self.password, disable_warning=True) - unread_messages = sum(1 for i in r.get_unread()) - if unread_messages: - d = vars(next(r.get_unread())) - fdict = { - "message_unread": unread_messages, - "message_author": d["author"], - "message_subject": d["subject"], - "message_body": d["body"].replace("\n", " "), - "status": self.status["new_mail"] - } - else: - fdict = { - "message_unread": "", - "status": self.status["no_mail"] - } + if self.message_pattern.search(self.format): + fdict.update(self.get_messages(reddit)) + if self.subreddit_pattern.search(self.format): + fdict.update(self.get_subreddit(reddit)) + if self.user_pattern.search(self.format): + fdict.update(self.get_redditor(reddit)) - if self.subreddit: - s = r.get_subreddit(self.subreddit) - else: - s = r - if self.sort_by == 'hot': - if not self.subreddit: - d = vars(next(s.get_front_page(limit=1))) - else: - d = vars(next(s.get_hot(limit=1))) - elif self.sort_by == 'new': - d = vars(next(s.get_new(limit=1))) - elif self.sort_by == 'rising': - d = vars(next(s.get_rising(limit=1))) - elif self.sort_by == 'controversial': - d = vars(next(s.get_controversial(limit=1))) - elif self.sort_by == 'top': - d = vars(next(s.get_top(limit=1))) - - fdict["submission_title"] = d["title"] - fdict["submission_author"] = d["author"] - fdict["submission_points"] = d["ups"] - fdict["submission_comments"] = d["num_comments"] - fdict["submission_permalink"] = d["permalink"] - fdict["submission_url"] = d["url"] - fdict["submission_domain"] = d["domain"] - fdict["submission_subreddit"] = d["subreddit"] - - self._permalink = fdict["submission_permalink"] - self._url = fdict["submission_url"] - - if self.colorize and fdict["message_unread"]: + if self.colorize and fdict.get("message_unread", False): color = self.color_orangered if self.mail_brackets: - fdict["message_unread"] = "[{}]".format(unread_messages) + fdict["message_unread"] = "[{}]".format(fdict["message_unread"]) else: color = self.color - if len(fdict["submission_title"]) > self.title_maxlen: - title = fdict["submission_title"][:(self.title_maxlen - 3)] + "..." - fdict["submission_title"] = title - - if self.username: - u = r.get_redditor(self.username) - fdict["link_karma"] = u.link_karma - fdict["comment_karma"] = u.comment_karma - else: - fdict["link_karma"] = "" - fdict["comment_karma"] = "" - full_text = self.format.format(**fdict) self.output = { "full_text": full_text, "color": color, } + def connect(self): + if not self.reddit_session: + self.reddit_session = praw.Reddit(user_agent='i3pystatus') + return self.reddit_session + + def get_redditor(self, reddit): + redditor_info = {} + if self.username: + u = reddit.get_redditor(self.username) + redditor_info["link_karma"] = u.link_karma + redditor_info["comment_karma"] = u.comment_karma + else: + redditor_info["link_karma"] = "" + redditor_info["comment_karma"] = "" + return redditor_info + + def get_messages(self, reddit): + message_info = { + "message_unread": "", + "status": self.status["no_mail"], + "message_author": "", + "message_subject": "", + "message_body": "" + } + if self.password: + self.log_in(reddit) + unread_messages = sum(1 for i in reddit.get_unread()) + if unread_messages: + d = vars(next(reddit.get_unread())) + message_info = { + "message_unread": unread_messages, + "message_author": d["author"], + "message_subject": d["subject"], + "message_body": d["body"].replace("\n", " "), + "status": self.status["new_mail"] + } + + return message_info + + def log_in(self, reddit): + if not reddit.is_logged_in(): + reddit.login(self.username, self.password, disable_warning=True) + + def get_subreddit(self, reddit): + fdict = {} + subreddit_dict = {} + if self.subreddit: + s = reddit.get_subreddit(self.subreddit) + else: + s = reddit + if self.sort_by == 'hot': + if not self.subreddit: + subreddit_dict = vars(next(s.get_front_page(limit=1))) + else: + subreddit_dict = vars(next(s.get_hot(limit=1))) + elif self.sort_by == 'new': + subreddit_dict = vars(next(s.get_new(limit=1))) + elif self.sort_by == 'rising': + subreddit_dict = vars(next(s.get_rising(limit=1))) + elif self.sort_by == 'controversial': + subreddit_dict = vars(next(s.get_controversial(limit=1))) + elif self.sort_by == 'top': + subreddit_dict = vars(next(s.get_top(limit=1))) + fdict["submission_title"] = subreddit_dict["title"] + fdict["submission_author"] = subreddit_dict["author"] + fdict["submission_points"] = subreddit_dict["ups"] + fdict["submission_comments"] = subreddit_dict["num_comments"] + fdict["submission_permalink"] = subreddit_dict["permalink"] + fdict["submission_url"] = subreddit_dict["url"] + fdict["submission_domain"] = subreddit_dict["domain"] + fdict["submission_subreddit"] = subreddit_dict["subreddit"] + + if len(fdict["submission_title"]) > self.title_maxlen: + title = fdict["submission_title"][:(self.title_maxlen - 3)] + "..." + fdict["submission_title"] = title + + self._permalink = fdict["submission_permalink"] + self._url = fdict["submission_url"] + + return fdict + def open_mail(self): user_open('https://www.reddit.com/message/unread/') From 32067112c7e0d681b84975b0e9b2fe974f1440ac Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 8 Nov 2015 15:28:07 +0800 Subject: [PATCH 002/168] Add IINet module --- i3pystatus/iinet.py | 93 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 i3pystatus/iinet.py diff --git a/i3pystatus/iinet.py b/i3pystatus/iinet.py new file mode 100644 index 0000000..83be78a --- /dev/null +++ b/i3pystatus/iinet.py @@ -0,0 +1,93 @@ +import requests + +from i3pystatus import IntervalModule +from i3pystatus.core.color import ColorRangeModule + +__author__ = 'facetoe' + + +class IINet(IntervalModule, ColorRangeModule): + """ + Check IINet Internet usage. + Requires `requests` and `colour` + + Formatters: + + * `{percentage_used}` — percentage of your quota that is used + * `{percentage_available}` — percentage of your quota that is available + """ + + settings = ( + "format", + ("username", "Username for IINet"), + ("password", "Password for IINet"), + ("start_color", "Beginning color for color range"), + ("end_color", "End color for color range") + ) + + format = '{percent_used}' + start_color = "#00FF00" + end_color = "#FF0000" + + username = None + password = None + + keyring_backend = None + + def init(self): + self.token = None + self.service_token = None + self.colors = self.get_hex_color_range(self.start_color, self.end_color, 100) + + def set_tokens(self): + if not self.token or not self.service_token: + response = requests.get('https://toolbox.iinet.net.au/cgi-bin/api.cgi?' + '_USERNAME=%(username)s&' + '_PASSWORD=%(password)s' + % self.__dict__).json() + + if self.valid_response(response): + self.token = response['token'] + self.service_token = self.get_service_token(response['response']['service_list']) + + else: + raise Exception("Failed to retrieve token for user: %s" % self.username) + + def get_service_token(self, service_list): + for service in service_list: + if service['pk_v'] == self.username: + return service['s_token'] + raise Exception("Failed to retrieve service token for user: %s" % self.username) + + def valid_response(self, response): + return "success" in response and response['success'] == 1 + + def run(self): + self.set_tokens() + + usage = self.get_usage() + allocation = usage['allocation'] + used = usage['used'] + + percent_used = self.percentage(used, allocation) + percent_avaliable = self.percentage(allocation - used, allocation) + color = self.get_gradient(percent_used, self.colors) + + usage['percent_used'] = '{0:.2f}%'.format(percent_used) + usage['percent_available'] = '{0:.2f}%'.format(percent_avaliable) + + self.output = { + "full_text": self.format.format(**usage), + "color": color + } + + def get_usage(self): + response = requests.get('https://toolbox.iinet.net.au/cgi-bin/api.cgi?Usage&' + '_TOKEN=%(token)s&' + '_SERVICE=%(service_token)s' % self.__dict__).json() + if self.valid_response(response): + for traffic_type in response['response']['usage']['traffic_types']: + if traffic_type['name'] == 'anytime': + return traffic_type + else: + raise Exception("Failed to retrieve usage information for: %s" % self.username) From 97c2f292f759759857c6cc5879842158b12bde70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Mand=C3=A1k?= Date: Thu, 26 Nov 2015 21:14:18 +0100 Subject: [PATCH 003/168] Added options to change logfile and internet check server. --- i3pystatus/core/__init__.py | 35 +++++++++++++++++++++++++---------- i3pystatus/core/util.py | 30 ++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index 9a580b5..d92d6e9 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -1,10 +1,11 @@ -import sys +import logging import os +import sys from threading import Thread -from i3pystatus.core.exceptions import ConfigError -from i3pystatus.core.imputil import ClassFinder from i3pystatus.core import io, util +from i3pystatus.core.exceptions import ConfigError +from i3pystatus.core.imputil import ClassFinder from i3pystatus.core.modules import Module @@ -39,17 +40,31 @@ class Status: """ The main class used for registering modules and managing I/O - :param standalone: Whether i3pystatus should read i3status-compatible input from `input_stream` - :param interval: Update interval in seconds + :param bool standalone: Whether i3pystatus should read i3status-compatible input from `input_stream`. + :param int interval: Update interval in seconds. :param input_stream: A file-like object that provides the input stream, if `standalone` is False. - :param click_events: Enable click events + :param bool click_events: Enable click events, if `standalone` is True. + :param str logfile: Path to log file that will be used by i3pystatus. + :param tuple internet_check: Address of server that will be used to check for internet connection by :py:class:`.internet`. """ - def __init__(self, standalone=False, interval=1, input_stream=sys.stdin, click_events=True): - self.modules = util.ModuleList(self, ClassFinder(Module)) + def __init__(self, standalone=False, **kwargs): self.standalone = standalone - self.click_events = click_events - if standalone: + self.click_events = kwargs.get("click_events", True) + interval = kwargs.get("interval", 1) + input_stream = kwargs.get("input_stream", sys.stdin) + if "logfile" in kwargs: + logger = logging.getLogger("i3pystatus") + for handler in logger.handlers: + logger.removeHandler(handler) + handler = logging.FileHandler(kwargs["logfile"], delay=True) + logger.addHandler(handler) + logger.setLevel(logging.CRITICAL) + if "internet_check" in kwargs: + util.internet.address = kwargs["internet_check"] + + self.modules = util.ModuleList(self, ClassFinder(Module)) + if self.standalone: self.io = io.StandaloneIO(self.click_events, self.modules, interval) if self.click_events: self.command_endpoint = CommandEndpoint( diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index 9bc11fd..7ed058c 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -335,7 +335,7 @@ def require(predicate): .. seealso:: - :py:func:`internet` + :py:class:`internet` """ @@ -351,18 +351,28 @@ def require(predicate): return decorator -def internet(): +class internet: """ - Checks for a internet connection by connecting to a Google DNS - server. + Checks for internet connection by connecting to a server. + + Used server is determined by the `address` class variable which consists of + server host name and port number. + + :rtype: bool: + + .. seealso:: + + :py:func:`require` - :returns: True if internet connection is available """ - try: - socket.create_connection(("google-public-dns-a.google.com", 53), 1).close() - return True - except OSError: - return False + address = ("google-public-dns-a.google.com", 53) + + def __new__(cls): + try: + socket.create_connection(cls.address, 1).close() + return True + except OSError: + return False def make_graph(values, lower_limit=0.0, upper_limit=100.0, style="blocks"): From 6d211f823c1acd2bbd11d82733868567e74adbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Mand=C3=A1k?= Date: Thu, 26 Nov 2015 21:14:18 +0100 Subject: [PATCH 004/168] Added options to change logfile and internet check server. --- i3pystatus/core/__init__.py | 35 +++++++++++++++++++++++++---------- i3pystatus/core/util.py | 30 ++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index 9a580b5..d92d6e9 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -1,10 +1,11 @@ -import sys +import logging import os +import sys from threading import Thread -from i3pystatus.core.exceptions import ConfigError -from i3pystatus.core.imputil import ClassFinder from i3pystatus.core import io, util +from i3pystatus.core.exceptions import ConfigError +from i3pystatus.core.imputil import ClassFinder from i3pystatus.core.modules import Module @@ -39,17 +40,31 @@ class Status: """ The main class used for registering modules and managing I/O - :param standalone: Whether i3pystatus should read i3status-compatible input from `input_stream` - :param interval: Update interval in seconds + :param bool standalone: Whether i3pystatus should read i3status-compatible input from `input_stream`. + :param int interval: Update interval in seconds. :param input_stream: A file-like object that provides the input stream, if `standalone` is False. - :param click_events: Enable click events + :param bool click_events: Enable click events, if `standalone` is True. + :param str logfile: Path to log file that will be used by i3pystatus. + :param tuple internet_check: Address of server that will be used to check for internet connection by :py:class:`.internet`. """ - def __init__(self, standalone=False, interval=1, input_stream=sys.stdin, click_events=True): - self.modules = util.ModuleList(self, ClassFinder(Module)) + def __init__(self, standalone=False, **kwargs): self.standalone = standalone - self.click_events = click_events - if standalone: + self.click_events = kwargs.get("click_events", True) + interval = kwargs.get("interval", 1) + input_stream = kwargs.get("input_stream", sys.stdin) + if "logfile" in kwargs: + logger = logging.getLogger("i3pystatus") + for handler in logger.handlers: + logger.removeHandler(handler) + handler = logging.FileHandler(kwargs["logfile"], delay=True) + logger.addHandler(handler) + logger.setLevel(logging.CRITICAL) + if "internet_check" in kwargs: + util.internet.address = kwargs["internet_check"] + + self.modules = util.ModuleList(self, ClassFinder(Module)) + if self.standalone: self.io = io.StandaloneIO(self.click_events, self.modules, interval) if self.click_events: self.command_endpoint = CommandEndpoint( diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index 9bc11fd..48774c3 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -335,7 +335,7 @@ def require(predicate): .. seealso:: - :py:func:`internet` + :py:class:`internet` """ @@ -351,18 +351,28 @@ def require(predicate): return decorator -def internet(): +class internet: """ - Checks for a internet connection by connecting to a Google DNS - server. + Checks for internet connection by connecting to a server. + + Used server is determined by the `address` class variable which consists of + server host name and port number. + + :rtype: bool + + .. seealso:: + + :py:func:`require` - :returns: True if internet connection is available """ - try: - socket.create_connection(("google-public-dns-a.google.com", 53), 1).close() - return True - except OSError: - return False + address = ("google-public-dns-a.google.com", 53) + + def __new__(cls): + try: + socket.create_connection(cls.address, 1).close() + return True + except OSError: + return False def make_graph(values, lower_limit=0.0, upper_limit=100.0, style="blocks"): From 2abaab1769ee2b47f33d820e7dde23227eced556 Mon Sep 17 00:00:00 2001 From: Fabian Tobias Rajter Date: Sat, 5 Dec 2015 18:12:44 +0100 Subject: [PATCH 005/168] Implemented optional volume display/setting as in AlsaMixer. --- i3pystatus/alsa.py | 87 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/i3pystatus/alsa.py b/i3pystatus/alsa.py index f53815b..52e1180 100644 --- a/i3pystatus/alsa.py +++ b/i3pystatus/alsa.py @@ -1,4 +1,5 @@ from alsaaudio import Mixer, ALSAAudioError +from math import exp, log, log10, ceil, floor from i3pystatus import IntervalModule @@ -28,7 +29,8 @@ class ALSA(IntervalModule): ("increment", "integer percentage of max volume to in/decrement volume on mousewheel"), "muted", "unmuted", "color_muted", "color", - "channel" + "channel", + ("map_volume", "volume display/setting as in AlsaMixer. increment option is ignored then.") ) muted = "M" @@ -43,6 +45,8 @@ class ALSA(IntervalModule): channel = 0 increment = 5 + map_volume = False + alsamixer = None has_mute = True @@ -63,6 +67,11 @@ class ALSA(IntervalModule): "mixer": self.mixer, } + self.dbRng = self.alsamixer.getrange() + + self.dbMin = self.dbRng[0] + self.dbMax = self.dbRng[1] + def create_mixer(self): self.alsamixer = Mixer( control=self.mixer, id=self.mixer_id, cardindex=self.card) @@ -74,8 +83,9 @@ class ALSA(IntervalModule): if self.has_mute: muted = self.alsamixer.getmute()[self.channel] == 1 - self.fdict["volume"] = self.alsamixer.getvolume()[self.channel] + self.fdict["volume"] = self.get_cur_volume() self.fdict["muted"] = self.muted if muted else self.unmuted + self.fdict["db"] = self.get_db() if muted and self.format_muted is not None: output_format = self.format_muted @@ -92,10 +102,75 @@ class ALSA(IntervalModule): muted = self.alsamixer.getmute()[self.channel] self.alsamixer.setmute(not muted) + def get_cur_volume(self): + if self.map_volume: + dbCur = self.get_db() * 100.0 + dbMin = self.dbMin * 100.0 + dbMax = self.dbMax * 100.0 + + dbCur_norm = self.exp10((dbCur - dbMax) / 6000.0) + dbMin_norm = self.exp10((dbMin - dbMax) / 6000.0) + + vol = (dbCur_norm - dbMin_norm) / (1 - dbMin_norm) + vol = int(round(vol * 100,0)) + + return vol + else: + return self.alsamixer.getvolume()[self.channel] + + def get_new_volume(self, direction): + if direction == "inc": + volume = (self.fdict["volume"] + 1) / 100 + elif direction == "dec": + volume = (self.fdict["volume"] - 1) / 100 + + dbMin = self.dbMin * 100 + dbMax = self.dbMax * 100 + + dbMin_norm = self.exp10((dbMin - dbMax) / 6000.0) + + vol = volume * (1 - dbMin_norm) + dbMin_norm + + if direction == "inc": + dbNew = min(self.dbMax, ceil(((6000.0 * log10(vol)) + dbMax) / 100)) + elif direction == "dec": + dbNew = max(self.dbMin, floor(((6000.0 * log10(vol)) + dbMax) / 100)) + + volNew = int(round(self.map_db(dbNew, self.dbMin, self.dbMax, 0, 100),0)) + + return volNew + def increase_volume(self, delta=None): - vol = self.alsamixer.getvolume()[self.channel] - self.alsamixer.setvolume(min(100, vol + (delta if delta else self.increment))) + if self.map_volume: + vol = self.get_new_volume("inc") + + self.alsamixer.setvolume(vol) + else: + vol = self.alsamixer.getvolume()[self.channel] + self.alsamixer.setvolume(min(100, vol + (delta if delta else self.increment))) def decrease_volume(self, delta=None): - vol = self.alsamixer.getvolume()[self.channel] - self.alsamixer.setvolume(max(0, vol - (delta if delta else self.increment))) + if self.map_volume: + vol = self.get_new_volume("dec") + + self.alsamixer.setvolume(vol) + else: + vol = self.alsamixer.getvolume()[self.channel] + self.alsamixer.setvolume(max(0, vol - (delta if delta else self.increment))) + + def get_db(self): + db = (((self.dbMax - self.dbMin) / 100) * self.alsamixer.getvolume()[self.channel]) + self.dbMin + db = int(round(db,0)) + + return db + + def map_db(self, value, dbMin, dbMax, volMin, volMax): + dbRange = dbMax - dbMin + volRange = volMax - volMin + + volScaled = float(value - dbMin) / float(dbRange) + + return volMin + (volScaled * volRange) + + def exp10(self, x): + return exp(x * log(10)) From 1376f5f6bed0e278e5efd635b216f8a9ca3472e2 Mon Sep 17 00:00:00 2001 From: Fabian Tobias Rajter Date: Sat, 5 Dec 2015 18:31:45 +0100 Subject: [PATCH 006/168] Changed cosmetical things Travis CI was complaining about. --- i3pystatus/alsa.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/i3pystatus/alsa.py b/i3pystatus/alsa.py index 52e1180..ba52e74 100644 --- a/i3pystatus/alsa.py +++ b/i3pystatus/alsa.py @@ -1,5 +1,5 @@ from alsaaudio import Mixer, ALSAAudioError -from math import exp, log, log10, ceil, floor +from math import exp, log, log10, ceil, floor from i3pystatus import IntervalModule @@ -83,7 +83,7 @@ class ALSA(IntervalModule): if self.has_mute: muted = self.alsamixer.getmute()[self.channel] == 1 - self.fdict["volume"] = self.get_cur_volume() + self.fdict["volume"] = self.get_cur_volume() self.fdict["muted"] = self.muted if muted else self.unmuted self.fdict["db"] = self.get_db() @@ -107,16 +107,16 @@ class ALSA(IntervalModule): dbCur = self.get_db() * 100.0 dbMin = self.dbMin * 100.0 dbMax = self.dbMax * 100.0 - + dbCur_norm = self.exp10((dbCur - dbMax) / 6000.0) dbMin_norm = self.exp10((dbMin - dbMax) / 6000.0) vol = (dbCur_norm - dbMin_norm) / (1 - dbMin_norm) - vol = int(round(vol * 100,0)) + vol = int(round(vol * 100, 0)) return vol else: - return self.alsamixer.getvolume()[self.channel] + return self.alsamixer.getvolume()[self.channel] def get_new_volume(self, direction): if direction == "inc": @@ -128,15 +128,15 @@ class ALSA(IntervalModule): dbMax = self.dbMax * 100 dbMin_norm = self.exp10((dbMin - dbMax) / 6000.0) - + vol = volume * (1 - dbMin_norm) + dbMin_norm - + if direction == "inc": dbNew = min(self.dbMax, ceil(((6000.0 * log10(vol)) + dbMax) / 100)) elif direction == "dec": dbNew = max(self.dbMin, floor(((6000.0 * log10(vol)) + dbMax) / 100)) - volNew = int(round(self.map_db(dbNew, self.dbMin, self.dbMax, 0, 100),0)) + volNew = int(round(self.map_db(dbNew, self.dbMin, self.dbMax, 0, 100), 0)) return volNew @@ -160,7 +160,7 @@ class ALSA(IntervalModule): def get_db(self): db = (((self.dbMax - self.dbMin) / 100) * self.alsamixer.getvolume()[self.channel]) + self.dbMin - db = int(round(db,0)) + db = int(round(db, 0)) return db From 3b82e8de1ec296b8c4f255c7a4d02870bea77809 Mon Sep 17 00:00:00 2001 From: Holden Salomon Date: Sat, 5 Dec 2015 13:37:32 -0500 Subject: [PATCH 007/168] Added previous song support, activated by scrolling down while over the module --- i3pystatus/spotify.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/i3pystatus/spotify.py b/i3pystatus/spotify.py index 4404fe8..a4d0987 100644 --- a/i3pystatus/spotify.py +++ b/i3pystatus/spotify.py @@ -39,6 +39,7 @@ class Spotify(IntervalModule): on_leftclick = 'playpause' on_rightclick = 'next_song' on_upscroll = 'next_song' + on_downscroll = 'previous_song' def get_info(self, player): """gets spotify track info from playerctl""" @@ -101,3 +102,7 @@ class Spotify(IntervalModule): def next_song(self): """skips to the next song""" self.player.next() + + def previous_song(self): + """Plays the previous song""" + self.player.previous() From 6f492ff406f886c2cff03cd1d00903c4b2c30703 Mon Sep 17 00:00:00 2001 From: Nikolay Polyarniy Date: Thu, 10 Dec 2015 01:30:04 +0300 Subject: [PATCH 008/168] gpu_mem: GPU memory module (nvidia-smi only) --- i3pystatus/gpu_mem.py | 68 ++++++++++++++++++++++++++++++++++++ i3pystatus/utils/__init__.py | 5 +++ i3pystatus/utils/gpu.py | 43 +++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 i3pystatus/gpu_mem.py create mode 100644 i3pystatus/utils/__init__.py create mode 100644 i3pystatus/utils/gpu.py diff --git a/i3pystatus/gpu_mem.py b/i3pystatus/gpu_mem.py new file mode 100644 index 0000000..c8118c1 --- /dev/null +++ b/i3pystatus/gpu_mem.py @@ -0,0 +1,68 @@ +from i3pystatus import IntervalModule +from .utils import gpu + + +class GPUMemory(IntervalModule): + """ + Shows GPU memory load + + Currently Nvidia only and nvidia-smi required + + .. rubric:: Available formatters + + * {avail_mem} + * {percent_used_mem} + * {used_mem} + * {total_mem} + """ + + settings = ( + ("format", "format string used for output."), + ("divisor", "divide all megabyte values by this value, default is 1 (megabytes)"), + ("warn_percentage", "minimal percentage for warn state"), + ("alert_percentage", "minimal percentage for alert state"), + ("color", "standard color"), + ("warn_color", "defines the color used wann warn percentage ist exceeded"), + ("alert_color", "defines the color used when alert percentage is exceeded"), + ("round_size", "defines number of digits in round"), + + ) + + format = "{avail_mem} MiB" + divisor = 1 + color = "#00FF00" + warn_color = "#FFFF00" + alert_color = "#FF0000" + warn_percentage = 50 + alert_percentage = 80 + round_size = 1 + + def run(self): + info = gpu.query_nvidia_smi() + + if info.used_mem is not None and info.total_mem is not None: + mem_percent = 100 * info.used_mem / info.total_mem + else: + mem_percent = None + + if mem_percent >= self.alert_percentage: + color = self.alert_color + elif mem_percent >= self.warn_percentage: + color = self.warn_color + else: + color = self.color + + cdict = { + "used_mem": info.used_mem / self.divisor, + "avail_mem": info.avail_mem / self.divisor, + "total_mem": info.total_mem / self.divisor, + "percent_used_mem": mem_percent, + } + for key, value in cdict.items(): + if value is not None: + cdict[key] = round(value, self.round_size) + + self.output = { + "full_text": self.format.format(**cdict), + "color": color + } diff --git a/i3pystatus/utils/__init__.py b/i3pystatus/utils/__init__.py new file mode 100644 index 0000000..cfd49ba --- /dev/null +++ b/i3pystatus/utils/__init__.py @@ -0,0 +1,5 @@ +# +# Copyright (c) 2015, Nikolay Polyarnyi +# All rights reserved. +# + diff --git a/i3pystatus/utils/gpu.py b/i3pystatus/utils/gpu.py new file mode 100644 index 0000000..3311fb4 --- /dev/null +++ b/i3pystatus/utils/gpu.py @@ -0,0 +1,43 @@ +import subprocess +from collections import namedtuple + +GPUUsageInfo = namedtuple('GPUUsageInfo', ['total_mem', 'avail_mem', 'used_mem', + 'temp', 'percent_fan', + 'usage_gpu', 'usage_mem']) + + +def query_nvidia_smi() -> GPUUsageInfo: + """ + :return: + all memory fields are in megabytes, + temperature in degrees celsius, + fan speed is integer percent from 0 to 100 inclusive, + usage_gpu and usage_mem are integer percents from 0 to 100 inclusive + (usage_mem != used_mem, usage_mem is about read/write access load) + read more in 'nvidia-smi --help-query-gpu'. + + Any field can be None if such information is not supported by nvidia-smi for current GPU + + Returns None if call failed (no nvidia-smi or query format was changed) + + Raises exception with readable comment + """ + params = ["memory.total", "memory.free", "memory.used", + "temperature.gpu", "fan.speed", + "utilization.gpu", "utilization.memory"] + try: + output = subprocess.check_output(["nvidia-smi", + "--query-gpu={}".format(','.join(params)), + "--format=csv,noheader,nounits"]) + except FileNotFoundError: + raise Exception("No nvidia-smi") + except subprocess.CalledProcessError: + raise Exception("nvidia-smi call failed") + + output = output.decode('utf-8').strip() + values = output.split(", ") + + # If value contains 'not' - it is not supported for this GPU (in fact, for now nvidia-smi returns '[Not Supported]') + values = [None if ("not" in value.lower()) else int(value) for value in values] + + return GPUUsageInfo(*values) From 8ae40efa30eb75df0abd5303cdc774b1c54e4e63 Mon Sep 17 00:00:00 2001 From: Nikolay Polyarniy Date: Thu, 10 Dec 2015 01:30:37 +0300 Subject: [PATCH 009/168] gpu_temp: GPU temperature module (nvidia-smi only) --- i3pystatus/gpu_temp.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 i3pystatus/gpu_temp.py diff --git a/i3pystatus/gpu_temp.py b/i3pystatus/gpu_temp.py new file mode 100644 index 0000000..2f0aacf --- /dev/null +++ b/i3pystatus/gpu_temp.py @@ -0,0 +1,34 @@ +from i3pystatus import IntervalModule +from .utils import gpu + + +class GPUTemperature(IntervalModule): + """ + Shows GPU temperature + + Currently Nvidia only and nvidia-smi required + + .. rubric:: Available formatters + + * `{temp}` — the temperature in integer degrees celsius + """ + + settings = ( + ("format", "format string used for output. {temp} is the temperature in integer degrees celsius"), + "color", + "alert_temp", + "alert_color", + ) + format = "{temp} °C" + color = "#FFFFFF" + alert_temp = 90 + alert_color = "#FF0000" + + def run(self): + temp = gpu.query_nvidia_smi().temp + temp_alert = temp is None or temp >= self.alert_temp + + self.output = { + "full_text": self.format.format(temp=temp), + "color": self.color if not temp_alert else self.alert_color, + } From e569b934b2491dd9d59816ce76b148e0fe8f4ac4 Mon Sep 17 00:00:00 2001 From: Nikolay Polyarniy Date: Thu, 10 Dec 2015 01:42:00 +0300 Subject: [PATCH 010/168] PEP8 fix for utils/__init__ and accidental copyright removed --- i3pystatus/utils/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/i3pystatus/utils/__init__.py b/i3pystatus/utils/__init__.py index cfd49ba..e69de29 100644 --- a/i3pystatus/utils/__init__.py +++ b/i3pystatus/utils/__init__.py @@ -1,5 +0,0 @@ -# -# Copyright (c) 2015, Nikolay Polyarnyi -# All rights reserved. -# - From 93220ac178966a84ab37362ed0b61ef5398478a0 Mon Sep 17 00:00:00 2001 From: facetoe Date: Sat, 12 Dec 2015 18:38:53 +0800 Subject: [PATCH 011/168] Make symbol configurable --- i3pystatus/openvpn.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/i3pystatus/openvpn.py b/i3pystatus/openvpn.py index 07d76d6..027be8d 100644 --- a/i3pystatus/openvpn.py +++ b/i3pystatus/openvpn.py @@ -20,7 +20,9 @@ class OpenVPN(IntervalModule): colour_up = "#00ff00" colour_down = "#FF0000" - format = "{label} {vpn_name} {status}" + status_up = '▲' + status_down = '▼' + format = "{vpn_name} {status}" status_command = "bash -c \"systemctl show openvpn@%(vpn_name)s | grep -oP 'ActiveState=\K(\w+)'\"" label = '' @@ -30,8 +32,9 @@ class OpenVPN(IntervalModule): ("format", "Format string"), ("colour_up", "VPN is up"), ("colour_down", "VPN is down"), + ("status_down", "Symbol to display when down"), + ("status_up", "Symbol to display when up"), ("vpn_name", "Name of VPN"), - ("label", "Set a label for this connection") ) def init(self): @@ -44,10 +47,10 @@ class OpenVPN(IntervalModule): if output == 'active': color = self.colour_up - status = '▲' + status = self.status_up else: color = self.colour_down - status = '▼' + status = self.status_down vpn_name = self.vpn_name label = self.label From 47f1f72c5cbbd7a5d461ab443b21784fc13249c7 Mon Sep 17 00:00:00 2001 From: Maximiliano Date: Tue, 22 Dec 2015 17:14:38 +0100 Subject: [PATCH 012/168] disk module: corrected doc string --- i3pystatus/disk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/disk.py b/i3pystatus/disk.py index b67d176..e2b37b1 100644 --- a/i3pystatus/disk.py +++ b/i3pystatus/disk.py @@ -6,7 +6,7 @@ from .core.util import round_dict class Disk(IntervalModule): """ - Gets ``{used}``, ``{free}``, ``{available}`` and ``{total}`` amount of bytes on the given mounted filesystem. + Gets ``{used}``, ``{free}``, ``{avail}`` and ``{total}`` amount of bytes on the given mounted filesystem. These values can also be expressed as percentages with the ``{percentage_used}``, ``{percentage_free}`` and ``{percentage_avail}`` formats. From 0a63932c62942e44f34228c09b8718e6c2fb6c85 Mon Sep 17 00:00:00 2001 From: fahrstuhl Date: Fri, 1 Jan 2016 14:04:32 +0100 Subject: [PATCH 013/168] adds variant to xkblayout --- i3pystatus/xkblayout.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/i3pystatus/xkblayout.py b/i3pystatus/xkblayout.py index d4ca783..90fd511 100644 --- a/i3pystatus/xkblayout.py +++ b/i3pystatus/xkblayout.py @@ -20,7 +20,7 @@ class Xkblayout(IntervalModule): on_leftclick = "change_layout" def run(self): - kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/{print $2}'", shell=True).decode('utf-8').strip() + kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n"," ").strip() self.output = { "full_text": self.format.format(name=kblayout).upper(), @@ -29,12 +29,12 @@ class Xkblayout(IntervalModule): def change_layout(self): layouts = self.layouts - kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/{print $2}'", shell=True).decode('utf-8').strip() + kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n"," ").strip() if kblayout in layouts: position = layouts.index(kblayout) try: - subprocess.check_call(["setxkbmap", layouts[position + 1]]) + subprocess.check_call(["setxkbmap"] + layouts[position + 1].split()) except IndexError: - subprocess.check_call(["setxkbmap", layouts[0]]) + subprocess.check_call(["setxkbmap"] + layouts[0].split()) else: - subprocess.check_call(["setxkbmap", layouts[0]]) + subprocess.check_call(["setxkbmap"] + layouts[0].split()) From 0a17ee2bfb04c4c9e0083f4d6c0454a7f1a98144 Mon Sep 17 00:00:00 2001 From: fahrstuhl Date: Fri, 1 Jan 2016 14:05:55 +0100 Subject: [PATCH 014/168] PEP8 compliance --- i3pystatus/xkblayout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i3pystatus/xkblayout.py b/i3pystatus/xkblayout.py index 90fd511..4ea4bb1 100644 --- a/i3pystatus/xkblayout.py +++ b/i3pystatus/xkblayout.py @@ -20,7 +20,7 @@ class Xkblayout(IntervalModule): on_leftclick = "change_layout" def run(self): - kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n"," ").strip() + kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n", " ").strip() self.output = { "full_text": self.format.format(name=kblayout).upper(), @@ -29,7 +29,7 @@ class Xkblayout(IntervalModule): def change_layout(self): layouts = self.layouts - kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n"," ").strip() + kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n", " ").strip() if kblayout in layouts: position = layouts.index(kblayout) try: From 2a65efa068fe96bd73c996525a55d0b4aff52727 Mon Sep 17 00:00:00 2001 From: fahrstuhl Date: Fri, 1 Jan 2016 18:18:01 +0100 Subject: [PATCH 015/168] adds doc for variant feature --- i3pystatus/xkblayout.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/i3pystatus/xkblayout.py b/i3pystatus/xkblayout.py index 4ea4bb1..35e9e74 100644 --- a/i3pystatus/xkblayout.py +++ b/i3pystatus/xkblayout.py @@ -9,6 +9,8 @@ class Xkblayout(IntervalModule): ``layouts`` setting and enables the layout following it. If the current layout is not in the ``layouts`` setting the first layout is enabled. + + ``layouts`` can be stated with or without variants, e.g.: status.register("xkblayout", layouts=["de neo", "de"]) """ interval = 1 From 09e2e64d36cc4de6574be71b6b24b08c1a08ee14 Mon Sep 17 00:00:00 2001 From: Jan Fader Date: Tue, 29 Dec 2015 21:08:58 +0100 Subject: [PATCH 016/168] initial commit of solaar.py --- i3pystatus/solaar.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 i3pystatus/solaar.py diff --git a/i3pystatus/solaar.py b/i3pystatus/solaar.py new file mode 100644 index 0000000..dbb81fe --- /dev/null +++ b/i3pystatus/solaar.py @@ -0,0 +1,62 @@ +from i3pystatus import IntervalModule +from i3pystatus.core.command import run_through_shell + + +class Solaar(IntervalModule): + """ + Shows output of shell command + + .. rubric:: Available formatters + + * `{output}` — percentage of battery and status + """ + + color = "#FFFFFF" + error_color = "#FF0000" + interval = 30 + + settings = ( + ("nameOfDevice", "name of the bluetooth-device"), + ("color", "standard color"), + ("error_color", "color to use when non zero exit code is returned"), + ) + + required = ("nameOfDevice",) + + def findDeviceNumber(self): + command = 'solaar-cli show' + retvalue, out, stderr = run_through_shell(command, enable_shell=True) + for line in out.split('\n'): + if line.count(self.nameOfDevice) > 0 and line.count(':') > 0: + numberOfDevice = line.split(':')[0] + return(0, numberOfDevice) + return(1, 0) + + def findBatteryStatus(self, numberOfDevice): + command = 'solaar-cli show -v %s' % (numberOfDevice) + retvalue, out, stderr = run_through_shell(command, enable_shell=True) + for line in out.split('\n'): + if line.count('Battery') > 0: + if line.count(':') > 0: + batterystatus = line.split(':')[1].strip().strip(",") + return(0, batterystatus) + else: + return(1, 0) + return(1, 0) + + def run(self): + self.output = {} + rcfindDeviceNumber = self.findDeviceNumber() + if rcfindDeviceNumber[0] != 0: + output = "problem finding device %s" % (self.nameOfDevice) + self.output['color'] = self.error_color + else: + numberOfDevice = rcfindDeviceNumber[1] + rcfindBatteryStatus = self.findBatteryStatus(numberOfDevice) + if rcfindBatteryStatus[0] != 0: + output = "problem finding battery status device %s" % (self.nameOfDevice) + self.output['color'] = self.error_color + else: + output = self.findBatteryStatus(self.findDeviceNumber()[1])[1] + self.output['color'] = self.color + self.output['full_text'] = output From b6a472e0143fb0de68bace96aede3272180aadb7 Mon Sep 17 00:00:00 2001 From: Jan Fader Date: Tue, 29 Dec 2015 21:12:18 +0100 Subject: [PATCH 017/168] fixed documentation of solaar --- i3pystatus/solaar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/solaar.py b/i3pystatus/solaar.py index dbb81fe..fc336ad 100644 --- a/i3pystatus/solaar.py +++ b/i3pystatus/solaar.py @@ -4,7 +4,7 @@ from i3pystatus.core.command import run_through_shell class Solaar(IntervalModule): """ - Shows output of shell command + Shows status and load percentage of bluetooth-device .. rubric:: Available formatters From 31374a2ec4218f4d3409a3f3f22fe335441997fe Mon Sep 17 00:00:00 2001 From: Alexandr Mikhailov Date: Wed, 16 Dec 2015 16:58:24 +0300 Subject: [PATCH 018/168] Added Zabbix module --- i3pystatus/zabbix.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 i3pystatus/zabbix.py diff --git a/i3pystatus/zabbix.py b/i3pystatus/zabbix.py new file mode 100644 index 0000000..96c7d1f --- /dev/null +++ b/i3pystatus/zabbix.py @@ -0,0 +1,63 @@ +from i3pystatus import IntervalModule +from pyzabbix import ZabbixAPI + + +class Zabbix(IntervalModule): + """ + Zabbix alerts watcher + + Requires pyzabbix + """ + settings = ( + ("zabbix_server", "Zabbix Server URL"), + ("zabbix_user", "Zabbix API User"), + ("zabbix_password", "Zabbix users password"), + ("interval", "Update interval"), + "format" + ) + required = ("zabbix_server", "zabbix_user", "zabbix_password") + interval = 60 + format = "{default}" + + def run(self): + + alerts_color = ["#DBDBDB", "#D6F6FF", "#FFF6A5", "#FFB689", "#FF9999", "#FF3838"] + zapi = ZabbixAPI(self.zabbix_server) + zapi.login(self.zabbix_user, self.zabbix_password) + triggers = zapi.trigger.get(only_true=1, + skipDependent=1, + monitored=1, + active=1, + min_severity=2, + output=["priority"], + withLastEventUnacknowledged=1, + ) + alerts_list = [t['priority'] for t in triggers] + alerts = [0, 0, 0, 0, 0, 0] + colors = [] + for i in range(0, 6): + alerts[i] = alerts_list.count(str(i)) + if alerts[i] == 0: + alerts_color[i] = "#FFFFFF" + + + cdict = { + "default": "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts), + "a5": alerts[5], + "a4": alerts[4], + "a3": alerts[3], + "a2": alerts[2], + "a1": alerts[1], + "a0": alerts[0], + "c5": alerts_color[5], + "c4": alerts_color[4], + "c3": alerts_color[3], + "c2": alerts_color[2], + "c1": alerts_color[1], + "c0": alerts_color[0], + "total": sum(alerts) + } + + self.output = { + "full_text": self.format.format(**cdict) + } From bac8a05d411ab795488e506ebb790bd496bdf790 Mon Sep 17 00:00:00 2001 From: Alexandr Mikhailov Date: Thu, 17 Dec 2015 16:44:30 +0300 Subject: [PATCH 019/168] Simplify individual alerts assignment --- i3pystatus/zabbix.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/i3pystatus/zabbix.py b/i3pystatus/zabbix.py index 96c7d1f..bdc8cf2 100644 --- a/i3pystatus/zabbix.py +++ b/i3pystatus/zabbix.py @@ -34,29 +34,17 @@ class Zabbix(IntervalModule): ) alerts_list = [t['priority'] for t in triggers] alerts = [0, 0, 0, 0, 0, 0] - colors = [] + cdict = {} for i in range(0, 6): - alerts[i] = alerts_list.count(str(i)) - if alerts[i] == 0: - alerts_color[i] = "#FFFFFF" + alerts[i] = alerts_list.count(str(i)) + cdict["a%s" % i]=alerts[i] + if alerts[i] == 0: + cdict["c%s" % i] = "#FFFFFF" + else: + cdict["c%s" % i] = alerts_color[i] - - cdict = { - "default": "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts), - "a5": alerts[5], - "a4": alerts[4], - "a3": alerts[3], - "a2": alerts[2], - "a1": alerts[1], - "a0": alerts[0], - "c5": alerts_color[5], - "c4": alerts_color[4], - "c3": alerts_color[3], - "c2": alerts_color[2], - "c1": alerts_color[1], - "c0": alerts_color[0], - "total": sum(alerts) - } + cdict["default"] = "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts) + cdict["total"] = sum(alerts) self.output = { "full_text": self.format.format(**cdict) From b2dd1b46ca05a7b96f5238e44a85a0b08487c166 Mon Sep 17 00:00:00 2001 From: Alexandr Mikhailov Date: Thu, 17 Dec 2015 23:18:37 +0300 Subject: [PATCH 020/168] Added Exception for incorrect Zabbix authentication and implemented global color for module result --- i3pystatus/zabbix.py | 58 +++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/i3pystatus/zabbix.py b/i3pystatus/zabbix.py index bdc8cf2..8532fc5 100644 --- a/i3pystatus/zabbix.py +++ b/i3pystatus/zabbix.py @@ -6,6 +6,12 @@ class Zabbix(IntervalModule): """ Zabbix alerts watcher + .. rubric:: Available formatters + * {default} - Full output count alerts like total:a5/a4/a3/a2/a1/a0 + * {total} - Total count of alerts + * {aX_count} - Count alerts of X severity + * {colorX} - Predicted color for X severity. It can be used with Pango markup hint for different colours at each severity with + Requires pyzabbix """ settings = ( @@ -23,29 +29,37 @@ class Zabbix(IntervalModule): alerts_color = ["#DBDBDB", "#D6F6FF", "#FFF6A5", "#FFB689", "#FF9999", "#FF3838"] zapi = ZabbixAPI(self.zabbix_server) - zapi.login(self.zabbix_user, self.zabbix_password) - triggers = zapi.trigger.get(only_true=1, - skipDependent=1, - monitored=1, - active=1, - min_severity=2, - output=["priority"], - withLastEventUnacknowledged=1, - ) - alerts_list = [t['priority'] for t in triggers] - alerts = [0, 0, 0, 0, 0, 0] - cdict = {} - for i in range(0, 6): - alerts[i] = alerts_list.count(str(i)) - cdict["a%s" % i]=alerts[i] - if alerts[i] == 0: - cdict["c%s" % i] = "#FFFFFF" - else: - cdict["c%s" % i] = alerts_color[i] + try: + zapi.login(self.zabbix_user, self.zabbix_password) + triggers = zapi.trigger.get(only_true=1, + skipDependent=1, + monitored=1, + active=1, + min_severity=2, + output=["priority"], + withLastEventUnacknowledged=1, + ) + alerts_list = [t['priority'] for t in triggers] + alerts = [0, 0, 0, 0, 0, 0] + cdict = {} + for i in range(0, 6): + alerts[i] = alerts_list.count(str(i)) + cdict["a%s_count" % i]=alerts[i] + if alerts[i] == 0: + cdict["color%s" % i] = "#FFFFFF" + else: + cdict["color%s" % i] = alerts_color[i] + + cdict["default"] = "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts) + cdict["total"] = sum(alerts) + color = alerts_color[max(alerts)] + result = self.format.format(**cdict) - cdict["default"] = "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts) - cdict["total"] = sum(alerts) + except Exception as e: + result = "Zabbix connection error" + color = "#FF0000" self.output = { - "full_text": self.format.format(**cdict) + "full_text": result, + "color": color } From 0aeec837413aa65858bd0d3bdb4fadfe82e2978f Mon Sep 17 00:00:00 2001 From: Alexandr Mikhailov Date: Thu, 17 Dec 2015 23:25:53 +0300 Subject: [PATCH 021/168] Pep8 correction --- i3pystatus/zabbix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/i3pystatus/zabbix.py b/i3pystatus/zabbix.py index 8532fc5..3bd81fb 100644 --- a/i3pystatus/zabbix.py +++ b/i3pystatus/zabbix.py @@ -44,12 +44,12 @@ class Zabbix(IntervalModule): cdict = {} for i in range(0, 6): alerts[i] = alerts_list.count(str(i)) - cdict["a%s_count" % i]=alerts[i] + cdict["a%s_count" % i] = alerts[i] if alerts[i] == 0: cdict["color%s" % i] = "#FFFFFF" else: cdict["color%s" % i] = alerts_color[i] - + cdict["default"] = "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts) cdict["total"] = sum(alerts) color = alerts_color[max(alerts)] From a09bf880077a2c7a5c6d3479d9af79807e677a4c Mon Sep 17 00:00:00 2001 From: Alexandr Mikhailov Date: Fri, 18 Dec 2015 00:46:10 +0300 Subject: [PATCH 022/168] Another PEP8 compatibility fix --- docs/conf.py | 3 ++- i3pystatus/zabbix.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 38a49b2..c60eff5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,8 @@ MOCK_MODULES = [ "bs4", "dota2py", "novaclient.v2", - "speedtest_cli" + "speedtest_cli", + "pyzabbix" ] for mod_name in MOCK_MODULES: diff --git a/i3pystatus/zabbix.py b/i3pystatus/zabbix.py index 3bd81fb..36db01f 100644 --- a/i3pystatus/zabbix.py +++ b/i3pystatus/zabbix.py @@ -6,14 +6,16 @@ class Zabbix(IntervalModule): """ Zabbix alerts watcher + Requires: pyzabbix + .. rubric:: Available formatters + * {default} - Full output count alerts like total:a5/a4/a3/a2/a1/a0 * {total} - Total count of alerts * {aX_count} - Count alerts of X severity * {colorX} - Predicted color for X severity. It can be used with Pango markup hint for different colours at each severity with - - Requires pyzabbix """ + settings = ( ("zabbix_server", "Zabbix Server URL"), ("zabbix_user", "Zabbix API User"), @@ -21,6 +23,7 @@ class Zabbix(IntervalModule): ("interval", "Update interval"), "format" ) + required = ("zabbix_server", "zabbix_user", "zabbix_password") interval = 60 format = "{default}" From 3d22043881a4115301d58b310fe1ff0c633fe7e0 Mon Sep 17 00:00:00 2001 From: Alexandr Mikhailov Date: Fri, 18 Dec 2015 01:20:49 +0300 Subject: [PATCH 023/168] Fixed global color with max severity alert --- i3pystatus/zabbix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/zabbix.py b/i3pystatus/zabbix.py index 36db01f..5e4d2ca 100644 --- a/i3pystatus/zabbix.py +++ b/i3pystatus/zabbix.py @@ -55,7 +55,7 @@ class Zabbix(IntervalModule): cdict["default"] = "{0}:{a[5]}/{a[4]}/{a[3]}/{a[2]}/{a[1]}/{a[0]}".format(sum(alerts), a=alerts) cdict["total"] = sum(alerts) - color = alerts_color[max(alerts)] + color = alerts_color[max(map(int, alerts_list))] result = self.format.format(**cdict) except Exception as e: From 87c01278f7d6219d14c29f2d2231b280ca77619b Mon Sep 17 00:00:00 2001 From: Nuno Cardoso Date: Sun, 8 Nov 2015 15:12:59 +0000 Subject: [PATCH 024/168] Added double click support --- i3pystatus/core/modules.py | 110 +++++++++++++++++++++++++----------- i3pystatus/core/util.py | 59 +++++++++++++++++++ tests/test_module_clicks.py | 88 +++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 33 deletions(-) create mode 100644 tests/test_module_clicks.py diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index a0e1b56..b2ec722 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -1,6 +1,7 @@ from i3pystatus.core.settings import SettingsBase from i3pystatus.core.threading import Manager -from i3pystatus.core.util import convert_position +from i3pystatus.core.util import (convert_position, + MultiClickHandler) from i3pystatus.core.command import execute from i3pystatus.core.command import run_through_shell @@ -14,6 +15,11 @@ class Module(SettingsBase): ('on_rightclick', "Callback called on right click (see :ref:`callbacks`)"), ('on_upscroll', "Callback called on scrolling up (see :ref:`callbacks`)"), ('on_downscroll', "Callback called on scrolling down (see :ref:`callbacks`)"), + ('on_doubleleftclick', "Callback called on double left click (see :ref:`callbacks`)"), + ('on_doublerightclick', "Callback called on double right click (see :ref:`callbacks`)"), + ('on_doubleupscroll', "Callback called on double scroll up (see :ref:`callbacks`)"), + ('on_doubledownscroll', "Callback called on double scroll down (see :ref:`callbacks`)"), + ('multi_click_timeout', "Time (in seconds) before a single click is executed."), ('hints', "Additional output blocks for module output (see :ref:`hints`)"), ) @@ -21,11 +27,23 @@ class Module(SettingsBase): on_rightclick = None on_upscroll = None on_downscroll = None + on_doubleleftclick = None + on_doublerightclick = None + on_doubleupscroll = None + on_doubledownscroll = None + + multi_click_timeout = 0.25 hints = {"markup": "none"} + def __init__(self, *args, **kwargs): + super(Module, self).__init__(*args, **kwargs) + self.__multi_click = MultiClickHandler(self.__button_callback_handler, + self.multi_click_timeout) + def registered(self, status_handler): """Called when this module is registered with a status handler""" + self.__status_handler = status_handler def inject(self, json): if self.output: @@ -46,6 +64,39 @@ class Module(SettingsBase): def run(self): pass + def __log_button_event(self, button, cb, args, action): + msg = "{}: button={}, cb='{}', args={}, type='{}'".format( + self.__name__, button, cb, args, action) + self.logger.debug(msg) + + def __button_callback_handler(self, button, cb): + if not cb: + self.__log_button_event(button, None, None, + "No callback attached") + return False + + if isinstance(cb, list): + cb, args = (cb[0], cb[1:]) + else: + args = [] + + if callable(cb): + self.__log_button_event(button, cb, args, "Python callback") + cb(self, *args) + elif hasattr(self, cb): + if cb is not "run": + self.__log_button_event(button, cb, args, "Member callback") + getattr(self, cb)(*args) + else: + self.__log_event(button, cb, args, "External command") + execute(cb, detach=True) + + # Notify status handler + try: + self.__status_handler.io.async_refresh() + except: + pass + def on_click(self, button): """ Maps a click event with its associated callback. @@ -83,46 +134,38 @@ class Module(SettingsBase): """ - def log_event(name, button, cb, args, action): - msg = "{}: button={}, cb='{}', args={}, type='{}'".format( - name, button, cb, args, action) - self.logger.debug(msg) - - def split_callback_and_args(cb): - if isinstance(cb, list): - return cb[0], cb[1:] - else: - return cb, [] - - cb = None if button == 1: # Left mouse button - cb = self.on_leftclick + action = 'leftclick' elif button == 3: # Right mouse button - cb = self.on_rightclick + action = 'rightclick' elif button == 4: # mouse wheel up - cb = self.on_upscroll + action = 'upscroll' elif button == 5: # mouse wheel down - cb = self.on_downscroll + action = 'downscroll' else: - log_event(self.__name__, button, None, None, "Unhandled button") + self.__log_button_event(button, None, None, "Unhandled button") return False - if not cb: - log_event(self.__name__, button, None, None, "No callback attached") - return False - else: - cb, args = split_callback_and_args(cb) + m_click = self.__multi_click + + with m_click.lock: + double = m_click.check_double(button) + double_action = 'double%s' % action + + if double: + action = double_action + + # Get callback function + cb = getattr(self, 'on_%s' % action, None) + + has_double_handler = getattr(self, 'on_%s' % double_action, None) is not None + delay_execution = (not double and has_double_handler) + + if delay_execution: + m_click.set_timer(button, cb) + else: + self.__button_callback_handler(button, cb) - if callable(cb): - log_event(self.__name__, button, cb, args, "Python callback") - cb(self, *args) - elif hasattr(self, cb): - if cb is not "run": - log_event(self.__name__, button, cb, args, "Member callback") - getattr(self, cb)(*args) - else: - log_event(self.__name__, button, cb, args, "External command") - execute(cb, detach=True) return True def move(self, position): @@ -162,6 +205,7 @@ class IntervalModule(Module): managers = {} def registered(self, status_handler): + super(IntervalModule, self).registered(status_handler) if self.interval in IntervalModule.managers: IntervalModule.managers[self.interval].append(self) else: diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index 48774c3..b1d0e2d 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -4,6 +4,8 @@ import re import socket import string +from threading import Timer, RLock + def lchop(string, prefix): """Removes a prefix from string @@ -499,3 +501,60 @@ def user_open(url_or_command): else: import subprocess subprocess.Popen(url_or_command, shell=True) + + +class MultiClickHandler(object): + def __init__(self, callback_handler, timeout): + self.callback_handler = callback_handler + self.timeout = timeout + + self.lock = RLock() + + self._timer_id = 0 + self.timer = None + self.button = None + self.cb = None + + def set_timer(self, button, cb): + with self.lock: + self.clear_timer() + + self.timer = Timer(self.timeout, + self._timer_function, + args=[self._timer_id]) + self.button = button + self.cb = cb + + self.timer.start() + + def clear_timer(self): + with self.lock: + if self.timer is None: + return + + self._timer_id += 1 # Invalidate existent timer + + self.timer.cancel() # Cancel the existent timer + + self.timer = None + self.button = None + self.cb = None + + def _timer_function(self, timer_id): + with self.lock: + if self._timer_id != timer_id: + return + self.callback_handler(self.button, self.cb) + self.clear_timer() + + def check_double(self, button): + if self.timer is None: + return False + + ret = True + if button != self.button: + self.callback_handler(self.button, self.cb) + ret = False + + self.clear_timer() + return ret diff --git a/tests/test_module_clicks.py b/tests/test_module_clicks.py new file mode 100644 index 0000000..458fa80 --- /dev/null +++ b/tests/test_module_clicks.py @@ -0,0 +1,88 @@ +import pytest + +from i3pystatus import IntervalModule +import time + +left_click = 1 +right_click = 3 +scroll_up = 4 +scroll_down = 5 + + +@pytest.mark.parametrize("events, expected", [ + # Fast click + (((0, left_click),), + 'no action'), + + # Slow click + (((0.4, left_click),), + 'leftclick'), + + # Slow double click + (((0.4, left_click), + (0.4, left_click),), + 'leftclick'), + + # Fast double click + (((0.2, left_click), + (0, left_click),), + 'doubleleftclick'), + + # Fast double click + Slow click + (((0.2, left_click), + (0, left_click), + (0.3, left_click),), + 'leftclick'), + + # Alternate double click + (((0.2, left_click), + (0, right_click),), + 'leftclick'), + + # Slow click, no callback + (((0.4, right_click),), + 'no action'), + + # Fast double click + (((0.2, right_click), + (0, right_click),), + 'doublerightclick'), + + # Fast double click + (((0, scroll_down), + (0, scroll_down),), + 'downscroll'), + + # Slow click + (((0.4, scroll_up),), + 'upscroll'), + + # Fast double click + (((0, scroll_up), + (0, scroll_up),), + 'doubleupscroll'), +]) +def test_clicks(events, expected): + class TestClicks(IntervalModule): + def set_action(self, action): + self._action = action + + on_leftclick = [set_action, "leftclick"] + on_doubleleftclick = [set_action, "doubleleftclick"] + + # on_rightclick = [set_action, "rightclick"] + on_doublerightclick = [set_action, "doublerightclick"] + + on_upscroll = [set_action, "upscroll"] + on_doubleupscroll = [set_action, "doubleupscroll"] + + on_downscroll = [set_action, "downscroll"] + # on_doubledownscroll = [set_action, "doubledownscroll"] + + _action = 'no action' + + m = TestClicks() + for sl, ev in events: + m.on_click(ev) + time.sleep(sl) + assert m._action == expected From 1c4f94130419cd9cb73b80c2ad51f12ce6516b02 Mon Sep 17 00:00:00 2001 From: Douglas Eddie Date: Thu, 19 Nov 2015 23:28:30 +0000 Subject: [PATCH 025/168] Improved handling of (un)mounted drives/partitions. richese provided base for new options. --- i3pystatus/disk.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/i3pystatus/disk.py b/i3pystatus/disk.py index e2b37b1..8623da1 100644 --- a/i3pystatus/disk.py +++ b/i3pystatus/disk.py @@ -13,25 +13,43 @@ class Disk(IntervalModule): """ settings = ( - "format", "path", + "format", + "path", ("divisor", "divide all byte values by this value, default is 1024**3 (gigabyte)"), ("display_limit", "if more space is available than this limit the module is hidden"), ("critical_limit", "critical space limit (see critical_color)"), ("critical_color", "the critical color"), ("color", "the common color"), ("round_size", "precision, None for INT"), + ("mounted_only", "display only if path is a valid mountpoint"), + "format_not_mounted", + "color_not_mounted" ) required = ("path",) color = "#FFFFFF" + color_not_mounted = "#FFFFFF" critical_color = "#FF0000" format = "{free}/{avail}" + format_not_mounted = None divisor = 1024 ** 3 display_limit = float('Inf') critical_limit = 0 round_size = 2 + mounted_only = False def run(self): - stat = os.statvfs(self.path) + try: + stat = os.statvfs(self.path) + except Exception: + if self.mounted_only: + self.output = {} + else: + self.output = {} if not self.format_not_mounted else { + "full_text": self.format_not_mounted, + "color": self.color_not_mounted, + } + return + available = (stat.f_bsize * stat.f_bavail) / self.divisor if available > self.display_limit: From 731749f0e3fd535c633ed373ab7e17b86e1e8000 Mon Sep 17 00:00:00 2001 From: Douglas Eddie Date: Tue, 24 Nov 2015 22:51:01 +0000 Subject: [PATCH 026/168] Added check for ismount() and empty directories. Previously the free space of the underlying filesystem would be reported if the path provided was a directory but not a valid mountpoint. This adds a check to first confirm whether a directory is a mountpoint using os.path.ismount(), and if not, then runs an os.listdir() to count the files; empty directories are considered not mounted. This functionality allows for usage on setups with NFS and will not report free space of underlying filesystem in cases with local mountpoints as path. --- i3pystatus/disk.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/i3pystatus/disk.py b/i3pystatus/disk.py index 8623da1..5b131ac 100644 --- a/i3pystatus/disk.py +++ b/i3pystatus/disk.py @@ -37,17 +37,25 @@ class Disk(IntervalModule): round_size = 2 mounted_only = False + def not_mounted(self): + if self.mounted_only: + self.output = {} + else: + self.output = {} if not self.format_not_mounted else { + "full_text": self.format_not_mounted, + "color": self.color_not_mounted, + } + def run(self): + if os.path.isdir(self.path) and not os.path.ismount(self.path): + if len(os.listdir(self.path)) == 0: + self.not_mounted() + return + try: stat = os.statvfs(self.path) except Exception: - if self.mounted_only: - self.output = {} - else: - self.output = {} if not self.format_not_mounted else { - "full_text": self.format_not_mounted, - "color": self.color_not_mounted, - } + self.not_mounted() return available = (stat.f_bsize * stat.f_bavail) / self.divisor From ef58c5a6fa530c9343ca64748a2f35f5f39c7213 Mon Sep 17 00:00:00 2001 From: Lorian Coltof Date: Tue, 22 Dec 2015 11:38:03 +0100 Subject: [PATCH 027/168] Added the bar_design option in the format of the battery module --- i3pystatus/battery.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/i3pystatus/battery.py b/i3pystatus/battery.py index f181a41..e7ad98d 100644 --- a/i3pystatus/battery.py +++ b/i3pystatus/battery.py @@ -131,7 +131,8 @@ class BatteryChecker(IntervalModule): * `{status}` * `{no_of_batteries}` — The number of batteries included * `{battery_ident}` — the same as the setting - * `{bar}` —bar displaying the percentage graphically + * `{bar}` —bar displaying the relative percentage graphically + * `{bar_design}` —bar displaying the absolute percentage graphically """ settings = ( @@ -277,6 +278,7 @@ class BatteryChecker(IntervalModule): "consumption": self.consumption(batteries), "remaining": TimeWrapper(0, "%E%h:%M"), "bar": make_bar(self.percentage(batteries)), + "bar_design": make_bar(self.percentage(batteries, design=True)), } status = self.battery_status(batteries) From a356e42c12e55bedb859be987276edd377d1b76d Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 5 Jan 2016 12:51:39 +0100 Subject: [PATCH 028/168] Fix pulseaudio creating zombies (#293) --- i3pystatus/pulseaudio/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/i3pystatus/pulseaudio/__init__.py b/i3pystatus/pulseaudio/__init__.py index 064bf5f..dc1e920 100644 --- a/i3pystatus/pulseaudio/__init__.py +++ b/i3pystatus/pulseaudio/__init__.py @@ -172,14 +172,14 @@ class PulseAudio(Module, ColorRangeModule): command += 'unmute' else: command += 'mute' - subprocess.Popen(command.split()) + subprocess.run(command.split()) def increase_volume(self): if self.has_amixer: command = "amixer -q -D pulse sset Master %s%%+" % self.step - subprocess.Popen(command.split()) + subprocess.run(command.split()) def decrease_volume(self): if self.has_amixer: command = "amixer -q -D pulse sset Master %s%%-" % self.step - subprocess.Popen(command.split()) + subprocess.run(command.split()) From 33c4ae2d9f405aa30ec5875edbdcd73dec2418ce Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 5 Jan 2016 13:39:43 +0100 Subject: [PATCH 029/168] Updated changelog 33e6d38..HEAD --- docs/changelog.rst | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0e79a4d..6dd7faa 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,16 @@ master branch - :py:mod:`.moon`: Display moon phase - :py:mod:`.online`: Display internet connectivity - :py:mod:`.xkblayout`: View and change keyboard layout + - :py:mod:`.plexstatus`: View status of Plex Media Server + - :py:mod:`.iinet`: View iiNet internet usage + - :py:mod:`.gpu_mem`, :py:mod:`.gpu_temp`: View memory and temperature stats of nVidia cards + - :py:mod:`.solaar`: Show battery status of Solaar / Logitech Unifying devices + - :py:mod:`.zabbix`: Alerts watcher for the Zabbix enterprise network monitor * Applications started from click events don't block other click events now +* Fixed crash with desktop notifications when python-gobject is installed, but no notification daemon is running +* Log file name is now an option (``logfile`` of :py:class:`.Status`) +* Server used for checking internet connectivity is now an option (``internet_check`` of :py:class:`.Status`) +* Added double click support for click events * :py:mod:`.dota2wins`: Now accepts usernames in place of a Steam ID * dota2wins: Changed win percentage to be a float * :py:mod:`.uptime`: Added days, hours, minutes, secs formatters @@ -26,7 +35,20 @@ master branch * mpd: Fixed a bug where an active playlist would be assumed, leading to no output * :py:mod:`.updates`: Added yaourt backend * :py:mod:`.reddit`: Added link\_karma and comment\_karma formatters - +* :py:mod:`.vpn`: Configurable up/down symbols +* :py:mod:`.disk`: Improved handling of unmounted drives. Previously + the free space of the underlying filesystem would be reported if the + path provided was a directory but not a valid mountpoint. This adds + a check to first confirm whether a directory is a mountpoint using + os.path.ismount(), and if not, then runs an os.listdir() to count + the files; empty directories are considered not mounted. This + functionality allows for usage on setups with NFS and will not + report free space of underlying filesystem in cases with local + mountpoints as path. +* :py:mod:`.battery`: Added ``bar_design`` formatter +* :py:mod:`.alsa`: Implemented optional volume display/setting as in AlsaMixer +* :py:mod:`.pulseaudio`: Fixed bug that created zombies on a click event + 3.33 (2015-06-23) +++++++++++++++++ From d020b3499bb83984b212bc87e646469768c6119e Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 5 Jan 2016 13:44:35 +0100 Subject: [PATCH 030/168] Speed up execution of test_module_clicks Ideally we'd monkey patch Timer et al so that no dependence on the host clock exists, which sooner or later might lead to false failures or positives. --- tests/test_module_clicks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_module_clicks.py b/tests/test_module_clicks.py index 458fa80..c02be2d 100644 --- a/tests/test_module_clicks.py +++ b/tests/test_module_clicks.py @@ -81,8 +81,11 @@ def test_clicks(events, expected): _action = 'no action' + # Divide all times by 10 to make the test run faster + TestClicks.multi_click_timeout /= 10 + m = TestClicks() for sl, ev in events: m.on_click(ev) - time.sleep(sl) + time.sleep(sl / 10) assert m._action == expected From c591356fc0e6a833b2ac856e3667a082a000d6b2 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 5 Jan 2016 16:28:15 +0100 Subject: [PATCH 031/168] Update changelog.rst --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6dd7faa..661973c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,7 +35,7 @@ master branch * mpd: Fixed a bug where an active playlist would be assumed, leading to no output * :py:mod:`.updates`: Added yaourt backend * :py:mod:`.reddit`: Added link\_karma and comment\_karma formatters -* :py:mod:`.vpn`: Configurable up/down symbols +* :py:mod:`.openvpn`: Configurable up/down symbols * :py:mod:`.disk`: Improved handling of unmounted drives. Previously the free space of the underlying filesystem would be reported if the path provided was a directory but not a valid mountpoint. This adds From 084269cf0dc06f6eb6d15329f9788397d3ee2911 Mon Sep 17 00:00:00 2001 From: David Bronke Date: Fri, 8 Jan 2016 02:12:32 +0000 Subject: [PATCH 032/168] Fix typo in MoonPhase.status. This fixes the default shown in the docs, so copy/pasting it into your config won't end up giving you a broken `Waning Crescent` mapping. --- i3pystatus/moon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/moon.py b/i3pystatus/moon.py index ad22f21..932a7d3 100644 --- a/i3pystatus/moon.py +++ b/i3pystatus/moon.py @@ -46,7 +46,7 @@ class MoonPhase(IntervalModule): "Full Moon": "FM", "Waning Gibbous": "WanGib", "Last Quarter": "LQ", - "Waning Cresent": "WanCres", + "Waning Crescent": "WanCres", } color = { From b132c4d842a101a0a043fef5bcf31d8e9d6cf078 Mon Sep 17 00:00:00 2001 From: Maxi Padulo Date: Sat, 9 Jan 2016 17:39:46 +0100 Subject: [PATCH 033/168] Fix backlight not increasing brightness --- i3pystatus/backlight.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/i3pystatus/backlight.py b/i3pystatus/backlight.py index dd50b48..6622b5c 100644 --- a/i3pystatus/backlight.py +++ b/i3pystatus/backlight.py @@ -41,12 +41,24 @@ class Backlight(File): def init(self): self.base_path = self.base_path.format(backlight=self.backlight) self.has_xbacklight = shutil.which("xbacklight") is not None + + # xbacklight expects a percentage as parameter. Calculate the percentage + # for one step (if smaller xbacklight doesn't increases the brightness) + if self.has_xbacklight: + parsefunc = self.components['max_brightness'][0] + maxbfile = self.components['max_brightness'][1] + with open(self.base_path + maxbfile, "r") as f: + max_steps = parsefunc(f.read().strip()) + if max_steps: + self.step_size = 100 // max_steps + 1 + else: + self.step_size = 5 # default? super().init() def lighter(self): if self.has_xbacklight: - run_through_shell(["xbacklight", "+5"]) + run_through_shell(["xbacklight", "-inc", str(self.step_size)]) def darker(self): if self.has_xbacklight: - run_through_shell(["xbacklight", "-5"]) + run_through_shell(["xbacklight", "-dec", str(self.step_size)]) From 2045c13dc47a5e331933dda9e62a9cf70845a194 Mon Sep 17 00:00:00 2001 From: Maxi Padulo Date: Sat, 9 Jan 2016 17:40:26 +0100 Subject: [PATCH 034/168] Updated changelog --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 661973c..7afc382 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -48,6 +48,7 @@ master branch * :py:mod:`.battery`: Added ``bar_design`` formatter * :py:mod:`.alsa`: Implemented optional volume display/setting as in AlsaMixer * :py:mod:`.pulseaudio`: Fixed bug that created zombies on a click event +* :py:mod:`.backlight`: Fixed bug preventing brightness increase 3.33 (2015-06-23) +++++++++++++++++ From de8b03f4bdf2258163dabe1a8cb015d9f0927d52 Mon Sep 17 00:00:00 2001 From: Maxi Padulo Date: Tue, 12 Jan 2016 13:01:10 +0100 Subject: [PATCH 035/168] Fix handler not executing external cmd (#301) Wrong name on function call was raising AttributeError exception disrutping the code execution. --- i3pystatus/core/modules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index b2ec722..f38cc6a 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -88,7 +88,7 @@ class Module(SettingsBase): self.__log_button_event(button, cb, args, "Member callback") getattr(self, cb)(*args) else: - self.__log_event(button, cb, args, "External command") + self.__log_button_event(button, cb, args, "External command") execute(cb, detach=True) # Notify status handler From f12d1f9d48c90b012306237ae067c87f29f3228a Mon Sep 17 00:00:00 2001 From: Maxi Padulo Date: Tue, 12 Jan 2016 13:01:10 +0100 Subject: [PATCH 036/168] Fix handler not executing external cmd (#301) Wrong name on function call was raising AttributeError exception disrutping the code execution. --- i3pystatus/core/modules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index b2ec722..f38cc6a 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -88,7 +88,7 @@ class Module(SettingsBase): self.__log_button_event(button, cb, args, "Member callback") getattr(self, cb)(*args) else: - self.__log_event(button, cb, args, "External command") + self.__log_button_event(button, cb, args, "External command") execute(cb, detach=True) # Notify status handler From 807248a38d19a98e5d5247018c6a51f541dadd6b Mon Sep 17 00:00:00 2001 From: Jan Oliver Oelerich Date: Tue, 19 Jan 2016 08:58:12 +0100 Subject: [PATCH 037/168] added .idea to gitignore, which contains PyCharm settings for the project. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 89a63b8..a14df7b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/* *~ .i3pystatus-* ci-build +.idea/ From 6925770e4f0b8bf2f7376287b6c7231c89998db6 Mon Sep 17 00:00:00 2001 From: Jan Oliver Oelerich Date: Tue, 19 Jan 2016 09:38:47 +0100 Subject: [PATCH 038/168] Added module for tracking the status of Batch computing jobs on a cluster running the Sun Grid Engine (SGE) --- i3pystatus/sge.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 i3pystatus/sge.py diff --git a/i3pystatus/sge.py b/i3pystatus/sge.py new file mode 100644 index 0000000..5bb35cf --- /dev/null +++ b/i3pystatus/sge.py @@ -0,0 +1,53 @@ +import subprocess + +from lxml import etree + +from i3pystatus import IntervalModule + + +class SGETracker(IntervalModule): + """ + Used to display status of Batch computing jobs on a cluster running Sun Grid Engine. + The data is collected via ssh, so a valid ssh address must be specified. + + Requires lxml. + """ + + interval = 60 + + settings = ( + ("ssh", "The SSH connection address. Can be user@host or user:password@host or user@host -p PORT etc."), + 'color', 'format' + ) + required = ("ssh",) + + format = "SGE qw: {queued} / r: {running} / Eqw: {error}" + on_leftclick = None + color = "#ffffff" + + def parse_qstat_xml(self): + xml = subprocess.check_output("ssh {0} \"qstat -f -xml\"".format(self.ssh), stderr=subprocess.STDOUT, + shell=True) + root = etree.fromstring(xml) + jobs = root.xpath('//job_info/job_info/job_list') + + job_dict = {'qw': 0, 'Eqw': 0, 'r': 0} + + for j in jobs: + job_dict[j.find("state").text] += 1 + + return job_dict + + def run(self): + jobs = self.parse_qstat_xml() + + fdict = { + "queued": jobs['qw'], + "error": jobs['Eqw'], + "running": jobs['r'] + } + + self.output = { + "full_text": self.format.format(**fdict).strip(), + "color": self.color + } From f32c8e06506727f1fe2182376949e9ba8d333ace Mon Sep 17 00:00:00 2001 From: Jan Oliver Oelerich Date: Tue, 19 Jan 2016 10:17:50 +0100 Subject: [PATCH 039/168] reverted .gitignore and fixed indents of sge.py --- .gitignore | 1 - i3pystatus/sge.py | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a14df7b..89a63b8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ dist/* *~ .i3pystatus-* ci-build -.idea/ diff --git a/i3pystatus/sge.py b/i3pystatus/sge.py index 5bb35cf..578a6bd 100644 --- a/i3pystatus/sge.py +++ b/i3pystatus/sge.py @@ -26,8 +26,9 @@ class SGETracker(IntervalModule): color = "#ffffff" def parse_qstat_xml(self): - xml = subprocess.check_output("ssh {0} \"qstat -f -xml\"".format(self.ssh), stderr=subprocess.STDOUT, - shell=True) + xml = subprocess.check_output("ssh {0} \"qstat -f -xml\"".format(self.ssh), + stderr=subprocess.STDOUT, + shell=True) root = etree.fromstring(xml) jobs = root.xpath('//job_info/job_info/job_list') From 0fce823952b9f6e165346fd35942b33f38abbea1 Mon Sep 17 00:00:00 2001 From: Jan Oliver Oelerich Date: Wed, 20 Jan 2016 09:09:32 +0100 Subject: [PATCH 040/168] fixed a bug of running jobs not being displayed. --- i3pystatus/sge.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/i3pystatus/sge.py b/i3pystatus/sge.py index 578a6bd..989558f 100644 --- a/i3pystatus/sge.py +++ b/i3pystatus/sge.py @@ -26,15 +26,16 @@ class SGETracker(IntervalModule): color = "#ffffff" def parse_qstat_xml(self): - xml = subprocess.check_output("ssh {0} \"qstat -f -xml\"".format(self.ssh), + xml = subprocess.check_output("ssh {0} \"qstat -xml\"".format(self.ssh), stderr=subprocess.STDOUT, shell=True) root = etree.fromstring(xml) - jobs = root.xpath('//job_info/job_info/job_list') - job_dict = {'qw': 0, 'Eqw': 0, 'r': 0} + + for j in root.xpath('//job_info/job_info/job_list'): + job_dict[j.find("state").text] += 1 - for j in jobs: + for j in root.xpath('//job_info/queue_info/job_list'): job_dict[j.find("state").text] += 1 return job_dict From 691453950ba0322c86da3e218328b19ab3a050f5 Mon Sep 17 00:00:00 2001 From: Jan Oliver Oelerich Date: Wed, 20 Jan 2016 09:11:30 +0100 Subject: [PATCH 041/168] fixed indentation --- i3pystatus/sge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/sge.py b/i3pystatus/sge.py index 989558f..5ffa3c4 100644 --- a/i3pystatus/sge.py +++ b/i3pystatus/sge.py @@ -31,7 +31,7 @@ class SGETracker(IntervalModule): shell=True) root = etree.fromstring(xml) job_dict = {'qw': 0, 'Eqw': 0, 'r': 0} - + for j in root.xpath('//job_info/job_info/job_list'): job_dict[j.find("state").text] += 1 From 45377b12c39e09325b33fdd9f2f7db691d15b214 Mon Sep 17 00:00:00 2001 From: Philipp Edelmann Date: Fri, 22 Jan 2016 23:24:18 +0900 Subject: [PATCH 042/168] add support for MPD connects via AF_UNIX sockets A port number equal to 0 now lets the mpd module interpret the host as a path to a socket. --- i3pystatus/mpd.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 3d309e9..65f2405 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -31,7 +31,7 @@ class MPD(IntervalModule): settings = ( ("host"), - ("port", "MPD port"), + ("port", "MPD port. If set to 0, host will we interpreted as a Unix socket."), ("format", "formatp string"), ("status", "Dictionary mapping pause, play and stop to output"), ("color", "The color of the text"), @@ -64,7 +64,11 @@ class MPD(IntervalModule): try: sock.send((command + "\n").encode("utf-8")) except Exception as e: - self.s = socket.create_connection((self.host, self.port)) + if self.port != 0: + self.s = socket.create_connection((self.host, self.port)) + else: + self.s = socket.socket(family=socket.AF_UNIX) + self.s.connect(self.host) sock = self.s sock.recv(8192) sock.send((command + "\n").encode("utf-8")) From 0bf0fd3591f7f45436adc667bf08ed2766d0c850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Mand=C3=A1k?= Date: Wed, 27 Jan 2016 11:59:26 +0100 Subject: [PATCH 043/168] Added `Timer` module. --- i3pystatus/timer.py | 188 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 i3pystatus/timer.py diff --git a/i3pystatus/timer.py b/i3pystatus/timer.py new file mode 100644 index 0000000..5923035 --- /dev/null +++ b/i3pystatus/timer.py @@ -0,0 +1,188 @@ +from enum import Enum +import time + +from i3pystatus import IntervalModule +from i3pystatus.core.command import execute +from i3pystatus.core.util import TimeWrapper + + +class TimerState(Enum): + stopped = 0 + running = 1 + overflow = 2 + + +class Timer(IntervalModule): + """ + Timer module to remind yourself that there probably is something else you + should be doing right now. + + Main features include: + + - Set custom time interval with click events. + - Different output formats triggered when remaining time is less than `x` + seconds. + - Execute custom python function or external command when timer overflows + (or reaches zero depending on how you look at it). + + .. note:: + This module requires `enum` module which was introduced in python 3.4. + If you have older version of python you can get the backported package + ``enum34`` from PyPI. + + .. rubric:: Available formatters + + Time formatters are available to show the remaining time. + These include ``%h``, ``%m``, ``%s``, ``%H``, ``%M``, ``%S``. + See :py:class:`.TimeWrapper` for detailed description. + + The ``format_custom`` setting allows you to display different formats when + certain amount of seconds is remaining. + This setting accepts list of tuples which contain time in seconds, + format string and color string each. + See the default settings for an example: + + - ``(0, "+%M:%S", "#ffffff")`` - Use this format after overflow. White text + with red background set by the urgent flag. + - ``(60, "-%M:%S", "#ffa500")`` - Change color to orange in last minute. + - ``(3600, "-%M:%S", "#00ff00")`` - Hide hour digits when remaining time is + less than one hour. + + Only first matching rule is applied (if any). + + .. rubric:: Callbacks + + Module contains three mouse event callback methods: + + - :py:meth:`.start` - Default: Left click starts (or adds) 5 minute + countdown. + - :py:meth:`.increase` - Default: Upscroll/downscroll increase/decrease time + by 1 minute. + - :py:meth:`.reset` - Default: Right click resets timer. + + Two new event settings were added: + + - ``on_overflow`` - Executed when remaining time reaches zero. + - ``on_reset`` - Executed when timer is reset but only if overflow occured. + + These settings accept either a python callable object or a string with shell + command. + Python callbacks should be non-blocking and without any arguments. + + Here is an example that plays a short sound file in 'loop' every 60 seconds + until timer is reset. + (``play`` is part of ``SoX`` - the Swiss Army knife of audio manipulation) + + :: + + on_overflow = "play -q /path/to/sound.mp3 pad 0 60 repeat -" + on_reset = "pkill -SIGTERM -f 'play -q /path/to/sound.mp3 pad 0 60 repeat -'" + + """ + + interval = 1 + + on_leftclick = ["start", 300] + on_rightclick = "reset" + on_upscroll = ["increase", 60] + on_downscroll = ["increase", -60] + + settings = ( + ("format", "Default format that is showed if no ``format_custom`` " + "rules are matched."), + ("format_stopped", "Format showed when timer is inactive."), + "color", + "color_stopped", + "format_custom", + ("overflow_urgent", "Set urgent flag on overflow."), + "on_overflow", + "on_reset", + ) + + format = '-%h:%M:%S' + format_stopped = "T" + color = "#00ff00" + color_stopped = "#ffffff" + format_custom = [ + (0, "+%M:%S", "#ffffff"), + (60, "-%M:%S", "#ffa500"), + (3600, "-%M:%S", "#00ff00"), + ] + overflow_urgent = True + on_overflow = None + on_reset = None + + def init(self): + self.compare = 0 + self.state = TimerState.stopped + if not self.format_custom: + self.format_custom = [] + + def run(self): + if self.state is not TimerState.stopped: + diff = self.compare - time.time() + + if diff < 0 and self.state is TimerState.running: + self.state = TimerState.overflow + if self.on_overflow: + if callable(self.on_overflow): + self.on_overflow() + else: + execute(self.on_overflow) + + fmt = self.format + color = self.color + for rule in self.format_custom: + if diff < rule[0]: + fmt = rule[1] + color = rule[2] + break + urgent = self.overflow_urgent and self.state is TimerState.overflow + + self.output = { + "full_text": format(TimeWrapper(abs(diff), fmt)), + "color": color, + "urgent": urgent, + } + else: + self.output = { + "full_text": self.format_stopped, + "color": self.color_stopped, + } + + def start(self, seconds=300): + """ + Starts timer. + If timer is already running it will increase remaining time instead. + + :param int seconds: Initial time. + """ + if self.state is TimerState.stopped: + self.compare = time.time() + abs(seconds) + self.state = TimerState.running + elif self.state is TimerState.running: + self.increase(seconds) + + def increase(self, seconds): + """ + Change remainig time value. + + :param int seconds: Seconds to add. Negative value substracts from + remaining time. + """ + if self.state is TimerState.running: + new_compare = self.compare + seconds + if new_compare > time.time(): + self.compare = new_compare + + def reset(self): + """ + Stop timer and execute ``on_reset`` if overflow occured. + """ + if self.state is not TimerState.stopped: + if self.on_reset and self.state is TimerState.overflow: + if callable(self.on_reset): + self.on_reset() + else: + execute(self.on_reset) + self.state = TimerState.stopped From 1c6b42d9e59747bdeb881758b344773e8f61bb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Mand=C3=A1k?= Date: Wed, 27 Jan 2016 14:20:09 +0100 Subject: [PATCH 044/168] Removed dependency on `enum` module. --- i3pystatus/timer.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/i3pystatus/timer.py b/i3pystatus/timer.py index 5923035..4013ac2 100644 --- a/i3pystatus/timer.py +++ b/i3pystatus/timer.py @@ -1,4 +1,3 @@ -from enum import Enum import time from i3pystatus import IntervalModule @@ -6,7 +5,7 @@ from i3pystatus.core.command import execute from i3pystatus.core.util import TimeWrapper -class TimerState(Enum): +class TimerState: stopped = 0 running = 1 overflow = 2 @@ -25,11 +24,6 @@ class Timer(IntervalModule): - Execute custom python function or external command when timer overflows (or reaches zero depending on how you look at it). - .. note:: - This module requires `enum` module which was introduced in python 3.4. - If you have older version of python you can get the backported package - ``enum34`` from PyPI. - .. rubric:: Available formatters Time formatters are available to show the remaining time. From aed169de4d43bd0a8223296328aa98d6bab1c213 Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 27 Jan 2016 19:07:59 +0100 Subject: [PATCH 045/168] Implement decided resolution of #304 - Remove self for normal callables - Retain self for methods (of course) - Add decorator to retrieve self for special callbacks that need it (Yes, the example is kinda stupid and would be unnecessary with #300) --- i3pystatus/core/modules.py | 15 ++++++++++++++- i3pystatus/core/util.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index f38cc6a..ab61442 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -1,3 +1,5 @@ +import inspect + from i3pystatus.core.settings import SettingsBase from i3pystatus.core.threading import Manager from i3pystatus.core.util import (convert_position, @@ -6,6 +8,14 @@ from i3pystatus.core.command import execute from i3pystatus.core.command import run_through_shell +def is_method_of(method, object): + """Decide whether ``method`` is contained within the MRO of ``object``.""" + for cls in inspect.getmro(object.__class__): + if method in cls.__dict__.values(): + return True + return False + + class Module(SettingsBase): output = None position = 0 @@ -80,8 +90,11 @@ class Module(SettingsBase): else: args = [] - if callable(cb): + our_method = is_method_of(cb, self) + if callable(cb) and not our_method: self.__log_button_event(button, cb, args, "Python callback") + cb(*args) + elif our_method: cb(self, *args) elif hasattr(self, cb): if cb is not "run": diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index b1d0e2d..c5169f4 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -3,7 +3,7 @@ import functools import re import socket import string - +import inspect from threading import Timer, RLock @@ -558,3 +558,32 @@ class MultiClickHandler(object): self.clear_timer() return ret + + +def get_module(function): + """Function decorator for retrieving the ``self`` argument from the stack. + + Intended for use with callbacks that need access to a modules variables, for example: + + .. code:: python + + from i3pystatus import Status + from i3pystatus.core.util import get_module + from i3pystatus.core.command import execute + status = Status(...) + # other modules etc. + @get_module + def display_ip_verbose(module): + execute('sh -c "ip addr show dev {dev} | xmessage -file -"'.format(dev=module.interface)) + status.register("network", interface="wlan1", on_leftclick=display_ip_verbose) + """ + @functools.wraps(function) + def call_wrapper(*args, **kwargs): + stack = inspect.stack() + caller_frame_info = stack[1] + self = caller_frame_info[0].f_locals["self"] + # not completly sure whether this is necessary + # see note in Python docs about stack frames + del stack + function(self, *args, **kwargs) + return call_wrapper From a7583a97862f3ab334ba0428250ce2000e4f0c18 Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 27 Jan 2016 19:31:12 +0100 Subject: [PATCH 046/168] Implement #300 --- i3pystatus/core/modules.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index ab61442..2d6d9e4 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -102,7 +102,9 @@ class Module(SettingsBase): getattr(self, cb)(*args) else: self.__log_button_event(button, cb, args, "External command") - execute(cb, detach=True) + if hasattr(self, "data"): + args = [arg.format(**self.data) for arg in args] + execute(cb + " " + " ".join(args), detach=True) # Notify status handler try: From 612b8b07eb1b5b41907652936ab80bfe761dfb92 Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 27 Jan 2016 19:53:33 +0100 Subject: [PATCH 047/168] Update modules to a7583a9 Not updated for various reasons: clock, dpms, gpu_temp, load, mail, mem_bar, modsde, net_speed, pianobar, pulseaudio, regex [no named formatters], runwatch, shell, solaar, temp, text, updates, weather, whosonlocation, xkblayout, zabbix This might break something: I can't test all these modules. If it does, file a bug / open a PR / send me a note. --- i3pystatus/alsa.py | 1 + i3pystatus/battery.py | 1 + i3pystatus/bitcoin.py | 1 + i3pystatus/cmus.py | 2 ++ i3pystatus/core/util.py | 2 +- i3pystatus/cpu_freq.py | 1 + i3pystatus/cpu_usage.py | 1 + i3pystatus/cpu_usage_bar.py | 1 + i3pystatus/cpu_usage_graph.py | 1 + i3pystatus/disk.py | 1 + i3pystatus/dota2wins.py | 1 + i3pystatus/file.py | 1 + i3pystatus/github.py | 1 + i3pystatus/gpu_mem.py | 1 + i3pystatus/iinet.py | 1 + i3pystatus/keyboard_locks.py | 10 +++++----- i3pystatus/makewatch.py | 1 + i3pystatus/mem.py | 1 + i3pystatus/moon.py | 1 + i3pystatus/mpd.py | 5 +++-- i3pystatus/network.py | 1 + i3pystatus/now_playing.py | 3 +++ i3pystatus/openstack_vms.py | 1 + i3pystatus/openvpn.py | 1 + i3pystatus/parcel.py | 1 + i3pystatus/plexstatus.py | 1 + i3pystatus/pomodoro.py | 2 +- i3pystatus/pyload.py | 1 + i3pystatus/reddit.py | 1 + i3pystatus/sge.py | 1 + i3pystatus/spotify.py | 2 ++ i3pystatus/uname.py | 1 + i3pystatus/uptime.py | 2 +- 33 files changed, 43 insertions(+), 10 deletions(-) diff --git a/i3pystatus/alsa.py b/i3pystatus/alsa.py index ba52e74..c2e67fb 100644 --- a/i3pystatus/alsa.py +++ b/i3pystatus/alsa.py @@ -92,6 +92,7 @@ class ALSA(IntervalModule): else: output_format = self.format + self.data = fdict self.output = { "full_text": output_format.format(**self.fdict), "color": self.color_muted if muted else self.color, diff --git a/i3pystatus/battery.py b/i3pystatus/battery.py index e7ad98d..93eef7d 100644 --- a/i3pystatus/battery.py +++ b/i3pystatus/battery.py @@ -313,6 +313,7 @@ class BatteryChecker(IntervalModule): fdict["status"] = self.status[fdict["status"]] + self.data = fdict self.output = { "full_text": formatp(self.format, **fdict), "instance": self.battery_ident, diff --git a/i3pystatus/bitcoin.py b/i3pystatus/bitcoin.py index 8c89c9f..a6ca236 100644 --- a/i3pystatus/bitcoin.py +++ b/i3pystatus/bitcoin.py @@ -120,6 +120,7 @@ class Bitcoin(IntervalModule): else: fdict["last_tx_type"] = "sent" + self.data = fdict self.output = { "full_text": self.format.format(**fdict), "color": color, diff --git a/i3pystatus/cmus.py b/i3pystatus/cmus.py index dd2cec4..ecfc37a 100644 --- a/i3pystatus/cmus.py +++ b/i3pystatus/cmus.py @@ -95,10 +95,12 @@ class Cmus(IntervalModule): filename = os.path.basename(fdict['file']) filebase, _ = os.path.splitext(filename) fdict['artist'], fdict['title'] = _extract_artist_title(filebase) + self.data = fdict self.output = {"full_text": formatp(self.format, **fdict), "color": self.color} else: + if hasattr(self, "data"): del self.data self.output = {"full_text": self.format_not_running, "color": self.color_not_running} diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index c5169f4..4de7671 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -112,7 +112,7 @@ class KeyConstraintDict(collections.UserDict): def __delitem__(self, key): self.seen_keys.remove(key) - del self.data[key] + if hasattr(self, "data"): del self.data[key] def __iter__(self): """Iteration will raise a MissingKeys exception unless all required keys are set diff --git a/i3pystatus/cpu_freq.py b/i3pystatus/cpu_freq.py index 7e283e6..cdc4176 100644 --- a/i3pystatus/cpu_freq.py +++ b/i3pystatus/cpu_freq.py @@ -43,6 +43,7 @@ class CpuFreq(IntervalModule): def run(self): cdict = self.createvaluesdict() + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": self.color, diff --git a/i3pystatus/cpu_usage.py b/i3pystatus/cpu_usage.py index ad1ff2f..b99b12f 100644 --- a/i3pystatus/cpu_usage.py +++ b/i3pystatus/cpu_usage.py @@ -118,6 +118,7 @@ class CpuUsage(IntervalModule): # for backward compatibility usage['usage'] = usage['usage_cpu'] + self.data = usage self.output = { "full_text": self.format.format_map(usage), "color": self.color diff --git a/i3pystatus/cpu_usage_bar.py b/i3pystatus/cpu_usage_bar.py index 2bf06e5..fe0cbf0 100644 --- a/i3pystatus/cpu_usage_bar.py +++ b/i3pystatus/cpu_usage_bar.py @@ -55,6 +55,7 @@ class CpuUsageBar(CpuUsage, ColorRangeModule): # for backward compatibility cpu_usage['usage_bar'] = cpu_usage['usage_bar_cpu'] + self.data = cpu_usage self.output = { "full_text": self.format.format_map(cpu_usage), 'color': self.get_gradient(cpu_usage[self.cpu], self.colors, 100) diff --git a/i3pystatus/cpu_usage_graph.py b/i3pystatus/cpu_usage_graph.py index 2de0b18..ec3f2bc 100644 --- a/i3pystatus/cpu_usage_graph.py +++ b/i3pystatus/cpu_usage_graph.py @@ -50,6 +50,7 @@ class CpuUsageGraph(CpuUsage, ColorRangeModule): format_options.update({'cpu_graph': graph}) color = self.get_gradient(core_reading, self.colors) + self.data = format_options self.output = { "full_text": self.format.format_map(format_options), 'color': color diff --git a/i3pystatus/disk.py b/i3pystatus/disk.py index 5b131ac..5b130f2 100644 --- a/i3pystatus/disk.py +++ b/i3pystatus/disk.py @@ -77,6 +77,7 @@ class Disk(IntervalModule): } round_dict(cdict, self.round_size) + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": self.critical_color if critical else self.color, diff --git a/i3pystatus/dota2wins.py b/i3pystatus/dota2wins.py index 9c4a75d..958573d 100644 --- a/i3pystatus/dota2wins.py +++ b/i3pystatus/dota2wins.py @@ -104,6 +104,7 @@ class Dota2wins(IntervalModule): "win_percent": win_percent, } + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": color diff --git a/i3pystatus/file.py b/i3pystatus/file.py index 7663d9e..cb1daf8 100644 --- a/i3pystatus/file.py +++ b/i3pystatus/file.py @@ -45,6 +45,7 @@ class File(IntervalModule): for key, transform in self.transforms.items(): cdict[key] = transform(cdict) + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": self.color diff --git a/i3pystatus/github.py b/i3pystatus/github.py index 24ed556..22a3f8c 100644 --- a/i3pystatus/github.py +++ b/i3pystatus/github.py @@ -57,6 +57,7 @@ class Github(IntervalModule): format_values['unread_count'] = unread format_values['unread'] = self.unread_marker + self.data = format_values self.output = { 'full_text': self.format.format(**format_values), 'color': self.color diff --git a/i3pystatus/gpu_mem.py b/i3pystatus/gpu_mem.py index c8118c1..068fc36 100644 --- a/i3pystatus/gpu_mem.py +++ b/i3pystatus/gpu_mem.py @@ -62,6 +62,7 @@ class GPUMemory(IntervalModule): if value is not None: cdict[key] = round(value, self.round_size) + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": color diff --git a/i3pystatus/iinet.py b/i3pystatus/iinet.py index 83be78a..784e137 100644 --- a/i3pystatus/iinet.py +++ b/i3pystatus/iinet.py @@ -76,6 +76,7 @@ class IINet(IntervalModule, ColorRangeModule): usage['percent_used'] = '{0:.2f}%'.format(percent_used) usage['percent_available'] = '{0:.2f}%'.format(percent_avaliable) + self.data = usage self.output = { "full_text": self.format.format(**usage), "color": color diff --git a/i3pystatus/keyboard_locks.py b/i3pystatus/keyboard_locks.py index d39378a..3b0b8a1 100644 --- a/i3pystatus/keyboard_locks.py +++ b/i3pystatus/keyboard_locks.py @@ -35,7 +35,7 @@ class Keyboard_locks(IntervalModule): scroll_on = "SCR" scroll_off = "___" color = "#FFFFFF" - fdict = {} + data = {} def get_status(self): xset = str(subprocess.check_output(["xset", "q"])) @@ -46,13 +46,13 @@ class Keyboard_locks(IntervalModule): def run(self): (cap, num, scr) = self.get_status() - self.fdict["caps"] = self.caps_on if cap else self.caps_off - self.fdict["num"] = self.num_on if num else self.num_off - self.fdict["scroll"] = self.scroll_on if scr else self.scroll_off + self.data["caps"] = self.caps_on if cap else self.caps_off + self.data["num"] = self.num_on if num else self.num_off + self.data["scroll"] = self.scroll_on if scr else self.scroll_off output_format = self.format self.output = { - "full_text": output_format.format(**self.fdict), + "full_text": output_format.format(**self.data), "color": self.color, } diff --git a/i3pystatus/makewatch.py b/i3pystatus/makewatch.py index be3bd0f..22cf00c 100644 --- a/i3pystatus/makewatch.py +++ b/i3pystatus/makewatch.py @@ -38,6 +38,7 @@ class MakeWatch(IntervalModule): "status": status } + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": color diff --git a/i3pystatus/mem.py b/i3pystatus/mem.py index dc0e018..90a4517 100644 --- a/i3pystatus/mem.py +++ b/i3pystatus/mem.py @@ -61,6 +61,7 @@ class Mem(IntervalModule): } round_dict(cdict, self.round_size) + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": color diff --git a/i3pystatus/moon.py b/i3pystatus/moon.py index 932a7d3..8743aa2 100644 --- a/i3pystatus/moon.py +++ b/i3pystatus/moon.py @@ -105,6 +105,7 @@ class MoonPhase(IntervalModule): "status": self.status[self.current_phase()], "illum": self.illum(), } + self.data = fdict self.output = { "full_text": formatp(self.format, **fdict), "color": self.color[self.current_phase()], diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 65f2405..7e9d6a9 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -91,7 +91,8 @@ class MPD(IntervalModule): self.output = { "full_text": "" } - return + if hasattr(self, "data"): del self.data + return fdict = { "pos": int(status.get("song", 0)) + 1, @@ -118,6 +119,7 @@ class MPD(IntervalModule): if len(fdict[key]) > self.max_field_len: fdict[key] = fdict[key][:self.max_field_len - 1] + "…" + self.data = fdict 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: @@ -128,7 +130,6 @@ class MPD(IntervalModule): fdict[key] = fdict[key][:shrink] + "…" full_text = formatp(self.format, **fdict).strip() - self.output = { "full_text": full_text, "color": self.color, diff --git a/i3pystatus/network.py b/i3pystatus/network.py index aa39099..879711f 100644 --- a/i3pystatus/network.py +++ b/i3pystatus/network.py @@ -393,6 +393,7 @@ class Network(IntervalModule, ColorRangeModule): format_values.update(network_info) format_values['interface'] = self.interface + self.data = format_values self.output = { "full_text": format_str.format(**format_values), 'color': color, diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index bdb00e1..dc6eb2b 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -113,6 +113,7 @@ class NowPlaying(IntervalModule): fdict["filename"] = '.'.join( basename((currentsong.get("xesam:url") or "")).split('.')[:-1]) + self.data = fdict self.output = { "full_text": formatp(self.format, **fdict).strip(), "color": self.color, @@ -126,6 +127,7 @@ class NowPlaying(IntervalModule): "full_text": self.format_no_player, "color": self.color_no_player, } + if hasattr(self, "data"): del self.data return except dbus.exceptions.DBusException as e: @@ -136,6 +138,7 @@ class NowPlaying(IntervalModule): "full_text": "DBus error: " + e.get_dbus_message(), "color": "#ff0000", } + if hasattr(self, "data"): del self.data return def playpause(self): diff --git a/i3pystatus/openstack_vms.py b/i3pystatus/openstack_vms.py index 7fb7ee1..3684857 100644 --- a/i3pystatus/openstack_vms.py +++ b/i3pystatus/openstack_vms.py @@ -60,6 +60,7 @@ class Openstack_vms(IntervalModule): "nonactive_servers": nonactive_servers, } + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": display_color diff --git a/i3pystatus/openvpn.py b/i3pystatus/openvpn.py index 027be8d..48a8ef8 100644 --- a/i3pystatus/openvpn.py +++ b/i3pystatus/openvpn.py @@ -55,6 +55,7 @@ class OpenVPN(IntervalModule): vpn_name = self.vpn_name label = self.label + self.data = locals() self.output = { "full_text": self.format.format(**locals()), 'color': color, diff --git a/i3pystatus/parcel.py b/i3pystatus/parcel.py index 1d9f58a..ce04b8f 100644 --- a/i3pystatus/parcel.py +++ b/i3pystatus/parcel.py @@ -162,6 +162,7 @@ class ParcelTracker(IntervalModule): } fdict.update(self.instance.status()) + self.data = fdict self.output = { "full_text": self.format.format(**fdict).strip(), "instance": self.name, diff --git a/i3pystatus/plexstatus.py b/i3pystatus/plexstatus.py index f2f10a8..50f5b13 100644 --- a/i3pystatus/plexstatus.py +++ b/i3pystatus/plexstatus.py @@ -50,6 +50,7 @@ class Plexstatus(IntervalModule): except AttributeError: pass + self.data = cdict if not cdict['title'] or not cdict['platform']: self.output = {} if not self.format_no_streams else { "full_text": self.format_no_stream, diff --git a/i3pystatus/pomodoro.py b/i3pystatus/pomodoro.py index 7d6bad1..2ccd5a1 100644 --- a/i3pystatus/pomodoro.py +++ b/i3pystatus/pomodoro.py @@ -85,7 +85,7 @@ class Pomodoro(IntervalModule): 'current_pomodoro': self.breaks, 'total_pomodoro': self.short_break_count + 1, } - + self.data = sdict self.output = { 'full_text': self.format.format(**sdict), 'color': color diff --git a/i3pystatus/pyload.py b/i3pystatus/pyload.py index aa41738..75dfa88 100644 --- a/i3pystatus/pyload.py +++ b/i3pystatus/pyload.py @@ -81,6 +81,7 @@ class pyLoad(IntervalModule): "free_space": self._rpc_call("freeSpace") / (1024 ** 3), } + self.data = fdict self.output = { "full_text": self.format.format(**fdict).strip(), "instance": self.address, diff --git a/i3pystatus/reddit.py b/i3pystatus/reddit.py index 07eceba..5a8ce0a 100644 --- a/i3pystatus/reddit.py +++ b/i3pystatus/reddit.py @@ -97,6 +97,7 @@ class Reddit(IntervalModule): else: color = self.color + self.data = fdict full_text = self.format.format(**fdict) self.output = { "full_text": full_text, diff --git a/i3pystatus/sge.py b/i3pystatus/sge.py index 5ffa3c4..2e0391f 100644 --- a/i3pystatus/sge.py +++ b/i3pystatus/sge.py @@ -49,6 +49,7 @@ class SGETracker(IntervalModule): "running": jobs['r'] } + self.data = fdict self.output = { "full_text": self.format.format(**fdict).strip(), "color": self.color diff --git a/i3pystatus/spotify.py b/i3pystatus/spotify.py index a4d0987..dd2edeb 100644 --- a/i3pystatus/spotify.py +++ b/i3pystatus/spotify.py @@ -87,6 +87,7 @@ class Spotify(IntervalModule): 'artist': response.get('artist', ''), 'length': response.get('length', 0), } + self.data = fdict self.output = {"full_text": formatp(self.format, **fdict), "color": self.color} @@ -94,6 +95,7 @@ class Spotify(IntervalModule): except: self.output = {"full_text": self.format_not_running, "color": self.color_not_running} + if hasattr(self, "data"): del self.data def playpause(self): """Pauses and plays spotify""" diff --git a/i3pystatus/uname.py b/i3pystatus/uname.py index c95fa47..c0fee66 100644 --- a/i3pystatus/uname.py +++ b/i3pystatus/uname.py @@ -30,6 +30,7 @@ class Uname(Module): "version": uname_result.version, "machine": uname_result.machine, } + self.data = fdict self.output = { "full_text": self.format.format(**fdict), } diff --git a/i3pystatus/uptime.py b/i3pystatus/uptime.py index 6027e0e..953f133 100644 --- a/i3pystatus/uptime.py +++ b/i3pystatus/uptime.py @@ -54,7 +54,7 @@ class Uptime(IntervalModule): "secs": seconds, "uptime": "{}:{}".format(hours, minutes), } - + self.data = fdict if self.alert: if seconds > self.seconds_alert: self.color = self.color_alert From 07437673855a652c25fb2e3725380ae5d0777c88 Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 27 Jan 2016 20:00:50 +0100 Subject: [PATCH 048/168] Two line if --- i3pystatus/cmus.py | 3 ++- i3pystatus/mpd.py | 3 ++- i3pystatus/now_playing.py | 6 ++++-- i3pystatus/spotify.py | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/i3pystatus/cmus.py b/i3pystatus/cmus.py index ecfc37a..6694ab3 100644 --- a/i3pystatus/cmus.py +++ b/i3pystatus/cmus.py @@ -100,7 +100,8 @@ class Cmus(IntervalModule): "color": self.color} else: - if hasattr(self, "data"): del self.data + if hasattr(self, "data"): + del.data self.output = {"full_text": self.format_not_running, "color": self.color_not_running} diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 7e9d6a9..97e10a9 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -91,7 +91,8 @@ class MPD(IntervalModule): self.output = { "full_text": "" } - if hasattr(self, "data"): del self.data + if hasattr(self, "data"): + del.data return fdict = { diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index dc6eb2b..0eb8c72 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -127,7 +127,8 @@ class NowPlaying(IntervalModule): "full_text": self.format_no_player, "color": self.color_no_player, } - if hasattr(self, "data"): del self.data + if hasattr(self, "data"): if hasattr(self, "data"): + del.data return except dbus.exceptions.DBusException as e: @@ -138,7 +139,8 @@ class NowPlaying(IntervalModule): "full_text": "DBus error: " + e.get_dbus_message(), "color": "#ff0000", } - if hasattr(self, "data"): del self.data + if hasattr(self, "data"): + del.data return def playpause(self): diff --git a/i3pystatus/spotify.py b/i3pystatus/spotify.py index dd2edeb..1828da9 100644 --- a/i3pystatus/spotify.py +++ b/i3pystatus/spotify.py @@ -95,7 +95,8 @@ class Spotify(IntervalModule): except: self.output = {"full_text": self.format_not_running, "color": self.color_not_running} - if hasattr(self, "data"): del self.data + if hasattr(self, "data"): + del.data def playpause(self): """Pauses and plays spotify""" From dec534ce819544c4efa601364cfce69878fb823c Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 27 Jan 2016 20:02:51 +0100 Subject: [PATCH 049/168] Fix errorneous recursive replace mishap --- i3pystatus/cmus.py | 2 +- i3pystatus/core/util.py | 2 +- i3pystatus/mpd.py | 2 +- i3pystatus/now_playing.py | 6 +++--- i3pystatus/spotify.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/i3pystatus/cmus.py b/i3pystatus/cmus.py index 6694ab3..25f6d6a 100644 --- a/i3pystatus/cmus.py +++ b/i3pystatus/cmus.py @@ -101,7 +101,7 @@ class Cmus(IntervalModule): else: if hasattr(self, "data"): - del.data + del self.data self.output = {"full_text": self.format_not_running, "color": self.color_not_running} diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index 4de7671..c5169f4 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -112,7 +112,7 @@ class KeyConstraintDict(collections.UserDict): def __delitem__(self, key): self.seen_keys.remove(key) - if hasattr(self, "data"): del self.data[key] + del self.data[key] def __iter__(self): """Iteration will raise a MissingKeys exception unless all required keys are set diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 97e10a9..1aed61a 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -92,7 +92,7 @@ class MPD(IntervalModule): "full_text": "" } if hasattr(self, "data"): - del.data + del self.data return fdict = { diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index 0eb8c72..f3f8d64 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -127,8 +127,8 @@ class NowPlaying(IntervalModule): "full_text": self.format_no_player, "color": self.color_no_player, } - if hasattr(self, "data"): if hasattr(self, "data"): - del.data + if hasattr(self, "data"): + del self.data return except dbus.exceptions.DBusException as e: @@ -140,7 +140,7 @@ class NowPlaying(IntervalModule): "color": "#ff0000", } if hasattr(self, "data"): - del.data + del self.data return def playpause(self): diff --git a/i3pystatus/spotify.py b/i3pystatus/spotify.py index 1828da9..54e92c3 100644 --- a/i3pystatus/spotify.py +++ b/i3pystatus/spotify.py @@ -96,7 +96,7 @@ class Spotify(IntervalModule): self.output = {"full_text": self.format_not_running, "color": self.color_not_running} if hasattr(self, "data"): - del.data + del self.data def playpause(self): """Pauses and plays spotify""" From 2aeda9c5e3241d1673f219b0ab0a44303130c811 Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 27 Jan 2016 20:15:59 +0100 Subject: [PATCH 050/168] Fix single string case & docs --- docs/changelog.rst | 2 ++ docs/configuration.rst | 12 ++++++++++++ i3pystatus/core/modules.py | 1 + 3 files changed, 15 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 7afc382..70d8006 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,8 @@ master branch * Log file name is now an option (``logfile`` of :py:class:`.Status`) * Server used for checking internet connectivity is now an option (``internet_check`` of :py:class:`.Status`) * Added double click support for click events +* Formatter data is now available with most modules for program callbacks +* Added :py:func:`.util.get_module` for advanced callbacks * :py:mod:`.dota2wins`: Now accepts usernames in place of a Steam ID * dota2wins: Changed win percentage to be a float * :py:mod:`.uptime`: Added days, hours, minutes, secs formatters diff --git a/docs/configuration.rst b/docs/configuration.rst index 57ad858..75485ae 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -370,6 +370,18 @@ consider creating an `python callback` or execute a script instead. on_rightclick = "firefox --new-window https://github.com/enkore/i3pystatus", ) +Most modules provide all the formatter data to program callbacks. The snippet below +demonstrates how this could be used, in this case XMessage will display a dialog box +showing verbose information about the network interface: + +.. code:: python + + status.register("network", + interface="eth0", + on_leftclick="ip addr show dev {interface} | xmessage -file -" + ) + + .. _hints: Hints diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index 2d6d9e4..d06cd8b 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -104,6 +104,7 @@ class Module(SettingsBase): self.__log_button_event(button, cb, args, "External command") if hasattr(self, "data"): args = [arg.format(**self.data) for arg in args] + cb = cb.format(**self.data) execute(cb + " " + " ".join(args), detach=True) # Notify status handler From fcc3bf67d479270cdda36eb39f7b9e8ff935afbd Mon Sep 17 00:00:00 2001 From: enkore Date: Thu, 28 Jan 2016 16:57:57 +0100 Subject: [PATCH 051/168] Update alsa.py cf #300 --- i3pystatus/alsa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/alsa.py b/i3pystatus/alsa.py index c2e67fb..9287952 100644 --- a/i3pystatus/alsa.py +++ b/i3pystatus/alsa.py @@ -92,7 +92,7 @@ class ALSA(IntervalModule): else: output_format = self.format - self.data = fdict + self.data = self.fdict self.output = { "full_text": output_format.format(**self.fdict), "color": self.color_muted if muted else self.color, From 8124668e2b0b57b81f5627b0270fbd7995b46526 Mon Sep 17 00:00:00 2001 From: enkore Date: Thu, 28 Jan 2016 19:20:45 +0100 Subject: [PATCH 052/168] Add test_callback_handler_method, test_callback_handler_function --- tests/test_module_clicks.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_module_clicks.py b/tests/test_module_clicks.py index c02be2d..4806dc8 100644 --- a/tests/test_module_clicks.py +++ b/tests/test_module_clicks.py @@ -1,7 +1,9 @@ +import time +from unittest.mock import MagicMock + import pytest from i3pystatus import IntervalModule -import time left_click = 1 right_click = 3 @@ -89,3 +91,32 @@ def test_clicks(events, expected): m.on_click(ev) time.sleep(sl / 10) assert m._action == expected + + +@pytest.mark.parametrize("button, stored_value", [ + (left_click, "leftclick"), + (right_click, "rightclick") +]) +def test_callback_handler_method(button, stored_value): + class TestClicks(IntervalModule): + def set_action(self, action): + self._action = action + + on_leftclick = [set_action, "leftclick"] + on_rightclick = ["set_action", "rightclick"] + + dut = TestClicks() + + dut.on_click(button) + assert dut._action == stored_value + + +def test_callback_handler_function(): + callback_mock = MagicMock() + + class TestClicks(IntervalModule): + on_upscroll = [callback_mock.callback, "upscroll"] + + dut = TestClicks() + dut.on_click(scroll_up) + callback_mock.callback.assert_called_once_with("upscroll") From cfe9ec4c43ff623795cd7f0a19a4ff711264fb84 Mon Sep 17 00:00:00 2001 From: enkore Date: Thu, 28 Jan 2016 20:05:13 +0100 Subject: [PATCH 053/168] Rename test_module_clicks.py to test_core_modules.py --- tests/{test_module_clicks.py => test_core_modules.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_module_clicks.py => test_core_modules.py} (100%) diff --git a/tests/test_module_clicks.py b/tests/test_core_modules.py similarity index 100% rename from tests/test_module_clicks.py rename to tests/test_core_modules.py From 739c595ef0b9c23fbdf775b39983e5509e5735c7 Mon Sep 17 00:00:00 2001 From: enkore Date: Thu, 28 Jan 2016 20:34:53 +0100 Subject: [PATCH 054/168] Fix is_method_of (cf #310), add regression test case --- i3pystatus/core/modules.py | 6 +++++- tests/test_core_modules.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index d06cd8b..fe45190 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -10,8 +10,12 @@ from i3pystatus.core.command import run_through_shell def is_method_of(method, object): """Decide whether ``method`` is contained within the MRO of ``object``.""" + if not callable(method) or not hasattr(method, "__name__"): + return False + if inspect.ismethod(method): + return method.__self__ is object for cls in inspect.getmro(object.__class__): - if method in cls.__dict__.values(): + if cls.__dict__.get(method.__name__, None) is method: return True return False diff --git a/tests/test_core_modules.py b/tests/test_core_modules.py index 4806dc8..0ca7897 100644 --- a/tests/test_core_modules.py +++ b/tests/test_core_modules.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from i3pystatus import IntervalModule +from i3pystatus.core.modules import is_method_of left_click = 1 right_click = 3 @@ -120,3 +121,27 @@ def test_callback_handler_function(): dut = TestClicks() dut.on_click(scroll_up) callback_mock.callback.assert_called_once_with("upscroll") + + +def test_is_method_of(): + class TestClass: + def method(self): + pass + + # member assigned functions cannot be distinguished in unbound state + # by principle from methods, since both are functions. However, in + # most cases it can still be shown correctly that a member assigned + # function is not a method, since the member name and function name + # are different (in this example the assigned member is 'assigned_function', + # while the name of the function is 'len', hence is_method_of can say for + # sure that assigned_function isn't a method + assigned_function = len + member = 1234 + string_member = "abcd" + + object = TestClass() + for source_object in [object, TestClass]: + assert is_method_of(source_object.method, object) + assert not is_method_of(source_object.assigned_function, object) + assert not is_method_of(source_object.member, object) + assert not is_method_of(source_object.string_member, object) From 70a9ead7efa06bd55fdc2c4a5ab4c9e7b3ba0012 Mon Sep 17 00:00:00 2001 From: Stefan Tatschner Date: Fri, 1 Jan 2016 17:40:05 +0100 Subject: [PATCH 055/168] Add Syncthing module This module provides support for Syncthing [1]. Currently this module only supports showing the up/down status of Syncthing and it is possible to start/shutdown Syncthing via click events. A few callback functions for usage with/without systemd are provided as well. The module is designed in a generic way (st_get(), st_post()), such that new features could be add very easily. [1]: https://syncthing.net --- i3pystatus/syncthing.py | 121 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 i3pystatus/syncthing.py diff --git a/i3pystatus/syncthing.py b/i3pystatus/syncthing.py new file mode 100644 index 0000000..e1f6511 --- /dev/null +++ b/i3pystatus/syncthing.py @@ -0,0 +1,121 @@ +import json +import os.path +import requests +from subprocess import call +from urllib.parse import urljoin +import xml.etree.ElementTree as ET +from i3pystatus import IntervalModule +from i3pystatus.core.util import user_open + + +class Syncthing(IntervalModule): + """ + Check Syncthing's online status and start/stop Syncthing via + click events. + + Requires `requests`. + """ + + format_up = 'ST up' + color_up = '#00ff00' + format_down = 'ST down' + color_down = '#ff0000' + configfile = '~/.config/syncthing/config.xml' + url = 'auto' + apikey = 'auto' + verify_ssl = True + interval = 10 + on_leftclick = 'st_open' + on_rightclick = 'st_toggle_systemd' + + settings = ( + ('format_up', 'Text to show when Syncthing is running'), + ('format_down', 'Text to show when Syncthing is not running'), + ('color_up', 'Color when Syncthing is running'), + ('color_down', 'Color when Syncthing is not running'), + ('configfile', 'Path to Syncthing config'), + ('url', 'Syncthing GUI URL; "auto" reads from local config'), + ('apikey', 'Syncthing APIKEY; "auto" reads from local config'), + ('verify_ssl', 'Verify SSL certificate'), + ) + + def st_get(self, endpoint): + response = requests.get( + urljoin(self.url, endpoint), + verify=self.verify_ssl, + ) + return json.loads(response.text) + + def st_post(self, endpoint, data=None): + headers = {'X-API-KEY': self.apikey} + requests.post( + urljoin(self.url, endpoint), + data=data, + headers=headers, + ) + + def read_config(self): + self.configfile = os.path.expanduser(self.configfile) + # Parse config only once! + if self.url == 'auto' or self.apikey == 'auto': + tree = ET.parse(self.configfile) + root = tree.getroot() + if self.url == 'auto': + tls = root.find('./gui').attrib['tls'] + address = root.find('./gui/address').text + if tls == 'true': + self.url = 'https://' + address + else: + self.url = 'http://' + address + if self.apikey == 'auto': + self.apikey = root.find('./gui/apikey').text + + def ping(self): + try: + ping_data = self.st_get('/rest/system/ping') + if ping_data['ping'] == 'pong': + return True + else: + return False + except requests.exceptions.ConnectionError: + return False + + def run(self): + self.read_config() + self.online = True if self.ping() else False + + if self.online: + self.output = { + 'full_text': self.format_up, + 'color': self.color_up + } + else: + self.output = { + 'full_text': self.format_down, + 'color': self.color_down + } + + # Callbacks + def st_open(self): + user_open(self.url) + + def st_restart(self): + self.st_post('/rest/system/restart') + + def st_stop(self): + self.st_post('/rest/system/shutdown') + + def st_start_systemd(self): + call(['systemctl', '--user', 'start', 'syncthing.service']) + + def st_restart_systemd(self): + call(['systemctl', '--user', 'restart', 'syncthing.service']) + + def st_stop_systemd(self): + call(['systemctl', '--user', 'stop', 'syncthing.service']) + + def st_toggle_systemd(self): + if self.online: + self.st_stop_systemd() + else: + self.st_start_systemd() From b3a1ab2508bb68e0ae371e9dffa9f3afb92e8059 Mon Sep 17 00:00:00 2001 From: enkore Date: Sun, 31 Jan 2016 18:33:34 +0100 Subject: [PATCH 056/168] syncthing: callback descriptions --- i3pystatus/syncthing.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/i3pystatus/syncthing.py b/i3pystatus/syncthing.py index e1f6511..ce836fa 100644 --- a/i3pystatus/syncthing.py +++ b/i3pystatus/syncthing.py @@ -97,24 +97,31 @@ class Syncthing(IntervalModule): # Callbacks def st_open(self): + """Callback: Open Syncthing web UI""" user_open(self.url) def st_restart(self): + """Callback: Restart Syncthing"""" self.st_post('/rest/system/restart') def st_stop(self): + """Callback: Stop Syncthing""" self.st_post('/rest/system/shutdown') def st_start_systemd(self): + """Callback: systemctl --user start syncthing.service""" call(['systemctl', '--user', 'start', 'syncthing.service']) def st_restart_systemd(self): + """Callback: systemctl --user restart syncthing.service""" call(['systemctl', '--user', 'restart', 'syncthing.service']) def st_stop_systemd(self): + """Callback: systemctl --user stop syncthing.service""" call(['systemctl', '--user', 'stop', 'syncthing.service']) def st_toggle_systemd(self): + """Callback: start Syncthing service if offline, or stop it when online""" if self.online: self.st_stop_systemd() else: From 5dbb8b6cacbdc5c08e799ed44c24076895fa53b8 Mon Sep 17 00:00:00 2001 From: enkore Date: Sun, 31 Jan 2016 18:34:07 +0100 Subject: [PATCH 057/168] Fix typo --- i3pystatus/syncthing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/syncthing.py b/i3pystatus/syncthing.py index ce836fa..58a47c3 100644 --- a/i3pystatus/syncthing.py +++ b/i3pystatus/syncthing.py @@ -101,7 +101,7 @@ class Syncthing(IntervalModule): user_open(self.url) def st_restart(self): - """Callback: Restart Syncthing"""" + """Callback: Restart Syncthing""" self.st_post('/rest/system/restart') def st_stop(self): From d018be872a366bdaa1feddb5460fb30880af27e1 Mon Sep 17 00:00:00 2001 From: enkore Date: Mon, 1 Feb 2016 12:18:03 +0100 Subject: [PATCH 058/168] Lift restriction that "run" cannot be used as a callback --- i3pystatus/core/modules.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index fe45190..e5982e7 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -101,9 +101,8 @@ class Module(SettingsBase): elif our_method: cb(self, *args) elif hasattr(self, cb): - if cb is not "run": - self.__log_button_event(button, cb, args, "Member callback") - getattr(self, cb)(*args) + self.__log_button_event(button, cb, args, "Member callback") + getattr(self, cb)(*args) else: self.__log_button_event(button, cb, args, "External command") if hasattr(self, "data"): From 39e2c6457083e70d5781a6257bb1ee111c9f860d Mon Sep 17 00:00:00 2001 From: enkore Date: Mon, 1 Feb 2016 12:20:30 +0100 Subject: [PATCH 059/168] updates: allow display of a "working/busy" message --- i3pystatus/updates/__init__.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/i3pystatus/updates/__init__.py b/i3pystatus/updates/__init__.py index e135cae..1ff2a77 100644 --- a/i3pystatus/updates/__init__.py +++ b/i3pystatus/updates/__init__.py @@ -42,12 +42,14 @@ class Updates(IntervalModule): settings = ( ("backends", "Required list of backends used to check for updates."), - ("format", "String shown when updates are available. " + ("format", "Format used when updates are available. " "May contain formatters."), ("format_no_updates", "String that is shown if no updates are available." " If not set the module will be hidden if no updates are available."), + ("format_working", "Format used while update queries are run. By default the same as ``format``."), "color", "color_no_updates", + "color_working", ("interval", "Default interval is set to one hour."), ) required = ("backends",) @@ -55,17 +57,30 @@ class Updates(IntervalModule): backends = None format = "Updates: {count}" format_no_updates = None + format_working = None color = "#00DD00" color_no_updates = "#FFFFFF" + color_working = None on_leftclick = "run" def init(self): if not isinstance(self.backends, list): self.backends = [self.backends] + if self.format_working is None: # we want to allow an empty format + self.format_working = self.format + self.color_working = self.color_working or self.color + self.data = { + "count": 0 + } @require(internet) def run(self): + self.output = { + "full_text": formatp(self.format_working, **self.data).strip(), + "color": self.color_working, + } + updates_count = 0 for backend in self.backends: updates_count += backend.updates @@ -77,10 +92,8 @@ class Updates(IntervalModule): } return - fdict = { - "count": updates_count, - } + self.data["count"] = updates_count self.output = { - "full_text": formatp(self.format, **fdict).strip(), + "full_text": formatp(self.format, **self.data).strip(), "color": self.color, } From d7af5c762fde7bd12fee7927c4f5f06a6d04a2c0 Mon Sep 17 00:00:00 2001 From: enkore Date: Mon, 1 Feb 2016 12:48:56 +0100 Subject: [PATCH 060/168] =?UTF-8?q?updates:=20new=20formula=C2=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start an extra worker ourselves, then use a condition to notify it of explicit update requests --- i3pystatus/updates/__init__.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/i3pystatus/updates/__init__.py b/i3pystatus/updates/__init__.py index 1ff2a77..8ed0e07 100644 --- a/i3pystatus/updates/__init__.py +++ b/i3pystatus/updates/__init__.py @@ -1,4 +1,6 @@ -from i3pystatus import SettingsBase, IntervalModule, formatp +import threading + +from i3pystatus import SettingsBase, Module, formatp from i3pystatus.core.util import internet, require @@ -7,7 +9,7 @@ class Backend(SettingsBase): updates = 0 -class Updates(IntervalModule): +class Updates(Module): """ Generic update checker. To use select appropriate backend(s) for your system. @@ -73,9 +75,19 @@ class Updates(IntervalModule): self.data = { "count": 0 } + self.condition = threading.Condition() + self.thread = threading.Thread(target=self.update_thread, daemon=True) + self.thread.start() + + def update_thread(self): + self.check_updates() + while True: + with self.condition: + self.condition.wait(self.interval) + self.check_updates() @require(internet) - def run(self): + def check_updates(self): self.output = { "full_text": formatp(self.format_working, **self.data).strip(), "color": self.color_working, @@ -97,3 +109,7 @@ class Updates(IntervalModule): "full_text": formatp(self.format, **self.data).strip(), "color": self.color, } + + def run(self): + with self.condition: + self.condition.notify() From 57be3c45a974e26689c2abd66be900847f78c810 Mon Sep 17 00:00:00 2001 From: enkore Date: Mon, 1 Feb 2016 13:11:58 +0100 Subject: [PATCH 061/168] updates: add an individual formatter for every backend --- i3pystatus/updates/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/i3pystatus/updates/__init__.py b/i3pystatus/updates/__init__.py index 8ed0e07..de45265 100644 --- a/i3pystatus/updates/__init__.py +++ b/i3pystatus/updates/__init__.py @@ -21,6 +21,10 @@ class Updates(Module): .. rubric:: Available formatters * `{count}` — Sum of all available updates from all backends. + * For each backend registered there is one formatter named after the backend, + multiple identical backends do not accumulate, but overwrite each other. + * For example, `{Cower}` (note capitcal C) is the number of updates reported by + the cower backend, assuming it has been registered. .. rubric:: Usage example @@ -95,7 +99,9 @@ class Updates(Module): updates_count = 0 for backend in self.backends: - updates_count += backend.updates + updates = backend.updates + updates_count += updates + self.data[backend.__class__.__name__] = updates if updates_count == 0: self.output = {} if not self.format_no_updates else { From 9759d6eea519dcfe85fc1e09feb6bd8399509bf5 Mon Sep 17 00:00:00 2001 From: enkore Date: Mon, 1 Feb 2016 17:09:47 +0100 Subject: [PATCH 062/168] Revert "Lift restriction that "run" cannot be used as a callback" This reverts commit d018be872a366bdaa1feddb5460fb30880af27e1. --- i3pystatus/core/modules.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index e5982e7..f60ac3e 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -101,8 +101,12 @@ class Module(SettingsBase): elif our_method: cb(self, *args) elif hasattr(self, cb): - self.__log_button_event(button, cb, args, "Member callback") - getattr(self, cb)(*args) + if cb is not "run": + # CommandEndpoint already calls run() after every + # callback to instantly update any changed state due + # to the callback's actions. + self.__log_button_event(button, cb, args, "Member callback") + getattr(self, cb)(*args) else: self.__log_button_event(button, cb, args, "External command") if hasattr(self, "data"): From dcda1bb3a43c6ae2ed00989d9303aed57dae8112 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 15:47:00 +0100 Subject: [PATCH 063/168] Fix initialization error if standalone is False Does anybody even use this scenario anymore? We could remove quite some code if we only supported standalone operation. --- i3pystatus/core/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index d92d6e9..c560084 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -50,7 +50,7 @@ class Status: def __init__(self, standalone=False, **kwargs): self.standalone = standalone - self.click_events = kwargs.get("click_events", True) + self.click_events = kwargs.get("click_events", True if standalone else False) interval = kwargs.get("interval", 1) input_stream = kwargs.get("input_stream", sys.stdin) if "logfile" in kwargs: From 289c090ea4cead4aa6d96846b4f6ea6935bb0cd1 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 15:47:18 +0100 Subject: [PATCH 064/168] Import core.util.get_module into i3pystatus main module --- i3pystatus/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/i3pystatus/__init__.py b/i3pystatus/__init__.py index 1d022a8..15effd2 100644 --- a/i3pystatus/__init__.py +++ b/i3pystatus/__init__.py @@ -3,7 +3,7 @@ from pkgutil import extend_path from i3pystatus.core import Status from i3pystatus.core.modules import Module, IntervalModule from i3pystatus.core.settings import SettingsBase -from i3pystatus.core.util import formatp +from i3pystatus.core.util import formatp, get_module import logging import os @@ -15,6 +15,7 @@ __all__ = [ "Module", "IntervalModule", "SettingsBase", "formatp", + "get_module", ] logpath = os.path.join(os.path.expanduser("~"), ".i3pystatus-%s" % os.getpid()) From 2890f942f395b243da957661d5dd4be61466871a Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 16:01:28 +0100 Subject: [PATCH 065/168] configuration.rst: update example callbacks --- docs/configuration.rst | 14 +++++++++++++- i3pystatus/core/util.py | 3 +-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 75485ae..0247b8c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -321,12 +321,21 @@ amount of percent to add/subtract from the current volume. .. rubric:: Python callbacks -These refer to to any callable Python object (most likely a function). +These refer to to any callable Python object (most likely a +function). To external Python callbacks that are not part of the +module the ``self`` parameter is not passed by default. This allows to +use many library functions with no additional wrapper. + +If ``self`` is needed to access the calling module, the +:py:func:`.get_module` decorator can be used on the callback: .. code:: python + from i3pystatus import get_module + # Note that the 'self' parameter is required and gives access to all # variables of the module. + @get_module def change_text(self): self.output["full_text"] = "Clicked" @@ -341,6 +350,9 @@ You can also create callbacks with parameters. .. code:: python + from i3pystatus import get_module + + @get_module def change_text(self, text="Hello world!", color="#ffffff"): self.output["full_text"] = text self.output["color"] = color diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index c5169f4..a3c40bd 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -567,8 +567,7 @@ def get_module(function): .. code:: python - from i3pystatus import Status - from i3pystatus.core.util import get_module + from i3pystatus import Status, get_module from i3pystatus.core.command import execute status = Status(...) # other modules etc. From c1bf93a951971428c1b77cb2fdeeca611ef58b64 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 16:01:37 +0100 Subject: [PATCH 066/168] Update changelog 33c4ae2..HEAD --- docs/changelog.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 70d8006..607d04f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,13 +16,16 @@ master branch - :py:mod:`.gpu_mem`, :py:mod:`.gpu_temp`: View memory and temperature stats of nVidia cards - :py:mod:`.solaar`: Show battery status of Solaar / Logitech Unifying devices - :py:mod:`.zabbix`: Alerts watcher for the Zabbix enterprise network monitor + - :py:mod:`.sge`: Sun Grid Engine (SGE) monitor + - :py:mod:`.timer`: Timer + - :py:mod:`.syncthing`: Syncthing monitor and control * Applications started from click events don't block other click events now * Fixed crash with desktop notifications when python-gobject is installed, but no notification daemon is running * Log file name is now an option (``logfile`` of :py:class:`.Status`) * Server used for checking internet connectivity is now an option (``internet_check`` of :py:class:`.Status`) * Added double click support for click events * Formatter data is now available with most modules for program callbacks -* Added :py:func:`.util.get_module` for advanced callbacks +* ``self`` is not passed anymore by default to external Python callbacks (see :py:func:`.get_module`) * :py:mod:`.dota2wins`: Now accepts usernames in place of a Steam ID * dota2wins: Changed win percentage to be a float * :py:mod:`.uptime`: Added days, hours, minutes, secs formatters @@ -35,7 +38,10 @@ master branch * :py:mod:`.cpu_usage`: Added color setting * :py:mod:`.mpd`: Added hide\_inactive settings * mpd: Fixed a bug where an active playlist would be assumed, leading to no output +* mpd: Added support for UNIX sockets * :py:mod:`.updates`: Added yaourt backend +* updates: Can display a working/busy message now +* updates: Additional formatters for every backend (to distinguish pacman vs. AUR updates, for example) * :py:mod:`.reddit`: Added link\_karma and comment\_karma formatters * :py:mod:`.openvpn`: Configurable up/down symbols * :py:mod:`.disk`: Improved handling of unmounted drives. Previously From fd708e078e0cb1cab4dd507bd779c63657463e4f Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 16:09:43 +0100 Subject: [PATCH 067/168] Bump copyright --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index c60eff5..3eebd68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,7 +66,7 @@ master_doc = 'index' # General information about the project. project = 'i3pystatus' -copyright = '2012-2015 i3pystatus developers. Free and open software under the MIT license' +copyright = '2012-2016 i3pystatus developers. Free and open software under the MIT license' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the From 1680ef4f178999ed380766493cf9326cc88a05b0 Mon Sep 17 00:00:00 2001 From: w8u Date: Wed, 25 Nov 2015 17:49:31 +0300 Subject: [PATCH 068/168] A module for monitoring message amount in VK --- i3pystatus/vk.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 i3pystatus/vk.py diff --git a/i3pystatus/vk.py b/i3pystatus/vk.py new file mode 100644 index 0000000..7da4f8a --- /dev/null +++ b/i3pystatus/vk.py @@ -0,0 +1,74 @@ +from i3pystatus import Status, IntervalModule +from i3pystatus.core.util import internet, require, user_open +import vk + + +class Vk(IntervalModule): + """ + Display amount of unread messages in VK social network. + Creating your own VK API app is highly recommended for your own privacy, though there is a default one provided. + Reference vk.com/dev for instructions on creating VK API app. + If access_token is not specified, the module will try to open a request page in browser. + You will need to manually copy obtained acess token to your config file. + Requires the PyPI package `vk`. + """ + + API_LINK = "https://oauth.vk.com/authorize?client_id={id}&display=page&revoke=1&scope=messages,offline&response_type=token&v=5.40" + app_id = 5160484 + access_token = None + session = None + token_error = "Vk: token error" + format = '{unread}/{total}' + interval = 1 + color = "#ffffff" + color_unread = "#ffffff" + color_bad = "#ff0000" + + settings = ( + ("app_id", "Id of your VK API app"), + ("access_token", "Your access token. You must have `messages` and `offline` access permissions"), + ("token_error", "Message to be shown if there's some problem with your token"), + ("color",), + ("color_bad",), + ("color_unread",), + ) + + @require(internet) + def token_request(self, func): + user_open(self.API_LINK.format(id=self.app_id)) + self.run = func + + @require(internet) + def init(self): + if self.access_token: + self.session = vk.AuthSession(app_id=self.app_id, access_token=self.access_token) + self.api = vk.API(self.session, v='5.40', lang='en', timeout=10) + try: + permissions = int(self.api.account.getAppPermissions()) + assert((permissions & 65536 == 65536) and (permissions & 4096 == 4096)) + except: + self.token_request(self.error) + else: + self.token_request(lambda: None) + + @require(internet) + def run(self): + total = self.api.messages.getDialogs()['count'] + unread = self.api.messages.getDialogs(unread=1)['count'] + + if unread > 0: + color = self.color_unread + else: + color = self.color + + self.output = { + "full_text": self.format.format( + total=total, + unread=unread + ), + "color": color + } + + def error(self): + self.output = {"full_text": self.token_error, + "color": self.color_bad} From b1a2c4d6c5b7a9043dff12fb43c76f098392fb67 Mon Sep 17 00:00:00 2001 From: w8u Date: Wed, 25 Nov 2015 20:40:51 +0300 Subject: [PATCH 069/168] corrected settings to avoid Sphinx errors --- i3pystatus/vk.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/i3pystatus/vk.py b/i3pystatus/vk.py index 7da4f8a..ba24fcf 100644 --- a/i3pystatus/vk.py +++ b/i3pystatus/vk.py @@ -28,9 +28,9 @@ class Vk(IntervalModule): ("app_id", "Id of your VK API app"), ("access_token", "Your access token. You must have `messages` and `offline` access permissions"), ("token_error", "Message to be shown if there's some problem with your token"), - ("color",), - ("color_bad",), - ("color_unread",), + ("color", "General color of the output"), + ("color_bad", "Color of the output in case of access token error"), + ("color_unread", "Color of the output if there are unread messages"), ) @require(internet) From 47fbb106604a3b068118bb6454758681185d0bb6 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 16:40:56 +0100 Subject: [PATCH 070/168] docfix for vk --- docs/changelog.rst | 1 + docs/conf.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 607d04f..53bb2f9 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,7 @@ master branch - :py:mod:`.sge`: Sun Grid Engine (SGE) monitor - :py:mod:`.timer`: Timer - :py:mod:`.syncthing`: Syncthing monitor and control + - :py:mpd:`.vk`: Displays number of messages in VKontakte * Applications started from click events don't block other click events now * Fixed crash with desktop notifications when python-gobject is installed, but no notification daemon is running * Log file name is now an option (``logfile`` of :py:class:`.Status`) diff --git a/docs/conf.py b/docs/conf.py index 3eebd68..7da9e13 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,8 @@ MOCK_MODULES = [ "dota2py", "novaclient.v2", "speedtest_cli", - "pyzabbix" + "pyzabbix", + "vk", ] for mod_name in MOCK_MODULES: From eca5431e0c66f67b389e9f880524d74a07dffa78 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 2 Feb 2016 16:44:11 +0100 Subject: [PATCH 071/168] Fix typo in changelog.rst --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 53bb2f9..11e56a1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,7 +19,7 @@ master branch - :py:mod:`.sge`: Sun Grid Engine (SGE) monitor - :py:mod:`.timer`: Timer - :py:mod:`.syncthing`: Syncthing monitor and control - - :py:mpd:`.vk`: Displays number of messages in VKontakte + - :py:mod:`.vk`: Displays number of messages in VKontakte * Applications started from click events don't block other click events now * Fixed crash with desktop notifications when python-gobject is installed, but no notification daemon is running * Log file name is now an option (``logfile`` of :py:class:`.Status`) From 4f490d6b4a85e4aeecf73a8f9f65ba1b562780f6 Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Fri, 16 Oct 2015 20:49:27 -0700 Subject: [PATCH 072/168] dota2wins: truncate win percentage Use only 2 decimals for win percentage so we don't fill all of the status bar with decimal places. --- i3pystatus/dota2wins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/i3pystatus/dota2wins.py b/i3pystatus/dota2wins.py index 9c4a75d..9abdc20 100644 --- a/i3pystatus/dota2wins.py +++ b/i3pystatus/dota2wins.py @@ -102,6 +102,7 @@ class Dota2wins(IntervalModule): "wins": wins, "losses": losses, "win_percent": win_percent, + "win_percent": "%.2f" % win_percent, } self.output = { From e2a0097316734b253af841b6cd6dbf01cc3e647c Mon Sep 17 00:00:00 2001 From: enkore Date: Mon, 8 Feb 2016 10:10:28 +0100 Subject: [PATCH 073/168] Decrease chattiness of ci-build.sh; error reporting is not reduced (but error visibility increased, because there is less output overall) --- ci-build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ci-build.sh b/ci-build.sh index fa142a3..ae9f04c 100755 --- a/ci-build.sh +++ b/ci-build.sh @@ -19,7 +19,7 @@ PYTHONPATH=${BUILD}/test-install python3 setup.py --quiet install --install-lib test -f ${BUILD}/test-install-bin/i3pystatus test -f ${BUILD}/test-install-bin/i3pystatus-setting-util -PYTHONPATH=${BUILD}/test-install py.test --junitxml ${BUILD}/testlog.xml tests +PYTHONPATH=${BUILD}/test-install py.test -q --junitxml ${BUILD}/testlog.xml tests # Check that the docs build w/o warnings (-W flag) -sphinx-build -b html -W docs ${BUILD}/docs/ +sphinx-build -Nq -b html -W docs ${BUILD}/docs/ From 99ca98eaea235d801af08af7953e0160229633f1 Mon Sep 17 00:00:00 2001 From: Julius Haertl Date: Wed, 10 Feb 2016 10:03:47 +0100 Subject: [PATCH 074/168] openvpn: Rename colour_up/colour_down to color_up/color_down --- i3pystatus/openvpn.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/i3pystatus/openvpn.py b/i3pystatus/openvpn.py index 48a8ef8..43acd63 100644 --- a/i3pystatus/openvpn.py +++ b/i3pystatus/openvpn.py @@ -18,8 +18,8 @@ class OpenVPN(IntervalModule): """ - colour_up = "#00ff00" - colour_down = "#FF0000" + color_up = "#00ff00" + color_down = "#FF0000" status_up = '▲' status_down = '▼' format = "{vpn_name} {status}" @@ -30,8 +30,8 @@ class OpenVPN(IntervalModule): settings = ( ("format", "Format string"), - ("colour_up", "VPN is up"), - ("colour_down", "VPN is down"), + ("color_up", "VPN is up"), + ("color_down", "VPN is down"), ("status_down", "Symbol to display when down"), ("status_up", "Symbol to display when up"), ("vpn_name", "Name of VPN"), @@ -46,10 +46,10 @@ class OpenVPN(IntervalModule): output = command_result.out.strip() if output == 'active': - color = self.colour_up + color = self.color_up status = self.status_up else: - color = self.colour_down + color = self.color_down status = self.status_down vpn_name = self.vpn_name From 67142bc6fee702c9d66685787a19d26c8d70a8dd Mon Sep 17 00:00:00 2001 From: facetoe Date: Wed, 20 Jan 2016 22:58:11 +0800 Subject: [PATCH 075/168] Add GoogleCalendar module --- i3pystatus/google_calendar.py | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 i3pystatus/google_calendar.py diff --git a/i3pystatus/google_calendar.py b/i3pystatus/google_calendar.py new file mode 100644 index 0000000..f995403 --- /dev/null +++ b/i3pystatus/google_calendar.py @@ -0,0 +1,120 @@ +import datetime + +import httplib2 +import oauth2client +import pytz +from apiclient import discovery +from dateutil import parser + +from i3pystatus import IntervalModule +from i3pystatus.core.color import ColorRangeModule +from i3pystatus.core.util import internet, require + + +class GoogleCalendar(IntervalModule, ColorRangeModule): + """ + Simple module for displaying next Google Calendar event. + + Requires the Google Calendar API package - https://developers.google.com/google-apps/calendar/quickstart/python. + Additionally requires the `colour`, `httplib2`, `oauth2client`, `pytz`, `apiclient` and `dateutil` modules. + + All top level keys returned by the Google Calendar API can be used as formatters. Some + examples include: + + .. rubric:: Available formatters + + * `{kind}` — type of event + * `{status}` — eg, confirmed + * `{summary}` — essentially the title + * `{htmlLink}` — link to the calendar event + + + """ + settings = ( + ('format', 'format string'), + ("credential_path", "Path to credentials"), + ("skip_recurring", "Skip recurring events."), + ("urgent_seconds", "Add urgent hint when this many seconds until event startTime"), + ("start_color", "Hex or English name for start of color range, eg '#00FF00' or 'green'"), + ("end_color", "Hex or English name for end of color range, eg '#FF0000' or 'red'"), + ) + + required = ('credential_path',) + + format = "{summary} ({remaining_time})" + urgent_seconds = 300 + interval = 30 + color = '#FFFFFF' + skip_recurring = True + credential_path = None + + service = None + credentials = None + + def init(self): + self.colors = self.get_hex_color_range(self.end_color, self.start_color, self.urgent_seconds * 2) + + @require(internet) + def run(self): + if not self.service: + self.connect_service() + + display_event = self.get_next_event() + if display_event: + start_time = parser.parse(display_event['start']['dateTime']) + now = datetime.datetime.now(tz=pytz.UTC) + + alert_time = now + datetime.timedelta(seconds=self.urgent_seconds) + display_event['remaining_time'] = str((start_time - now)).partition('.')[0] + urgent = alert_time > start_time + color = self.get_color(now, start_time) + + self.output = { + 'full_text': self.format.format(**display_event), + 'color': color, + 'urgent': urgent + } + else: + self.output = { + 'full_text': "", + } + + def connect_service(self): + self.credentials = oauth2client.file.Storage(self.credential_path).get() + self.service = discovery.build('calendar', 'v3', http=self.credentials.authorize(httplib2.Http())) + + def get_next_event(self): + for event in self.get_events(): + start_time = parser.parse(event['start']['dateTime']) + now = datetime.datetime.now(tz=pytz.UTC) + if 'recurringEventId' in event and self.skip_recurring: + continue + elif start_time < now: + continue + return event + + def get_events(self): + now, later = self.get_timerange() + events_result = self.service.events().list( + calendarId='primary', + timeMin=now, + timeMax=later, + maxResults=10, + singleEvents=True, + orderBy='startTime', + timeZone='utc' + ).execute() + return events_result.get('items', []) + + def get_timerange(self): + now = datetime.datetime.utcnow() + later = now + datetime.timedelta(days=1) + now = now.isoformat() + 'Z' + later = later.isoformat() + 'Z' + return now, later + + def get_color(self, now, start_time): + seconds_to_event = (start_time - now).seconds + v = self.percentage(seconds_to_event, self.urgent_seconds) + color = self.get_gradient(v, self.colors) + return color From c21a98cde0bb33ffc4a7ea7a1bd04a243c486740 Mon Sep 17 00:00:00 2001 From: facetoe Date: Wed, 10 Feb 2016 22:14:51 +0800 Subject: [PATCH 076/168] Add google-api-python-client to prevent build failing. Not sure if correct solution... --- dev-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index ed38b0e..62e971c 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -3,3 +3,4 @@ sphinx>=1.1 colour>=0.0.5 mock>=1.0 pep8>=1.5.7 +google-api-python-client>=1.4.2 From 60f6d200a8698a2b3bd9b222e3d0d60ef0b3643f Mon Sep 17 00:00:00 2001 From: facetoe Date: Wed, 10 Feb 2016 22:19:11 +0800 Subject: [PATCH 077/168] Add python-dateutil to dev-requirements.txt --- dev-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index 62e971c..d1484b2 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -4,3 +4,4 @@ colour>=0.0.5 mock>=1.0 pep8>=1.5.7 google-api-python-client>=1.4.2 +python-dateutil>=2.4.2 From e0a3140f99ae3306c2f26a3c09022f787bb86dce Mon Sep 17 00:00:00 2001 From: facetoe Date: Wed, 10 Feb 2016 22:30:27 +0800 Subject: [PATCH 078/168] Add mail backend configuration example for #303. --- docs/i3pystatus.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/i3pystatus.rst b/docs/i3pystatus.rst index 035bdb7..47e669d 100644 --- a/docs/i3pystatus.rst +++ b/docs/i3pystatus.rst @@ -26,6 +26,19 @@ Module reference Mail Backends ------------- +The generic mail module can be configured to use multiple mail backends. Here is an +example configuration for the MaildirMail backend: + +.. code:: python + + status.register("mail", + backends=[maildir.MaildirMail( + directory="/home/name/Mail/inbox") + ], + format="P {unread}", + log_level=20, + hide_if_null=False, ) + .. autogen:: i3pystatus.mail SettingsBase .. nothin' From 6b4974d995efcd95a0c222f4480d6ecb7768095a Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 10 Feb 2016 19:06:26 +0100 Subject: [PATCH 079/168] docs: fix wrong package name of submodules This showed up with the backends of mail and updates, where the docs claimed that e.g. the Thunderbird backend class was actually located in i3pystatus.thunderbird (correct: i3pystatus.mail.thunderbird) --- docs/module_docs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/module_docs.py b/docs/module_docs.py index e415fa9..e163dd7 100644 --- a/docs/module_docs.py +++ b/docs/module_docs.py @@ -97,16 +97,16 @@ def process_signature(app, what, name, obj, options, signature, return_annotatio return ("", return_annotation) -def get_modules(path, name): +def get_modules(path, package): modules = [] for finder, modname, is_package in pkgutil.iter_modules(path): if modname not in IGNORE_MODULES: - modules.append(get_module(finder, modname)) + modules.append(get_module(finder, modname, package)) return modules -def get_module(finder, modname): - fullname = "i3pystatus.{modname}".format(modname=modname) +def get_module(finder, modname, package): + fullname = "{package}.{modname}".format(package=package, modname=modname) return (modname, finder.find_loader(fullname)[0].load_module(fullname)) From 8e3857ccd0ba7c87118bfcb9c41313268fe2b3bb Mon Sep 17 00:00:00 2001 From: enkore Date: Wed, 10 Feb 2016 19:09:30 +0100 Subject: [PATCH 080/168] Add import to mail backend example --- docs/i3pystatus.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/i3pystatus.rst b/docs/i3pystatus.rst index 47e669d..a49f031 100644 --- a/docs/i3pystatus.rst +++ b/docs/i3pystatus.rst @@ -31,6 +31,7 @@ example configuration for the MaildirMail backend: .. code:: python + from i3pystatus.mail import maildir status.register("mail", backends=[maildir.MaildirMail( directory="/home/name/Mail/inbox") From 8254eaf43e6ddddb83fd756cf17eadbfdefdabb8 Mon Sep 17 00:00:00 2001 From: Holden Salomon Date: Thu, 11 Feb 2016 12:00:49 -0500 Subject: [PATCH 081/168] Made the openvpn module more flexible by allowing custom status commands, making it compatible with networkmanager based VPNs. Also changed the spelling of colour_down and colour_up to color so the module is the same as all of the others --- i3pystatus/openvpn.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/i3pystatus/openvpn.py b/i3pystatus/openvpn.py index 43acd63..55b7c96 100644 --- a/i3pystatus/openvpn.py +++ b/i3pystatus/openvpn.py @@ -23,7 +23,7 @@ class OpenVPN(IntervalModule): status_up = '▲' status_down = '▼' format = "{vpn_name} {status}" - status_command = "bash -c \"systemctl show openvpn@%(vpn_name)s | grep -oP 'ActiveState=\K(\w+)'\"" + status_command = "bash -c \"systemctl show openvpn@%(vpn_name)s | grep 'ActiveState=active'" label = '' vpn_name = '' @@ -35,6 +35,7 @@ class OpenVPN(IntervalModule): ("status_down", "Symbol to display when down"), ("status_up", "Symbol to display when up"), ("vpn_name", "Name of VPN"), + ("status_command", "command to find out if the VPN is active"), ) def init(self): @@ -45,7 +46,7 @@ class OpenVPN(IntervalModule): command_result = run_through_shell(self.status_command % {'vpn_name': self.vpn_name}, enable_shell=True) output = command_result.out.strip() - if output == 'active': + if output: color = self.color_up status = self.status_up else: From 58ca67109c7c32dfa4363526f6ca16c7902d70eb Mon Sep 17 00:00:00 2001 From: enkore Date: Fri, 12 Feb 2016 14:12:43 +0100 Subject: [PATCH 082/168] Change default mode to standalone=True --- docs/changelog.rst | 1 + docs/configuration.rst | 9 +++++---- i3pystatus/__init__.py | 2 +- i3pystatus/core/__init__.py | 16 ++++++++-------- i3pystatus/updates/__init__.py | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 11e56a1..aa7a1f3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,7 @@ master branch * Server used for checking internet connectivity is now an option (``internet_check`` of :py:class:`.Status`) * Added double click support for click events * Formatter data is now available with most modules for program callbacks +* Changed default mode to standalone mode * ``self`` is not passed anymore by default to external Python callbacks (see :py:func:`.get_module`) * :py:mod:`.dota2wins`: Now accepts usernames in place of a Steam ID * dota2wins: Changed win percentage to be a float diff --git a/docs/configuration.rst b/docs/configuration.rst index 0247b8c..eefe795 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -13,7 +13,7 @@ example): from i3pystatus import Status - status = Status(standalone=True) + status = Status() # Displays clock like this: # Tue 30 Jul 11:59:46 PM KW31 @@ -470,9 +470,10 @@ Or make two modules look like one. Refreshing the bar ------------------ -The whole bar can be refreshed by sending SIGUSR1 signal to i3pystatus process. -This feature is available only in standalone operation (:py:class:`.Status` was -created with ``standalone=True`` parameter). +The whole bar can be refreshed by sending SIGUSR1 signal to i3pystatus +process. This feature is not available in chained mode +(:py:class:`.Status` was created with ``standalone=False`` parameter +and gets it's input from ``i3status`` or a similar program). To find the PID of the i3pystatus process look for the ``status_command`` you use in your i3 config file. diff --git a/i3pystatus/__init__.py b/i3pystatus/__init__.py index 15effd2..e6c4ac8 100644 --- a/i3pystatus/__init__.py +++ b/i3pystatus/__init__.py @@ -28,6 +28,6 @@ logger.setLevel(logging.CRITICAL) def main(): from i3pystatus.clock import Clock - status = Status(standalone=True) + status = Status() status.register(Clock()) status.run() diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index c560084..16e3bd3 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -48,20 +48,20 @@ class Status: :param tuple internet_check: Address of server that will be used to check for internet connection by :py:class:`.internet`. """ - def __init__(self, standalone=False, **kwargs): + def __init__(self, standalone=True, click_events=True, interval=1, + input_stream=None, logfile=None, internet_check=None): self.standalone = standalone - self.click_events = kwargs.get("click_events", True if standalone else False) - interval = kwargs.get("interval", 1) - input_stream = kwargs.get("input_stream", sys.stdin) - if "logfile" in kwargs: + self.click_events = standalone and click_events + input_stream = input_stream or sys.stdin + if logfile: logger = logging.getLogger("i3pystatus") for handler in logger.handlers: logger.removeHandler(handler) - handler = logging.FileHandler(kwargs["logfile"], delay=True) + handler = logging.FileHandler(logfile, delay=True) logger.addHandler(handler) logger.setLevel(logging.CRITICAL) - if "internet_check" in kwargs: - util.internet.address = kwargs["internet_check"] + if internet_check: + util.internet.address = internet_check self.modules = util.ModuleList(self, ClassFinder(Module)) if self.standalone: diff --git a/i3pystatus/updates/__init__.py b/i3pystatus/updates/__init__.py index de45265..e0d1a69 100644 --- a/i3pystatus/updates/__init__.py +++ b/i3pystatus/updates/__init__.py @@ -33,7 +33,7 @@ class Updates(Module): from i3pystatus import Status from i3pystatus.updates import pacman, cower - status = Status(standalone=True) + status = Status() status.register("updates", format = "Updates: {count}", From f228d5347cd9a606a0cbd0f84fb6dc00ad1218d6 Mon Sep 17 00:00:00 2001 From: enkore Date: Sun, 14 Feb 2016 01:18:39 +0100 Subject: [PATCH 083/168] 3.34 --- docs/changelog.rst | 7 ++++++- setup.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index aa7a1f3..4482a58 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,7 +5,10 @@ Changelog master branch +++++++++++++ -.. _r3.34: +.. _r3.35: + +3.34 (2016-02-14) ++++++++++++++++++ * New modules - :py:mod:`.moon`: Display moon phase @@ -46,6 +49,8 @@ master branch * updates: Additional formatters for every backend (to distinguish pacman vs. AUR updates, for example) * :py:mod:`.reddit`: Added link\_karma and comment\_karma formatters * :py:mod:`.openvpn`: Configurable up/down symbols +* openvpn: Rename colour_up/colour_down to color_up/color_down +* openvpn: NetworkManager compatibility * :py:mod:`.disk`: Improved handling of unmounted drives. Previously the free space of the underlying filesystem would be reported if the path provided was a directory but not a valid mountpoint. This adds diff --git a/setup.py b/setup.py index 5bc2d0d..2af4da2 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup setup(name="i3pystatus", - version="3.33", + version="3.34", description="A complete replacement for i3status", url="http://github.com/enkore/i3pystatus", license="MIT", From 3addb4b22935c60a887cfbedae1df6846b9352e3 Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 14 Feb 2016 12:15:22 +0800 Subject: [PATCH 084/168] Update PlaintextKeyring example with new import location. --- docs/configuration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index eefe795..dffcd34 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -146,7 +146,7 @@ If you don't want to use the default you can set a specific keyring like so: .. code:: python - from keyring.backends.file import PlaintextKeyring + from keyrings.alt.file import PlaintextKeyring status.register('github', keyring_backend=PlaintextKeyring()) i3pystatus will locate and set the credentials during the module From 577e3df17fd51d779088124d660cf365d6d75e76 Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 14 Feb 2016 14:49:55 +0800 Subject: [PATCH 085/168] Fix bugs in setting_util.py. An exception was thrown while loading classes and the -l paramater wasn't working correctly. --- i3pystatus/tools/setting_util.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/i3pystatus/tools/setting_util.py b/i3pystatus/tools/setting_util.py index 60bed5d..3263552 100755 --- a/i3pystatus/tools/setting_util.py +++ b/i3pystatus/tools/setting_util.py @@ -13,6 +13,7 @@ import keyring import i3pystatus from i3pystatus import Module, SettingsBase from i3pystatus.core import ClassFinder +from i3pystatus.core.exceptions import ConfigInvalidModuleError def signal_handler(signal, frame): @@ -58,11 +59,12 @@ def get_credential_modules(): for module_name in get_modules(): try: module = class_finder.get_module(module_name) - except ImportError: + clazz = class_finder.get_class(module) + except (ImportError, ConfigInvalidModuleError): if verbose: print("ImportError while importing", module_name) continue - 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)) @@ -97,11 +99,11 @@ Options: if "-l" in sys.argv: for name, module in credential_modules.items(): print(name) - for credential in enumerate_choices(module["credentials"]): - if keyring.get_password("%s.%s" % (module["key"], credential), getpass.getuser()): + for credential in module['credentials']: + if keyring.get_password("%s.%s" % (module['key'], credential), getpass.getuser()): print(" - %s: set" % credential) - else: - print(" (none stored)") + else: + print(" - %s: unset" % credential) return choices = list(credential_modules.keys()) From 4abff4bc0a02596e2dd81de23aa1faa4b3ff631d Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 14 Feb 2016 19:13:42 +0800 Subject: [PATCH 086/168] Place mock modules in correct location --- dev-requirements.txt | 2 -- docs/conf.py | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index d1484b2..ed38b0e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -3,5 +3,3 @@ sphinx>=1.1 colour>=0.0.5 mock>=1.0 pep8>=1.5.7 -google-api-python-client>=1.4.2 -python-dateutil>=2.4.2 diff --git a/docs/conf.py b/docs/conf.py index 7da9e13..8d46f11 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,12 @@ MOCK_MODULES = [ "speedtest_cli", "pyzabbix", "vk", + "google-api-python-client", + "dateutil", + "httplib2", + "oauth2client", + "apiclient" + ] for mod_name in MOCK_MODULES: From ee5a7061070dea48907cbad46d19fa85c08520db Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 14 Feb 2016 22:07:40 +0800 Subject: [PATCH 087/168] Document that the keyring example requires the keyrings.alt package. --- docs/configuration.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index dffcd34..6c8a0c8 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -146,6 +146,7 @@ If you don't want to use the default you can set a specific keyring like so: .. code:: python + # Requires the keyrings.alt package from keyrings.alt.file import PlaintextKeyring status.register('github', keyring_backend=PlaintextKeyring()) From 7117df6fb2f53e1bdf771561a9d5e8dde7c2a206 Mon Sep 17 00:00:00 2001 From: enkore Date: Tue, 16 Feb 2016 13:40:30 +0100 Subject: [PATCH 088/168] Display exception class name (in-line exception display) cf #322 --- i3pystatus/core/threading.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/i3pystatus/core/threading.py b/i3pystatus/core/threading.py index f6eee9b..c4ff0a5 100644 --- a/i3pystatus/core/threading.py +++ b/i3pystatus/core/threading.py @@ -75,12 +75,16 @@ class ExceptionWrapper(Wrapper): if hasattr(self.workload, "logger"): self.workload.logger.error(message, exc_info=True) self.workload.output = { - "full_text": "{}: {}".format(self.workload.__class__.__name__, - self.format_error(str(sys.exc_info()[1]))), + "full_text": self.format_exception(), "color": "#FF0000", } - def format_error(self, exception_message): + def format_exception(self): + type, value, _ = sys.exc_info() + exception = self.truncate_error("%s: %s" % (type.__name__, value)) + return "%s: %s" % (self.workload.__class__.__name__, exception) + + def truncate_error(self, exception_message): if hasattr(self.workload, 'max_error_len'): error_len = self.workload.max_error_len if len(exception_message) > error_len: From ee79a691b2efeae80b698ea8d617e3df453c4068 Mon Sep 17 00:00:00 2001 From: facetoe Date: Thu, 18 Feb 2016 20:19:31 +0800 Subject: [PATCH 089/168] Fix dateTime bug identified in #322 Also fixed bug where an event with no title would cause a crash. --- i3pystatus/google_calendar.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/i3pystatus/google_calendar.py b/i3pystatus/google_calendar.py index f995403..9fadd01 100644 --- a/i3pystatus/google_calendar.py +++ b/i3pystatus/google_calendar.py @@ -26,6 +26,8 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): * `{kind}` — type of event * `{status}` — eg, confirmed * `{summary}` — essentially the title + * `{remaining_time}` - how long remaining until the event + * `{start_time}` - when this event starts * `{htmlLink}` — link to the calendar event @@ -61,7 +63,7 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): display_event = self.get_next_event() if display_event: - start_time = parser.parse(display_event['start']['dateTime']) + start_time = display_event['start_time'] now = datetime.datetime.now(tz=pytz.UTC) alert_time = now + datetime.timedelta(seconds=self.urgent_seconds) @@ -85,12 +87,21 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): def get_next_event(self): for event in self.get_events(): - start_time = parser.parse(event['start']['dateTime']) + # If we don't have a dateTime just make do with a date. + if 'dateTime' not in event['start']: + event['start_time'] = pytz.utc.localize(parser.parse(event['start']['date'])) + else: + event['start_time'] = parser.parse(event['start']['dateTime']) + now = datetime.datetime.now(tz=pytz.UTC) if 'recurringEventId' in event and self.skip_recurring: continue - elif start_time < now: + elif event['start_time'] < now: continue + + # It is possible for there to be no title... + if 'summary' not in event: + event['summary'] = '(no title)' return event def get_events(self): From 357aaa76a113fe4862980a2e11091ba998790359 Mon Sep 17 00:00:00 2001 From: facetoe Date: Sat, 20 Feb 2016 22:51:27 +0800 Subject: [PATCH 090/168] Fix uneven quotes in status command. --- i3pystatus/openvpn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/openvpn.py b/i3pystatus/openvpn.py index 55b7c96..48d1792 100644 --- a/i3pystatus/openvpn.py +++ b/i3pystatus/openvpn.py @@ -23,7 +23,7 @@ class OpenVPN(IntervalModule): status_up = '▲' status_down = '▼' format = "{vpn_name} {status}" - status_command = "bash -c \"systemctl show openvpn@%(vpn_name)s | grep 'ActiveState=active'" + status_command = "bash -c 'systemctl show openvpn@%(vpn_name)s | grep ActiveState=active'" label = '' vpn_name = '' From 530b9f2d8849f2b7f04fab72bfabaa81523f3706 Mon Sep 17 00:00:00 2001 From: Lennart Braun Date: Fri, 26 Feb 2016 15:34:38 +0100 Subject: [PATCH 091/168] Add mpd password support --- i3pystatus/mpd.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 1aed61a..feeb178 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -39,10 +39,12 @@ class MPD(IntervalModule): ("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."), ("hide_inactive", "Hides status information when MPD is not running"), + ("password", "A password for access to MPD. (This is sent in cleartext to the server.)"), ) host = "localhost" port = 6600 + password = None s = None format = "{title} {status}" status = { @@ -71,6 +73,9 @@ class MPD(IntervalModule): self.s.connect(self.host) sock = self.s sock.recv(8192) + if self.password is not None: + sock.send('password "{}"\n'.format(self.password).encode()) + sock.recv(8192) sock.send((command + "\n").encode("utf-8")) try: reply = sock.recv(16384).decode("utf-8") From f2b444712671cc4935bb47ecb62ab81929783687 Mon Sep 17 00:00:00 2001 From: Lennart Braun Date: Fri, 26 Feb 2016 16:18:12 +0100 Subject: [PATCH 092/168] Fix explicit utf-8 encoding --- i3pystatus/mpd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index feeb178..5d9ffd7 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -74,7 +74,7 @@ class MPD(IntervalModule): sock = self.s sock.recv(8192) if self.password is not None: - sock.send('password "{}"\n'.format(self.password).encode()) + sock.send('password "{}"\n'.format(self.password).encode("utf-8")) sock.recv(8192) sock.send((command + "\n").encode("utf-8")) try: From 7d905742121169de5414ff348275659cdf33f29d Mon Sep 17 00:00:00 2001 From: Kenny Keslar Date: Wed, 2 Mar 2016 15:40:16 -0500 Subject: [PATCH 093/168] Pulseaudio - use execute helper & fix program check --- i3pystatus/pulseaudio/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/i3pystatus/pulseaudio/__init__.py b/i3pystatus/pulseaudio/__init__.py index dc1e920..09f9977 100644 --- a/i3pystatus/pulseaudio/__init__.py +++ b/i3pystatus/pulseaudio/__init__.py @@ -4,8 +4,8 @@ from i3pystatus.core.color import ColorRangeModule from i3pystatus.core.util import make_vertical_bar, make_bar from .pulse import * +from i3pystatus.core.command import execute from i3pystatus import Module -import subprocess class PulseAudio(Module, ColorRangeModule): @@ -80,7 +80,7 @@ class PulseAudio(Module, ColorRangeModule): self.colors = self.get_hex_color_range(self.color_muted, self.color_unmuted, 100) # Check that we have amixer for toggling mute/unmute and incrementing/decrementing volume - self.has_amixer = shutil.which('alsamixer') is not None + self.has_amixer = shutil.which('amixer') is not None def request_update(self, context): """Requests a sink info update (sink_info_cb is called)""" @@ -172,14 +172,14 @@ class PulseAudio(Module, ColorRangeModule): command += 'unmute' else: command += 'mute' - subprocess.run(command.split()) + execute(command) def increase_volume(self): if self.has_amixer: command = "amixer -q -D pulse sset Master %s%%+" % self.step - subprocess.run(command.split()) + execute(command) def decrease_volume(self): if self.has_amixer: command = "amixer -q -D pulse sset Master %s%%-" % self.step - subprocess.run(command.split()) + execute(command) From 693d2ebdb702be536cdc393f66384f0451966904 Mon Sep 17 00:00:00 2001 From: Kenny Keslar Date: Wed, 2 Mar 2016 15:57:57 -0500 Subject: [PATCH 094/168] Catch exceptions in button handlers --- i3pystatus/core/modules.py | 41 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index f60ac3e..952b221 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -94,25 +94,28 @@ class Module(SettingsBase): else: args = [] - our_method = is_method_of(cb, self) - if callable(cb) and not our_method: - self.__log_button_event(button, cb, args, "Python callback") - cb(*args) - elif our_method: - cb(self, *args) - elif hasattr(self, cb): - if cb is not "run": - # CommandEndpoint already calls run() after every - # callback to instantly update any changed state due - # to the callback's actions. - self.__log_button_event(button, cb, args, "Member callback") - getattr(self, cb)(*args) - else: - self.__log_button_event(button, cb, args, "External command") - if hasattr(self, "data"): - args = [arg.format(**self.data) for arg in args] - cb = cb.format(**self.data) - execute(cb + " " + " ".join(args), detach=True) + try: + our_method = is_method_of(cb, self) + if callable(cb) and not our_method: + self.__log_button_event(button, cb, args, "Python callback") + cb(*args) + elif our_method: + cb(self, *args) + elif hasattr(self, cb): + if cb is not "run": + # CommandEndpoint already calls run() after every + # callback to instantly update any changed state due + # to the callback's actions. + self.__log_button_event(button, cb, args, "Member callback") + getattr(self, cb)(*args) + else: + self.__log_button_event(button, cb, args, "External command") + if hasattr(self, "data"): + args = [arg.format(**self.data) for arg in args] + cb = cb.format(**self.data) + execute(cb + " " + " ".join(args), detach=True) + except Exception as e: + self.logger.critical("Exception while processing button callback: {!r}".format(e)) # Notify status handler try: From ad67762b43ec40ea8b44115c63a777ba97a4e0d9 Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Thu, 10 Mar 2016 10:51:19 -0800 Subject: [PATCH 095/168] plexstatus: add unittests Add unittest for the plexstatus module. Currently verifies that if there is no stream, the output is null, and if there is a stream, it is properly getting parsed out of the xml. --- tests/plexstatus.xml | 19 +++++++++++++++++++ tests/test_plexstatus.py | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/plexstatus.xml create mode 100644 tests/test_plexstatus.py diff --git a/tests/plexstatus.xml b/tests/plexstatus.xml new file mode 100644 index 0000000..bc40818 --- /dev/null +++ b/tests/plexstatus.xml @@ -0,0 +1,19 @@ + + + + diff --git a/tests/test_plexstatus.py b/tests/test_plexstatus.py new file mode 100644 index 0000000..93d50a3 --- /dev/null +++ b/tests/test_plexstatus.py @@ -0,0 +1,41 @@ +""" +Basic test for the plexstatus module +""" + +import unittest +from mock import patch +from unittest.mock import MagicMock +from urllib.request import urlopen +from i3pystatus import plexstatus + + +class PlexstatusTest(unittest.TestCase): + + @patch('i3pystatus.plexstatus.urlopen', autospec=True) + def test_not_stream(self, urlopen): + """ + Test output when nothing is being streamed + """ + null_stream = b'\n\n' + plexstatus.urlopen.return_value.read.return_value = null_stream + plxstat = plexstatus.Plexstatus(apikey='111111', address='127.0.0.1') + plxstat.run() + self.assertTrue(plxstat.output == {}) + + @patch('i3pystatus.plexstatus.urlopen', autospec=True) + def test_streaming(self, urlopen): + """ + Test output from side-loaded xml (generated from a real plex server + response) + """ + streamfile = open('plexstatus.xml', 'rb') + stream = streamfile.read() + streamfile.close() + plexstatus.urlopen.return_value.read.return_value = stream + plxstat = plexstatus.Plexstatus(apikey='111111', address='127.0.0.1') + plxstat.run() + self.assertTrue(plxstat.output['full_text'] == 'Chrome: Big Buck Bunny') + + +if __name__ == '__main__': + unittest.main() From 7c6114df216029b4c6a605fe011cc75d69f9e32e Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Thu, 10 Mar 2016 11:39:21 -0800 Subject: [PATCH 096/168] plexstatus: pull xml inline To remove external/filesystem deps, move the stream info xml inline. --- tests/plexstatus.xml | 19 ------------------- tests/test_plexstatus.py | 25 +++++++++++++++++++++---- 2 files changed, 21 insertions(+), 23 deletions(-) delete mode 100644 tests/plexstatus.xml diff --git a/tests/plexstatus.xml b/tests/plexstatus.xml deleted file mode 100644 index bc40818..0000000 --- a/tests/plexstatus.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - diff --git a/tests/test_plexstatus.py b/tests/test_plexstatus.py index 93d50a3..fab9468 100644 --- a/tests/test_plexstatus.py +++ b/tests/test_plexstatus.py @@ -8,6 +8,26 @@ from unittest.mock import MagicMock from urllib.request import urlopen from i3pystatus import plexstatus +# inline xml of stream info from plex server +STREAM = b''' + + +''' class PlexstatusTest(unittest.TestCase): @@ -28,10 +48,7 @@ class PlexstatusTest(unittest.TestCase): Test output from side-loaded xml (generated from a real plex server response) """ - streamfile = open('plexstatus.xml', 'rb') - stream = streamfile.read() - streamfile.close() - plexstatus.urlopen.return_value.read.return_value = stream + plexstatus.urlopen.return_value.read.return_value = STREAM plxstat = plexstatus.Plexstatus(apikey='111111', address='127.0.0.1') plxstat.run() self.assertTrue(plxstat.output['full_text'] == 'Chrome: Big Buck Bunny') From 86c73464761aa26ef18c60ae86bbfee36139a777 Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Thu, 10 Mar 2016 12:15:27 -0800 Subject: [PATCH 097/168] remove tabs --- tests/test_plexstatus.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_plexstatus.py b/tests/test_plexstatus.py index fab9468..ad8e15e 100644 --- a/tests/test_plexstatus.py +++ b/tests/test_plexstatus.py @@ -11,22 +11,22 @@ from i3pystatus import plexstatus # inline xml of stream info from plex server STREAM = b''' - + ''' class PlexstatusTest(unittest.TestCase): From afb726e37d5039a9bd48738abe7f5438079099af Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Fri, 11 Mar 2016 07:30:59 -0800 Subject: [PATCH 098/168] pep8 compliance --- tests/test_plexstatus.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_plexstatus.py b/tests/test_plexstatus.py index ad8e15e..5399c62 100644 --- a/tests/test_plexstatus.py +++ b/tests/test_plexstatus.py @@ -29,6 +29,7 @@ STREAM = b''' ''' + class PlexstatusTest(unittest.TestCase): @patch('i3pystatus.plexstatus.urlopen', autospec=True) From ff31e08b74b3915c051dd850d486feaa2dac0b8c Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sat, 12 Mar 2016 22:34:34 -0600 Subject: [PATCH 099/168] Add settings entry for color_icons This allows this item to be overridden in i3pystatus with custom colors and icons. --- i3pystatus/weather.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/i3pystatus/weather.py b/i3pystatus/weather.py index ed6d6e9..a71242d 100644 --- a/i3pystatus/weather.py +++ b/i3pystatus/weather.py @@ -28,6 +28,9 @@ class Weather(IntervalModule): settings = ( ("location_code", "Location code from www.weather.com"), ("colorize", "Enable color with temperature and UTF-8 icons."), + ("color_icons", "Dictionary mapping weather conditions to tuples " + "containing a UTF-8 code for the icon, and the color " + "to be used."), ("units", "Celsius (metric) or Fahrenheit (imperial)"), "format", ) From 7163122e1bb36ccf446e09a6720db89908a27130 Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Wed, 16 Mar 2016 16:27:02 -0700 Subject: [PATCH 100/168] last.fm: initial commit of last.fm module This last.fm module will report to the status bar the current track that is being played. Last.fm requires an API key for access to their APIs, so the user must provide their own API key which can be easily obtained for free from http://www.last.fm/api/. --- i3pystatus/lastfm.py | 56 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_lastfm.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 i3pystatus/lastfm.py create mode 100644 tests/test_lastfm.py diff --git a/i3pystatus/lastfm.py b/i3pystatus/lastfm.py new file mode 100644 index 0000000..3ac37d7 --- /dev/null +++ b/i3pystatus/lastfm.py @@ -0,0 +1,56 @@ +from urllib.request import urlopen +import json +from i3pystatus import IntervalModule + + +class LastFM(IntervalModule): + """ + Displays currently playing song as reported by last.fm. Get your API key + from http://www.last.fm/api. + """ + + settings = ( + ("apikey", "API key used to make calls to last.fm."), + ("user", "Name of last.fm user to track."), + ("playing_format", "Output format when a song is playing"), + ("stopped_format", "Output format when nothing is playing"), + "playing_color", + "stopped_color", + "interval", + ) + required = ("apikey", "user") + playing_color = 'FFFFFF' + stopped_color = '000000' + interval = 5 + playing_format = "{artist} - {track}" + stopped_format = "" + + def run(self): + apiurl = 'http://ws.audioscrobbler.com/2.0/' + uri = '?method=user.getrecenttracks'\ + '&user=%s&api_key=%s' \ + '&format=json&'\ + 'limit=1' % (self.user, self.apikey) + content = urlopen(apiurl + uri).read() + responsestr = content.decode('utf-8') + response = json.loads(responsestr) + + try: + track = response['recenttracks']['track'][0] + if track['@attr']['nowplaying'] == 'true': + cdict = { + "artist": track['artist']['#text'], + "track": track['name'], + "album": track['album']['#text'], + } + + self.data = cdict + self.output = { + "full_text": self.playing_format.format(**cdict), + "color": self.playing_color + } + except KeyError: + self.output = { + "full_text": self.stopped_format, + "color": self.stopped_color + } diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py new file mode 100644 index 0000000..ae75f94 --- /dev/null +++ b/tests/test_lastfm.py @@ -0,0 +1,40 @@ +""" +Basic test for the plexstatus module +""" + +import unittest +from mock import patch +from unittest.mock import MagicMock +from urllib.request import urlopen +from i3pystatus import lastfm + +# inline json of stream info from last.fm APIs +ACTIVE_CONTENT = b'''{"recenttracks":{"track":[{"artist":{"#text":"Tuomas Holopainen","mbid":"ae4c7a2c-fb0f-4bfd-a9be-c815d00030b8"},"name":"The Last Sled","streamable":"0","mbid":"61739f28-42ab-4f5c-88ca-69715fb9f96b","album":{"#text":"Music Inspired by the Life and Times of Scrooge","mbid":"da39ccaf-10af-40c1-a49c-c8ebb95adb2c"},"url":"http://www.last.fm/music/Tuomas+Holopainen/_/The+Last+Sled","image":[{"#text":"http://img2-ak.lst.fm/i/u/34s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"small"},{"#text":"http://img2-ak.lst.fm/i/u/64s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"medium"},{"#text":"http://img2-ak.lst.fm/i/u/174s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"large"},{"#text":"http://img2-ak.lst.fm/i/u/300x300/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"extralarge"}],"@attr":{"nowplaying":"true"}},{"artist":{"#text":"Gungor","mbid":"f68ad842-13b9-4302-8eeb-ade8af70ce96"},"name":"Beautiful Things","streamable":"0","mbid":"f8f52d8f-f934-41ed-92dc-2ea81e708393","album":{"#text":"Beautiful Things","mbid":"f054aca4-b472-42d2-984b-9c52f75da83a"},"url":"http://www.last.fm/music/Gungor/_/Beautiful+Things","image":[{"#text":"http://img2-ak.lst.fm/i/u/34s/e8ba0f40c87040599f1680f04a002d31.png","size":"small"},{"#text":"http://img2-ak.lst.fm/i/u/64s/e8ba0f40c87040599f1680f04a002d31.png","size":"medium"},{"#text":"http://img2-ak.lst.fm/i/u/174s/e8ba0f40c87040599f1680f04a002d31.png","size":"large"},{"#text":"http://img2-ak.lst.fm/i/u/300x300/e8ba0f40c87040599f1680f04a002d31.png","size":"extralarge"}],"date":{"uts":"1458168739","#text":"16 Mar 2016, 22:52"}}],"@attr":{"user":"drwahl","page":"1","perPage":"1","totalPages":"15018","total":"15018"}}}''' + +INACTIVE_CONTENT = b'''{"recenttracks":{"track":[{"artist":{"#text":"Tuomas Holopainen","mbid":"ae4c7a2c-fb0f-4bfd-a9be-c815d00030b8"},"name":"The Last Sled","streamable":"0","mbid":"61739f28-42ab-4f5c-88ca-69715fb9f96b","album":{"#text":"Music Inspired by the Life and Times of Scrooge","mbid":"da39ccaf-10af-40c1-a49c-c8ebb95adb2c"},"url":"http://www.last.fm/music/Tuomas+Holopainen/_/The+Last+Sled","image":[{"#text":"http://img2-ak.lst.fm/i/u/34s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"small"},{"#text":"http://img2-ak.lst.fm/i/u/64s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"medium"},{"#text":"http://img2-ak.lst.fm/i/u/174s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"large"},{"#text":"http://img2-ak.lst.fm/i/u/300x300/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"extralarge"}],"date":{"uts":"1458169072","#text":"16 Mar 2016, 22:57"}}],"@attr":{"user":"drwahl","page":"1","perPage":"1","totalPages":"15019","total":"15019"}}}''' + +class LastFMTest(unittest.TestCase): + + @patch('i3pystatus.lastfm.urlopen', autospec=True) + def test_not_stream(self, urlopen): + """ + Test output when no song is being played + """ + lastfm.urlopen.return_value.read.return_value = INACTIVE_CONTENT + i3lastfm = lastfm.LastFM(apikey='111111', user='drwahl') + i3lastfm.run() + self.assertTrue(i3lastfm.output['full_text'] == i3lastfm.stopped_format) + + @patch('i3pystatus.lastfm.urlopen', autospec=True) + def test_streaming(self, urlopen): + """ + Test output when a song is being played + """ + lastfm.urlopen.return_value.read.return_value = ACTIVE_CONTENT + i3lastfm = lastfm.LastFM(apikey='111111', user='drwahl') + i3lastfm.run() + self.assertTrue(i3lastfm.output['full_text'] == 'Tuomas Holopainen - The Last Sled') + + +if __name__ == '__main__': + unittest.main() From 2a886ffa1e6db356d3d1e9edceeab2cd88d00360 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 16 Mar 2016 21:45:26 -0500 Subject: [PATCH 101/168] Fix KeyError when using backend-specific updates formatter When a backend-specific formatter (i.e. ``{Pacman}``, ``{Cower}``, etc.) is used, and the initial "working" status is set, the loop in which the update totals is compiled has not yet run, leading to a KeyError. This commit fixes the traceback by setting initial values of "?" for these formatters before the initial "working" status is set. --- i3pystatus/updates/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/i3pystatus/updates/__init__.py b/i3pystatus/updates/__init__.py index e0d1a69..7fd15ac 100644 --- a/i3pystatus/updates/__init__.py +++ b/i3pystatus/updates/__init__.py @@ -92,6 +92,11 @@ class Updates(Module): @require(internet) def check_updates(self): + for backend in self.backends: + key = backend.__class__.__name__ + if key not in self.data: + self.data[key] = '?' + self.output = { "full_text": formatp(self.format_working, **self.data).strip(), "color": self.color_working, From e27c0421b7f3e002a2c15dfe5ed0eec724d22fda Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Thu, 17 Mar 2016 19:59:02 -0700 Subject: [PATCH 102/168] test_lastfm: pep8 compliance --- tests/test_lastfm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py index ae75f94..7225669 100644 --- a/tests/test_lastfm.py +++ b/tests/test_lastfm.py @@ -13,6 +13,7 @@ ACTIVE_CONTENT = b'''{"recenttracks":{"track":[{"artist":{"#text":"Tuomas Holopa INACTIVE_CONTENT = b'''{"recenttracks":{"track":[{"artist":{"#text":"Tuomas Holopainen","mbid":"ae4c7a2c-fb0f-4bfd-a9be-c815d00030b8"},"name":"The Last Sled","streamable":"0","mbid":"61739f28-42ab-4f5c-88ca-69715fb9f96b","album":{"#text":"Music Inspired by the Life and Times of Scrooge","mbid":"da39ccaf-10af-40c1-a49c-c8ebb95adb2c"},"url":"http://www.last.fm/music/Tuomas+Holopainen/_/The+Last+Sled","image":[{"#text":"http://img2-ak.lst.fm/i/u/34s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"small"},{"#text":"http://img2-ak.lst.fm/i/u/64s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"medium"},{"#text":"http://img2-ak.lst.fm/i/u/174s/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"large"},{"#text":"http://img2-ak.lst.fm/i/u/300x300/b3efb95e128e427cc25bbf9d97e9f57c.png","size":"extralarge"}],"date":{"uts":"1458169072","#text":"16 Mar 2016, 22:57"}}],"@attr":{"user":"drwahl","page":"1","perPage":"1","totalPages":"15019","total":"15019"}}}''' + class LastFMTest(unittest.TestCase): @patch('i3pystatus.lastfm.urlopen', autospec=True) From e529fa8c95b58d8d2a2e05efeeb68ed7f6a3debc Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Fri, 16 Oct 2015 20:49:27 -0700 Subject: [PATCH 103/168] dota2wins: truncate win percentage Use only 2 decimals for win percentage so we don't fill all of the status bar with decimal places. --- i3pystatus/dota2wins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/i3pystatus/dota2wins.py b/i3pystatus/dota2wins.py index db85bae..2c50520 100644 --- a/i3pystatus/dota2wins.py +++ b/i3pystatus/dota2wins.py @@ -101,6 +101,7 @@ class Dota2wins(IntervalModule): "screenname": screenname, "wins": wins, "losses": losses, + "win_percent": win_percent, "win_percent": "%.2f" % win_percent, } From 9a091ab024c9b028420b0bacfd77313efee6f1f1 Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Mon, 21 Mar 2016 19:51:44 -0300 Subject: [PATCH 104/168] Improve solaar error handling/display Improve errors shown by solaar plugin, also cleaning up how error are internally handled. See #335 --- i3pystatus/solaar.py | 46 ++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/i3pystatus/solaar.py b/i3pystatus/solaar.py index fc336ad..04f2e69 100644 --- a/i3pystatus/solaar.py +++ b/i3pystatus/solaar.py @@ -2,6 +2,17 @@ from i3pystatus import IntervalModule from i3pystatus.core.command import run_through_shell +class DeviceNotFound(Exception): + pass + + +class NoBatteryStatus(Exception): + message = None + + def __init__(self, message): + self.message = message + + class Solaar(IntervalModule): """ Shows status and load percentage of bluetooth-device @@ -29,8 +40,8 @@ class Solaar(IntervalModule): for line in out.split('\n'): if line.count(self.nameOfDevice) > 0 and line.count(':') > 0: numberOfDevice = line.split(':')[0] - return(0, numberOfDevice) - return(1, 0) + return numberOfDevice + raise DeviceNotFound() def findBatteryStatus(self, numberOfDevice): command = 'solaar-cli show -v %s' % (numberOfDevice) @@ -39,24 +50,25 @@ class Solaar(IntervalModule): if line.count('Battery') > 0: if line.count(':') > 0: batterystatus = line.split(':')[1].strip().strip(",") - return(0, batterystatus) + return batterystatus + elif line.count('offline'): + raise NoBatteryStatus('offline') else: - return(1, 0) - return(1, 0) + raise NoBatteryStatus('unknown') + raise NoBatteryStatus('unknown/error') def run(self): self.output = {} - rcfindDeviceNumber = self.findDeviceNumber() - if rcfindDeviceNumber[0] != 0: - output = "problem finding device %s" % (self.nameOfDevice) + + try: + device_number = self.findDeviceNumber() + output = self.findBatteryStatus(device_number) + self.output['color'] = self.color + except DeviceNotFound: + output = "device absent" self.output['color'] = self.error_color - else: - numberOfDevice = rcfindDeviceNumber[1] - rcfindBatteryStatus = self.findBatteryStatus(numberOfDevice) - if rcfindBatteryStatus[0] != 0: - output = "problem finding battery status device %s" % (self.nameOfDevice) - self.output['color'] = self.error_color - else: - output = self.findBatteryStatus(self.findDeviceNumber()[1])[1] - self.output['color'] = self.color + except NoBatteryStatus as e: + output = e.message + self.output['color'] = self.error_color + self.output['full_text'] = output From 6a7e80ec3883eadc43cd0b4bbecfc99f7e76f257 Mon Sep 17 00:00:00 2001 From: Jo De Boeck Date: Tue, 22 Mar 2016 12:13:29 +0200 Subject: [PATCH 105/168] now_playing: use get_dbus_method for compatibility Calling method directly on dbus proxy object does not always work. --- i3pystatus/now_playing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index bdb00e1..2ca27ea 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -69,7 +69,9 @@ class NowPlaying(IntervalModule): old_player = None def find_player(self): - players = [a for a in dbus.SessionBus().get_object("org.freedesktop.DBus", "/org/freedesktop/DBus").ListNames() if a.startswith("org.mpris.MediaPlayer2.")] + obj = dbus.SessionBus().get_object("org.freedesktop.DBus", "/org/freedesktop/DBus") + method = obj.get_dbus_method('ListNames', 'org.freedesktop.DBus') + players = [a for a in method() if a.startswith("org.mpris.MediaPlayer2.")] if self.old_player in players: return self.old_player if not players: From d15b3173f1564bf1fc7e091cfa5cfa47e0ebefbd Mon Sep 17 00:00:00 2001 From: Mathis FELARDOS Date: Wed, 23 Mar 2016 08:00:59 +0100 Subject: [PATCH 106/168] core: Change command_endpoint and on_click for supporting i3bar mouse positions * command_endpoint: get the position from the mouse when the click occured. Parameters names are set here: pos_x pos_y. Positions are passed to on_click through keyword arguments. * Module: - change __log_button_event, __button_callback_handler and on_click methods for handling keyword arguments. - "Member", "Method" and "Python" callbacks are handled by detecting if they have pos_x or pos_y as parameters, or if they have a keyword arguments. The special case of wrapped callbacks (made with get_module decorator for example) is handled in a similar way. - "External command" is handled by considering the position as a format dictionary. Actually no distinctions are made of how self.data and the new keyword argument are treated on this. - the parameter kwargs as been added to the doc string of on_click. * MultiClickHandler: now handle keyword arguments. Signed-off-by: Mathis FELARDOS --- CONTRIBUTORS | 1 + i3pystatus/core/__init__.py | 13 +++++-- i3pystatus/core/modules.py | 74 +++++++++++++++++++++++++------------ i3pystatus/core/util.py | 8 ++-- 4 files changed, 67 insertions(+), 29 deletions(-) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 66e1301..22d9d7a 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -39,6 +39,7 @@ Kenneth Lyons krypt-n Lukáš Mandák Łukasz Jędrzejewski +Mathis Felardos Matthias Pronk Matthieu Coudron Matus Telgarsky diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index 16e3bd3..5800500 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -29,9 +29,16 @@ class CommandEndpoint: self.thread.start() def _command_endpoint(self): - for command in self.io_handler_factory().read(): - target_module = self.modules.get(command["instance"]) - if target_module and target_module.on_click(command["button"]): + for cmd in self.io_handler_factory().read(): + target_module = self.modules.get(cmd["instance"]) + + try: + pos = {"pos_x": int(cmd["x"]), + "pos_y": int(cmd["y"])} + except Exception: + pos = {} + + if target_module and target_module.on_click(cmd["button"], **pos): target_module.run() self.io.async_refresh() diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index 952b221..1461d5b 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -1,11 +1,11 @@ import inspect +import traceback from i3pystatus.core.settings import SettingsBase from i3pystatus.core.threading import Manager from i3pystatus.core.util import (convert_position, MultiClickHandler) from i3pystatus.core.command import execute -from i3pystatus.core.command import run_through_shell def is_method_of(method, object): @@ -78,15 +78,32 @@ class Module(SettingsBase): def run(self): pass - def __log_button_event(self, button, cb, args, action): - msg = "{}: button={}, cb='{}', args={}, type='{}'".format( - self.__name__, button, cb, args, action) + def __log_button_event(self, button, cb, args, action, **kwargs): + msg = "{}: button={}, cb='{}', args={}, kwargs={}, type='{}'".format( + self.__name__, button, cb, args, kwargs, action) self.logger.debug(msg) - def __button_callback_handler(self, button, cb): + def __button_callback_handler(self, button, cb, **kwargs): + + def call_callback(cb, *args, **kwargs): + # Recover the function if wrapped (with get_module for example) + wrapped_cb = getattr(cb, "__wrapped__", None) + if wrapped_cb: + locals()["self"] = self # Add self to the local stack frame + args_spec = inspect.getargspec(wrapped_cb) + else: + args_spec = inspect.getargspec(cb) + + # Remove all variables present in kwargs that are not used in the + # callback, except if there is a keyword argument. + if not args_spec.keywords: + kwargs = {k: v for k, v in kwargs.items() + if k in args_spec.args} + cb(*args, **kwargs) + if not cb: self.__log_button_event(button, None, None, - "No callback attached") + "No callback attached", **kwargs) return False if isinstance(cb, list): @@ -97,25 +114,35 @@ class Module(SettingsBase): try: our_method = is_method_of(cb, self) if callable(cb) and not our_method: - self.__log_button_event(button, cb, args, "Python callback") - cb(*args) + self.__log_button_event(button, cb, args, + "Python callback", **kwargs) + call_callback(cb, *args, **kwargs) elif our_method: - cb(self, *args) + self.__log_button_event(button, cb, args, + "Method callback", **kwargs) + call_callback(cb, self, *args, **kwargs) elif hasattr(self, cb): if cb is not "run": # CommandEndpoint already calls run() after every # callback to instantly update any changed state due # to the callback's actions. - self.__log_button_event(button, cb, args, "Member callback") - getattr(self, cb)(*args) + self.__log_button_event(button, cb, args, + "Member callback", **kwargs) + call_callback(getattr(self, cb), *args, **kwargs) else: - self.__log_button_event(button, cb, args, "External command") + self.__log_button_event(button, cb, args, + "External command", **kwargs) + if hasattr(self, "data"): - args = [arg.format(**self.data) for arg in args] - cb = cb.format(**self.data) + kwargs.update(self.data) + + args = [str(arg).format(**kwargs) for arg in args] + cb = cb.format(**kwargs) execute(cb + " " + " ".join(args), detach=True) except Exception as e: - self.logger.critical("Exception while processing button callback: {!r}".format(e)) + self.logger.critical("Exception while processing button " + "callback: {!r}".format(e)) + self.logger.critical(traceback.format_exc()) # Notify status handler try: @@ -123,7 +150,7 @@ class Module(SettingsBase): except: pass - def on_click(self, button): + def on_click(self, button, **kwargs): """ Maps a click event with its associated callback. @@ -144,8 +171,8 @@ class Module(SettingsBase): 1. If null callback (``None``), no action is taken. 2. If it's a `python function`, call it and pass any additional arguments. - 3. If it's name of a `member method` of current module (string), call it - and pass any additional arguments. + 3. If it's name of a `member method` of current module (string), call + it and pass any additional arguments. 4. If the name does not match with `member method` name execute program with such name. @@ -153,7 +180,8 @@ class Module(SettingsBase): callback settings and examples. :param button: The ID of button event received from i3bar. - :type button: int + :param kwargs: Further information received from i3bar like the + positions of the mouse where the click occured. :return: Returns ``True`` if a valid callback action was executed. ``False`` otherwise. :rtype: bool @@ -184,13 +212,13 @@ class Module(SettingsBase): # Get callback function cb = getattr(self, 'on_%s' % action, None) - has_double_handler = getattr(self, 'on_%s' % double_action, None) is not None - delay_execution = (not double and has_double_handler) + double_handler = getattr(self, 'on_%s' % double_action, None) + delay_execution = (not double and double_handler) if delay_execution: - m_click.set_timer(button, cb) + m_click.set_timer(button, cb, **kwargs) else: - self.__button_callback_handler(button, cb) + self.__button_callback_handler(button, cb, **kwargs) return True diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index a3c40bd..d9b4d37 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -514,8 +514,9 @@ class MultiClickHandler(object): self.timer = None self.button = None self.cb = None + self.kwargs = None - def set_timer(self, button, cb): + def set_timer(self, button, cb, **kwargs): with self.lock: self.clear_timer() @@ -524,6 +525,7 @@ class MultiClickHandler(object): args=[self._timer_id]) self.button = button self.cb = cb + self.kwargs = kwargs self.timer.start() @@ -544,7 +546,7 @@ class MultiClickHandler(object): with self.lock: if self._timer_id != timer_id: return - self.callback_handler(self.button, self.cb) + self.callback_handler(self.button, self.cb, **self.kwargs) self.clear_timer() def check_double(self, button): @@ -553,7 +555,7 @@ class MultiClickHandler(object): ret = True if button != self.button: - self.callback_handler(self.button, self.cb) + self.callback_handler(self.button, self.cb, **self.kwargs) ret = False self.clear_timer() From 98e8a1cc048fda88b800663a60229a57852432db Mon Sep 17 00:00:00 2001 From: Mathis FELARDOS Date: Wed, 23 Mar 2016 10:14:14 +0100 Subject: [PATCH 107/168] core: handle callbacks that are not functions on Python 3.3 * Fix inspect.getargspec issue for non functions callbacks by creating an empty ArgSpec. There for we ignore all kwargs parameters. Signed-off-by: Mathis FELARDOS --- i3pystatus/core/modules.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index 1461d5b..d32a491 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -90,9 +90,14 @@ class Module(SettingsBase): wrapped_cb = getattr(cb, "__wrapped__", None) if wrapped_cb: locals()["self"] = self # Add self to the local stack frame - args_spec = inspect.getargspec(wrapped_cb) + tmp_cb = wrapped_cb else: - args_spec = inspect.getargspec(cb) + tmp_cb = cb + + try: + args_spec = inspect.getargspec(tmp_cb) + except Exception: + args_spec = inspect.ArgSpec([], None, None, None) # Remove all variables present in kwargs that are not used in the # callback, except if there is a keyword argument. From b711ba96ed69a264e134a3ace4e0ff96222adde2 Mon Sep 17 00:00:00 2001 From: Jo De Boeck Date: Wed, 23 Mar 2016 20:35:09 +0200 Subject: [PATCH 108/168] Pulseaudio: Display/control active sink Make sink a property which checks which sink is currently active. Use pactl to control volumes which gets standard insalled with libpulse which is already a requirement. --- i3pystatus/pulseaudio/__init__.py | 38 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/i3pystatus/pulseaudio/__init__.py b/i3pystatus/pulseaudio/__init__.py index 09f9977..9f11721 100644 --- a/i3pystatus/pulseaudio/__init__.py +++ b/i3pystatus/pulseaudio/__init__.py @@ -6,6 +6,7 @@ from .pulse import * from i3pystatus.core.command import execute from i3pystatus import Module +import subprocess class PulseAudio(Module, ColorRangeModule): @@ -79,9 +80,6 @@ class PulseAudio(Module, ColorRangeModule): self.colors = self.get_hex_color_range(self.color_muted, self.color_unmuted, 100) - # Check that we have amixer for toggling mute/unmute and incrementing/decrementing volume - self.has_amixer = shutil.which('amixer') is not None - def request_update(self, context): """Requests a sink info update (sink_info_cb is called)""" pa_operation_unref(pa_context_get_sink_info_by_name( @@ -90,12 +88,24 @@ class PulseAudio(Module, ColorRangeModule): def success_cb(self, context, success, userdata): pass + @property + def sink(self): + sinks = subprocess.Popen(['pactl', 'list', 'short', 'sinks'], stdout=subprocess.PIPE).stdout.read() + bestsink = None + state = b'DEFAULT' + for sink in sinks.splitlines(): + attribs = sink.split() + if attribs[-1] == b'RUNNING': + bestsink = attribs[1] + state = 'RUNNING' + elif attribs[-1] == b'IDLE' and state == b'DEFAULT': + bestsink = attribs[1] + state = b'IDLE' + return bestsink + def server_info_cb(self, context, server_info_p, userdata): """Retrieves the default sink and calls request_update""" server_info = server_info_p.contents - - self.sink = server_info.default_sink_name - self.request_update(context) def context_notify_cb(self, context, _): @@ -166,20 +176,10 @@ class PulseAudio(Module, ColorRangeModule): } def switch_mute(self): - if self.has_amixer: - command = "amixer -q -D pulse sset Master " - if self.currently_muted: - command += 'unmute' - else: - command += 'mute' - execute(command) + subprocess.Popen(['pactl', 'set-sink-mute', self.sink, "toggle"]) def increase_volume(self): - if self.has_amixer: - command = "amixer -q -D pulse sset Master %s%%+" % self.step - execute(command) + subprocess.Popen(['pactl', 'set-sink-volume', self.sink, "+%s%%" % self.step]) def decrease_volume(self): - if self.has_amixer: - command = "amixer -q -D pulse sset Master %s%%-" % self.step - execute(command) + subprocess.Popen(['pactl', 'set-sink-volume', self.sink, "-%s%%" % self.step]) From 6161d591d2e55b8bc46d0d04cb878b9ff73f3d92 Mon Sep 17 00:00:00 2001 From: Jo De Boeck Date: Thu, 24 Mar 2016 21:25:27 +0200 Subject: [PATCH 109/168] Now Playing: Check for mpris service in activatable services --- i3pystatus/now_playing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index 2fecf65..1c066de 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -70,8 +70,14 @@ class NowPlaying(IntervalModule): def find_player(self): obj = dbus.SessionBus().get_object("org.freedesktop.DBus", "/org/freedesktop/DBus") - method = obj.get_dbus_method('ListNames', 'org.freedesktop.DBus') - players = [a for a in method() if a.startswith("org.mpris.MediaPlayer2.")] + + def get_players(methodname): + method = obj.get_dbus_method(methodname, 'org.freedesktop.DBus') + return [a for a in method() if a.startswith("org.mpris.MediaPlayer2.")] + + players = get_players('ListNames') + if not players: + players = get_players('ListActivatableNames') if self.old_player in players: return self.old_player if not players: From 95f625cd6bb13abaa65a96e03c8607a81416870e Mon Sep 17 00:00:00 2001 From: Mathis FELARDOS Date: Fri, 25 Mar 2016 04:32:29 +0100 Subject: [PATCH 110/168] core: Add the middle click support and unhandled button support * This commit fix #259 * Support of middle click button * Add an unhandled click events for all button that will not be handled * Remove the return type of on_click: it became useless now * Fix the unique call of on_click in CommandEndpoint Signed-off-by: Mathis FELARDOS --- i3pystatus/core/__init__.py | 3 ++- i3pystatus/core/modules.py | 30 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index 5800500..f0c7173 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -38,7 +38,8 @@ class CommandEndpoint: except Exception: pos = {} - if target_module and target_module.on_click(cmd["button"], **pos): + if target_module: + target_module.on_click(cmd["button"], **pos) target_module.run() self.io.async_refresh() diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index d32a491..cb29164 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -26,26 +26,36 @@ class Module(SettingsBase): settings = ( ('on_leftclick', "Callback called on left click (see :ref:`callbacks`)"), + ('on_middleclick', "Callback called on middle click (see :ref:`callbacks`)"), ('on_rightclick', "Callback called on right click (see :ref:`callbacks`)"), ('on_upscroll', "Callback called on scrolling up (see :ref:`callbacks`)"), ('on_downscroll', "Callback called on scrolling down (see :ref:`callbacks`)"), ('on_doubleleftclick', "Callback called on double left click (see :ref:`callbacks`)"), + ('on_doubleleftclick', "Callback called on double left click (see :ref:`callbacks`)"), + ('on_doublemiddleclick', "Callback called on double middle click (see :ref:`callbacks`)"), ('on_doublerightclick', "Callback called on double right click (see :ref:`callbacks`)"), ('on_doubleupscroll', "Callback called on double scroll up (see :ref:`callbacks`)"), ('on_doubledownscroll', "Callback called on double scroll down (see :ref:`callbacks`)"), + ('on_unhandledclick', "Callback called on unhandled click (see :ref:`callbacks`)"), + ('on_doubleunhandledclick', "Callback called on double unhandled click (see :ref:`callbacks`)"), ('multi_click_timeout', "Time (in seconds) before a single click is executed."), ('hints', "Additional output blocks for module output (see :ref:`hints`)"), ) on_leftclick = None + on_middleclick = None on_rightclick = None on_upscroll = None on_downscroll = None on_doubleleftclick = None + on_doublemiddleclick = None on_doublerightclick = None on_doubleupscroll = None on_doubledownscroll = None + on_unhandledclick = None + on_doubleunhandledclick = None + multi_click_timeout = 0.25 hints = {"markup": "none"} @@ -189,21 +199,15 @@ class Module(SettingsBase): positions of the mouse where the click occured. :return: Returns ``True`` if a valid callback action was executed. ``False`` otherwise. - :rtype: bool - """ - if button == 1: # Left mouse button - action = 'leftclick' - elif button == 3: # Right mouse button - action = 'rightclick' - elif button == 4: # mouse wheel up - action = 'upscroll' - elif button == 5: # mouse wheel down - action = 'downscroll' - else: + actions = ['leftclick', 'middleclick', 'rightclick', + 'upscroll', 'downscroll'] + try: + action = actions[button - 1] + except (TypeError, IndexError): self.__log_button_event(button, None, None, "Unhandled button") - return False + action = "unhandled" m_click = self.__multi_click @@ -225,8 +229,6 @@ class Module(SettingsBase): else: self.__button_callback_handler(button, cb, **kwargs) - return True - def move(self, position): self.position = position return self From 4867afeda116fa6170a6b36486a4aadc174bb652 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Fri, 25 Mar 2016 00:29:19 -0500 Subject: [PATCH 111/168] Change color_no_updates to None to default to i3bar color If ``statusline`` is not set in ``~/.config/i3/config``, it defaults to ``#ffffff`` (white). Therefore, the default behavior of the updates module in this case is to show white for both ``color_no_updates`` (system is up-to-date) and ``color_working`` (update check in progress). However, if one sets ``statusline`` in their ``~/.config/i3/config``, then the color will be white when the system is up-to-date, forcing the user to manually set ``color_no_updates`` if they would prefer it match their default i3bar color. This commit changes the default value of ``color_no_updates`` to ``None`` so that it matches the default i3bar unless overridden. --- i3pystatus/updates/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/updates/__init__.py b/i3pystatus/updates/__init__.py index 7fd15ac..eee9e90 100644 --- a/i3pystatus/updates/__init__.py +++ b/i3pystatus/updates/__init__.py @@ -65,7 +65,7 @@ class Updates(Module): format_no_updates = None format_working = None color = "#00DD00" - color_no_updates = "#FFFFFF" + color_no_updates = None color_working = None on_leftclick = "run" From bf71e78e238347d6eb746107d48df95dfd7cc5fd Mon Sep 17 00:00:00 2001 From: Raphael Scholer Date: Fri, 25 Mar 2016 18:02:20 +0100 Subject: [PATCH 112/168] dpms: Allow a different format string when DPMS is disabled. Substitution of {status} is still possible, for backwards compatibility. --- i3pystatus/dpms.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/i3pystatus/dpms.py b/i3pystatus/dpms.py index 644fed8..7ccae71 100644 --- a/i3pystatus/dpms.py +++ b/i3pystatus/dpms.py @@ -17,6 +17,7 @@ class DPMS(IntervalModule): settings = ( "format", + "format_disabled", "color", "color_disabled", ) @@ -24,6 +25,7 @@ class DPMS(IntervalModule): color_disabled = "#AAAAAA" color = "#FFFFFF" format = "DPMS: {status}" + format_disabled = "DPMS: {status}" on_leftclick = "toggle_dpms" @@ -33,10 +35,16 @@ class DPMS(IntervalModule): self.status = run_through_shell("xset -q | grep -q 'DPMS is Enabled'", True).rc == 0 - self.output = { - "full_text": self.format.format(status='on' if self.status else 'off'), - "color": self.color if self.status else self.color_disabled - } + if self.status: + self.output = { + "full_text": self.format.format(status="off"), + "color": self.color + } + else: + self.output = { + "full_text": self.format_disabled.format(status="off"), + "color": self.color_disabled + } def toggle_dpms(self): if self.status: From f26a9f9d1d9bf910fd97a8512ee2b516f6db875b Mon Sep 17 00:00:00 2001 From: Mathis FELARDOS Date: Fri, 25 Mar 2016 22:18:14 +0100 Subject: [PATCH 113/168] core: Improve the support of other button * This commit fix #259 * Change 'unhandled' callback by 'other' * Add the an optional parameter 'button_id' for all callbacks Signed-off-by: Mathis FELARDOS --- i3pystatus/core/__init__.py | 10 ++++++---- i3pystatus/core/modules.py | 30 ++++++++++++++++-------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index f0c7173..4bd54a6 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -32,14 +32,16 @@ class CommandEndpoint: for cmd in self.io_handler_factory().read(): target_module = self.modules.get(cmd["instance"]) + button = cmd["button"] + kwargs = {"button_id": button} try: - pos = {"pos_x": int(cmd["x"]), - "pos_y": int(cmd["y"])} + kwargs.update({"pos_x": cmd["x"], + "pos_y": cmd["y"]}) except Exception: - pos = {} + continue if target_module: - target_module.on_click(cmd["button"], **pos) + target_module.on_click(button, **kwargs) target_module.run() self.io.async_refresh() diff --git a/i3pystatus/core/modules.py b/i3pystatus/core/modules.py index cb29164..41a5936 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -36,8 +36,8 @@ class Module(SettingsBase): ('on_doublerightclick', "Callback called on double right click (see :ref:`callbacks`)"), ('on_doubleupscroll', "Callback called on double scroll up (see :ref:`callbacks`)"), ('on_doubledownscroll', "Callback called on double scroll down (see :ref:`callbacks`)"), - ('on_unhandledclick', "Callback called on unhandled click (see :ref:`callbacks`)"), - ('on_doubleunhandledclick', "Callback called on double unhandled click (see :ref:`callbacks`)"), + ('on_otherclick', "Callback called on other click (see :ref:`callbacks`)"), + ('on_doubleotherclick', "Callback called on double other click (see :ref:`callbacks`)"), ('multi_click_timeout', "Time (in seconds) before a single click is executed."), ('hints', "Additional output blocks for module output (see :ref:`hints`)"), ) @@ -53,8 +53,8 @@ class Module(SettingsBase): on_doubleupscroll = None on_doubledownscroll = None - on_unhandledclick = None - on_doubleunhandledclick = None + on_otherclick = None + on_doubleotherclick = None multi_click_timeout = 0.25 @@ -171,14 +171,16 @@ class Module(SettingsBase): Currently implemented events are: - =========== ================ ========= - Event Callback setting Button ID - =========== ================ ========= - Left click on_leftclick 1 - Right click on_rightclick 3 - Scroll up on_upscroll 4 - Scroll down on_downscroll 5 - =========== ================ ========= + ============ ================ ========= + Event Callback setting Button ID + ============ ================ ========= + Left click on_leftclick 1 + Middle click on_middleclick 2 + Right click on_rightclick 3 + Scroll up on_upscroll 4 + Scroll down on_downscroll 5 + Others on_otherclick > 5 + ============ ================ ========= The action is determined by the nature (type and value) of the callback setting in the following order: @@ -206,8 +208,8 @@ class Module(SettingsBase): try: action = actions[button - 1] except (TypeError, IndexError): - self.__log_button_event(button, None, None, "Unhandled button") - action = "unhandled" + self.__log_button_event(button, None, None, "Other button") + action = "otherclick" m_click = self.__multi_click From 3422469df0d50bedb30094864e474170a0573e27 Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 27 Mar 2016 09:58:45 +0800 Subject: [PATCH 114/168] Fix sink selection bug. A sink in the SUSPENDED state would never be selected. Not sure if this solution is correct... --- i3pystatus/pulseaudio/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/i3pystatus/pulseaudio/__init__.py b/i3pystatus/pulseaudio/__init__.py index 9f11721..9e42c24 100644 --- a/i3pystatus/pulseaudio/__init__.py +++ b/i3pystatus/pulseaudio/__init__.py @@ -95,10 +95,11 @@ class PulseAudio(Module, ColorRangeModule): state = b'DEFAULT' for sink in sinks.splitlines(): attribs = sink.split() - if attribs[-1] == b'RUNNING': + sink_state = attribs[-1] + if sink_state == b'RUNNING': bestsink = attribs[1] state = 'RUNNING' - elif attribs[-1] == b'IDLE' and state == b'DEFAULT': + elif (sink_state == b'IDLE' or sink_state == b'SUSPENDED') and state == b'DEFAULT': bestsink = attribs[1] state = b'IDLE' return bestsink From 0c17b6f435da1a0e3b0e7538dbc98506ac47e04c Mon Sep 17 00:00:00 2001 From: facetoe Date: Sun, 27 Mar 2016 12:16:15 +0800 Subject: [PATCH 115/168] Disable update check that breaks everything --- i3pystatus/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/reddit.py b/i3pystatus/reddit.py index 5a8ce0a..5c9bca3 100644 --- a/i3pystatus/reddit.py +++ b/i3pystatus/reddit.py @@ -106,7 +106,7 @@ class Reddit(IntervalModule): def connect(self): if not self.reddit_session: - self.reddit_session = praw.Reddit(user_agent='i3pystatus') + self.reddit_session = praw.Reddit(user_agent='i3pystatus', disable_update_check=True) return self.reddit_session def get_redditor(self, reddit): From b111fd62f1d796d5bf3ff6348bca9dea85a1228c Mon Sep 17 00:00:00 2001 From: facetoe Date: Mon, 28 Mar 2016 08:46:25 +0800 Subject: [PATCH 116/168] Clean up code. --- i3pystatus/pulseaudio/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/i3pystatus/pulseaudio/__init__.py b/i3pystatus/pulseaudio/__init__.py index 9e42c24..3fb22f5 100644 --- a/i3pystatus/pulseaudio/__init__.py +++ b/i3pystatus/pulseaudio/__init__.py @@ -99,9 +99,8 @@ class PulseAudio(Module, ColorRangeModule): if sink_state == b'RUNNING': bestsink = attribs[1] state = 'RUNNING' - elif (sink_state == b'IDLE' or sink_state == b'SUSPENDED') and state == b'DEFAULT': + elif sink_state in (b'IDLE', b'SUSPENDED') and state == b'DEFAULT': bestsink = attribs[1] - state = b'IDLE' return bestsink def server_info_cb(self, context, server_info_p, userdata): From 38c56616d88d0e55c436cab25fec86c0162a333c Mon Sep 17 00:00:00 2001 From: Mathis FELARDOS Date: Mon, 28 Mar 2016 15:57:52 +0200 Subject: [PATCH 117/168] ping: add ping module This module allow an user to display the current ping value between himself and another host. It can be useful for: * Testing your connection all the time * Checking if one of your server is alive Signed-off-by: Mathis FELARDOS --- i3pystatus/ping.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 i3pystatus/ping.py diff --git a/i3pystatus/ping.py b/i3pystatus/ping.py new file mode 100644 index 0000000..5fb50cf --- /dev/null +++ b/i3pystatus/ping.py @@ -0,0 +1,85 @@ +import subprocess + +from i3pystatus import IntervalModule + + +class Ping(IntervalModule): + """ + This module display the ping value between your computer and a host. + + ``switch_state`` callback can disable the Ping when desired. + ``host`` propertie can be changed for set a specific host. + + .. rubric:: Available formatters + + * {ping} the ping value in milliseconds. + """ + + interval = 5 + + settings = ( + "color", + "format", + ("color_disabled", "color when disabled"), + ("color_down", "color when ping fail"), + ("format_disabled", "format string when disabled"), + ("format_down", "format string when ping fail"), + ("host", "host to ping") + ) + + color = "#FFFFFF" + color_down = "#FF0000" + color_disabled = None + + disabled = False + + format = "{ping} ms" + format_down = "down" + format_disabled = None + + host = "8.8.8.8" + + on_leftclick = "switch_state" + + def init(self): + if not self.color_down: + self.color_down = self.color + if not self.format_disabled: + self.format_disabled = self.format_down + if not self.color_disabled: + self.color_disabled = self.color_down + + def switch_state(self): + self.disabled = not self.disabled + + def ping_host(self): + p = subprocess.Popen(["ping", "-c1", "-w%d" % self.interval, + self.host], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + out, _ = p.communicate() + if p.returncode == 0: + return float(out.decode().split("\n")[1] + .split("time=")[1].split()[0]) + else: + return None + + def run(self): + if self.disabled: + self.output = { + "full_text": self.format_disabled, + "color": self.color_disabled + } + return + + ping = self.ping_host() + if not ping: + self.output = { + "full_text": self.format_down, + "color": self.color_down + } + return + + self.output = { + "full_text": self.format.format(ping=ping), + "color": self.color + } From 23747d8181e7b9c576d07c4b3d50a0ac8a60cd3e Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 01:35:31 -0500 Subject: [PATCH 118/168] Add wunderground module This module tries to use as much of the same variable naming conventions that the ``weather`` module uses as possible, to make transitioning easier in the future in case we decide to make a base class for all modules which provide weather data. An API key is required to use this module, information on obtaining one can be found in the docstring. --- i3pystatus/wunderground.py | 219 +++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 i3pystatus/wunderground.py diff --git a/i3pystatus/wunderground.py b/i3pystatus/wunderground.py new file mode 100644 index 0000000..3299c33 --- /dev/null +++ b/i3pystatus/wunderground.py @@ -0,0 +1,219 @@ +from i3pystatus import IntervalModule +from i3pystatus.core.util import user_open, internet, require + +from datetime import datetime +from urllib.request import urlopen +import json +import re + +GEOLOOKUP_URL = 'http://api.wunderground.com/api/%s/geolookup%s/q/%s.json' +STATION_LOOKUP_URL = 'http://api.wunderground.com/api/%s/conditions/q/%s.json' + + +class Wunderground(IntervalModule): + ''' + This module retrieves weather from the Weather Underground API. + + .. note:: + A Weather Underground API key is required to use this module, you can + sign up for one for a developer API key free at + https://www.wunderground.com/weather/api/ + + A developer API key is allowed 500 queries per day. + + Valid values for ``location_code`` include: + + * **State/City_Name** - CA/San_Francisco + * **Country/City** - France/Paris + * **Geolocation by IP** - autoip + * **Zip or Postal Code** - 60616 + * **ICAO Airport Code** - icao:LAX + * **Latitude/Longitude** - 41.8301943,-87.6342619 + * **Personal Weather Station (PWS)** - pws:KILCHICA30 + + When not using a PWS, the location will be queried, and the closest + station will be used. When possible, it is recommended to use a PWS + location, as this will result in fewer API calls. + + .. rubric:: Available formatters + + * `{city}` — Location of weather observation + * `{conditon}` — Current condition (Rain, Snow, Overcast, etc.) + * `{observation_time}` — Time of weather observation (supports strftime format flags) + * `{current_temp}` — Current temperature, excluding unit + * `{degrees}` — ``°C`` if ``units`` is set to ``metric``, otherwise ``°F`` + * `{feelslike}` — Wunderground "Feels Like" temperature, excluding unit + * `{current_wind}` — Wind speed in mph/kph, excluding unit + * `{current_wind_direction}` — Wind direction + * `{current_wind_gust}` — Speed of wind gusts in mph/kph, excluding unit + * `{pressure_in}` — Barometric pressure (in inches), excluding unit + * `{pressure_mb}` — Barometric pressure (in millibars), excluding unit + * `{pressure_trend}` — ``+`` (rising) or ``-`` (falling) + * `{visibility}` — Visibility in mi/km, excluding unit + * `{humidity}` — Current humidity, excluding percentage symbol + * `{dewpoint}` — Dewpoint temperature, excluding unit + * `{uv_index}` — UV Index + + ''' + + interval = 300 + + settings = ( + ('api_key', 'Weather Underground API key'), + ('location_code', 'Location code from www.weather.com'), + ('units', 'Celsius (metric) or Fahrenheit (imperial)'), + ('use_pws', 'Set to False to use only airport stations'), + ('error_log', 'If set, tracebacks will be logged to this file'), + 'format', + ) + required = ('api_key', 'location_code') + + api_key = None + location_code = None + units = "metric" + format = "{current_temp}{degrees}" + use_pws = True + error_log = None + + station_id = None + forecast_url = None + + on_leftclick = 'open_wunderground' + + def open_wunderground(self): + ''' + Open the forecast URL, if one was retrieved + ''' + if self.forecast_url and self.forecast_url != 'N/A': + user_open(self.forecast_url) + + def api_request(self, url): + ''' + Execute an HTTP POST to the specified URL and return the content + ''' + with urlopen(url) as content: + try: + content_type = dict(content.getheaders())['Content-Type'] + charset = re.search(r'charset=(.*)', content_type).group(1) + except AttributeError: + charset = 'utf-8' + response = json.loads(content.read().decode(charset)) + try: + raise Exception(response['response']['error']['description']) + except KeyError: + pass + return response + + def geolookup(self): + ''' + Use the location_code to perform a geolookup and find the closest + station. If the location is a pws or icao station ID, no lookup will be + peformed. + ''' + if self.station_id is None: + try: + for no_lookup in ('pws', 'icao'): + sid = self.location_code.partition(no_lookup + ':')[-1] + if sid: + self.station_id = self.location_code + return + except AttributeError: + # Numeric or some other type, either way we'll just stringify + # it below and perform a lookup. + pass + + extra_opts = '/pws:0' if not self.use_pws else '' + api_url = GEOLOOKUP_URL % (self.api_key, + extra_opts, + self.location_code) + response = self.api_request(api_url) + station_type = 'pws' if self.use_pws else 'airport' + try: + stations = response['location']['nearby_weather_stations'] + nearest = stations[station_type]['station'][0] + except (KeyError, IndexError): + raise Exception('No locations matched location_code %s' + % self.location_code) + + if self.use_pws: + nearest_pws = nearest.get('id', '') + if not nearest_pws: + raise Exception('No id entry for station') + self.station_id = 'pws:%s' % nearest_pws + else: + nearest_airport = nearest.get('icao', '') + if not nearest_airport: + raise Exception('No icao entry for station') + self.station_id = 'icao:%s' % nearest_airport + + def query_station(self): + ''' + Query a specific station + ''' + # If necessary, do a geolookup to set the station_id + self.geolookup() + + query_url = STATION_LOOKUP_URL % (self.api_key, self.station_id) + try: + response = self.api_request(query_url)['current_observation'] + self.forecast_url = response.pop('forecast_url', None) + except KeyError: + raise Exception('No weather data found for %s' % self.station_id) + + def _find(key, data=None): + data = data or response + return data.get(key, 'N/A') + + if self.units == 'metric': + temp_unit = 'c' + speed_unit = 'kph' + distance_unit = 'km' + else: + temp_unit = 'f' + speed_unit = 'mph' + distance_unit = 'mi' + + try: + observation_time = int(_find('observation_epoch')) + except TypeError: + observation_time = 0 + + return dict( + forecast_url=_find('forecast_url'), + city=_find('city', response['observation_location']), + condition=_find('weather'), + observation_time=datetime.fromtimestamp(observation_time), + current_temp=_find('temp_' + temp_unit), + feelslike=_find('feelslike_' + temp_unit), + current_wind=_find('wind_' + speed_unit), + current_wind_direction=_find('wind_dir'), + current_wind_gust=_find('wind_gust_' + speed_unit), + pressure_in=_find('pressure_in'), + pressure_mb=_find('pressure_mb'), + pressure_trend=_find('pressure_trend'), + visibility=_find('visibility_' + distance_unit), + humidity=_find('relative_humidity').rstrip('%'), + dewpoint=_find('dewpoint_' + temp_unit), + uv_index=_find('uv'), + ) + + @require(internet) + def run(self): + try: + result = self.query_station() + except Exception as exc: + if self.error_log: + import traceback + with open(self.error_log, 'a') as f: + f.write('%s : An exception was raised:\n' % + datetime.isoformat(datetime.now())) + f.write(''.join(traceback.format_exc())) + f.write(80 * '-' + '\n') + raise + + result['degrees'] = '°%s' % ('C' if self.units == 'metric' else 'F') + + self.output = { + "full_text": self.format.format(**result), + # "color": self.color # TODO: add some sort of color effect + } From 048fd8f83db807b4a5fb346695faca53d855070b Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 01:45:34 -0500 Subject: [PATCH 119/168] Add link to PWS stations --- i3pystatus/wunderground.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/i3pystatus/wunderground.py b/i3pystatus/wunderground.py index 3299c33..8a9b941 100644 --- a/i3pystatus/wunderground.py +++ b/i3pystatus/wunderground.py @@ -31,9 +31,11 @@ class Wunderground(IntervalModule): * **Latitude/Longitude** - 41.8301943,-87.6342619 * **Personal Weather Station (PWS)** - pws:KILCHICA30 - When not using a PWS, the location will be queried, and the closest - station will be used. When possible, it is recommended to use a PWS - location, as this will result in fewer API calls. + When not using a ``pws`` or ``icao`` station ID, the location will be + queried, and the closest station will be used. For a list of PWS + station IDs, visit the following URL: + + http://www.wunderground.com/weatherstation/ListStations.asp .. rubric:: Available formatters From 3c955ac8977f46a9306aaae0f996a3130099b5b0 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 01:50:05 -0500 Subject: [PATCH 120/168] Use ob_url instead of forecast_url, it is more accurate --- i3pystatus/wunderground.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/i3pystatus/wunderground.py b/i3pystatus/wunderground.py index 8a9b941..2edd8b5 100644 --- a/i3pystatus/wunderground.py +++ b/i3pystatus/wunderground.py @@ -158,7 +158,7 @@ class Wunderground(IntervalModule): query_url = STATION_LOOKUP_URL % (self.api_key, self.station_id) try: response = self.api_request(query_url)['current_observation'] - self.forecast_url = response.pop('forecast_url', None) + self.forecast_url = response.pop('ob_url', None) except KeyError: raise Exception('No weather data found for %s' % self.station_id) @@ -181,7 +181,6 @@ class Wunderground(IntervalModule): observation_time = 0 return dict( - forecast_url=_find('forecast_url'), city=_find('city', response['observation_location']), condition=_find('weather'), observation_time=datetime.fromtimestamp(observation_time), From 8d1646e92cabe2b471b797e74fb628f2f8d34a92 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 14 Mar 2016 02:17:07 -0500 Subject: [PATCH 121/168] Add myself to CONTRIBUTORS --- CONTRIBUTORS | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 22d9d7a..97aef19 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -17,6 +17,7 @@ David Garcia Quintas David Wahlstrom dubwoc eBrnd +Erik Johnson enkore facetoe Frank Tackitt From dbfa2672360bb7a632fad84bb34b3393bce9aa86 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:26:15 -0500 Subject: [PATCH 122/168] Link to weather backends --- docs/i3pystatus.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/i3pystatus.rst b/docs/i3pystatus.rst index a49f031..b9bda22 100644 --- a/docs/i3pystatus.rst +++ b/docs/i3pystatus.rst @@ -52,3 +52,12 @@ Update Backends .. autogen:: i3pystatus.updates SettingsBase .. nothin' + +.. _weatherbackends: + +Weather Backends +---------------- + +.. autogen:: i3pystatus.weather SettingsBase + + .. nothin' From f0d19aacec9bfb5249cf3413c3a726400e17ae97 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:26:36 -0500 Subject: [PATCH 123/168] Initial commit of general weather module --- i3pystatus/weather/__init__.py | 141 +++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 i3pystatus/weather/__init__.py diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py new file mode 100644 index 0000000..c8fa2ed --- /dev/null +++ b/i3pystatus/weather/__init__.py @@ -0,0 +1,141 @@ +from i3pystatus import SettingsBase, IntervalModule, formatp +from i3pystatus.core.util import user_open, internet, require + + +class Backend(SettingsBase): + settings = () + + +class Weather(IntervalModule): + ''' + This is a generic weather-checker which must use a configured weather + backend. For list of all available backends see :ref:`weatherbackends`. + + Left clicking on the module will launch the forecast page for the location + being checked. + + .. _weather-formatters: + + .. rubric:: Available formatters + + * `{city}` — Location of weather observation + * `{condition}` — Current weather condition (Rain, Snow, Overcast, etc.) + * `{icon}` — Icon representing the current weather condition + * `{observation_time}` — Time of weather observation (supports strftime format flags) + * `{current_temp}` — Current temperature, excluding unit + * `{low_temp}` — Forecasted low temperature, excluding unit + * `{high_temp}` — Forecasted high temperature, excluding unit (may be + empty in the late afternoon) + * `{temp_unit}` — Either ``°C`` or ``°F``, depending on whether metric or + * `{feelslike}` — "Feels Like" temperature, excluding unit + * `{dewpoint}` — Dewpoint temperature, excluding unit + imperial units are being used + * `{wind_speed}` — Wind speed, excluding unit + * `{wind_unit}` — Either ``kph`` or ``mph``, depending on whether metric or + imperial units are being used + * `{wind_direction}` — Wind direction + * `{wind_gust}` — Speed of wind gusts in mph/kph, excluding unit + * `{pressure}` — Barometric pressure, excluding unit + * `{pressure_unit}` — ``mb`` or ``in``, depending on whether metric or + imperial units are being used + * `{pressure_trend}` — ``+`` if rising, ``-`` if falling, or an empty + string if the pressure is steady (neither rising nor falling) + * `{visibility}` — Visibility distance, excluding unit + * `{visibility_unit}` — Either ``km`` or ``mi``, depending on whether + metric or imperial units are being used + * `{humidity}` — Current humidity, excluding percentage symbol + * `{uv_index}` — UV Index + + This module supports the :ref:`formatp ` extended string format + syntax. This allows for values to be hidden when they evaluate as False. + This comes in handy for the :py:mod:`weathercom <.weather.weathercom>` + backend, which at a certain point in the afternoon will have a blank + ``{high_temp}`` value. Using the following snippet in your format string + will only display the high temperature information if it is not blank: + + :: + + {current_temp}{temp_unit}[ Hi: {high_temp}[{temp_unit}]] Lo: {low_temp}{temp_unit} + + Brackets are evaluated from the outside-in, so the fact that the only + formatter in the outer block (``{high_temp}``) is empty would keep the + inner block from being evaluated at all, and entire block would not be + displayed. + + See the following links for usage examples for the available weather + backends: + + - :ref:`Weather.com ` + - :ref:`Weather Underground ` + ''' + + settings = ( + ('colorize', 'Vary the color depending on the current conditions.'), + ('color_icons', 'Dictionary mapping weather conditions to tuples ' + 'containing a UTF-8 code for the icon, and the color ' + 'to be used.'), + ('color', 'Display color (or fallback color if ``colorize`` is True). ' + 'If not specified, falls back to default i3bar color.'), + ('backend', 'Weather backend instance'), + 'interval', + 'format', + ) + required = ('backend',) + + colorize = False + color_icons = { + 'Fair': (u'\u2600', '#ffcc00'), + 'Cloudy': (u'\u2601', '#f8f8ff'), + 'Partly Cloudy': (u'\u2601', '#f8f8ff'), # \u26c5 is not in many fonts + 'Rainy': (u'\u26c8', '#cbd2c0'), + 'Thunderstorm': (u'\u03de', '#cbd2c0'), + 'Sunny': (u'\u263c', '#ffff00'), + 'Snow': (u'\u2603', '#ffffff'), + 'default': ('', None), + } + + color = None + backend = None + interval = 1800 + format = '{current_temp}{temp_unit}' + + on_leftclick = 'open_forecast_url' + + def open_forecast_url(self): + if self.backend.forecast_url and self.backend.forecast_url != 'N/A': + user_open(self.backend.forecast_url) + + def init(self): + pass + + def get_color_data(self, condition): + ''' + Disambiguate similarly-named weather conditions, and return the icon + and color that match. + ''' + condition_lc = condition.lower() + if condition_lc == 'mostly cloudy': + condition = 'Cloudy' + elif condition_lc == 'clear': + condition = 'Fair' + elif condition_lc == 'rain': + condition = 'Rainy' + elif 'thunder' in condition_lc: + condition = 'Thunderstorm' + elif 'snow' in condition_lc: + condition = 'Snow' + + return self.color_icons['default'] \ + if condition not in self.color_icons \ + else self.color_icons[condition] + + @require(internet) + def run(self): + data = self.backend.weather_data() + data['icon'], condition_color = self.get_color_data(data['condition']) + color = condition_color if self.colorize else self.color + + self.output = { + 'full_text': formatp(self.format, **data).strip(), + 'color': color, + } From 7f5338d772c78d2340ba75fa184db005aecaf925 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:27:18 -0500 Subject: [PATCH 124/168] Move weather.py, wunderground.py to i3pystatus.weather --- i3pystatus/{ => weather}/weather.py | 0 i3pystatus/{ => weather}/wunderground.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename i3pystatus/{ => weather}/weather.py (100%) rename i3pystatus/{ => weather}/wunderground.py (100%) diff --git a/i3pystatus/weather.py b/i3pystatus/weather/weather.py similarity index 100% rename from i3pystatus/weather.py rename to i3pystatus/weather/weather.py diff --git a/i3pystatus/wunderground.py b/i3pystatus/weather/wunderground.py similarity index 100% rename from i3pystatus/wunderground.py rename to i3pystatus/weather/wunderground.py From 099ddc795c9007bb9759879e1cbcddb911cb9348 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 22 Mar 2016 00:27:48 -0500 Subject: [PATCH 125/168] Rename weather.py to weathercom.py With the addition of wunderground.py, this makes the naming of this module less general. --- i3pystatus/weather/{weather.py => weathercom.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename i3pystatus/weather/{weather.py => weathercom.py} (100%) diff --git a/i3pystatus/weather/weather.py b/i3pystatus/weather/weathercom.py similarity index 100% rename from i3pystatus/weather/weather.py rename to i3pystatus/weather/weathercom.py From c32e458514257c168619c0a577e732f96ed1f049 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 23 Mar 2016 00:50:33 -0500 Subject: [PATCH 126/168] Rework weather.com This alters the weather.com forecast code such that it is a backend for the new generalized weather module. --- i3pystatus/weather/weathercom.py | 188 +++++++++++++++++-------------- 1 file changed, 104 insertions(+), 84 deletions(-) diff --git a/i3pystatus/weather/weathercom.py b/i3pystatus/weather/weathercom.py index a71242d..5965a99 100644 --- a/i3pystatus/weather/weathercom.py +++ b/i3pystatus/weather/weathercom.py @@ -1,57 +1,75 @@ from i3pystatus import IntervalModule from i3pystatus.core.util import internet, require +from i3pystatus.weather import Backend +from datetime import datetime from urllib.request import urlopen import re import xml.etree.ElementTree as ElementTree WEATHER_COM_URL = 'http://wxdata.weather.com/wxdata/weather/local/%s?unit=%s&dayf=1&cc=*' +ON_LEFTCLICK_URL = 'https://weather.com/weather/today/l/%s' -class Weather(IntervalModule): - """ - This module gets the weather from weather.com. - First, you need to get the code for the location from www.weather.com +class Weathercom(Backend): + ''' + This module gets the weather from weather.com. The ``location_code`` + parameter should be set to the location code from weather.com. To obtain + this code, search for the location on weather.com, and the location code + will be everything after the last slash (e.g. ``94107:4:US``). - .. rubric:: Available formatters + .. _weather-usage-weathercom: - * `{current_temp}` — current temperature including unit (and symbol if colorize is true) - * `{min_temp}` — today's minimum temperature including unit - * `{max_temp}` — today's maximum temperature including unit - * `{current_wind}` — current wind direction, speed including unit - * `{humidity}` — current humidity excluding percentage symbol + .. rubric:: Usage example - """ + :: + from i3pystatus import Status + from i3pystatus.weather import weathercom - interval = 20 + status = Status() + status.register( + 'weather', + format='{condition} {current_temp}{temp_unit}{icon}[ Hi: {high_temp}] Lo: {low_temp}', + colorize=True, + backend=weathercom.Weathercom( + location_code='94107:4:US', + units='imperial', + ), + ) + + status.run() + + See :ref:`here ` for a list of formatters which can be + used. + ''' settings = ( - ("location_code", "Location code from www.weather.com"), - ("colorize", "Enable color with temperature and UTF-8 icons."), - ("color_icons", "Dictionary mapping weather conditions to tuples " - "containing a UTF-8 code for the icon, and the color " - "to be used."), - ("units", "Celsius (metric) or Fahrenheit (imperial)"), - "format", + ('location_code', 'Location code from www.weather.com'), + ('units', '\'metric\' or \'imperial\''), ) - required = ("location_code",) + required = ('location_code',) location_code = None - units = "metric" - format = "{current_temp}" - colorize = False - color_icons = { - "Fair": (u"\u2600", "#FFCC00"), - "Cloudy": (u"\u2601", "#F8F8FF"), - "Partly Cloudy": (u"\u2601", "#F8F8FF"), # \u26c5 is not in many fonts - "Rainy": (u"\u2614", "#CBD2C0"), - "Sunny": (u"\u263C", "#FFFF00"), - "Snow": (u"\u2603", "#FFFFFF"), - "default": ("", None), - } - def fetch_weather(self): - '''Fetches the current weather from wxdata.weather.com service.''' + units = 'metric' + + # This will be set once weather data has been checked + forecast_url = None + + @require(internet) + def weather_data(self): + ''' + Fetches the current weather from wxdata.weather.com service. + ''' + if self.forecast_url is None and ':' in self.location_code: + # Set the URL so that clicking the weather will launch the + # weather.com forecast page. Only set it though if there is a colon + # in the location_code. Technically, the weather.com API will + # successfully return weather data if a U.S. ZIP code is used as + # the location_code (e.g. 94107), but if substituted in + # ON_LEFTCLICK_URL it may or may not result in a valid URL. + self.forecast_url = ON_LEFTCLICK_URL % self.location_code + unit = '' if self.units == 'imperial' or self.units == '' else 'm' url = WEATHER_COM_URL % (self.location_code, unit) with urlopen(url) as handler: @@ -62,55 +80,57 @@ class Weather(IntervalModule): charset = 'utf-8' xml = handler.read().decode(charset) doc = ElementTree.XML(xml) + + # Cut off the timezone from the end of the string (it's after the last + # space, hence the use of rpartition). International timezones (or ones + # outside the system locale) don't seem to be handled well by + # datetime.datetime.strptime(). + observation_time_str = doc.findtext('cc/lsup').rpartition(' ')[0] + try: + observation_time = datetime.strptime(observation_time_str, + '%m/%d/%y %I:%M %p') + except ValueError: + observation_time = datetime.fromtimestamp(0) + + pressure_trend_str = doc.findtext('cc/bar/d').lower() + if pressure_trend_str == 'rising': + pressure_trend = '+' + elif pressure_trend_str == 'falling': + pressure_trend = '-' + else: + pressure_trend = '' + + if not doc.findtext('dayf/day[@d="0"]/part[@p="d"]/icon').strip(): + # If the "d" (day) part of today's forecast's keys are empty, there + # is no high temp anymore (this happens in the afternoon), but + # instead of handling things in a sane way and setting the high + # temp to an empty string or something like that, the API returns + # the current temp as the high temp, which is incorrect. This if + # statement catches it so that we can correctly report that there + # is no high temp at this point of the day. + high_temp = '' + else: + high_temp = doc.findtext('dayf/day[@d="0"]/hi') + return dict( - current_conditions=dict( - text=doc.findtext('cc/t'), - temperature=doc.findtext('cc/tmp'), - humidity=doc.findtext('cc/hmid'), - wind=dict( - text=doc.findtext('cc/wind/t'), - speed=doc.findtext('cc/wind/s'), - ), - ), - today=dict( - min_temperature=doc.findtext('dayf/day[@d="0"]/low'), - max_temperature=doc.findtext('dayf/day[@d="0"]/hi'), - ), - units=dict( - temperature=doc.findtext('head/ut'), - speed=doc.findtext('head/us'), - ), + city=doc.findtext('loc/dnam'), + condition=doc.findtext('cc/t'), + observation_time=observation_time, + current_temp=doc.findtext('cc/tmp'), + low_temp=doc.findtext('dayf/day[@d="0"]/low'), + high_temp=high_temp, + temp_unit='°' + doc.findtext('head/ut').upper(), + feelslike=doc.findtext('cc/flik'), + dewpoint=doc.findtext('cc/dewp'), + wind_speed=doc.findtext('cc/wind/s'), + wind_unit=doc.findtext('head/us'), + wind_direction=doc.findtext('cc/wind/t'), + wind_gust=doc.findtext('cc/wind/gust'), + pressure=doc.findtext('cc/bar/r'), + pressure_unit=doc.findtext('head/up'), + pressure_trend=pressure_trend, + visibility=doc.findtext('cc/vis'), + visibility_unit=doc.findtext('head/ud'), + humidity=doc.findtext('cc/hmid'), + uv_index=doc.findtext('cc/uv/i'), ) - - @require(internet) - def run(self): - result = self.fetch_weather() - conditions = result["current_conditions"] - temperature = conditions["temperature"] - humidity = conditions["humidity"] - wind = conditions["wind"] - units = result["units"] - color = None - current_temp = "{t}°{d}".format(t=temperature, d=units["temperature"]) - min_temp = "{t}°{d}".format(t=result["today"]["min_temperature"], d=units["temperature"]) - max_temp = "{t}°{d}".format(t=result["today"]["max_temperature"], d=units["temperature"]) - current_wind = "{t} {s}{d}".format(t=wind["text"], s=wind["speed"], d=units["speed"]) - - if self.colorize: - icon, color = self.color_icons.get(conditions["text"], - self.color_icons["default"]) - current_temp = "{t}°{d} {i}".format(t=temperature, - d=units["temperature"], - i=icon) - color = color - - self.output = { - "full_text": self.format.format( - current_temp=current_temp, - current_wind=current_wind, - humidity=humidity, - min_temp=min_temp, - max_temp=max_temp, - ), - "color": color - } From abf5b6ad1cd0582d0a518a11c5a12f64bdfd8567 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 00:47:17 -0500 Subject: [PATCH 127/168] update docs for weathercom --- i3pystatus/weather/weathercom.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/i3pystatus/weather/weathercom.py b/i3pystatus/weather/weathercom.py index 5965a99..5568a54 100644 --- a/i3pystatus/weather/weathercom.py +++ b/i3pystatus/weather/weathercom.py @@ -22,7 +22,8 @@ class Weathercom(Backend): .. rubric:: Usage example - :: + .. code-block:: python + from i3pystatus import Status from i3pystatus.weather import weathercom From f3f2b59c5b969a659b0979b7dc68d3705b99a2d3 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 27 Mar 2016 22:14:04 -0500 Subject: [PATCH 128/168] Rework wunderground module as a backend of i3pystatus.weather --- i3pystatus/weather/wunderground.py | 162 ++++++++++++++++------------- 1 file changed, 91 insertions(+), 71 deletions(-) diff --git a/i3pystatus/weather/wunderground.py b/i3pystatus/weather/wunderground.py index 2edd8b5..fcbb496 100644 --- a/i3pystatus/weather/wunderground.py +++ b/i3pystatus/weather/wunderground.py @@ -1,5 +1,5 @@ from i3pystatus import IntervalModule -from i3pystatus.core.util import user_open, internet, require +from i3pystatus.core.util import internet, require from datetime import datetime from urllib.request import urlopen @@ -7,19 +7,21 @@ import json import re GEOLOOKUP_URL = 'http://api.wunderground.com/api/%s/geolookup%s/q/%s.json' -STATION_LOOKUP_URL = 'http://api.wunderground.com/api/%s/conditions/q/%s.json' +STATION_QUERY_URL = 'http://api.wunderground.com/api/%s/%s/q/%s.json' class Wunderground(IntervalModule): ''' - This module retrieves weather from the Weather Underground API. + This module retrieves weather data using the Weather Underground API. .. note:: A Weather Underground API key is required to use this module, you can - sign up for one for a developer API key free at + sign up for a developer API key free at https://www.wunderground.com/weather/api/ - A developer API key is allowed 500 queries per day. + A developer API key is allowed 500 queries per day, and no more than 10 + in a given minute. Therefore, it is recommended to be conservative when + setting the update interval. Valid values for ``location_code`` include: @@ -37,58 +39,60 @@ class Wunderground(IntervalModule): http://www.wunderground.com/weatherstation/ListStations.asp - .. rubric:: Available formatters + .. _weather-usage-wunderground: - * `{city}` — Location of weather observation - * `{conditon}` — Current condition (Rain, Snow, Overcast, etc.) - * `{observation_time}` — Time of weather observation (supports strftime format flags) - * `{current_temp}` — Current temperature, excluding unit - * `{degrees}` — ``°C`` if ``units`` is set to ``metric``, otherwise ``°F`` - * `{feelslike}` — Wunderground "Feels Like" temperature, excluding unit - * `{current_wind}` — Wind speed in mph/kph, excluding unit - * `{current_wind_direction}` — Wind direction - * `{current_wind_gust}` — Speed of wind gusts in mph/kph, excluding unit - * `{pressure_in}` — Barometric pressure (in inches), excluding unit - * `{pressure_mb}` — Barometric pressure (in millibars), excluding unit - * `{pressure_trend}` — ``+`` (rising) or ``-`` (falling) - * `{visibility}` — Visibility in mi/km, excluding unit - * `{humidity}` — Current humidity, excluding percentage symbol - * `{dewpoint}` — Dewpoint temperature, excluding unit - * `{uv_index}` — UV Index + .. rubric:: Usage example + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.weather import wunderground + + status = Status() + + status.register( + 'weather', + format='{condition} {current_temp}{temp_unit}{icon}[ Hi: {high_temp}] Lo: {low_temp}', + colorize=True, + backend=wunderground.Wunderground( + api_key='dbafe887d56ba4ad', + location_code='pws:MAT645', + units='imperial', + ), + ) + + status.run() + + See :ref:`here ` for a list of formatters which can be + used. ''' interval = 300 settings = ( ('api_key', 'Weather Underground API key'), - ('location_code', 'Location code from www.weather.com'), - ('units', 'Celsius (metric) or Fahrenheit (imperial)'), + ('location_code', 'Location code from wunderground.com'), + ('units', '\'metric\' or \'imperial\''), ('use_pws', 'Set to False to use only airport stations'), - ('error_log', 'If set, tracebacks will be logged to this file'), - 'format', + ('forecast', 'Set to ``True`` to check forecast (generates one ' + 'additional API request per weather update). If set to ' + '``False``, then the ``low_temp`` and ``high_temp`` ' + 'formatters will be set to empty strings.'), ) + required = ('api_key', 'location_code') api_key = None location_code = None - units = "metric" - format = "{current_temp}{degrees}" + units = 'metric' use_pws = True - error_log = None + forecast = False + # These will be set once weather data has been checked station_id = None forecast_url = None - on_leftclick = 'open_wunderground' - - def open_wunderground(self): - ''' - Open the forecast URL, if one was retrieved - ''' - if self.forecast_url and self.forecast_url != 'N/A': - user_open(self.forecast_url) - + @require(internet) def api_request(self, url): ''' Execute an HTTP POST to the specified URL and return the content @@ -106,6 +110,7 @@ class Wunderground(IntervalModule): pass return response + @require(internet) def geolookup(self): ''' Use the location_code to perform a geolookup and find the closest @@ -148,32 +153,63 @@ class Wunderground(IntervalModule): raise Exception('No icao entry for station') self.station_id = 'icao:%s' % nearest_airport - def query_station(self): + @require(internet) + def get_forecast(self): ''' - Query a specific station + If configured to do so, make an API request to retrieve the forecast + data for the configured/queried weather station, and return the low and + high temperatures. Otherwise, return two empty strings. + ''' + if self.forecast: + query_url = STATION_QUERY_URL % (self.api_key, + 'forecast', + self.station_id) + try: + response = self.api_request(query_url)['forecast'] + response = response['simpleforecast']['forecastday'][0] + except (KeyError, IndexError, TypeError): + raise Exception('No forecast data found for %s' % self.station_id) + + unit = 'celsius' if self.units == 'metric' else 'fahrenheit' + low_temp = response.get('low', {}).get(unit, '') + high_temp = response.get('high', {}).get(unit, '') + return low_temp, high_temp + else: + return '', '' + + @require(internet) + def weather_data(self): + ''' + Query the configured/queried station and return the weather data ''' # If necessary, do a geolookup to set the station_id self.geolookup() - query_url = STATION_LOOKUP_URL % (self.api_key, self.station_id) + query_url = STATION_QUERY_URL % (self.api_key, + 'conditions', + self.station_id) try: response = self.api_request(query_url)['current_observation'] self.forecast_url = response.pop('ob_url', None) except KeyError: raise Exception('No weather data found for %s' % self.station_id) - def _find(key, data=None): - data = data or response - return data.get(key, 'N/A') + low_temp, high_temp = self.get_forecast() if self.units == 'metric': temp_unit = 'c' speed_unit = 'kph' distance_unit = 'km' + pressure_unit = 'mb' else: temp_unit = 'f' speed_unit = 'mph' distance_unit = 'mi' + pressure_unit = 'in' + + def _find(key, data=None): + data = data or response + return data.get(key, 'N/A') try: observation_time = int(_find('observation_epoch')) @@ -185,36 +221,20 @@ class Wunderground(IntervalModule): condition=_find('weather'), observation_time=datetime.fromtimestamp(observation_time), current_temp=_find('temp_' + temp_unit), + low_temp=low_temp, + high_temp=high_temp, + temp_unit='°' + temp_unit.upper(), feelslike=_find('feelslike_' + temp_unit), - current_wind=_find('wind_' + speed_unit), - current_wind_direction=_find('wind_dir'), - current_wind_gust=_find('wind_gust_' + speed_unit), - pressure_in=_find('pressure_in'), - pressure_mb=_find('pressure_mb'), + dewpoint=_find('dewpoint_' + temp_unit), + wind_speed=_find('wind_' + speed_unit), + wind_unit=speed_unit, + wind_direction=_find('wind_dir'), + wind_gust=_find('wind_gust_' + speed_unit), + pressure=_find('pressure_' + pressure_unit), + pressure_unit=pressure_unit, pressure_trend=_find('pressure_trend'), visibility=_find('visibility_' + distance_unit), + visibility_unit=distance_unit, humidity=_find('relative_humidity').rstrip('%'), - dewpoint=_find('dewpoint_' + temp_unit), uv_index=_find('uv'), ) - - @require(internet) - def run(self): - try: - result = self.query_station() - except Exception as exc: - if self.error_log: - import traceback - with open(self.error_log, 'a') as f: - f.write('%s : An exception was raised:\n' % - datetime.isoformat(datetime.now())) - f.write(''.join(traceback.format_exc())) - f.write(80 * '-' + '\n') - raise - - result['degrees'] = '°%s' % ('C' if self.units == 'metric' else 'F') - - self.output = { - "full_text": self.format.format(**result), - # "color": self.color # TODO: add some sort of color effect - } From 19af60831293ccff759e7ad9afb2336d1e232b02 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 27 Mar 2016 22:25:19 -0500 Subject: [PATCH 129/168] Add i3pystatus.weather to packages list --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 2af4da2..a91b397 100755 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ setup(name="i3pystatus", "i3pystatus.mail", "i3pystatus.pulseaudio", "i3pystatus.updates", + "i3pystatus.weather", ], entry_points={ "console_scripts": [ From 0ec2bf7b53b3ca53fb5b64882f81de31312a2903 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 00:45:21 -0500 Subject: [PATCH 130/168] Add docs/_build to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 89a63b8..9be1197 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/* *~ .i3pystatus-* ci-build +docs/_build From 66bc56b7a4c690cc3bb5be47c3d894911845cdde Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 00:46:00 -0500 Subject: [PATCH 131/168] Add reference to weather module to the formatp documentation --- docs/configuration.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 6c8a0c8..30ade98 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -175,9 +175,9 @@ decimal dot formatp ~~~~~~~ -Some modules use an extended format string syntax (the :py:mod:`.mpd` -module, for example). Given the format string below the output adapts -itself to the available data. +Some modules use an extended format string syntax (the :py:mod:`.mpd` and +:py:mod:`.weather` modules, for example). Given the format string below the +output adapts itself to the available data. :: From 97600454ed652b1e49983cae044eb0c8cfd5f95b Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 09:16:49 -0500 Subject: [PATCH 132/168] Improve documentation for battery module This adds a mention and usage example for ``formatp`` to hide the status icon when the battery is full. Resolves #232. --- i3pystatus/battery.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/i3pystatus/battery.py b/i3pystatus/battery.py index 93eef7d..5071602 100644 --- a/i3pystatus/battery.py +++ b/i3pystatus/battery.py @@ -118,9 +118,11 @@ class BatteryEnergy(Battery): class BatteryChecker(IntervalModule): """ This class uses the /sys/class/power_supply/…/uevent interface to check for the - battery status - It provides the "ALL" battery_ident which will summarise all available batteries - for the moment and aggregate the % as well as the time remaining on the charge. + battery status. + + Setting ``battery_ident`` to ``ALL`` will summarise all available batteries + and aggregate the % as well as the time remaining on the charge. This is + helpful when the machine has more than one battery available. .. rubric:: Available formatters @@ -133,6 +135,35 @@ class BatteryChecker(IntervalModule): * `{battery_ident}` — the same as the setting * `{bar}` —bar displaying the relative percentage graphically * `{bar_design}` —bar displaying the absolute percentage graphically + + This module supports the :ref:`formatp ` extended string format + syntax. By setting the ``FULL`` status to an empty string, and including + brackets around the ``{status}`` formatter, the text within the brackets + will be hidden when the battery is full, as can be seen in the below + example: + + .. code-block:: python + + from i3pystatus import Status + + status = Status() + + status.register( + 'battery', + interval=5, + format='{battery_ident}: [{status} ]{percentage_design:.2f}%', + alert=True, + alert_percentage=15, + status = { + 'DPL': 'DPL', + 'CHR': 'CHR', + 'DIS': 'DIS', + 'FULL': '', + } + ) + + status.run() + """ settings = ( From f30b929752f4fe42db35971a5530bff2d44a3c16 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 23:27:59 -0500 Subject: [PATCH 133/168] Make number of days for event search configurable --- i3pystatus/google_calendar.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/i3pystatus/google_calendar.py b/i3pystatus/google_calendar.py index 9fadd01..6fa62c2 100644 --- a/i3pystatus/google_calendar.py +++ b/i3pystatus/google_calendar.py @@ -36,6 +36,7 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): ('format', 'format string'), ("credential_path", "Path to credentials"), ("skip_recurring", "Skip recurring events."), + ("days", "Only show events between now and this many days in the future"), ("urgent_seconds", "Add urgent hint when this many seconds until event startTime"), ("start_color", "Hex or English name for start of color range, eg '#00FF00' or 'green'"), ("end_color", "Hex or English name for end of color range, eg '#FF0000' or 'red'"), @@ -43,12 +44,14 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): required = ('credential_path',) - format = "{summary} ({remaining_time})" - urgent_seconds = 300 interval = 30 - color = '#FFFFFF' - skip_recurring = True + + format = "{summary} ({remaining_time})" credential_path = None + skip_recurring = True + days = 1 + urgent_seconds = 300 + color = '#FFFFFF' service = None credentials = None @@ -119,7 +122,7 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): def get_timerange(self): now = datetime.datetime.utcnow() - later = now + datetime.timedelta(days=1) + later = now + datetime.timedelta(days=self.days) now = now.isoformat() + 'Z' later = later.isoformat() + 'Z' return now, later From 7c25dff1a1fa3aa7b75f53d81cb7217ec83bb88a Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 29 Mar 2016 23:39:43 -0500 Subject: [PATCH 134/168] Let color default to i3bar color --- i3pystatus/google_calendar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/google_calendar.py b/i3pystatus/google_calendar.py index 6fa62c2..3ba3eee 100644 --- a/i3pystatus/google_calendar.py +++ b/i3pystatus/google_calendar.py @@ -51,7 +51,7 @@ class GoogleCalendar(IntervalModule, ColorRangeModule): skip_recurring = True days = 1 urgent_seconds = 300 - color = '#FFFFFF' + color = None service = None credentials = None From 2ac7c6af3de22f04dabe6ce176e578bd957d4165 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 30 Mar 2016 11:40:30 -0500 Subject: [PATCH 135/168] Support more types of "rain" conditions --- i3pystatus/weather/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py index c8fa2ed..e45e39d 100644 --- a/i3pystatus/weather/__init__.py +++ b/i3pystatus/weather/__init__.py @@ -118,7 +118,7 @@ class Weather(IntervalModule): condition = 'Cloudy' elif condition_lc == 'clear': condition = 'Fair' - elif condition_lc == 'rain': + elif 'rain' in condition_lc: condition = 'Rainy' elif 'thunder' in condition_lc: condition = 'Thunderstorm' From 0fafb1a652510f73c3bb3bc703341d6c950701a9 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 30 Mar 2016 13:19:38 -0500 Subject: [PATCH 136/168] Identify more kinds of cloudy weather --- i3pystatus/weather/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py index e45e39d..45ffe4e 100644 --- a/i3pystatus/weather/__init__.py +++ b/i3pystatus/weather/__init__.py @@ -114,10 +114,10 @@ class Weather(IntervalModule): and color that match. ''' condition_lc = condition.lower() - if condition_lc == 'mostly cloudy': - condition = 'Cloudy' - elif condition_lc == 'clear': + if condition_lc == 'clear': condition = 'Fair' + if 'cloudy' in condition_lc: + condition = 'Cloudy' elif 'rain' in condition_lc: condition = 'Rainy' elif 'thunder' in condition_lc: From e3194147fa572e97fe0dda96e545f7e0cc9423ca Mon Sep 17 00:00:00 2001 From: Robin McCorkell Date: Fri, 1 Apr 2016 13:07:49 +0100 Subject: [PATCH 137/168] Properly set MPD filename if no title --- i3pystatus/mpd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 5d9ffd7..2f3ef4a 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -114,7 +114,7 @@ class MPD(IntervalModule): "bitrate": int(status.get("bitrate", 0)), } - if not fdict["title"] and "filename" in fdict: + if not fdict["title"]: fdict["filename"] = '.'.join( basename(currentsong["file"]).split('.')[:-1]) else: From 01395c6b39b2b8152e31d5c3f4f41313f1b881d9 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Thu, 7 Apr 2016 16:18:39 -0500 Subject: [PATCH 138/168] Catch "Showers" as "Rainy" weather condition (#356) * Catch "Showers" as "Rainy" weather condition Weather.com has a "Showers in the Vicinity" weather condition that I just happened to see this morning. This commit assigns this condition as "Rainy" so it is properly colorized. * Make "Clear / Windy" map to "Fair" weather condition Another odd weather condition from the weather.com API * Reverse icons for Fair and Sunny "Sunny" should have a filled-in sun icon as it implies a brighter weather condition than "Fair" does. * Properly detect "Sunny / Windy" as "Sunny" weather condition Also, do not check for similarly-named conditions if an exact match is found. * Properly detect "Fair / Windy" as "Fair" --- i3pystatus/weather/__init__.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py index 45ffe4e..7f1dd2e 100644 --- a/i3pystatus/weather/__init__.py +++ b/i3pystatus/weather/__init__.py @@ -84,12 +84,12 @@ class Weather(IntervalModule): colorize = False color_icons = { - 'Fair': (u'\u2600', '#ffcc00'), + 'Fair': (u'\u263c', '#ffcc00'), 'Cloudy': (u'\u2601', '#f8f8ff'), 'Partly Cloudy': (u'\u2601', '#f8f8ff'), # \u26c5 is not in many fonts 'Rainy': (u'\u26c8', '#cbd2c0'), 'Thunderstorm': (u'\u03de', '#cbd2c0'), - 'Sunny': (u'\u263c', '#ffff00'), + 'Sunny': (u'\u2600', '#ffff00'), 'Snow': (u'\u2603', '#ffffff'), 'default': ('', None), } @@ -113,17 +113,24 @@ class Weather(IntervalModule): Disambiguate similarly-named weather conditions, and return the icon and color that match. ''' - condition_lc = condition.lower() - if condition_lc == 'clear': - condition = 'Fair' - if 'cloudy' in condition_lc: - condition = 'Cloudy' - elif 'rain' in condition_lc: - condition = 'Rainy' - elif 'thunder' in condition_lc: - condition = 'Thunderstorm' - elif 'snow' in condition_lc: - condition = 'Snow' + if condition not in self.color_icons: + # Check for similarly-named conditions if no exact match found + condition_lc = condition.lower() + if 'cloudy' in condition_lc: + if 'partly' in condition_lc: + condition = 'Partly Cloudy' + else: + condition = 'Cloudy' + elif 'thunder' in condition_lc: + condition = 'Thunderstorm' + elif 'snow' in condition_lc: + condition = 'Snow' + elif 'rain' in condition_lc or 'showers' in condition_lc: + condition = 'Rainy' + elif 'sunny' in condition_lc: + condition = 'Sunny' + elif 'clear' in condition_lc or 'fair' in condition_lc: + condition = 'Fair' return self.color_icons['default'] \ if condition not in self.color_icons \ From c93bfe16b67ef226109003dfc0ac24234a9ed252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20Pila=C5=99?= Date: Thu, 7 Apr 2016 23:19:59 +0200 Subject: [PATCH 139/168] Taskwarrior module (#354) * Taskwarrior module * Taskwarrior - sort by urgency * Taskwarrior - filter with multiple constraints --- i3pystatus/taskwarrior.py | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100755 i3pystatus/taskwarrior.py diff --git a/i3pystatus/taskwarrior.py b/i3pystatus/taskwarrior.py new file mode 100755 index 0000000..6616f55 --- /dev/null +++ b/i3pystatus/taskwarrior.py @@ -0,0 +1,85 @@ +from i3pystatus import IntervalModule +from json import loads +import subprocess + + +class Taskwarrior(IntervalModule): + """ + Check Taskwarrior for pending tasks + Requires `json` + + Formaters: + + * `{ready}` — contains number of tasks returned by ready_filter + * `{urgent}` — contains number of tasks returned by urgent_filter + * `{next}` — contains the description of next task + """ + + format = 'Task: {next}' + ready_filter = '+READY' + urgent_filter = '+TODAY' + enable_mark_done = False + color_urgent = '#FF0000' + color_ready = '#78EAF2' + ready_tasks = [] + urgent_tasks = [] + current_tasks = [] + next_id = 0 + next_task = None + + on_upscroll = "get_prev_task" + on_downscroll = "get_next_task" + on_rightclick = 'mark_task_as_done' + + settings = ( + ('format', 'format string'), + ('ready_filter', 'Filters to get ready tasks example: `+READY`'), + ('urgent_filter', 'Filters to get urgent tasks example: `+TODAY`'), + ('enable_mark_done', 'Enable right click mark task as done'), + ('color_urgent', '#FF0000'), + ('color_ready', '#78EAF2') + ) + + def get_next_task(self): + self.next_id = (self.next_id + 1) % len(self.current_tasks) + self.next_task = self.current_tasks[self.next_id] + + def get_prev_task(self): + self.next_id = (self.next_id - 1) % len(self.current_tasks) + self.next_task = self.current_tasks[self.next_id] + + def mark_task_as_done(self): + if self.enable_mark_done and self.next_task is not None: + subprocess.check_output(['task', str(self.next_task['id']), 'done']) + self.get_next_task() + + def run(self): + try: + urgent_params = ['task'] + self.urgent_filter.split(' ') + ['export'] + urgent_tasks_json = subprocess.check_output(urgent_params) + self.urgent_tasks = loads(urgent_tasks_json.decode("utf-8")) + self.urgent_tasks = sorted(self.urgent_tasks, key=lambda x: x['urgency'], reverse=True) + + ready_params = ['task'] + self.ready_filter.split(' ') + ['export'] + ready_tasks = subprocess.check_output(ready_params) + self.ready_tasks = loads(ready_tasks.decode("utf-8")) + self.ready_tasks = sorted(self.ready_tasks, key=lambda x: x['urgency'], reverse=True) + + self.current_tasks = self.urgent_tasks if len(self.urgent_tasks) > 0 else self.ready_tasks + if self.next_id < len(self.current_tasks): + self.next_task = self.current_tasks[self.next_id] + else: + self.next_id = 0 + + except ValueError: + print('Decoding JSON has failed') + + format_values = dict(urgent=len(self.urgent_tasks), ready=len(self.ready_tasks), next='') + + if self.next_task is not None: + format_values['next'] = self.next_task['description'] + + self.output = { + 'full_text': self.format.format(**format_values), + 'color': self.color_urgent if len(self.urgent_tasks) > 0 else self.color_ready + } From c0cdfae1f872fae426f61308e9a32c5a34f11413 Mon Sep 17 00:00:00 2001 From: Maximiliano Date: Thu, 7 Apr 2016 23:20:37 +0200 Subject: [PATCH 140/168] mod bitcoin: multiple exchange support (#353) * mod bitcoin: add 'volume_percent' * mod bitcoin: Fix exception on url opening (#304) Calling user_open as a 'Python callback' raises an exception because this function doesn't expects 'self'. Wrote a wrapper function as a 'Member callback' to filter it out. * mod bitcoin: add specific exchange support * mod bitcoin: add request age attribute * mod bitcoin: refactor * mod bitcoin: btc volume divisor * bitcoin: Deal with diffrent locales * Fixing PEP8 * mod bitcoin: Updated docs --- i3pystatus/bitcoin.py | 73 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/i3pystatus/bitcoin.py b/i3pystatus/bitcoin.py index a6ca236..ee36473 100644 --- a/i3pystatus/bitcoin.py +++ b/i3pystatus/bitcoin.py @@ -1,10 +1,27 @@ import urllib.request import json -import time +from datetime import datetime from i3pystatus import IntervalModule from i3pystatus.core.util import internet, require, user_open +import locale +import threading +from contextlib import contextmanager + +LOCALE_LOCK = threading.Lock() + + +@contextmanager +def setlocale(name): + # To deal with locales only in this module and keep it thread save + with LOCALE_LOCK: + saved = locale.setlocale(locale.LC_ALL) + try: + yield locale.setlocale(locale.LC_ALL, name) + finally: + locale.setlocale(locale.LC_ALL, saved) + class Bitcoin(IntervalModule): @@ -12,8 +29,10 @@ class Bitcoin(IntervalModule): This module fetches and displays current Bitcoin market prices and optionally monitors transactions to and from a list of user-specified wallet addresses. Market data is pulled from the BitcoinAverage Price - Index API while transaction data is pulled - from blockchain.info . + Index API and it is possible to specify + the exchange to be monitored. + Transaction data is pulled from blockchain.info + . .. rubric:: Available formatters @@ -22,6 +41,9 @@ class Bitcoin(IntervalModule): * {bid_price} * {daily_average} * {volume} + * {volume_thousend} + * {volume_percent} + * {age} * {status} * {last_tx_type} * {last_tx_addr} @@ -37,6 +59,7 @@ class Bitcoin(IntervalModule): ("currency", "Base fiat currency used for pricing."), ("wallet_addresses", "List of wallet address(es) to monitor."), ("color", "Standard color"), + ("exchange", "Get ticker from a custom exchange instead"), ("colorize", "Enable color change on price increase/decrease"), ("color_up", "Color for price increases"), ("color_down", "Color for price decreases"), @@ -46,6 +69,7 @@ class Bitcoin(IntervalModule): ) format = "{symbol} {status}{last_price}" currency = "USD" + exchange = None symbol = "฿" wallet_addresses = "" color = "#FFFFFF" @@ -59,14 +83,39 @@ class Bitcoin(IntervalModule): } on_leftclick = "electrum" - on_rightclick = [user_open, "https://bitcoinaverage.com/"] + on_rightclick = ["open_something", "https://bitcoinaverage.com/"] _price_prev = 0 + def _get_age(self, bitcoinaverage_timestamp): + with setlocale('C'): # Deal with locales (months name differ) + # Assume format is always utc, to avoid import pytz + utc_tstamp = datetime.strptime( + bitcoinaverage_timestamp.split(', ')[1], + u'%d %b %Y %H:%M:%S -0000') + diff = datetime.utcnow() - utc_tstamp + return int(diff.total_seconds()) + + def _query_api(self, api_url): + url = "{}{}".format(api_url, self.currency.upper()) + response = urllib.request.urlopen(url).read().decode("utf-8") + return json.loads(response) + def _fetch_price_data(self): - api = "https://api.bitcoinaverage.com/ticker/global/" - url = "{}{}".format(api, self.currency.upper()) - return json.loads(urllib.request.urlopen(url).read().decode("utf-8")) + if self.exchange is None: + api_url = "https://api.bitcoinaverage.com/ticker/global/" + return self._query_api(api_url) + else: + api_url = "https://api.bitcoinaverage.com/exchanges/" + ret = self._query_api(api_url) + exchange = ret[self.exchange.lower()] + # Adapt values to global ticker format + exchange['ask'] = exchange['rates']['ask'] + exchange['bid'] = exchange['rates']['bid'] + exchange['last'] = exchange['rates']['last'] + exchange['24h_avg'] = None + exchange['timestamp'] = ret['timestamp'] + return exchange def _fetch_blockchain_data(self): api = "https://blockchain.info/multiaddr?active=" @@ -77,6 +126,7 @@ class Bitcoin(IntervalModule): @require(internet) def run(self): price_data = self._fetch_price_data() + fdict = { "symbol": self.symbol, "daily_average": price_data["24h_avg"], @@ -84,6 +134,9 @@ class Bitcoin(IntervalModule): "bid_price": price_data["bid"], "last_price": price_data["last"], "volume": price_data["volume_btc"], + "volume_thousend": price_data["volume_btc"] / 1000, + "volume_percent": price_data["volume_percent"], + "age": self._get_age(price_data['timestamp']) } if self._price_prev and fdict["last_price"] > self._price_prev: @@ -125,3 +178,9 @@ class Bitcoin(IntervalModule): "full_text": self.format.format(**fdict), "color": color, } + + def open_something(self, url_or_command): + """ + Wrapper function, to pass the arguments to user_open + """ + user_open(url_or_command) From 7cb2dcc25579569701e8e2404a93d043eba09328 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Thu, 7 Apr 2016 16:21:23 -0500 Subject: [PATCH 141/168] Fix improper usage of time.tzset() (#347) * Fix improper usage of time.tzset() time.tzname is a tuple containing the non-daylight-savings and daylight-savings timezone abbreviations. However, when the TZ environment variable is set to just the daylight-savings timezone (as the clock module was changed to do in e31c58f), time.tzset() will break time.tzname by setting both elements of the tuple to that timezone, causing the effective timezone to fallback to UTC: >>> time.tzname ('CST', 'CDT') >>> time.localtime().tm_hour 1 >>> os.environ.putenv('TZ', 'CST') >>> time.tzset() >>> time.tzname ('CST', 'CST') >>> # ^^^ This is broken ... >>> time.localtime().tm_hour 6 >>> os.environ.putenv('TZ', 'CST+06:00CDT') >>> time.tzset() >>> time.tzname ('CST', 'CDT') >>> time.localtime().tm_hour 1 This fixes this incorrect behavior by building a proper TZ environment variable to set localtime. * Use time.timezone instead of time.altzone * Make _get_local_tz a static method --- i3pystatus/clock.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/i3pystatus/clock.py b/i3pystatus/clock.py index dc6532d..6e657c4 100644 --- a/i3pystatus/clock.py +++ b/i3pystatus/clock.py @@ -84,8 +84,25 @@ class Clock(IntervalModule): elif isinstance(self.format, str) or isinstance(self.format, tuple): self.format = [self.format] + self._local_tzname = self._get_local_tz() + self._non_daylight_zone = time.tzname[0] self.format = self.expand_formats(self.format) + @staticmethod + def _get_local_tz(): + ''' + Returns a string representing localtime, suitable for setting localtime + using time.tzset(). + + https://docs.python.org/3/library/time.html#time.tzset + ''' + hours_offset = time.timezone / 3600.0 + plus_minus = '+' if hours_offset >= 0 else '-' + hh = int(hours_offset) + mm = 60 * (hours_offset % 1) + return '%s%s%02d:%02d%s' % (time.tzname[0], plus_minus, + hh, mm, time.tzname[1]) + @staticmethod def expand_formats(formats): def expand_format(format_): @@ -94,15 +111,18 @@ class Clock(IntervalModule): if len(format_) > 1 and os.path.isfile('/usr/share/zoneinfo/' + format_[1]): return (format_[0], format_[1]) else: - return (format_[0], time.tzname[0]) - return (format_, time.tzname[0]) + return (format_[0], None) + return (format_, None) return [expand_format(format_) for format_ in formats] def run(self): # set timezone - if time.tzname[0] is not self.format[self.current_format_id][1]: - os.environ.putenv('TZ', self.format[self.current_format_id][1]) + target_tz = self.format[self.current_format_id][1] + if target_tz is None and time.tzname[0] != self._non_daylight_zone \ + or target_tz is not None and time.tzname[0] != target_tz: + new_tz = self._local_tzname if target_tz is None else target_tz + os.environ.putenv('TZ', new_tz) time.tzset() self.output = { From be83476aef89f7bd30b228c48d93eee6c62d3029 Mon Sep 17 00:00:00 2001 From: Jo De Boeck Date: Sun, 3 Apr 2016 15:49:37 +0200 Subject: [PATCH 142/168] Now playing: be more tolerant for mpris properties Some mpris clients dont implement all properties --- i3pystatus/now_playing.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/i3pystatus/now_playing.py b/i3pystatus/now_playing.py index 1c066de..953c4d8 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -100,14 +100,20 @@ class NowPlaying(IntervalModule): try: player = self.get_player() properties = dbus.Interface(player, "org.freedesktop.DBus.Properties") - get_prop = functools.partial(properties.Get, "org.mpris.MediaPlayer2.Player") + + def get_prop(name, default=None): + try: + return properties.Get("org.mpris.MediaPlayer2.Player", name) + except dbus.exceptions.DBusException: + return default + currentsong = get_prop("Metadata") fdict = { "status": self.status[self.statusmap[get_prop("PlaybackStatus")]], "len": 0, # TODO: Use optional(!) TrackList interface for this to gain 100 % mpd<->now_playing compat "pos": 0, - "volume": int(get_prop("Volume") * 100), + "volume": int(get_prop("Volume", 0) * 100), "title": currentsong.get("xesam:title", ""), "album": currentsong.get("xesam:album", ""), From c33a798b86199d8f0a81d97e9ca9ffb7022da89f Mon Sep 17 00:00:00 2001 From: ncoop Date: Sun, 3 Apr 2016 01:24:44 -0700 Subject: [PATCH 143/168] Simple 'dnf check-updates' backend for updates --- i3pystatus/updates/dnf.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 i3pystatus/updates/dnf.py diff --git a/i3pystatus/updates/dnf.py b/i3pystatus/updates/dnf.py new file mode 100644 index 0000000..a8180b2 --- /dev/null +++ b/i3pystatus/updates/dnf.py @@ -0,0 +1,22 @@ +from i3pystatus.core.command import run_through_shell +from i3pystatus.updates import Backend + + +class Dnf(Backend): + """ + Gets update count for RPM-based distributions with dnf. + + https://dnf.readthedocs.org/en/latest/command_ref.html#check-update-command + """ + + @property + def updates(self): + command = ["dnf", "check-update"] + dnf = run_through_shell(command) + + update_count = 0 + if dnf.rc == 100: + update_count = len(dnf.out.split("\n")[2:-1]) + return update_count + +Backend = Dnf From 52ef0e07df4b9a032014f4383d0c84f769137eb0 Mon Sep 17 00:00:00 2001 From: ncoop Date: Sat, 9 Apr 2016 02:29:48 -0700 Subject: [PATCH 144/168] Not all lines after the second are updates. There are 14 here: ``` Last metadata expiration check: 1:16:29 ago on Sat Apr 9 01:14:36 2016. google-chrome-stable.x86_64 49.0.2623.112-1 google-chrome kernel.x86_64 4.4.6-301.fc23 updates kernel-core.x86_64 4.4.6-301.fc23 updates kernel-debug-devel.x86_64 4.4.6-301.fc23 updates kernel-devel.x86_64 4.4.6-301.fc23 updates kernel-headers.x86_64 4.4.6-301.fc23 updates kernel-modules.x86_64 4.4.6-301.fc23 updates kernel-modules-extra.x86_64 4.4.6-301.fc23 updates openssh.x86_64 7.2p2-2.fc23 updates openssh-askpass.x86_64 7.2p2-2.fc23 updates openssh-clients.x86_64 7.2p2-2.fc23 updates openssh-server.x86_64 7.2p2-2.fc23 updates webkitgtk3.x86_64 2.4.10-2.fc23 updates Obsoleting Packages kernel-headers.x86_64 4.4.6-301.fc23 updates kernel-headers.x86_64 4.4.6-300.fc23 @updates ``` --- i3pystatus/updates/dnf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/i3pystatus/updates/dnf.py b/i3pystatus/updates/dnf.py index a8180b2..efd4bda 100644 --- a/i3pystatus/updates/dnf.py +++ b/i3pystatus/updates/dnf.py @@ -1,5 +1,6 @@ from i3pystatus.core.command import run_through_shell from i3pystatus.updates import Backend +from re import split class Dnf(Backend): @@ -16,7 +17,9 @@ class Dnf(Backend): update_count = 0 if dnf.rc == 100: - update_count = len(dnf.out.split("\n")[2:-1]) + lines = dnf.out.splitlines()[2:] + lines = [l for l in lines if len(split("\s{2,}", l.rstrip())) == 3] + update_count = len(lines) return update_count Backend = Dnf From c6b20772639bd3a0342a4db86920721490c4f733 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 4 Apr 2016 13:22:02 -0500 Subject: [PATCH 145/168] Add support for logformat parameter to i3pystatus.Status() This improves the usefulness of log messages, especially when it comes to debug logging added for the purpose of future troubleshooting. --- i3pystatus/core/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/i3pystatus/core/__init__.py b/i3pystatus/core/__init__.py index 4bd54a6..1c0a9d1 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -8,6 +8,8 @@ from i3pystatus.core.exceptions import ConfigError from i3pystatus.core.imputil import ClassFinder from i3pystatus.core.modules import Module +DEFAULT_LOG_FORMAT = '%(asctime)s [%(levelname)-8s][%(name)s %(lineno)d] %(message)s' + class CommandEndpoint: """ @@ -59,17 +61,21 @@ class Status: """ def __init__(self, standalone=True, click_events=True, interval=1, - input_stream=None, logfile=None, internet_check=None): + input_stream=None, logfile=None, internet_check=None, + logformat=DEFAULT_LOG_FORMAT): self.standalone = standalone self.click_events = standalone and click_events input_stream = input_stream or sys.stdin + logger = logging.getLogger("i3pystatus") if logfile: - logger = logging.getLogger("i3pystatus") for handler in logger.handlers: logger.removeHandler(handler) handler = logging.FileHandler(logfile, delay=True) logger.addHandler(handler) logger.setLevel(logging.CRITICAL) + if logformat: + for index in range(len(logger.handlers)): + logger.handlers[index].setFormatter(logging.Formatter(logformat)) if internet_check: util.internet.address = internet_check From 8e9b6dfba308ba8447a0f0fb7eabacfacb9661b3 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 4 Apr 2016 13:24:10 -0500 Subject: [PATCH 146/168] Remove leading newline from exception logging The new log formatting makes this unnecessary. --- i3pystatus/core/threading.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/core/threading.py b/i3pystatus/core/threading.py index c4ff0a5..1e3d8bb 100644 --- a/i3pystatus/core/threading.py +++ b/i3pystatus/core/threading.py @@ -67,7 +67,7 @@ class ExceptionWrapper(Wrapper): try: self.workload() except: - message = "\n> Exception in {thread} at {time}, module {name}".format( + message = "Exception in {thread} at {time}, module {name}".format( thread=threading.current_thread().name, time=time.strftime("%c"), name=self.workload.__class__.__name__ From 3d97ea80b8f17db8dc3fd1c735b06a99c699298c Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Thu, 7 Apr 2016 19:07:00 -0500 Subject: [PATCH 147/168] Add information on setting logfile in i3pystatus.status.Status() constructor --- docs/configuration.rst | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 30ade98..4dd04ac 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -233,7 +233,23 @@ to files in your home directory named ``.i3pystatus-``. Some modules might log additional information. -.. rubric:: Log level +Setting a specific logfile +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When instantiating your ``Status`` object, the path to a log file can be +specified. If this is done, then log messages will be sent to that file and not +to an ``.i3pystatus-`` file in your home directory. This is +useful in that it helps keep your home directory from becoming cluttered with +files containing errors. + +.. code-block:: python + + from i3pystatus import Status + + status = Status(logfile='/home/username/var/i3pystatus.log') + +Log level +~~~~~~~~~ Every module has a ``log_level`` option which sets the *minimum* severity required for an event to be logged. From 16b28ec493a1315653e796379cfea4d87db95323 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Thu, 7 Apr 2016 19:13:01 -0500 Subject: [PATCH 148/168] Document "logformat" option for i3pystatus.status.Status() --- docs/configuration.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index 4dd04ac..4ff694e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -248,6 +248,26 @@ files containing errors. status = Status(logfile='/home/username/var/i3pystatus.log') +Changing log format +~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 3.35 + +The ``logformat`` option can be useed to change the format of the log files, +using `LogRecord attributes`__. + +.. code-block:: python + + from i3pystatus import Status + + status = Status( + logfile='/home/username/var/i3pystatus.log', + logformat='%(asctime)s %(levelname)s:', + ) + +.. __: https://docs.python.org/3/library/logging.html#logrecord-attributes + + Log level ~~~~~~~~~ From 753860157c412e4bdb7fa9461840d372c16a89c2 Mon Sep 17 00:00:00 2001 From: Keyvan Hedayati Date: Tue, 12 Apr 2016 11:16:38 +0430 Subject: [PATCH 149/168] Added interval option to online module --- i3pystatus/online.py | 1 + 1 file changed, 1 insertion(+) diff --git a/i3pystatus/online.py b/i3pystatus/online.py index a363269..d3a00e8 100644 --- a/i3pystatus/online.py +++ b/i3pystatus/online.py @@ -11,6 +11,7 @@ class Online(IntervalModule): ('color_offline', 'Text color when offline'), ('format_online', 'Status text when online'), ('format_offline', 'Status text when offline'), + ("interval", "Update interval"), ) color = '#ffffff' From 762315dde1d931b9120cc34ad76a71261dd24571 Mon Sep 17 00:00:00 2001 From: ncoop Date: Sat, 9 Apr 2016 17:57:24 -0700 Subject: [PATCH 150/168] Corrected `setxkbmap -query` output Only includes the one or two lines desired. Also, uses a class function instead of relying on sed. --- i3pystatus/xkblayout.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/i3pystatus/xkblayout.py b/i3pystatus/xkblayout.py index 35e9e74..a64c8b3 100644 --- a/i3pystatus/xkblayout.py +++ b/i3pystatus/xkblayout.py @@ -10,7 +10,8 @@ class Xkblayout(IntervalModule): current layout is not in the ``layouts`` setting the first layout is enabled. - ``layouts`` can be stated with or without variants, e.g.: status.register("xkblayout", layouts=["de neo", "de"]) + ``layouts`` can be stated with or without variants, + e.g.: status.register("xkblayout", layouts=["de neo", "de"]) """ interval = 1 @@ -22,7 +23,7 @@ class Xkblayout(IntervalModule): on_leftclick = "change_layout" def run(self): - kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n", " ").strip() + kblayout = self.kblayout() self.output = { "full_text": self.format.format(name=kblayout).upper(), @@ -31,12 +32,21 @@ class Xkblayout(IntervalModule): def change_layout(self): layouts = self.layouts - kblayout = subprocess.check_output("setxkbmap -query | awk '/layout/,/variant/{print $2}'", shell=True).decode('utf-8').replace("\n", " ").strip() + kblayout = self.kblayout() if kblayout in layouts: position = layouts.index(kblayout) try: - subprocess.check_call(["setxkbmap"] + layouts[position + 1].split()) + subprocess.check_call(["setxkbmap"] + + layouts[position + 1].split()) except IndexError: subprocess.check_call(["setxkbmap"] + layouts[0].split()) else: subprocess.check_call(["setxkbmap"] + layouts[0].split()) + + def kblayout(self): + kblayout = subprocess.check_output("setxkbmap -query", shell=True)\ + .decode("utf-8").splitlines() + kblayout = [l.split() for l in kblayout] + kblayout = [l[1].strip() for l in kblayout + if l[0].startswith(("layout", "variant"))] + return (" ").join(kblayout) From e7ca6d7cb98eaa04ea8ddf8c84cbb8ee5b344bd5 Mon Sep 17 00:00:00 2001 From: ncoop Date: Fri, 15 Apr 2016 02:00:41 -0700 Subject: [PATCH 151/168] Module should be chmod a-x --- i3pystatus/taskwarrior.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 i3pystatus/taskwarrior.py diff --git a/i3pystatus/taskwarrior.py b/i3pystatus/taskwarrior.py old mode 100755 new mode 100644 From 84e438caf4d7dff5c44cc6f779dc9d4703fcbe0e Mon Sep 17 00:00:00 2001 From: ncoop Date: Fri, 15 Apr 2016 20:08:32 -0700 Subject: [PATCH 152/168] Ensure currentsong dictionary has "file" key --- i3pystatus/mpd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/mpd.py b/i3pystatus/mpd.py index 2f3ef4a..17c040a 100644 --- a/i3pystatus/mpd.py +++ b/i3pystatus/mpd.py @@ -114,7 +114,7 @@ class MPD(IntervalModule): "bitrate": int(status.get("bitrate", 0)), } - if not fdict["title"]: + if not fdict["title"] and "file" in currentsong: fdict["filename"] = '.'.join( basename(currentsong["file"]).split('.')[:-1]) else: From afdcf323883c9729c0b45a69f2a1be4b66cd79c2 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 4 Apr 2016 00:06:09 -0500 Subject: [PATCH 153/168] Add module to display sports scores This is a pluggable module which supports multiple backends. The first backend displays MLB scores, with more planned (NHL to start). --- docs/i3pystatus.rst | 9 + i3pystatus/scores/__init__.py | 661 ++++++++++++++++++++++++++++++++++ i3pystatus/scores/mlb.py | 318 ++++++++++++++++ 3 files changed, 988 insertions(+) create mode 100644 i3pystatus/scores/__init__.py create mode 100644 i3pystatus/scores/mlb.py diff --git a/docs/i3pystatus.rst b/docs/i3pystatus.rst index b9bda22..c909fa4 100644 --- a/docs/i3pystatus.rst +++ b/docs/i3pystatus.rst @@ -44,6 +44,15 @@ example configuration for the MaildirMail backend: .. nothin' +.. _scorebackends: + +Score Backends +-------------- + +.. autogen:: i3pystatus.scores SettingsBase + + .. nothin' + .. _updatebackends: Update Backends diff --git a/i3pystatus/scores/__init__.py b/i3pystatus/scores/__init__.py new file mode 100644 index 0000000..58f5ffa --- /dev/null +++ b/i3pystatus/scores/__init__.py @@ -0,0 +1,661 @@ +import copy +import json +import operator +import pytz +import re +import threading +import time +from datetime import datetime, timedelta +from urllib.request import urlopen +from urllib.error import HTTPError, URLError + +from i3pystatus import SettingsBase, Module, formatp +from i3pystatus.core.util import user_open, internet, require + + +class ScoresBackend(SettingsBase): + settings = () + favorite_teams = [] + all_games = True + date = None + games = {} + scroll_order = [] + last_update = 0 + + def init(self): + # Merge the passed team colors with the global ones. A simple length + # check is sufficient here because i3pystatus.scores.Scores instance + # will already have checked to see if any invalid teams were specified + # in team_colors. + if len(self.team_colors) != len(self._default_colors): + self.logger.debug( + 'Overriding %s team colors with: %s', + self.__class__.__name__, + self.team_colors + ) + new_colors = copy.copy(self._default_colors) + new_colors.update(self.team_colors) + self.team_colors = new_colors + self.logger.debug('%s team colors: %s', + self.__class__.__name__, self.team_colors) + + def api_request(self, url): + self.logger.debug('Making %s API request to %s', + self.__class__.__name__, url) + try: + with urlopen(url) as content: + try: + content_type = dict(content.getheaders())['Content-Type'] + charset = re.search(r'charset=(.*)', content_type).group(1) + except AttributeError: + charset = 'utf-8' + response = json.loads(content.read().decode(charset)) + self.logger.log(5, 'API response: %s', response) + return response + except HTTPError as exc: + self.logger.critical( + 'Error %s (%s) making request to %s', + exc.code, exc.reason, exc.url, + ) + return {} + except URLError as exc: + self.logger.critical('Error making request to %s: %s', url, exc) + return {} + + def get_api_date(self): + ''' + Figure out the date to use for API requests. Assumes yesterday's date + if between midnight and 10am Eastern time. Override this function in a + subclass to change how the API date is calculated. + ''' + # NOTE: If you are writing your own function to get the date, make sure + # to include the first if block below to allow for the ``date`` + # parameter to hard-code a date. + api_date = None + if self.date is not None and not isinstance(self.date, datetime): + try: + api_date = datetime.strptime(self.date, '%Y-%m-%d') + except (TypeError, ValueError): + self.logger.warning('Invalid date \'%s\'', self.date) + + if api_date is None: + utc_time = pytz.utc.localize(datetime.utcnow()) + eastern = pytz.timezone('US/Eastern') + api_date = eastern.normalize(utc_time.astimezone(eastern)) + if api_date.hour < 10: + # The scores on NHL.com change at 10am Eastern, if it's before + # that time of day then we will use yesterday's date. + api_date -= timedelta(days=1) + self.date = api_date + + @staticmethod + def add_ordinal(number): + try: + number = int(number) + except ValueError: + return number + if 4 <= number <= 20: + return '%d%s' % (number, 'th') + else: + ord_map = {1: 'st', 2: 'nd', 3: 'rd'} + return '%d%s' % (number, ord_map.get(number % 10, 'th')) + + @staticmethod + def force_int(value): + try: + return int(value) + except (TypeError, ValueError): + return 0 + + def get_nested(self, data, expr, callback=None, default=None): + if callback is None: + def callback(x): + return x + try: + for key in expr.split(':'): + if key.isdigit() and isinstance(data, list): + key = int(key) + data = data[key] + except (KeyError, IndexError, TypeError): + self.logger.debug('No %s data found at %s, falling back to %s', + self.__class__.__name__, expr, repr(default)) + return default + return callback(data) + + def interpret_api_return(self, data, team_game_map): + favorite_games = [] + # Cycle through the followed teams to ensure that games show up in the + # order of teams being followed. + for team in self.favorite_teams: + for id_ in team_game_map.get(team, []): + if id_ not in favorite_games: + favorite_games.append(id_) + + # If all games are being tracked, add any games not from + # explicitly-followed teams. + if self.all_games: + additional_games = [x for x in data if x not in favorite_games] + else: + additional_games = [] + + # Process the API return data for each tracked game + self.games = {} + for game_id in favorite_games + additional_games: + self.games[game_id] = self.process_game(data[game_id]) + + # Favorite games come first + self.scroll_order = [self.games[x]['id'] for x in favorite_games] + + # For any remaining games being tracked, sort each group by start time + # and add them to the list + for status in self.display_order: + time_map = { + x: self.games[x]['start_time'] for x in self.games + if x not in favorite_games and self.games[x]['status'] == status + } + sorted_games = sorted(time_map.items(), key=operator.itemgetter(1)) + self.scroll_order.extend([x[0] for x in sorted_games]) + + # Reverse map so that we can know the scroll position for a given game + # by just its ID. This will help us to place the game in its new order + # when that order changes due to the game changing from one status to + # another. + self.scroll_order_revmap = {y: x for x, y in enumerate(self.scroll_order)} + + +class Scores(Module): + ''' + This is a generic score checker, which must use at least one configured + :ref:`score backend `. + + Followed games can be scrolled through with the mouse/trackpad. + Left-clicking on the module will refresh the scores, while right-clicking + it will cycle through the configured backends. Double-clicking the module + with the left button will launch the league-specific (MLB Gameday / NHL + GameCenter / etc.) URL for the game. If there is not an active game, + double-clicking will launch the league-specific scoreboard URL containing + all games for the current day. + + Double-clicking with the right button will reset the current backend to the + first game in the scroll list. This is useful for quickly switching back to + a followed team's game after looking at other game scores. + + Scores for the previous day's games will be shown until 10am Eastern Time + (US), after which time the current day's games will be shown. + + .. rubric:: Available formatters + + Formatters are set in the backend instances, see the :ref:`scorebackends` + for more information. + + This module supports the :ref:`formatp ` extended string format + syntax. This allows for values to be hidden when they evaluate as False + (e.g. when a formatter is blank (an empty string). The default values for + the format strings set in the :ref:`score backends ` + (``format_pregame``, ``format_in_progress``, etc.) make heavy use of + formatp, hiding many formatters when they are blank. + + .. rubric:: Usage example + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.scores import mlb, nhl + + status = Status() + + status.register( + 'scores', + hints={'markup': 'pango'}, + colorize_teams=True, + favorite_icon='', + backends=[ + mlb.MLB( + teams=['CWS', 'SF'], + format_no_games='No games today :(', + inning_top='⬆', + inning_bottom='⬇', + ), + nhl.NHL(teams=['CHI']), + nba.NBA( + teams=['GSW'], + all_games=False, + ), + epl.EPL(), + ], + ) + + status.run() + + To enable colorized team name/city/abbbreviation, ``colorize_teams`` must + be set to ``True``. This also requires that i3bar is configured to use + Pango, and that the :ref:`hints ` param is set for the module and + includes a ``markup`` key, as in the example above. To ensure that i3bar is + configured to use Pango, the `font param`__ in your i3 config file must + start with ``pango:``. + + .. __: http://i3wm.org/docs/userguide.html#fonts + + .. _scores-game-order: + + If a ``teams`` param is not specified for the backend, then all games for + the current day will be tracked, and will be ordered by the start time of + the game. Otherwise, only games from explicitly-followed teams will be + tracked, and will be in the same order as listed. If ``ALL`` is part of the + list, then games from followed teams will be first in the scroll list, + followed by all remaining games in order of start time. + + Therefore, in the above example, only White Sox and Giants games would be + tracked, while in the below example all games would be tracked, with + White Sox and Giants games appearing first in the scroll list and the + remaining games appearing after them, in order of start time. + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.scores import mlb + + status = Status() + + status.register( + 'scores', + hints={'markup': 'pango'}, + colorize_teams=True, + favorite_icon='', + backends=[ + mlb.MLB( + teams=['CWS', 'SF', 'ALL'], + team_colors={ + 'NYM': '#1D78CA', + }, + ), + ], + ) + + status.run() + + .. rubric:: Troubleshooting + + If the module gets stuck during an update (i.e. the ``refresh_icon`` does + not go away), then the update thread probably encountered a traceback. This + traceback will (by default) be logged to ``~/.i3pystatus-`` where + ```` is the PID of the thread. However, it may be more convenient to + manually set the logfile to make the location of the log data reliable and + avoid clutter in your home directory. For example: + + .. code-block:: python + + import logging + from i3pystatus import Status + from i3pystatus.scores import mlb, nhl + + status = Status( + logfile='/home/username/var/i3pystatus.log', + ) + + status.register( + 'scores', + log_level=logging.DEBUG, + backends=[ + mlb.MLB( + teams=['CWS', 'SF'], + log_level=logging.DEBUG, + ), + nhl.NHL( + teams=['CHI'], + log_level=logging.DEBUG, + ), + nba.NBA( + teams=['CHI'], + log_level=logging.DEBUG, + ), + ], + ) + + status.run() + + .. note:: + The ``log_level`` must be set separately in both the module and the + backend instances (as shown above), otherwise the backends will + still use the default log level. + ''' + interval = 300 + + settings = ( + ('backends', 'List of backend instances'), + ('favorite_icon', 'Value for the ``{away_favorite}`` and ' + '``{home_favorite}`` formatter when the displayed game ' + 'is being played by a followed team'), + ('color', 'Color to be used for non-colorized text (defaults to the ' + 'i3bar color)'), + ('color_no_games', 'Color to use when no games are scheduled for the ' + 'currently-displayed backend (defaults to the ' + 'i3bar color)'), + ('colorize_teams', 'Dislay team city, name, and abbreviation in the ' + 'team\'s color (as defined in the ' + ':ref:`backend `\'s ``team_colors`` ' + 'attribute)'), + ('scroll_arrow', 'Value used for the ``{scroll}`` formatter to ' + 'indicate that more than one game is being tracked ' + 'for the currently-displayed backend'), + ('refresh_icon', 'Text to display (in addition to any text currently ' + 'shown by the module) when refreshing scores. ' + '**NOTE:** Depending on how quickly the update is ' + 'performed, the icon may not be displayed.'), + ) + + backends = [] + favorite_icon = '★' + color = None + color_no_games = None + colorize_teams = False + scroll_arrow = '⬍' + refresh_icon = '⟳' + + output = {'full_text': ''} + game_map = {} + backend_id = 0 + + on_upscroll = ['scroll_game', 1] + on_downscroll = ['scroll_game', -1] + on_leftclick = ['check_scores', 'click event'] + on_rightclick = ['cycle_backend', 1] + on_doubleleftclick = ['launch_web'] + on_doublerightclick = ['reset_backend'] + + def init(self): + if not isinstance(self.backends, list): + self.backends = [self.backends] + + if not self.backends: + raise ValueError('At least one backend is required') + + # Initialize each backend's game index + for index in range(len(self.backends)): + self.game_map[index] = None + + for backend in self.backends: + if hasattr(backend, '_valid_teams'): + for index in range(len(backend.favorite_teams)): + # Force team abbreviation to uppercase + team_uc = str(backend.favorite_teams[index]).upper() + # Check to make sure the team abbreviation is valid + if team_uc not in backend._valid_teams: + raise ValueError( + 'Invalid %s team \'%s\'' % ( + backend.__class__.__name__, + backend.favorite_teams[index] + ) + ) + backend.favorite_teams[index] = team_uc + + for index in range(len(backend.display_order)): + order_lc = str(backend.display_order[index]).lower() + # Check to make sure the display order item is valid + if order_lc not in backend._valid_display_order: + raise ValueError( + 'Invalid %s display_order \'%s\'' % ( + backend.__class__.__name__, + backend.display_order[index] + ) + ) + backend.display_order[index] = order_lc + + self.condition = threading.Condition() + self.thread = threading.Thread(target=self.update_thread, daemon=True) + self.thread.start() + + def update_thread(self): + try: + self.check_scores(force='scheduled') + while True: + with self.condition: + self.condition.wait(self.interval) + self.check_scores(force='scheduled') + except: + msg = 'Exception in {thread} at {time}, module {name}'.format( + thread=threading.current_thread().name, + time=time.strftime('%c'), + name=self.__class__.__name__, + ) + self.logger.error(msg, exc_info=True) + + @property + def current_backend(self): + return self.backends[self.backend_id] + + @property + def current_scroll_index(self): + return self.game_map[self.backend_id] + + @property + def current_game_id(self): + try: + return self.current_backend.scroll_order[self.current_scroll_index] + except (AttributeError, TypeError): + return None + + @property + def current_game(self): + try: + return self.current_backend.games[self.current_game_id] + except KeyError: + return None + + def scroll_game(self, step=1): + cur_index = self.current_scroll_index + if cur_index is None: + self.logger.debug( + 'Cannot scroll, no tracked {backend} games for ' + '{date:%Y-%m-%d}'.format( + backend=self.current_backend.__class__.__name__, + date=self.current_backend.date, + ) + ) + else: + new_index = (cur_index + step) % len(self.current_backend.scroll_order) + if new_index != cur_index: + cur_id = self.current_game_id + # Don't reference self.current_scroll_index here, we're setting + # a new value for the data point for which + # self.current_scroll_index serves as a shorthand. + self.game_map[self.backend_id] = new_index + self.logger.debug( + 'Scrolled from %s game %d (ID: %s) to %d (ID: %s)', + self.current_backend.__class__.__name__, + cur_index, + cur_id, + new_index, + self.current_backend.scroll_order[new_index], + ) + self.refresh_display() + else: + self.logger.debug( + 'Cannot scroll, only one tracked {backend} game ' + '(ID: {id_}) for {date:%Y-%m-%d}'.format( + backend=self.current_backend.__class__.__name__, + id_=self.current_game_id, + date=self.current_backend.date, + ) + ) + + def cycle_backend(self, step=1): + if len(self.backends) < 2: + self.logger.debug( + 'Only one backend (%s) configured, backend cannot be changed', + self.current_backend.__class__.__name__, + ) + return + old = self.backend_id + # Set the new backend + self.backend_id = (self.backend_id + step) % len(self.backends) + self.logger.debug( + 'Changed scores backend from %s to %s', + self.backends[old].__class__.__name__, + self.current_backend.__class__.__name__, + ) + # Display the score for the new backend. This gets rid of lag between + # when the mouse is clicked and when the new backend is shown, caused + # by any network latency encountered when updating scores. + self.refresh_display() + # Update scores (if necessary) and display them + self.check_scores() + + def reset_backend(self): + if self.current_backend.games: + self.game_map[self.backend_id] = 0 + self.logger.debug( + 'Resetting to first game in %s scroll list (ID: %s)', + self.current_backend.__class__.__name__, + self.current_game_id, + ) + self.refresh_display() + else: + self.logger.debug( + 'No %s games, cannot reset to first game in scroll list', + self.current_backend.__class__.__name__, + ) + + def launch_web(self): + game = self.current_game + if game is None: + live_url = self.current_backend.scoreboard_url + else: + live_url = game['live_url'] + self.logger.debug('Launching %s in browser', live_url) + user_open(live_url) + + @require(internet) + def check_scores(self, force=False): + update_needed = False + if not self.current_backend.last_update: + update_needed = True + self.logger.debug( + 'Performing initial %s score check', + self.current_backend.__class__.__name__, + ) + elif force: + update_needed = True + self.logger.debug( + '%s score check triggered (%s)', + self.current_backend.__class__.__name__, + force + ) + else: + update_diff = time.time() - self.current_backend.last_update + msg = ('Seconds since last %s update (%f) ' % + (self.current_backend.__class__.__name__, update_diff)) + if update_diff >= self.interval: + update_needed = True + msg += ('meets or exceeds update interval (%d), update ' + 'triggered' % self.interval) + else: + msg += ('does not exceed update interval (%d), update ' + 'skipped' % self.interval) + self.logger.debug(msg) + + if update_needed: + self.show_refresh_icon() + cur_id = self.current_game_id + cur_games = self.current_backend.games.keys() + self.current_backend.check_scores() + if cur_games == self.current_backend.games.keys(): + # Set the index to the scroll position of the current game (it + # may have changed due to this game or other games changing + # status. + if cur_id is None: + self.logger.debug( + 'No tracked {backend} games for {date:%Y-%m-%d}'.format( + backend=self.current_backend.__class__.__name__, + date=self.current_backend.date, + ) + ) + else: + cur_pos = self.game_map[self.backend_id] + new_pos = self.current_backend.scroll_order_revmap[cur_id] + if cur_pos != new_pos: + self.game_map[self.backend_id] = new_pos + self.logger.debug( + 'Scroll position for current %s game (%s) updated ' + 'from %d to %d', + self.current_backend.__class__.__name__, + cur_id, + cur_pos, + new_pos, + ) + else: + self.logger.debug( + 'Scroll position (%d) for current %s game (ID: %s) ' + 'unchanged', + cur_pos, + self.current_backend.__class__.__name__, + cur_id, + ) + else: + # Reset the index to 0 if there are any tracked games, + # otherwise set it to None to signify no tracked games for the + # backend. + if self.current_backend.games: + self.game_map[self.backend_id] = 0 + self.logger.debug( + 'Tracked %s games updated, setting scroll position to ' + '0 (ID: %s)', + self.current_backend.__class__.__name__, + self.current_game_id + ) + else: + self.game_map[self.backend_id] = None + self.logger.debug( + 'No tracked {backend} games for {date:%Y-%m-%d}'.format( + backend=self.current_backend.__class__.__name__, + date=self.current_backend.date, + ) + ) + self.current_backend.last_update = time.time() + self.refresh_display() + + def show_refresh_icon(self): + self.output['full_text'] = \ + self.refresh_icon + self.output.get('full_text', '') + + def refresh_display(self): + if self.current_scroll_index is None: + output = self.current_backend.format_no_games + color = self.color_no_games + else: + game = copy.copy(self.current_game) + + fstr = str(getattr( + self.current_backend, + 'format_%s' % game['status'] + )) + + for team in ('home', 'away'): + abbrev_key = '%s_abbrev' % team + # Set favorite icon, if applicable + game['%s_favorite' % team] = self.favorite_icon \ + if game[abbrev_key] in self.current_backend.favorite_teams \ + else '' + + if self.colorize_teams: + # Wrap in Pango markup + color = self.current_backend.team_colors.get( + game.get(abbrev_key) + ) + if color is not None: + for item in ('abbrev', 'city', 'name', 'name_short'): + key = '%s_%s' % (team, item) + if key in game: + val = '%s' % (color, game[key]) + game[key] = val + + game['scroll'] = self.scroll_arrow \ + if len(self.current_backend.games) > 1 \ + else '' + + output = formatp(fstr, **game).strip() + + self.output = {'full_text': output, 'color': self.color} + + def run(self): + pass diff --git a/i3pystatus/scores/mlb.py b/i3pystatus/scores/mlb.py new file mode 100644 index 0000000..8b09053 --- /dev/null +++ b/i3pystatus/scores/mlb.py @@ -0,0 +1,318 @@ +from i3pystatus.core.util import internet, require +from i3pystatus.scores import ScoresBackend + +import copy +import json +import pytz +import re +import time +from datetime import datetime +from urllib.request import urlopen + +LIVE_URL = 'http://mlb.mlb.com/mlb/gameday/index.jsp?gid=%s' +SCOREBOARD_URL = 'http://m.mlb.com/scoreboard' +API_URL = 'http://gd2.mlb.com/components/game/mlb/year_%04d/month_%02d/day_%02d/miniscoreboard.json' + + +class MLB(ScoresBackend): + ''' + Backend to retrieve MLB scores. For usage examples, see :py:mod:`here + <.scores>`. + + .. rubric:: Available formatters + + * `{home_name}` — Name of home team + * `{home_city}` — Name of home team's city + * `{home_abbrev}` — 2 or 3-letter abbreviation for home team's city + * `{home_score}` — Home team's current score + * `{home_wins}` — Home team's number of wins + * `{home_losses}` — Home team's number of losses + * `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the home team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{away_name}` — Name of away team + * `{away_city}` — Name of away team's city + * `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city + * `{away_score}` — Away team's current score + * `{away_wins}` — Away team's number of wins + * `{away_losses}` — Away team's number of losses + * `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the away team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{top_bottom}` — Displays the value of either ``inning_top`` or + ``inning_bottom`` based on whether the game is in the top or bottom of an + inning. + * `{inning}` — Current inning + * `{outs}` — Number of outs in current inning + * `{venue}` — Name of ballpark where game is being played + * `{start_time}` — Start time of game in system's localtime (supports + strftime formatting, e.g. `{start_time:%I:%M %p}`) + * `{delay}` — Reason for delay, if game is currently delayed. Otherwise, + this formatter will be blank. + * `{postponed}` — Reason for postponement, if game has been postponed. + Otherwise, this formatter will be blank. + * `{extra_innings}` — When a game lasts longer than 9 innings, this + formatter will show that number of innings. Otherwise, it will blank. + + .. rubric:: Team abbreviations + + * **ARI** — Arizona Diamondbacks + * **ATL** — Atlanta Braves + * **BAL** — Baltimore Orioles + * **BOS** — Boston Red Sox + * **CHC** — Chicago Cubs + * **CIN** — Cincinnati Reds + * **CLE** — Cleveland Indians + * **COL** — Colorado Rockies + * **CWS** — Chicago White Sox + * **DET** — Detroit Tigers + * **HOU** — Houston Astros + * **KC** — Kansas City Royals + * **LAA** — Los Angeles Angels of Anaheim + * **LAD** — Los Angeles Dodgers + * **MIA** — Miami Marlins + * **MIL** — Milwaukee Brewers + * **MIN** — Minnesota Twins + * **NYY** — New York Yankees + * **NYM** — New York Mets + * **OAK** — Oakland Athletics + * **PHI** — Philadelphia Phillies + * **PIT** — Pittsburgh Pirates + * **SD** — San Diego Padres + * **SEA** — Seattle Mariners + * **SF** — San Francisco Giants + * **STL** — St. Louis Cardinals + * **TB** — Tampa Bay Rays + * **TEX** — Texas Rangers + * **TOR** — Toronto Blue Jays + * **WSH** — Washington Nationals + ''' + interval = 300 + + settings = ( + ('favorite_teams', 'List of abbreviations of favorite teams. Games ' + 'for these teams will appear first in the scroll ' + 'list. A detailed description of how games are ' + 'ordered can be found ' + ':ref:`here `.'), + ('all_games', 'If set to ``True``, all games will be present in ' + 'the scroll list. If set to ``False``, then only ' + 'games from **favorite_teams** will be present in ' + 'the scroll list.'), + ('display_order', 'When **all_games** is set to ``True``, this ' + 'option will dictate the order in which games from ' + 'teams not in **favorite_teams** are displayed'), + ('format_no_games', 'Format used when no tracked games are scheduled ' + 'for the current day (does not support formatter ' + 'placeholders)'), + ('format_pregame', 'Format used when the game has not yet started'), + ('format_in_progress', 'Format used when the game is in progress'), + ('format_final', 'Format used when the game is complete'), + ('format_postponed', 'Format used when the game has been postponed'), + ('inning_top', 'Value for the ``{top_bottom}`` formatter when game ' + 'is in the top half of an inning'), + ('inning_bottom', 'Value for the ``{top_bottom}`` formatter when game ' + 'is in the bottom half of an inning'), + ('team_colors', 'Dictionary mapping team abbreviations to hex color ' + 'codes. If overridden, the passed values will be ' + 'merged with the defaults, so it is not necessary to ' + 'define all teams if specifying this value.'), + ('date', 'Date for which to display game scores, in **YYYY-MM-DD** ' + 'format. If unspecified, the current day\'s games will be ' + 'displayed starting at 10am Eastern time, with last ' + 'evening\'s scores being shown before then. This option ' + 'exists primarily for troubleshooting purposes.'), + ('live_url', 'Alternate URL string to launch MLB Gameday. This value ' + 'should not need to be changed'), + ('scoreboard_url', 'Link to the MLB.com scoreboard page. Like ' + '**live_url**, this value should not need to be ' + 'changed.'), + ('api_url', 'Alternate URL string from which to retrieve score data. ' + 'Like **live_url*** this value should not need to be ' + 'changed.'), + ) + + required = () + + _default_colors = { + 'ARI': '#A71930', + 'ATL': '#CE1141', + 'BAL': '#DF4601', + 'BOS': '#BD3039', + 'CHC': '#004EC1', + 'CIN': '#C6011F', + 'CLE': '#E31937', + 'COL': '#5E5EB6', + 'CWS': '#DADADA', + 'DET': '#FF6600', + 'HOU': '#EB6E1F', + 'KC': '#0046DD', + 'LAA': '#BA0021', + 'LAD': '#005A9C', + 'MIA': '#F14634', + 'MIL': '#0747CC', + 'MIN': '#D31145', + 'NYY': '#0747CC', + 'NYM': '#FF5910', + 'OAK': '#006659', + 'PHI': '#E81828', + 'PIT': '#FFCC01', + 'SD': '#285F9A', + 'SEA': '#2E8B90', + 'SF': '#FD5A1E', + 'STL': '#B53B30', + 'TB': '#8FBCE6', + 'TEX': '#C0111F', + 'TOR': '#0046DD', + 'WSH': '#C70003', + } + + _valid_teams = [x for x in _default_colors] + _valid_display_order = ['in_progress', 'final', 'postponed', 'pregame'] + + display_order = _valid_display_order + format_no_games = 'MLB: No games' + format_pregame = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}[ ({delay} Delay)]' + format_in_progress = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} {away_score}, [{home_favorite} ]{home_abbrev} {home_score} ({top_bottom} {inning}, {outs} Out)[ ({delay} Delay)]' + format_final = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}) (Final[/{extra_innings}])' + format_postponed = '[{scroll} ]MLB: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) (PPD: {postponed})' + inning_top = 'Top' + inning_bottom = 'Bot' + team_colors = _default_colors + live_url = LIVE_URL + scoreboard_url = SCOREBOARD_URL + api_url = API_URL + + @require(internet) + def check_scores(self): + self.get_api_date() + url = self.api_url % (self.date.year, self.date.month, self.date.day) + + game_list = self.get_nested(self.api_request(url), + 'data:games:game', + default=[]) + + # Convert list of games to dictionary for easy reference later on + data = {} + team_game_map = {} + for game in game_list: + try: + id_ = game['id'] + except KeyError: + continue + + try: + for team in (game['home_name_abbrev'], game['away_name_abbrev']): + team = team.upper() + if team in self.favorite_teams: + team_game_map.setdefault(team, []).append(id_) + except KeyError: + continue + + data[id_] = game + + self.interpret_api_return(data, team_game_map) + + def process_game(self, game): + ret = {} + + def _update(ret_key, game_key=None, callback=None, default='?'): + ret[ret_key] = self.get_nested(game, + game_key or ret_key, + callback=callback, + default=default) + + self.logger.debug('Processing %s game data: %s', + self.__class__.__name__, game) + + for key in ('id', 'venue'): + _update(key) + + for key in ('inning', 'outs'): + _update(key, callback=self.force_int, default=0) + + ret['live_url'] = self.live_url % game['gameday_link'] + + for team in ('home', 'away'): + _update('%s_wins' % team, '%s_win' % team, + callback=self.force_int) + _update('%s_losses' % team, '%s_loss' % team, + callback=self.force_int) + _update('%s_score' % team, '%s_team_runs' % team, + callback=self.force_int, default=0) + + _update('%s_abbrev' % team, '%s_name_abbrev' % team) + for item in ('city', 'name'): + _update('%s_%s' % (team, item), '%s_team_%s' % (team, item)) + + try: + ret['status'] = game.get('status').lower().replace(' ', '_') + except AttributeError: + # During warmup ret['status'] may be a dictionary. Treat these as + # pregame + ret['status'] = 'pregame' + + for key in ('delay', 'postponed'): + ret[key] = '' + + if ret['status'] == 'delayed_start': + ret['status'] = 'pregame' + ret['delay'] = game.get('reason', 'Unknown') + elif ret['status'] == 'postponed': + ret['postponed'] = game.get('reason', 'Unknown Reason') + elif ret['status'] == 'game_over': + ret['status'] = 'final' + elif ret['status'] not in ('in_progress', 'final'): + ret['status'] = 'pregame' + + try: + inning = game.get('inning', '0') + ret['extra_innings'] = inning \ + if ret['status'] == 'final' and int(inning) > 9 \ + else '' + except ValueError: + ret['extra_innings'] = '' + + top_bottom = game.get('top_inning') + ret['top_bottom'] = self.inning_top if top_bottom == 'Y' \ + else self.inning_bottom if top_bottom == 'N' \ + else '' + + time_zones = { + 'PT': 'US/Pacific', + 'MT': 'US/Mountain', + 'CT': 'US/Central', + 'ET': 'US/Eastern', + } + game_tz = pytz.timezone( + time_zones.get( + game.get('time_zone', 'ET'), + 'US/Eastern' + ) + ) + game_time_str = ' '.join(( + game.get('time_date', ''), + game.get('ampm', '') + )) + try: + game_time = datetime.strptime(game_time_str, '%Y/%m/%d %I:%M %p') + except ValueError as exc: + # Log when the date retrieved from the API return doesn't match the + # expected format (to help troubleshoot API changes), and set an + # actual datetime so format strings work as expected. The times + # will all be wrong, but the logging here will help us make the + # necessary changes to adapt to any API changes. + self.logger.error( + 'Error encountered determining %s game time for game %s:', + self.__class__.__name__, + game['id'], + exc_info=True + ) + game_time = datetime.datetime(1970, 1, 1) + + ret['start_time'] = game_tz.localize(game_time).astimezone() + + self.logger.debug('Returned %s formatter data: %s', + self.__class__.__name__, ret) + + return ret From ddde786763ec43861a251a687b6419174c162a3b Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 6 Apr 2016 01:16:00 -0500 Subject: [PATCH 154/168] Add NHL backend for scores module --- i3pystatus/scores/nhl.py | 302 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 i3pystatus/scores/nhl.py diff --git a/i3pystatus/scores/nhl.py b/i3pystatus/scores/nhl.py new file mode 100644 index 0000000..aa3406c --- /dev/null +++ b/i3pystatus/scores/nhl.py @@ -0,0 +1,302 @@ +from i3pystatus.core.util import internet, require +from i3pystatus.scores import ScoresBackend + +import copy +import json +import pytz +import re +import time +from datetime import datetime +from urllib.request import urlopen + +LIVE_URL = 'https://www.nhl.com/gamecenter/%s' +SCOREBOARD_URL = 'https://www.nhl.com/scores' +API_URL = 'https://statsapi.web.nhl.com/api/v1/schedule?startDate=%04d-%02d-%02d&endDate=%04d-%02d-%02d&expand=schedule.teams,schedule.linescore,schedule.broadcasts.all&site=en_nhl&teamId=' + + +class NHL(ScoresBackend): + ''' + Backend to retrieve NHL scores. For usage examples, see :py:mod:`here + <.scores>`. + + .. rubric:: Available formatters + + * `{home_name}` — Name of home team + * `{home_city}` — Name of home team's city + * `{home_abbrev}` — 3-letter abbreviation for home team's city + * `{home_score}` — Home team's current score + * `{home_wins}` — Home team's number of wins + * `{home_losses}` — Home team's number of losses + * `{home_otl}` — Home team's number of overtime losses + * `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the home team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{home_empty_net}` — Shows the value from the ``empty_net`` parameter + when the home team's net is empty. + * `{away_name}` — Name of away team + * `{away_city}` — Name of away team's city + * `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city + * `{away_score}` — Away team's current score + * `{away_wins}` — Away team's number of wins + * `{away_losses}` — Away team's number of losses + * `{away_otl}` — Away team's number of overtime losses + * `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the away team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{away_empty_net}` — Shows the value from the ``empty_net`` parameter + when the away team's net is empty. + * `{period}` — Current period + * `{venue}` — Name of arena where game is being played + * `{start_time}` — Start time of game in system's localtime (supports + strftime formatting, e.g. `{start_time:%I:%M %p}`) + * `{overtime}` — If the game ended in overtime or a shootout, this + formatter will show ``OT`` kor ``SO``. If the game ended in regulation, + or has not yet completed, this formatter will be blank. + + .. rubric:: Team abbreviations + + * **ANA** — Anaheim Ducks + * **ARI** — Arizona Coyotes + * **BOS** — Boston Bruins + * **BUF** — Buffalo Sabres + * **CAR** — Carolina Hurricanes + * **CBJ** — Columbus Blue Jackets + * **CGY** — Calgary Flames + * **CHI** — Chicago Blackhawks + * **COL** — Colorado Avalanche + * **DAL** — Dallas Stars + * **DET** — Detroit Red Wings + * **EDM** — Edmonton Oilers + * **FLA** — Florida Panthers + * **LAK** — Los Angeles Kings + * **MIN** — Minnesota Wild + * **MTL** — Montreal Canadiens + * **NJD** — New Jersey Devils + * **NSH** — Nashville Predators + * **NYI** — New York Islanders + * **NYR** — New York Rangers + * **OTT** — Ottawa Senators + * **PHI** — Philadelphia Flyers + * **PIT** — Pittsburgh Penguins + * **SJS** — San Jose Sharks + * **STL** — St. Louis Blues + * **TBL** — Tampa Bay Lightning + * **TOR** — Toronto Maple Leafs + * **VAN** — Vancouver Canucks + * **WPG** — Winnipeg Jets + * **WSH** — Washington Capitals + ''' + interval = 300 + + settings = ( + ('favorite_teams', 'List of abbreviations of favorite teams. Games ' + 'for these teams will appear first in the scroll ' + 'list. A detailed description of how games are ' + 'ordered can be found ' + ':ref:`here `.'), + ('all_games', 'If set to ``True``, all games will be present in ' + 'the scroll list. If set to ``False``, then only ' + 'games from **favorite_teams** will be present in ' + 'the scroll list.'), + ('display_order', 'When **all_games** is set to ``True``, this ' + 'option will dictate the order in which games from ' + 'teams not in **favorite_teams** are displayed'), + ('format_no_games', 'Format used when no tracked games are scheduled ' + 'for the current day (does not support formatter ' + 'placeholders)'), + ('format_pregame', 'Format used when the game has not yet started'), + ('format_in_progress', 'Format used when the game is in progress'), + ('format_final', 'Format used when the game is complete'), + ('empty_net', 'Value for the ``{away_empty_net}`` or ' + '``{home_empty_net}`` formatter when the net is empty. ' + 'When the net is not empty, these formatters will be ' + 'empty strings.'), + ('team_colors', 'Dictionary mapping team abbreviations to hex color ' + 'codes. If overridden, the passed values will be ' + 'merged with the defaults, so it is not necessary to ' + 'define all teams if specifying this value.'), + ('date', 'Date for which to display game scores, in **YYYY-MM-DD** ' + 'format. If unspecified, the current day\'s games will be ' + 'displayed starting at 10am Eastern time, with last ' + 'evening\'s scores being shown before then. This option ' + 'exists primarily for troubleshooting purposes.'), + ('live_url', 'URL string to launch NHL GameCenter. This value should ' + 'not need to be changed.'), + ('scoreboard_url', 'Link to the NHL.com scoreboard page. Like ' + '**live_url**, this value should not need to be ' + 'changed.'), + ('api_url', 'Alternate URL string from which to retrieve score data. ' + 'Like **live_url**, this value should not need to be ' + 'changed.'), + ) + + required = () + + _default_colors = { + 'ANA': '#B4A277', + 'ARI': '#AC313A', + 'BOS': '#F6BD27', + 'BUF': '#1568C5', + 'CAR': '#FA272E', + 'CBJ': '#1568C5', + 'CGY': '#D23429', + 'CHI': '#CD0E24', + 'COL': '#9F415B', + 'DAL': '#058158', + 'DET': '#E51937', + 'EDM': '#2F6093', + 'FLA': '#E51837', + 'LAK': '#DADADA', + 'MIN': '#176B49', + 'MTL': '#C8011D', + 'NJD': '#CC0000', + 'NSH': '#FDB71A', + 'NYI': '#F8630D', + 'NYR': '#1576CA', + 'OTT': '#C50B2F', + 'PHI': '#FF690B', + 'PIT': '#D9CBAE', + 'SJS': '#007888', + 'STL': '#1764AD', + 'TBL': '#296AD5', + 'TOR': '#296AD5', + 'VAN': '#0454FA', + 'WPG': '#1568C5', + 'WSH': '#E51937', + } + + _valid_teams = [x for x in _default_colors] + _valid_display_order = ['in_progress', 'final', 'pregame'] + + display_order = _valid_display_order + format_no_games = 'NHL: No games' + format_pregame = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}-{away_otl}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}-{home_otl}) {start_time:%H:%M %Z}' + format_in_progress = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})][ ({away_empty_net})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})][ ({home_empty_net})] ({time_remaining} {period})' + format_final = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}-{away_otl}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}-{home_otl}) (Final[/{overtime}])' + empty_net = 'EN' + team_colors = _default_colors + live_url = LIVE_URL + scoreboard_url = SCOREBOARD_URL + api_url = API_URL + + @require(internet) + def check_scores(self): + self.get_api_date() + url = self.api_url % (self.date.year, self.date.month, self.date.day, + self.date.year, self.date.month, self.date.day) + + game_list = self.get_nested(self.api_request(url), + 'dates:0:games', + default=[]) + + # Convert list of games to dictionary for easy reference later on + data = {} + team_game_map = {} + for game in game_list: + try: + id_ = game['gamePk'] + except KeyError: + continue + + try: + for key in ('home', 'away'): + team = game['teams'][key]['team']['abbreviation'].upper() + if team in self.favorite_teams: + team_game_map.setdefault(team, []).append(id_) + except KeyError: + continue + + data[id_] = game + + self.interpret_api_return(data, team_game_map) + + def process_game(self, game): + ret = {} + + def _update(ret_key, game_key=None, callback=None, default='?'): + ret[ret_key] = self.get_nested(game, + game_key or ret_key, + callback=callback, + default=default) + + self.logger.debug('Processing %s game data: %s', + self.__class__.__name__, game) + + _update('id', 'gamePk') + ret['live_url'] = self.live_url % ret['id'] + _update('period', 'linescore:currentPeriodOrdinal', default='') + _update('time_remaining', + 'linescore:currentPeriodTimeRemaining', + lambda x: x.capitalize(), + default='') + _update('venue', 'venue:name') + + pp_strength = self.get_nested(game, + 'linescore:powerPlayStrength', + default='') + for team in ('home', 'away'): + _update('%s_score' % team, + 'teams:%s:score' % team, + callback=self.force_int, + default=0) + _update('%s_wins' % team, + 'teams:%s:leagueRecord:wins' % team, + callback=self.force_int, + default=0) + _update('%s_losses' % team, + 'teams:%s:leagueRecord:losses' % team, + callback=self.force_int, + default=0) + _update('%s_otl' % team, + 'teams:%s:leagueRecord:ot' % team, + callback=self.force_int, + default=0) + + _update('%s_city' % team, 'teams:%s:team:shortName' % team) + _update('%s_name' % team, 'teams:%s:team:teamName' % team) + _update('%s_abbrev' % team, 'teams:%s:team:abbreviation' % team) + _update('%s_power_play' % team, + 'linescore:teams:%s:powerPlay' % team, + lambda x: pp_strength if x and pp_strength != 'Even' else '') + _update('%s_empty_net' % team, + 'linescore:teams:%s:goaliePulled' % team, + lambda x: self.empty_net if x else '') + + _update('status', + 'status:abstractGameState', + lambda x: x.lower().replace(' ', '_')) + + if ret['status'] == 'live': + ret['status'] = 'in_progress' + elif ret['status'] == 'final': + _update('overtime', + 'linescore:currentPeriodOrdinal', + lambda x: x if x in ('OT', 'SO') else '') + elif ret['status'] != 'in_progress': + ret['status'] = 'pregame' + + # Game time is in UTC, ISO format, thank the FSM + # Ex. 2016-04-02T17:00:00Z + game_time_str = game.get('gameDate', '') + try: + game_time = datetime.strptime(game_time_str, '%Y-%m-%dT%H:%M:%SZ') + except ValueError as exc: + # Log when the date retrieved from the API return doesn't match the + # expected format (to help troubleshoot API changes), and set an + # actual datetime so format strings work as expected. The times + # will all be wrong, but the logging here will help us make the + # necessary changes to adapt to any API changes. + self.logger.error( + 'Error encountered determining %s game time for game %s:', + self.__class__.__name__, + game['id'], + exc_info=True + ) + game_time = datetime.datetime(1970, 1, 1) + + ret['start_time'] = pytz.utc.localize(game_time).astimezone() + + self.logger.debug('Returned %s formatter data: %s', + self.__class__.__name__, ret) + + return ret From 5d053f70946f808762e50f844d09a7a23787e119 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 10 Apr 2016 12:57:57 -0500 Subject: [PATCH 155/168] Add NBA backend for scores module --- i3pystatus/scores/nba.py | 314 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 i3pystatus/scores/nba.py diff --git a/i3pystatus/scores/nba.py b/i3pystatus/scores/nba.py new file mode 100644 index 0000000..4f5e3b6 --- /dev/null +++ b/i3pystatus/scores/nba.py @@ -0,0 +1,314 @@ +from i3pystatus.core.util import internet, require +from i3pystatus.scores import ScoresBackend + +import copy +import pytz +import time +from datetime import datetime + +LIVE_URL = 'http://www.nba.com/gametracker/#/%s/lp' +SCOREBOARD_URL = 'http://www.nba.com/scores' +API_URL = 'http://data.nba.com/data/10s/json/cms/noseason/scoreboard/%04d%02d%02d/games.json' +STANDINGS_URL = 'http://data.nba.com/data/json/cms/%s/league/standings.json' + + +class NBA(ScoresBackend): + ''' + Backend to retrieve NBA scores. For usage examples, see :py:mod:`here + <.scores>`. + + .. rubric:: Available formatters + + * `{home_name}` — Name of home team + * `{home_city}` — Name of home team's city + * `{home_abbrev}` — 3-letter abbreviation for home team's city + * `{home_score}` — Home team's current score + * `{home_wins}` — Home team's number of wins + * `{home_losses}` — Home team's number of losses + * `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the home team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{away_name}` — Name of away team + * `{away_city}` — Name of away team's city + * `{away_abbrev}` — 2 or 3-letter abbreviation for away team's city + * `{away_score}` — Away team's current score + * `{away_wins}` — Away team's number of wins + * `{away_losses}` — Away team's number of losses + * `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the away team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{time_remaining}` — Time remaining in the current quarter/OT period + * `{quarter}` — Number of the current quarter + * `{venue}` — Name of arena where game is being played + * `{start_time}` — Start time of game in system's localtime (supports + strftime formatting, e.g. `{start_time:%I:%M %p}`) + * `{overtime}` — If the game ended in overtime, this formatter will show + ``OT``. If the game ended in regulation, or has not yet completed, this + formatter will be blank. + + .. rubric:: Team abbreviations + + * **ATL** — Atlanta Hawks + * **BKN** — Brooklyn Nets + * **BOS** — Boston Celtics + * **CHA** — Charlotte Hornets + * **CHI** — Chicago Bulls + * **CLE** — Cleveland Cavaliers + * **DAL** — Dallas Mavericks + * **DEN** — Denver Nuggets + * **DET** — Detroit Pistons + * **GSW** — Golden State Warriors + * **HOU** — Houston Rockets + * **IND** — Indiana Pacers + * **MIA** — Miami Heat + * **MEM** — Memphis Grizzlies + * **MIL** — Milwaukee Bucks + * **LAC** — Los Angeles Clippers + * **LAL** — Los Angeles Lakers + * **MIN** — Minnesota Timberwolves + * **NOP** — New Orleans Pelicans + * **NYK** — New York Knicks + * **OKC** — Oklahoma City Thunder + * **ORL** — Orlando Magic + * **PHI** — Philadelphia 76ers + * **PHX** — Phoenix Suns + * **POR** — Portland Trailblazers + * **SAC** — Sacramento Kings + * **SAS** — San Antonio Spurs + * **TOR** — Toronto Raptors + * **UTA** — Utah Jazz + * **WAS** — Washington Wizards + ''' + interval = 300 + + settings = ( + ('favorite_teams', 'List of abbreviations of favorite teams. Games ' + 'for these teams will appear first in the scroll ' + 'list. A detailed description of how games are ' + 'ordered can be found ' + ':ref:`here `.'), + ('all_games', 'If set to ``True``, all games will be present in ' + 'the scroll list. If set to ``False``, then only ' + 'games from **favorite_teams** will be present in ' + 'the scroll list.'), + ('display_order', 'When **all_games** is set to ``True``, this ' + 'option will dictate the order in which games from ' + 'teams not in **favorite_teams** are displayed'), + ('format_no_games', 'Format used when no tracked games are scheduled ' + 'for the current day (does not support formatter ' + 'placeholders)'), + ('format_pregame', 'Format used when the game has not yet started'), + ('format_in_progress', 'Format used when the game is in progress'), + ('format_final', 'Format used when the game is complete'), + ('team_colors', 'Dictionary mapping team abbreviations to hex color ' + 'codes. If overridden, the passed values will be ' + 'merged with the defaults, so it is not necessary to ' + 'define all teams if specifying this value.'), + ('date', 'Date for which to display game scores, in **YYYY-MM-DD** ' + 'format. If unspecified, the current day\'s games will be ' + 'displayed starting at 10am Eastern time, with last ' + 'evening\'s scores being shown before then. This option ' + 'exists primarily for troubleshooting purposes.'), + ('live_url', 'URL string to launch NBA Game Tracker. This value ' + 'should not need to be changed.'), + ('scoreboard_url', 'Link to the NBA.com scoreboard page. Like ' + '**live_url**, this value should not need to be ' + 'changed.'), + ('api_url', 'Alternate URL string from which to retrieve score data. ' + 'Like, **live_url**, this value should not need to be ' + 'changed.'), + ('standings_url', 'Alternate URL string from which to retrieve team ' + 'standings. Like **live_url**, this value should ' + 'not need to be changed.'), + ) + + required = () + + _default_colors = { + 'ATL': '#E2383F', + 'BKN': '#DADADA', + 'BOS': '#178D58', + 'CHA': '#00798D', + 'CHI': '#CD1041', + 'CLE': '#FDBA31', + 'DAL': '#006BB7', + 'DEN': '#5593C3', + 'DET': '#207EC0', + 'GSW': '#DEB934', + 'HOU': '#CD1042', + 'IND': '#FFBB33', + 'MIA': '#A72249', + 'MEM': '#628BBC', + 'MIL': '#4C7B4B', + 'LAC': '#ED174C', + 'LAL': '#FDB827', + 'MIN': '#35749F', + 'NOP': '#A78F59', + 'NYK': '#F68428', + 'OKC': '#F05033', + 'ORL': '#1980CB', + 'PHI': '#006BB7', + 'PHX': '#E76120', + 'POR': '#B03037', + 'SAC': '#7A58A1', + 'SAS': '#DADADA', + 'TOR': '#CD112C', + 'UTA': '#4B7059', + 'WAS': '#E51735', + } + + _valid_teams = [x for x in _default_colors] + _valid_display_order = ['in_progress', 'final', 'pregame'] + + display_order = _valid_display_order + format_no_games = 'NBA: No games' + format_pregame = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}' + format_in_progress = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})] ({time_remaining} {quarter})' + format_final = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}) (Final[/{overtime}])' + team_colors = _default_colors + live_url = LIVE_URL + scoreboard_url = SCOREBOARD_URL + api_url = API_URL + standings_url = STANDINGS_URL + + def check_scores(self): + self.get_api_date() + url = self.api_url % (self.date.year, self.date.month, self.date.day) + + response = self.api_request(url) + game_list = self.get_nested(response, + 'sports_content:games:game', + default=[]) + + standings_year = self.get_nested( + response, + 'sports_content:sports_meta:season_meta:standings_season_year', + default=self.date.year, + ) + + stats_list = self.get_nested( + self.api_request(self.standings_url % standings_year), + 'sports_content:standings:team', + default=[], + ) + team_stats = {} + for item in stats_list: + try: + key = item.pop('abbreviation') + except KeyError: + self.logger.debug('Error occurred obtaining team stats', + exc_info=True) + continue + team_stats[key] = item.get('team_stats', {}) + + self.logger.debug('%s team stats: %s', + self.__class__.__name__, team_stats) + + # Convert list of games to dictionary for easy reference later on + data = {} + team_game_map = {} + for game in game_list: + try: + id_ = game['game_url'] + except KeyError: + continue + + try: + for key in ('home', 'visitor'): + team = game[key]['abbreviation'].upper() + if team in self.favorite_teams: + team_game_map.setdefault(team, []).append(id_) + except KeyError: + continue + + data[id_] = game + # Merge in the team stats, because they are not returned in the + # initial API request. + for key in ('home', 'visitor'): + team = data[id_][key]['abbreviation'].upper() + data[id_][key].update(team_stats.get(team, {})) + + self.interpret_api_return(data, team_game_map) + + def process_game(self, game): + ret = {} + + def _update(ret_key, game_key=None, callback=None, default='?'): + ret[ret_key] = self.get_nested(game, + game_key or ret_key, + callback=callback, + default=default) + + self.logger.debug('Processing %s game data: %s', + self.__class__.__name__, game) + + _update('id', 'game_url') + ret['live_url'] = self.live_url % ret['id'] + + status_map = { + '1': 'pregame', + '2': 'in_progress', + '3': 'final', + } + period_data = game.get('period_time', {}) + status_code = period_data.get('game_status', '1') + status = status_map.get(status_code) + if status is None: + self.logger.debug('Unknown %s game status code \'%s\'', + self.__class__.__name__, status_code) + status_code = '1' + ret['status'] = status_map[status_code] + + if ret['status'] in ('in_progress', 'final'): + period_number = int(period_data.get('period_value', 1)) + total_periods = int(period_data.get('total_periods', 0)) + period_diff = period_number - total_periods + ret['quarter'] = 'OT' \ + if period_diff == 1 \ + else '%dOT' if period_diff > 1 \ + else self.add_ordinal(period_number) + else: + ret['quarter'] = '' + + ret['time_remaining'] = period_data.get('game_clock') + if ret['time_remaining'] == '': + ret['time_remaining'] = 'End' + elif ret['time_remaining'] is None: + ret['time_remaining'] = '' + ret['overtime'] = ret['quarter'] if 'OT' in ret['quarter'] else '' + + _update('venue', 'arena') + + for ret_key, game_key in (('home', 'home'), ('away', 'visitor')): + _update('%s_score' % ret_key, '%s:score' % game_key) + _update('%s_wins' % ret_key, '%s:wins' % game_key) + _update('%s_losses' % ret_key, '%s:losses' % game_key) + _update('%s_city' % ret_key, '%s:city' % game_key) + _update('%s_name' % ret_key, '%s:nickname' % game_key) + _update('%s_abbrev' % ret_key, '%s:abbreviation' % game_key) + + # From API data, date is YYYYMMDD, time is HHMM + game_time_str = '%s%s' % (game.get('date', ''), game.get('time', '')) + try: + game_time = datetime.strptime(game_time_str, '%Y%m%d%H%M') + except ValueError as exc: + # Log when the date retrieved from the API return doesn't match the + # expected format (to help troubleshoot API changes), and set an + # actual datetime so format strings work as expected. The times + # will all be wrong, but the logging here will help us make the + # necessary changes to adapt to any API changes. + self.logger.error( + 'Error encountered determining game time for %s game %s:', + self.__class__.__name__, + game['id'], + exc_info=True + ) + game_time = datetime.datetime(1970, 1, 1) + + eastern = pytz.timezone('US/Eastern') + ret['start_time'] = eastern.localize(game_time).astimezone() + + self.logger.debug('Returned %s formatter data: %s', + self.__class__.__name__, ret) + + return ret From 11db5baca6a3750272eeecad3dcd2ff12e67e216 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Sun, 10 Apr 2016 20:55:02 -0500 Subject: [PATCH 156/168] Add EPL backend for scores module --- i3pystatus/scores/epl.py | 373 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 i3pystatus/scores/epl.py diff --git a/i3pystatus/scores/epl.py b/i3pystatus/scores/epl.py new file mode 100644 index 0000000..cbd505e --- /dev/null +++ b/i3pystatus/scores/epl.py @@ -0,0 +1,373 @@ +from i3pystatus.core.util import internet, require +from i3pystatus.scores import ScoresBackend + +import copy +import pytz +import time +from collections import namedtuple +from datetime import datetime + +LIVE_URL = 'http://live.premierleague.com/#/gameweek/%s/matchday/%s/match/%s' +CONTEXT_URL = 'http://live.premierleague.com/syndicationdata/context.json' +SCOREBOARD_URL = 'http://live.premierleague.com/' +API_URL = 'http://live.premierleague.com/syndicationdata/competitionId=%s/seasonId=%s/gameWeekId=%s/scores.json' +STATS_URL = 'http://live.premierleague.com/syndicationdata/competitionId=%s/seasonId=%s/matchDayId=%s/league-table.json' +MATCH_DETAILS_URL = 'http://live.premierleague.com/syndicationdata/competitionId=%s/seasonId=%s/matchDayId=%s/matchId=%s/match-details.json' + +MATCH_STATUS_PREGAME = 1 +MATCH_STATUS_IN_PROGRESS = 2 +MATCH_STATUS_FINAL = 3 +MATCH_STATUS_HALFTIME = 4 + + +class EPL(ScoresBackend): + ''' + Backend to retrieve scores from the English Premier League. For usage + examples, see :py:mod:`here <.scores>`. + + .. rubric:: Promotion / Relegation + + Due to promotion/relegation, the **team_colors** configuration will + eventuall become out of date. When this happens, it will be necessary to + manually set the colors for the newly-promoted teams until the source for + this module is updated. An example of setting colors for newly promoted + teams can be seen below: + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.scores import epl + + status = Status() + + status.register( + 'scores', + hints={'markup': 'pango'}, + colorize_teams=True, + backends=[ + epl.EPL( + teams=['LIV'], + team_colors={ + 'ABC': '#1D78CA', + 'DEF': '#8AFEC3', + 'GHI': '#33FA6D', + }, + ), + ], + ) + + status.run() + + .. rubric:: Available formatters + + * `{home_name}` — Name of home team (e.g. **Tottenham Hotspur**) + * `{home_name_short}` — Shortened team name (e.g. **Spurs**) + * `{home_abbrev}` — 2 or 3-letter abbreviation for home team's city (e.g. + **TOT**) + * `{home_score}` — Home team's current score + * `{home_wins}` — Home team's number of wins + * `{home_losses}` — Home team's number of losses + * `{home_draws}` — Home team's number of draws + * `{home_points}` — Home team's number of standings points + * `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the home team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{away_name}` — Name of away team (e.g. **Manchester United**) + * `{away_name_short}` — Name of away team's city (e.g. **Man Utd**) + * `{away_abbrev}` — 2 or 3-letter abbreviation for away team's name (e.g. + **MUN**) + * `{away_score}` — Away team's current score + * `{away_wins}` — Away team's number of wins + * `{away_losses}` — Away team's number of losses + * `{away_draws}` — Away team's number of draws + * `{away_points}` — Away team's number of standings points + * `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's + ``favorite`` attribute, if the away team is one of the teams being + followed. Otherwise, this formatter will be blank. + * `{minute}` — Current minute of game when in progress + * `{start_time}` — Start time of game in system's localtime (supports + strftime formatting, e.g. `{start_time:%I:%M %p}`) + + .. rubric:: Team abbreviations + + * **ARS** — Arsenal + * **AVL** — Aston Villa + * **BOU** — Bournemouth + * **CHE** — Chelsea + * **CRY** — Crystal Palace + * **EVE** — Everton + * **LEI** — Leicester City + * **LIV** — Liverpool + * **MCI** — Manchester City + * **MUN** — Manchester United + * **NEW** — Newcastle United + * **NOR** — Norwich City + * **SOU** — Southampton + * **STK** — Stoke City + * **SUN** — Sunderland Association + * **SWA** — Swansea City + * **TOT** — Tottenham Hotspur + * **WAT** — Watford + * **WBA** — West Bromwich Albion + * **WHU** — West Ham United + ''' + interval = 300 + + settings = ( + ('favorite_teams', 'List of abbreviations of favorite teams. Games ' + 'for these teams will appear first in the scroll ' + 'list. A detailed description of how games are ' + 'ordered can be found ' + ':ref:`here `.'), + ('all_games', 'If set to ``True``, all games will be present in ' + 'the scroll list. If set to ``False``, then only ' + 'games from **favorite_teams** will be present in ' + 'the scroll list.'), + ('display_order', 'When **all_games** is set to ``True``, this ' + 'option will dictate the order in which games from ' + 'teams not in **favorite_teams** are displayed'), + ('format_no_games', 'Format used when no tracked games are scheduled ' + 'for the current day (does not support formatter ' + 'placeholders)'), + ('format_pregame', 'Format used when the game has not yet started'), + ('format_in_progress', 'Format used when the game is in progress'), + ('format_final', 'Format used when the game is complete'), + ('team_colors', 'Dictionary mapping team abbreviations to hex color ' + 'codes. If overridden, the passed values will be ' + 'merged with the defaults, so it is not necessary to ' + 'define all teams if specifying this value.'), + ('date', 'Date for which to display game scores, in **YYYY-MM-DD** ' + 'format. If unspecified, the date will be determined by ' + 'the return value of an API call to the **context_url**. ' + 'Due to API limitations, the date can presently only be ' + 'overridden to another date in the current week. This ' + 'option exists primarily for troubleshooting purposes.'), + ('live_url', 'URL string to launch EPL Live Match Centre. This value ' + 'should not need to be changed.'), + ('scoreboard_url', 'Link to the EPL scoreboard page. Like ' + '**live_url**, this value should not need to be ' + 'changed.'), + ('api_url', 'Alternate URL string from which to retrieve score data. ' + 'Like **live_url**, this value should not need to be ' + 'changed.'), + ('stats_url', 'Alternate URL string from which to retrieve team ' + 'statistics. Like **live_url**, this value should not ' + 'need to be changed.'), + ('match_details_url', 'Alternate URL string from which to retrieve ' + 'match details. Like **live_url**, this value ' + 'should not need to be changed.'), + ) + + required = () + + _default_colors = { + 'ARS': '#ED1B22', + 'AVL': '#94BEE5', + 'BOU': '#CB0B0F', + 'CHE': '#195FAF', + 'CRY': '#195FAF', + 'EVE': '#004F9E', + 'LEI': '#304FB6', + 'LIV': '#D72129', + 'MCI': '#74B2E0', + 'MUN': '#DD1921', + 'NEW': '#06B3EB', + 'NOR': '#00A651', + 'SOU': '#DB1C26', + 'STK': '#D81732', + 'SUN': '#BC0007', + 'SWA': '#B28250', + 'TOT': '#DADADA', + 'WAT': '#E4D500', + 'WBA': '#B43C51', + 'WHU': '#9DE4FA', + } + + _valid_display_order = ['in_progress', 'final', 'pregame'] + + display_order = _valid_display_order + format_no_games = 'EPL: No games' + format_pregame = '[{scroll} ]EPL: [{away_favorite} ]{away_abbrev} ({away_points}, {away_wins}-{away_losses}-{away_draws}) at [{home_favorite} ]{home_abbrev} ({home_points}, {home_wins}-{home_losses}-{home_draws}) {start_time:%H:%M %Z}' + format_in_progress = '[{scroll} ]EPL: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})] ({minute})' + format_final = '[{scroll} ]EPL: [{away_favorite} ]{away_abbrev} {away_score} ({away_points}, {away_wins}-{away_losses}-{away_draws}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_points}, {home_wins}-{home_losses}-{home_draws}) (Final)' + team_colors = _default_colors + context_url = CONTEXT_URL + live_url = LIVE_URL + scoreboard_url = SCOREBOARD_URL + api_url = API_URL + stats_url = STATS_URL + match_details_url = MATCH_DETAILS_URL + + def get_api_date(self): + # NOTE: We're not really using this date for EPL API calls, but we do + # need it to allow for a 'date' param to override which date we use for + # scores. + if self.date is not None and not isinstance(self.date, datetime): + try: + self.date = datetime.strptime(self.date, '%Y-%m-%d') + except (TypeError, ValueError): + self.logger.warning('Invalid date \'%s\'', self.date) + + if self.date is None: + self.date = datetime.strptime(self.context.date, '%Y%m%d') + + def get_context(self): + response = self.api_request(self.context_url) + context_tuple = namedtuple( + 'Context', + ('competition', 'date', 'game_week', 'match_day', 'season') + ) + self.context = context_tuple( + *[ + response.get(x, '') + for x in ('competitionId', 'currentDay', 'gameWeekId', + 'matchDayId', 'seasonId') + ] + ) + + def get_team_stats(self): + ret = {} + url = self.stats_url % (self.context.competition, + self.context.season, + self.context.match_day) + for item in self.api_request(url).get('Data', []): + try: + key = item.pop('TeamCode') + except KeyError: + self.logger.debug('Error occurred obtaining %s team stats', + self.__class__.__name__, + exc_info=True) + continue + ret[key] = item + return ret + + def get_minute(self, data, id_): + match_status = data[id_].get('StatusId', MATCH_STATUS_PREGAME) + if match_status == MATCH_STATUS_HALFTIME: + return 'Halftime' + if match_status == MATCH_STATUS_IN_PROGRESS: + url = self.match_details_url % (self.context.competition, + self.context.season, + data[id_].get('MatchDayId', ''), + id_) + try: + response = self.api_request(url) + return '%s\'' % response['Data']['Minute'] + except (KeyError, TypeError): + return '?\'' + else: + return '?\'' + + def check_scores(self): + self.get_context() + self.get_api_date() + + url = self.api_url % (self.context.competition, + self.context.season, + self.context.game_week) + + for item in self.api_request(url).get('Data', []): + if item.get('Key', '') == self.date.strftime('%Y%m%d'): + game_list = item.get('Scores', []) + break + else: + game_list = [] + self.logger.debug('game_list = %s', game_list) + + team_stats = self.get_team_stats() + + # Convert list of games to dictionary for easy reference later on + data = {} + team_game_map = {} + for game in game_list: + try: + id_ = game['Id'] + except KeyError: + continue + + try: + for key in ('HomeTeam', 'AwayTeam'): + team = game[key]['Code'].upper() + if team in self.favorite_teams: + team_game_map.setdefault(team, []).append(id_) + except KeyError: + continue + + data[id_] = game + # Merge in the team stats, because they are not returned in the + # initial API request. + for key in ('HomeTeam', 'AwayTeam'): + team = game[key]['Code'].upper() + data[id_][key]['Stats'] = team_stats.get(team, {}) + # Add the minute, if applicable + data[id_]['Minute'] = self.get_minute(data, id_) + + self.interpret_api_return(data, team_game_map) + + def process_game(self, game): + ret = {} + + def _update(ret_key, game_key=None, callback=None, default='?'): + ret[ret_key] = self.get_nested(game, + game_key or ret_key, + callback=callback, + default=default) + + self.logger.debug('Processing %s game data: %s', + self.__class__.__name__, game) + + _update('id', 'Id') + _update('minute', 'Minute') + ret['live_url'] = self.live_url % (self.context.game_week, + self.context.match_day, + ret['id']) + + status_map = { + MATCH_STATUS_PREGAME: 'pregame', + MATCH_STATUS_IN_PROGRESS: 'in_progress', + MATCH_STATUS_FINAL: 'final', + MATCH_STATUS_HALFTIME: 'in_progress', + } + status_code = game.get('StatusId') + if status_code is None: + self.logger.debug('%s game %s is missing StatusId', + self.__class__.__name__, ret['id']) + status_code = 1 + ret['status'] = status_map[status_code] + + for ret_key, game_key in (('home', 'HomeTeam'), ('away', 'AwayTeam')): + _update('%s_score' % ret_key, '%s:Score' % game_key, default=0) + _update('%s_name' % ret_key, '%s:Name' % game_key) + _update('%s_name_short' % ret_key, '%s:ShortName' % game_key) + _update('%s_abbrev' % ret_key, '%s:Code' % game_key) + _update('%s_wins' % ret_key, '%s:Stats:Won' % game_key, default=0) + _update('%s_losses' % ret_key, '%s:Stats:Lost' % game_key) + _update('%s_draws' % ret_key, '%s:Stats:Drawn' % game_key) + _update('%s_points' % ret_key, '%s:Stats:Points' % game_key) + + try: + game_time = datetime.strptime( + game.get('DateTime', ''), + '%Y-%m-%dT%H:%M:%S' + ) + except ValueError as exc: + # Log when the date retrieved from the API return doesn't match the + # expected format (to help troubleshoot API changes), and set an + # actual datetime so format strings work as expected. The times + # will all be wrong, but the logging here will help us make the + # necessary changes to adapt to any API changes. + self.logger.error( + 'Error encountered determining game time for %s game %s:', + self.__class__.__name__, + ret['id'], + exc_info=True + ) + game_time = datetime.datetime(1970, 1, 1) + + london = pytz.timezone('Europe/London') + ret['start_time'] = london.localize(game_time).astimezone() + + self.logger.debug('Returned %s formatter data: %s', + self.__class__.__name__, ret) + + return ret From 157f0f57c8178dc2f80d8dff368c4a6f984bec03 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Wed, 20 Apr 2016 15:18:32 -0500 Subject: [PATCH 157/168] Properly identify delayed games in-progress Had to wait for an in-progress game to be delayed to see the API return to properly catch this. --- i3pystatus/scores/mlb.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/i3pystatus/scores/mlb.py b/i3pystatus/scores/mlb.py index 8b09053..b28dec9 100644 --- a/i3pystatus/scores/mlb.py +++ b/i3pystatus/scores/mlb.py @@ -258,6 +258,9 @@ class MLB(ScoresBackend): if ret['status'] == 'delayed_start': ret['status'] = 'pregame' ret['delay'] = game.get('reason', 'Unknown') + elif ret['status'] == 'delayed': + ret['status'] = 'in_progress' + ret['delay'] = game.get('reason', 'Unknown') elif ret['status'] == 'postponed': ret['postponed'] = game.get('reason', 'Unknown Reason') elif ret['status'] == 'game_over': From c930fe833030d37e0fcd3714bd47e63631fa50c3 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Fri, 22 Apr 2016 00:37:43 -0500 Subject: [PATCH 158/168] Properly identify multi-OT NHL games --- i3pystatus/scores/nhl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/scores/nhl.py b/i3pystatus/scores/nhl.py index aa3406c..013bab4 100644 --- a/i3pystatus/scores/nhl.py +++ b/i3pystatus/scores/nhl.py @@ -271,7 +271,7 @@ class NHL(ScoresBackend): elif ret['status'] == 'final': _update('overtime', 'linescore:currentPeriodOrdinal', - lambda x: x if x in ('OT', 'SO') else '') + lambda x: x if 'OT' in x or x == 'SO' else '') elif ret['status'] != 'in_progress': ret['status'] = 'pregame' From 633ea4628ec1986cd9a3307457e8f3a2dd800ce8 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 25 Apr 2016 22:40:36 -0500 Subject: [PATCH 159/168] Use playoff W/L numbers if in playoffs Also add seed info --- i3pystatus/scores/nba.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/i3pystatus/scores/nba.py b/i3pystatus/scores/nba.py index 4f5e3b6..9bc4592 100644 --- a/i3pystatus/scores/nba.py +++ b/i3pystatus/scores/nba.py @@ -25,6 +25,8 @@ class NBA(ScoresBackend): * `{home_score}` — Home team's current score * `{home_wins}` — Home team's number of wins * `{home_losses}` — Home team's number of losses + * `{home_seed}` — During the playoffs, shows the home team's playoff seed. + When not in the playoffs, this formatter will be blank. * `{home_favorite}` — Displays the value for the :py:mod:`.scores` module's ``favorite`` attribute, if the home team is one of the teams being followed. Otherwise, this formatter will be blank. @@ -34,6 +36,8 @@ class NBA(ScoresBackend): * `{away_score}` — Away team's current score * `{away_wins}` — Away team's number of wins * `{away_losses}` — Away team's number of losses + * `{away_seed}` — During the playoffs, shows the away team's playoff seed. + When not in the playoffs, this formatter will be blank. * `{away_favorite}` — Displays the value for the :py:mod:`.scores` module's ``favorite`` attribute, if the away team is one of the teams being followed. Otherwise, this formatter will be blank. @@ -162,7 +166,7 @@ class NBA(ScoresBackend): display_order = _valid_display_order format_no_games = 'NBA: No games' - format_pregame = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}' + format_pregame = '[{scroll} ]NBA: [{away_favorite} ][{away_seed} ]{away_abbrev} ({away_wins}-{away_losses}) at [{home_favorite} ][{home_seed} ]{home_abbrev} ({home_wins}-{home_losses}) {start_time:%H:%M %Z}' format_in_progress = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score}[ ({away_power_play})], [{home_favorite} ]{home_abbrev} {home_score}[ ({home_power_play})] ({time_remaining} {quarter})' format_final = '[{scroll} ]NBA: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}-{away_losses}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}-{home_losses}) (Final[/{overtime}])' team_colors = _default_colors @@ -280,12 +284,26 @@ class NBA(ScoresBackend): _update('venue', 'arena') for ret_key, game_key in (('home', 'home'), ('away', 'visitor')): - _update('%s_score' % ret_key, '%s:score' % game_key) - _update('%s_wins' % ret_key, '%s:wins' % game_key) - _update('%s_losses' % ret_key, '%s:losses' % game_key) + _update('%s_score' % ret_key, '%s:score' % game_key, + callback=self.force_int, default=0) _update('%s_city' % ret_key, '%s:city' % game_key) _update('%s_name' % ret_key, '%s:nickname' % game_key) _update('%s_abbrev' % ret_key, '%s:abbreviation' % game_key) + if 'playoffs' in game: + _update('%s_wins' % ret_key, 'playoffs:%s_wins' % game_key, + callback=self.force_int, default=0) + _update('%s_seed' % ret_key, 'playoffs:%s_seed' % game_key, + callback=self.force_int, default=0) + else: + _update('%s_wins' % ret_key, '%s:wins' % game_key, + callback=self.force_int, default=0) + _update('%s_losses' % ret_key, '%s:losses' % game_key, + callback=self.force_int, default=0) + ret['%s_seed' % ret_key] = '' + + if 'playoffs' in game: + ret['home_losses'] = ret['away_wins'] + ret['away_losses'] = ret['home_wins'] # From API data, date is YYYYMMDD, time is HHMM game_time_str = '%s%s' % (game.get('date', ''), game.get('time', '')) From da104268ab6c3c760f1bf8fe2ccf2345c2ef10a0 Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Mon, 2 May 2016 21:47:07 -0500 Subject: [PATCH 160/168] Catch another odd weather.com weather condition This properly colorizes when there is a thunderstorm and the weather.com API response defines the current weather condition as "T-Storm". No idea why they decide to do this, it's definitely not for brevity as they have a "Thunderstorms in the Vicinity" weather condition as well. Just a weird quirk of their API, I guess. --- i3pystatus/weather/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py index 7f1dd2e..8ac9eb8 100644 --- a/i3pystatus/weather/__init__.py +++ b/i3pystatus/weather/__init__.py @@ -121,7 +121,7 @@ class Weather(IntervalModule): condition = 'Partly Cloudy' else: condition = 'Cloudy' - elif 'thunder' in condition_lc: + elif 'thunder' in condition_lc or 't-storm' in condition_lc: condition = 'Thunderstorm' elif 'snow' in condition_lc: condition = 'Snow' From cc3781a6c01c7c4e7a11044ef2382825b7c58674 Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Wed, 11 May 2016 13:26:38 -0700 Subject: [PATCH 161/168] gpu_temp: add "display_if" setting Adds a "display_if" setting to the gpu_temp module that allows the output to be squelched unless some snippet has been evaluated as true. --- i3pystatus/gpu_temp.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/i3pystatus/gpu_temp.py b/i3pystatus/gpu_temp.py index 2f0aacf..6c2fb5d 100644 --- a/i3pystatus/gpu_temp.py +++ b/i3pystatus/gpu_temp.py @@ -15,6 +15,7 @@ class GPUTemperature(IntervalModule): settings = ( ("format", "format string used for output. {temp} is the temperature in integer degrees celsius"), + ("display_if", "snippet that gets evaluated. if true, displays the module output"), "color", "alert_temp", "alert_color", @@ -23,12 +24,14 @@ class GPUTemperature(IntervalModule): color = "#FFFFFF" alert_temp = 90 alert_color = "#FF0000" + display_if = True def run(self): temp = gpu.query_nvidia_smi().temp temp_alert = temp is None or temp >= self.alert_temp - self.output = { - "full_text": self.format.format(temp=temp), - "color": self.color if not temp_alert else self.alert_color, - } + if eval(self.display_if): + self.output = { + "full_text": self.format.format(temp=temp), + "color": self.color if not temp_alert else self.alert_color, + } From b901cec5a6fd42475d47ab8242c803112e80affa Mon Sep 17 00:00:00 2001 From: David Wahlstrom Date: Wed, 11 May 2016 13:38:28 -0700 Subject: [PATCH 162/168] temp: add a "display_if" setting Adds a "display_if" setting to temp module. This is a snippet that will be evaulated, and if the result is true, it will display the module's output. --- i3pystatus/temp.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/i3pystatus/temp.py b/i3pystatus/temp.py index 9e37ade..cb42667 100644 --- a/i3pystatus/temp.py +++ b/i3pystatus/temp.py @@ -11,6 +11,7 @@ class Temperature(IntervalModule): settings = ( ("format", "format string used for output. {temp} is the temperature in degrees celsius"), + ('display_if', 'snippet that gets evaluated. if true, displays the module output'), "color", "file", "alert_temp", @@ -21,12 +22,14 @@ class Temperature(IntervalModule): file = "/sys/class/thermal/thermal_zone0/temp" alert_temp = 90 alert_color = "#FF0000" + display_if = 'True' def run(self): with open(self.file, "r") as f: temp = float(f.read().strip()) / 1000 - self.output = { - "full_text": self.format.format(temp=temp), - "color": self.color if temp < self.alert_temp else self.alert_color, - } + if eval(self.display_if): + self.output = { + "full_text": self.format.format(temp=temp), + "color": self.color if temp < self.alert_temp else self.alert_color, + } From 8f9c8786891cb94359ffe024c16cd2162d09610d Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Tue, 17 May 2016 09:26:21 -0500 Subject: [PATCH 163/168] Make NHL team wins reflect series wins during playoffs The win/loss values from the NHL data feed reset in the playoffs, but don't reflect the current series. Instead, they reflect the current win/loss total since the beginning of the playoffs. This commit checks for a key in the API return data indicating that the game is a playoff game, and if the game is a playoff game the team's wins will be set to the remainder of the total wins divided by 4 (a team with 6 overall wins will be assumed to have 2 wins in the current playoff series). The team's losses during a playoff series will be set to the amount of wins for the opposing team. --- i3pystatus/scores/nhl.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/i3pystatus/scores/nhl.py b/i3pystatus/scores/nhl.py index 013bab4..c9f450a 100644 --- a/i3pystatus/scores/nhl.py +++ b/i3pystatus/scores/nhl.py @@ -53,6 +53,33 @@ class NHL(ScoresBackend): formatter will show ``OT`` kor ``SO``. If the game ended in regulation, or has not yet completed, this formatter will be blank. + .. rubric:: Playoffs + + In the playoffs, losses are not important (as the losses will be equal to + the other team's wins). Therefore, it is a good idea during the playoffs to + manually set format strings to exclude information on team losses. For + example: + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.scores import nhl + + status = Status() + status.register( + 'scores', + hints={'markup': 'pango'}, + colorize_teams=True, + favorite_icon='', + backends=[ + nhl.NHL( + favorite_teams=['CHI'], + format_pregame = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} ({away_wins}) at [{home_favorite} ]{home_abbrev} ({home_wins}) {start_time:%H:%M %Z}', + format_final = '[{scroll} ]NHL: [{away_favorite} ]{away_abbrev} {away_score} ({away_wins}) at [{home_favorite} ]{home_abbrev} {home_score} ({home_wins}) (Final[/{overtime}])', + ), + ], + ) + .. rubric:: Team abbreviations * **ANA** — Anaheim Ducks @@ -262,6 +289,14 @@ class NHL(ScoresBackend): 'linescore:teams:%s:goaliePulled' % team, lambda x: self.empty_net if x else '') + if game.get('gameType') == 'P': + for team in ('home', 'away'): + # Series wins are the remainder of dividing wins by 4 + ret['_'.join((team, 'wins'))] %= 4 + # Series losses are the other team's wins + ret['home_losses'] = ret['away_wins'] + ret['away_losses'] = ret['home_wins'] + _update('status', 'status:abstractGameState', lambda x: x.lower().replace(' ', '_')) From 3ab7a58d03434572c998c6374edf76607be8c373 Mon Sep 17 00:00:00 2001 From: enkore Date: Sun, 22 May 2016 22:18:29 +0200 Subject: [PATCH 164/168] spotify: pass player_name=spotify to playerctl --- i3pystatus/spotify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i3pystatus/spotify.py b/i3pystatus/spotify.py index 54e92c3..c12bc36 100644 --- a/i3pystatus/spotify.py +++ b/i3pystatus/spotify.py @@ -75,7 +75,7 @@ class Spotify(IntervalModule): # tries to create player object and get data from player try: - self.player = Playerctl.Player() + self.player = Playerctl.Player(player_name="spotify") response = self.get_info(self.player) From f0591844113d0ebd4847e30442b04ccee69f1f10 Mon Sep 17 00:00:00 2001 From: Stefan Tatschner Date: Tue, 24 May 2016 10:16:01 +0200 Subject: [PATCH 165/168] Use CSFR Token in Syncthing module Fixes #390 --- i3pystatus/syncthing.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/i3pystatus/syncthing.py b/i3pystatus/syncthing.py index 58a47c3..8891c9e 100644 --- a/i3pystatus/syncthing.py +++ b/i3pystatus/syncthing.py @@ -40,11 +40,16 @@ class Syncthing(IntervalModule): ) def st_get(self, endpoint): - response = requests.get( - urljoin(self.url, endpoint), - verify=self.verify_ssl, - ) - return json.loads(response.text) + # TODO: Maybe we can share a session across multiple GETs. + with requests.Session() as s: + r = s.get(self.url) + csrf_name, csfr_value = r.headers['Set-Cookie'].split('=') + s.headers.update({'X-' + csrf_name: csfr_value}) + r = s.get( + urljoin(self.url, endpoint), + verify=self.verify_ssl, + ) + return json.loads(r.text) def st_post(self, endpoint, data=None): headers = {'X-API-KEY': self.apikey} From cee28601380b80dbc598b71a1e7328772721dbe7 Mon Sep 17 00:00:00 2001 From: eBrnd Date: Tue, 24 May 2016 12:21:06 +0200 Subject: [PATCH 166/168] add weekcal module (#388) --- i3pystatus/weekcal.py | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 i3pystatus/weekcal.py diff --git a/i3pystatus/weekcal.py b/i3pystatus/weekcal.py new file mode 100644 index 0000000..4d0f16a --- /dev/null +++ b/i3pystatus/weekcal.py @@ -0,0 +1,70 @@ +from calendar import Calendar +from datetime import date, timedelta + +from i3pystatus import IntervalModule + + +class WeekCal(IntervalModule): + """ + Displays the days of the current week as they would be represented on a calendar sheet, + with the current day highlighted. + By default, the current day of week is displayed in the front, and the month and year are + displayed in the back. + + Example: ``Sat 16 17 18 19 20[21]22 May 2016`` + """ + + settings = ( + ("startofweek", "First day of the week (0 = Monday, 6 = Sunday), defaults to 0."), + ("prefixformat", "Prefix in strftime-format"), + ("suffixformat", "Suffix in strftime-format"), + ("todayhighlight", "Characters to highlight today's date"), + ) + startofweek = 0 + interval = 30 + prefixformat = "%a" + suffixformat = "%b %Y" + todayhighlight = ("[", "]") + + def __init__(self, *args, **kwargs): + IntervalModule.__init__(self, *args, **kwargs) + self.cal = Calendar(self.startofweek) + + def run(self): + today = date.today() + yesterday = today - timedelta(days=1) + + outstr = today.strftime(self.prefixformat) + " " + + weekdays = self.cal.iterweekdays() + if today.weekday() == self.startofweek: + outstr += self.todayhighlight[0] + else: + outstr += " " + + nextweek = False # keep track of offset if week doesn't start on monday + + for w in weekdays: + if w == 0 and self.startofweek != 0: + nextweek = True + if nextweek and today.weekday() >= self.startofweek: + w += 7 + elif not nextweek and today.weekday() < self.startofweek: + w -= 7 + + weekday_offset = today.weekday() - w + weekday_delta = timedelta(days=weekday_offset) + weekday = today - weekday_delta + if weekday == yesterday: + outstr += weekday.strftime("%d") + self.todayhighlight[0] + elif weekday == today: + outstr += weekday.strftime("%d") + self.todayhighlight[1] + else: + outstr += weekday.strftime("%d ") + + outstr += " " + today.strftime(self.suffixformat) + + self.output = { + "full_text": outstr, + "urgent": False, + } From 2d7b3afaca97a3e6a115c077586d0a9fb9daf8b2 Mon Sep 17 00:00:00 2001 From: Mehdi ABAAKOUK Date: Tue, 24 May 2016 12:22:30 +0200 Subject: [PATCH 167/168] Fix imap connection lost (#380) Nothing in imap mail backend reinit the imap connection when this one is lost, and then the backend always output "socket.error:..." This change fixes that by cleanup the connection object when connection is lost so get_connection() will recreate a new one. This also remove the unless utils.internet() checks already done by Mail().run() --- i3pystatus/mail/imap.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/i3pystatus/mail/imap.py b/i3pystatus/mail/imap.py index 1ac0c5c..58dc252 100644 --- a/i3pystatus/mail/imap.py +++ b/i3pystatus/mail/imap.py @@ -1,8 +1,7 @@ -import sys import imaplib +import socket from i3pystatus.mail import Backend -from i3pystatus.core.util import internet class IMAP(Backend): @@ -33,20 +32,30 @@ class IMAP(Backend): self.imap_class = imaplib.IMAP4_SSL def get_connection(self): + if self.connection: + try: + self.connection.select(self.mailbox) + except socket.error: + # NOTE(sileht): retry just once if the connection have been + # broken to ensure this is not a sporadic connection lost. + # Like wifi reconnect, sleep wake up + try: + self.connection.logout() + except socket.error: + pass + self.connection = None + if not self.connection: self.connection = self.imap_class(self.host, self.port) self.connection.login(self.username, self.password) self.connection.select(self.mailbox) - self.connection.select(self.mailbox) - return self.connection @property def unread(self): - if internet(): - conn = self.get_connection() - self.last = len(conn.search(None, "UnSeen")[1][0].split()) + conn = self.get_connection() + self.last = len(conn.search(None, "UnSeen")[1][0].split()) return self.last From ecb532a5ac7f6870ee82a050b2070e6d7e530264 Mon Sep 17 00:00:00 2001 From: colajam93 Date: Sat, 4 Jun 2016 01:39:31 +0900 Subject: [PATCH 168/168] Fix typo --- i3pystatus/gpu_mem.py | 2 +- i3pystatus/mem.py | 2 +- i3pystatus/mem_bar.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i3pystatus/gpu_mem.py b/i3pystatus/gpu_mem.py index 068fc36..3e24401 100644 --- a/i3pystatus/gpu_mem.py +++ b/i3pystatus/gpu_mem.py @@ -22,7 +22,7 @@ class GPUMemory(IntervalModule): ("warn_percentage", "minimal percentage for warn state"), ("alert_percentage", "minimal percentage for alert state"), ("color", "standard color"), - ("warn_color", "defines the color used wann warn percentage ist exceeded"), + ("warn_color", "defines the color used when warn percentage is exceeded"), ("alert_color", "defines the color used when alert percentage is exceeded"), ("round_size", "defines number of digits in round"), diff --git a/i3pystatus/mem.py b/i3pystatus/mem.py index 90a4517..c327ceb 100644 --- a/i3pystatus/mem.py +++ b/i3pystatus/mem.py @@ -34,7 +34,7 @@ class Mem(IntervalModule): ("alert_percentage", "minimal percentage for alert state"), ("color", "standard color"), ("warn_color", - "defines the color used wann warn percentage ist exceeded"), + "defines the color used when warn percentage is exceeded"), ("alert_color", "defines the color used when alert percentage is exceeded"), ("round_size", "defines number of digits in round"), diff --git a/i3pystatus/mem_bar.py b/i3pystatus/mem_bar.py index 560171c..5097782 100644 --- a/i3pystatus/mem_bar.py +++ b/i3pystatus/mem_bar.py @@ -32,7 +32,7 @@ class MemBar(IntervalModule, ColorRangeModule): ("alert_percentage", "minimal percentage for alert state"), ("color", "standard color"), ("warn_color", - "defines the color used wann warn percentage ist exceeded"), + "defines the color used when warn percentage is exceeded"), ("alert_color", "defines the color used when alert percentage is exceeded"), ("multi_colors", "whether to use range of colors from 'color' to 'alert_color' based on memory usage."),