diff --git a/.gitignore b/.gitignore index 89a63b8..9be1197 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/* *~ .i3pystatus-* ci-build +docs/_build diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 66e1301..97aef19 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -17,6 +17,7 @@ David Garcia Quintas David Wahlstrom dubwoc eBrnd +Erik Johnson enkore facetoe Frank Tackitt @@ -39,6 +40,7 @@ Kenneth Lyons krypt-n Lukáš Mandák Łukasz Jędrzejewski +Mathis Felardos Matthias Pronk Matthieu Coudron Matus Telgarsky 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/ diff --git a/docs/changelog.rst b/docs/changelog.rst index 0e79a4d..4482a58 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,13 +5,32 @@ Changelog master branch +++++++++++++ -.. _r3.34: +.. _r3.35: + +3.34 (2016-02-14) ++++++++++++++++++ * New modules - :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 + - :py:mod:`.sge`: Sun Grid Engine (SGE) monitor + - :py:mod:`.timer`: Timer + - :py:mod:`.syncthing`: Syncthing monitor and control + - :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`) +* 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 * :py:mod:`.uptime`: Added days, hours, minutes, secs formatters @@ -24,9 +43,28 @@ 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 +* 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 + 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 +* :py:mod:`.backlight`: Fixed bug preventing brightness increase + 3.33 (2015-06-23) +++++++++++++++++ diff --git a/docs/conf.py b/docs/conf.py index c60eff5..8d46f11 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,14 @@ MOCK_MODULES = [ "dota2py", "novaclient.v2", "speedtest_cli", - "pyzabbix" + "pyzabbix", + "vk", + "google-api-python-client", + "dateutil", + "httplib2", + "oauth2client", + "apiclient" + ] for mod_name in MOCK_MODULES: @@ -66,7 +73,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 diff --git a/docs/configuration.rst b/docs/configuration.rst index 57ad858..4ff694e 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 @@ -146,7 +146,8 @@ 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 + # Requires the keyrings.alt package + from keyrings.alt.file import PlaintextKeyring status.register('github', keyring_backend=PlaintextKeyring()) i3pystatus will locate and set the credentials during the module @@ -174,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. :: @@ -232,7 +233,43 @@ 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') + +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 +~~~~~~~~~ Every module has a ``log_level`` option which sets the *minimum* severity required for an event to be logged. @@ -321,12 +358,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 +387,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 @@ -370,6 +419,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 @@ -446,9 +507,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/docs/i3pystatus.rst b/docs/i3pystatus.rst index 035bdb7..c909fa4 100644 --- a/docs/i3pystatus.rst +++ b/docs/i3pystatus.rst @@ -26,10 +26,33 @@ 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 + + from i3pystatus.mail import maildir + 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' +.. _scorebackends: + +Score Backends +-------------- + +.. autogen:: i3pystatus.scores SettingsBase + + .. nothin' + .. _updatebackends: Update Backends @@ -38,3 +61,12 @@ Update Backends .. autogen:: i3pystatus.updates SettingsBase .. nothin' + +.. _weatherbackends: + +Weather Backends +---------------- + +.. autogen:: i3pystatus.weather SettingsBase + + .. nothin' 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)) diff --git a/i3pystatus/__init__.py b/i3pystatus/__init__.py index 1d022a8..e6c4ac8 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()) @@ -27,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/alsa.py b/i3pystatus/alsa.py index f53815b..9287952 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,14 +83,16 @@ 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 else: output_format = self.format + self.data = self.fdict self.output = { "full_text": output_format.format(**self.fdict), "color": self.color_muted if muted else self.color, @@ -92,10 +103,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)) 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)]) diff --git a/i3pystatus/battery.py b/i3pystatus/battery.py index f181a41..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 @@ -131,7 +133,37 @@ 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 + + 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 = ( @@ -277,6 +309,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) @@ -311,6 +344,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..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: @@ -120,7 +173,14 @@ class Bitcoin(IntervalModule): else: fdict["last_tx_type"] = "sent" + self.data = fdict self.output = { "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) 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 = { diff --git a/i3pystatus/cmus.py b/i3pystatus/cmus.py index dd2cec4..25f6d6a 100644 --- a/i3pystatus/cmus.py +++ b/i3pystatus/cmus.py @@ -95,10 +95,13 @@ 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/__init__.py b/i3pystatus/core/__init__.py index 9a580b5..1c0a9d1 100644 --- a/i3pystatus/core/__init__.py +++ b/i3pystatus/core/__init__.py @@ -1,12 +1,15 @@ -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 +DEFAULT_LOG_FORMAT = '%(asctime)s [%(levelname)-8s][%(name)s %(lineno)d] %(message)s' + class CommandEndpoint: """ @@ -28,9 +31,19 @@ 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"]) + + button = cmd["button"] + kwargs = {"button_id": button} + try: + kwargs.update({"pos_x": cmd["x"], + "pos_y": cmd["y"]}) + except Exception: + continue + + if target_module: + target_module.on_click(button, **kwargs) target_module.run() self.io.async_refresh() @@ -39,17 +52,35 @@ 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=True, click_events=True, interval=1, + input_stream=None, logfile=None, internet_check=None, + logformat=DEFAULT_LOG_FORMAT): self.standalone = standalone - self.click_events = click_events - if standalone: + self.click_events = standalone and click_events + input_stream = input_stream or sys.stdin + logger = logging.getLogger("i3pystatus") + if logfile: + 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 + + 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/modules.py b/i3pystatus/core/modules.py index a0e1b56..41a5936 100644 --- a/i3pystatus/core/modules.py +++ b/i3pystatus/core/modules.py @@ -1,8 +1,23 @@ +import inspect +import traceback + 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 + + +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 cls.__dict__.get(method.__name__, None) is method: + return True + return False class Module(SettingsBase): @@ -11,21 +26,48 @@ 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_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`)"), ) 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_otherclick = None + on_doubleotherclick = 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,20 +88,99 @@ class Module(SettingsBase): def run(self): pass - def on_click(self, button): + 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, **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 + tmp_cb = wrapped_cb + else: + 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. + 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", **kwargs) + return False + + if isinstance(cb, list): + cb, args = (cb[0], cb[1:]) + else: + args = [] + + try: + our_method = is_method_of(cb, self) + if callable(cb) and not our_method: + self.__log_button_event(button, cb, args, + "Python callback", **kwargs) + call_callback(cb, *args, **kwargs) + elif our_method: + 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", **kwargs) + call_callback(getattr(self, cb), *args, **kwargs) + else: + self.__log_button_event(button, cb, args, + "External command", **kwargs) + + if hasattr(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(traceback.format_exc()) + + # Notify status handler + try: + self.__status_handler.io.async_refresh() + except: + pass + + def on_click(self, button, **kwargs): """ Maps a click event with its associated callback. 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: @@ -67,8 +188,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. @@ -76,54 +197,39 @@ 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 - """ - def log_event(name, button, cb, args, action): - msg = "{}: button={}, cb='{}', args={}, type='{}'".format( - name, button, cb, args, action) - self.logger.debug(msg) + actions = ['leftclick', 'middleclick', 'rightclick', + 'upscroll', 'downscroll'] + try: + action = actions[button - 1] + except (TypeError, IndexError): + self.__log_button_event(button, None, None, "Other button") + action = "otherclick" - def split_callback_and_args(cb): - if isinstance(cb, list): - return cb[0], cb[1:] + 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) + + 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, **kwargs) else: - return cb, [] - - cb = None - if button == 1: # Left mouse button - cb = self.on_leftclick - elif button == 3: # Right mouse button - cb = self.on_rightclick - elif button == 4: # mouse wheel up - cb = self.on_upscroll - elif button == 5: # mouse wheel down - cb = self.on_downscroll - else: - log_event(self.__name__, 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) - - 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 + self.__button_callback_handler(button, cb, **kwargs) def move(self, position): self.position = position @@ -162,6 +268,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/threading.py b/i3pystatus/core/threading.py index f6eee9b..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__ @@ -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: diff --git a/i3pystatus/core/util.py b/i3pystatus/core/util.py index 9bc11fd..d9b4d37 100644 --- a/i3pystatus/core/util.py +++ b/i3pystatus/core/util.py @@ -3,6 +3,8 @@ import functools import re import socket import string +import inspect +from threading import Timer, RLock def lchop(string, prefix): @@ -335,7 +337,7 @@ def require(predicate): .. seealso:: - :py:func:`internet` + :py:class:`internet` """ @@ -351,18 +353,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"): @@ -489,3 +501,90 @@ 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 + self.kwargs = None + + def set_timer(self, button, cb, **kwargs): + 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.kwargs = kwargs + + 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.kwargs) + 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, **self.kwargs) + ret = False + + 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, 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 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 b67d176..5b130f2 100644 --- a/i3pystatus/disk.py +++ b/i3pystatus/disk.py @@ -6,32 +6,58 @@ 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. """ 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 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): - stat = os.statvfs(self.path) + 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: + self.not_mounted() + return + available = (stat.f_bsize * stat.f_bavail) / self.divisor if available > self.display_limit: @@ -51,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..2c50520 100644 --- a/i3pystatus/dota2wins.py +++ b/i3pystatus/dota2wins.py @@ -102,8 +102,10 @@ class Dota2wins(IntervalModule): "wins": wins, "losses": losses, "win_percent": win_percent, + "win_percent": "%.2f" % win_percent, } + self.data = cdict self.output = { "full_text": self.format.format(**cdict), "color": color 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: 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/google_calendar.py b/i3pystatus/google_calendar.py new file mode 100644 index 0000000..3ba3eee --- /dev/null +++ b/i3pystatus/google_calendar.py @@ -0,0 +1,134 @@ +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 + * `{remaining_time}` - how long remaining until the event + * `{start_time}` - when this event starts + * `{htmlLink}` — link to the calendar event + + + """ + settings = ( + ('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'"), + ) + + required = ('credential_path',) + + interval = 30 + + format = "{summary} ({remaining_time})" + credential_path = None + skip_recurring = True + days = 1 + urgent_seconds = 300 + color = 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 = display_event['start_time'] + 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(): + # 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 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): + 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=self.days) + 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 diff --git a/i3pystatus/gpu_mem.py b/i3pystatus/gpu_mem.py new file mode 100644 index 0000000..3e24401 --- /dev/null +++ b/i3pystatus/gpu_mem.py @@ -0,0 +1,69 @@ +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 when warn percentage is 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.data = cdict + self.output = { + "full_text": self.format.format(**cdict), + "color": color + } diff --git a/i3pystatus/gpu_temp.py b/i3pystatus/gpu_temp.py new file mode 100644 index 0000000..6c2fb5d --- /dev/null +++ b/i3pystatus/gpu_temp.py @@ -0,0 +1,37 @@ +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"), + ("display_if", "snippet that gets evaluated. if true, displays the module output"), + "color", + "alert_temp", + "alert_color", + ) + format = "{temp} °C" + 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 + + 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, + } diff --git a/i3pystatus/iinet.py b/i3pystatus/iinet.py new file mode 100644 index 0000000..784e137 --- /dev/null +++ b/i3pystatus/iinet.py @@ -0,0 +1,94 @@ +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.data = usage + 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) 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/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/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 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..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"), @@ -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/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."), diff --git a/i3pystatus/moon.py b/i3pystatus/moon.py index ad22f21..8743aa2 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 = { @@ -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 3d309e9..17c040a 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"), @@ -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 = { @@ -64,9 +66,16 @@ 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) + if self.password is not None: + sock.send('password "{}"\n'.format(self.password).encode("utf-8")) + sock.recv(8192) sock.send((command + "\n").encode("utf-8")) try: reply = sock.recv(16384).decode("utf-8") @@ -87,7 +96,9 @@ class MPD(IntervalModule): self.output = { "full_text": "" } - return + if hasattr(self, "data"): + del self.data + return fdict = { "pos": int(status.get("song", 0)) + 1, @@ -103,7 +114,7 @@ class MPD(IntervalModule): "bitrate": int(status.get("bitrate", 0)), } - if not fdict["title"] and "filename" in fdict: + if not fdict["title"] and "file" in currentsong: fdict["filename"] = '.'.join( basename(currentsong["file"]).split('.')[:-1]) else: @@ -114,6 +125,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: @@ -124,7 +136,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..953c4d8 100644 --- a/i3pystatus/now_playing.py +++ b/i3pystatus/now_playing.py @@ -69,7 +69,15 @@ 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") + + 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: @@ -92,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", ""), @@ -113,6 +127,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 +141,8 @@ 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 +153,8 @@ 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/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' 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 07d76d6..48d1792 100644 --- a/i3pystatus/openvpn.py +++ b/i3pystatus/openvpn.py @@ -18,20 +18,24 @@ class OpenVPN(IntervalModule): """ - colour_up = "#00ff00" - colour_down = "#FF0000" - format = "{label} {vpn_name} {status}" - status_command = "bash -c \"systemctl show openvpn@%(vpn_name)s | grep -oP 'ActiveState=\K(\w+)'\"" + color_up = "#00ff00" + color_down = "#FF0000" + status_up = '▲' + status_down = '▼' + format = "{vpn_name} {status}" + status_command = "bash -c 'systemctl show openvpn@%(vpn_name)s | grep ActiveState=active'" label = '' vpn_name = '' 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"), - ("label", "Set a label for this connection") + ("status_command", "command to find out if the VPN is active"), ) def init(self): @@ -42,16 +46,17 @@ 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': - color = self.colour_up - status = '▲' + if output: + color = self.color_up + status = self.status_up else: - color = self.colour_down - status = '▼' + color = self.color_down + status = self.status_down 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/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 + } 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/pulseaudio/__init__.py b/i3pystatus/pulseaudio/__init__.py index 064bf5f..3fb22f5 100644 --- a/i3pystatus/pulseaudio/__init__.py +++ b/i3pystatus/pulseaudio/__init__.py @@ -4,6 +4,7 @@ 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 @@ -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('alsamixer') 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() + sink_state = attribs[-1] + if sink_state == b'RUNNING': + bestsink = attribs[1] + state = 'RUNNING' + elif sink_state in (b'IDLE', b'SUSPENDED') and state == b'DEFAULT': + bestsink = attribs[1] + 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' - subprocess.Popen(command.split()) + 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 - subprocess.Popen(command.split()) + 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 - subprocess.Popen(command.split()) + subprocess.Popen(['pactl', 'set-sink-volume', self.sink, "-%s%%" % self.step]) 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 402e676..5c9bca3 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,82 +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.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.colorize and fdict.get("message_unread", False): + color = self.color_orangered + if self.mail_brackets: + fdict["message_unread"] = "[{}]".format(fdict["message_unread"]) + else: + color = self.color + + self.data = fdict + 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', disable_update_check=True) + 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: - r.login(self.username, self.password, disable_warning=True) - unread_messages = sum(1 for i in r.get_unread()) + self.log_in(reddit) + unread_messages = sum(1 for i in reddit.get_unread()) if unread_messages: - d = vars(next(r.get_unread())) - fdict = { + 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"] } - else: - fdict = { - "message_unread": "", - "status": self.status["no_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 = r.get_subreddit(self.subreddit) + s = reddit.get_subreddit(self.subreddit) else: - s = r + s = reddit if self.sort_by == 'hot': if not self.subreddit: - d = vars(next(s.get_front_page(limit=1))) + subreddit_dict = vars(next(s.get_front_page(limit=1))) else: - d = vars(next(s.get_hot(limit=1))) + subreddit_dict = vars(next(s.get_hot(limit=1))) elif self.sort_by == 'new': - d = vars(next(s.get_new(limit=1))) + subreddit_dict = vars(next(s.get_new(limit=1))) elif self.sort_by == 'rising': - d = vars(next(s.get_rising(limit=1))) + subreddit_dict = vars(next(s.get_rising(limit=1))) elif self.sort_by == 'controversial': - d = vars(next(s.get_controversial(limit=1))) + subreddit_dict = 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"]: - color = self.color_orangered - if self.mail_brackets: - fdict["message_unread"] = "[{}]".format(unread_messages) - else: - color = self.color + 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 - 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"] = "" + self._permalink = fdict["submission_permalink"] + self._url = fdict["submission_url"] - full_text = self.format.format(**fdict) - self.output = { - "full_text": full_text, - "color": color, - } + return fdict def open_mail(self): user_open('https://www.reddit.com/message/unread/') 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/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 diff --git a/i3pystatus/scores/mlb.py b/i3pystatus/scores/mlb.py new file mode 100644 index 0000000..b28dec9 --- /dev/null +++ b/i3pystatus/scores/mlb.py @@ -0,0 +1,321 @@ +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'] == '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': + 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 diff --git a/i3pystatus/scores/nba.py b/i3pystatus/scores/nba.py new file mode 100644 index 0000000..9bc4592 --- /dev/null +++ b/i3pystatus/scores/nba.py @@ -0,0 +1,332 @@ +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_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. + * `{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_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. + * `{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_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 + 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, + 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', '')) + 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 diff --git a/i3pystatus/scores/nhl.py b/i3pystatus/scores/nhl.py new file mode 100644 index 0000000..c9f450a --- /dev/null +++ b/i3pystatus/scores/nhl.py @@ -0,0 +1,337 @@ +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:: 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 + * **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 '') + + 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(' ', '_')) + + if ret['status'] == 'live': + ret['status'] = 'in_progress' + elif ret['status'] == 'final': + _update('overtime', + 'linescore:currentPeriodOrdinal', + lambda x: x if 'OT' in x or x == '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 diff --git a/i3pystatus/sge.py b/i3pystatus/sge.py new file mode 100644 index 0000000..2e0391f --- /dev/null +++ b/i3pystatus/sge.py @@ -0,0 +1,56 @@ +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 -xml\"".format(self.ssh), + stderr=subprocess.STDOUT, + 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 + + for j in root.xpath('//job_info/queue_info/job_list'): + 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.data = fdict + self.output = { + "full_text": self.format.format(**fdict).strip(), + "color": self.color + } diff --git a/i3pystatus/solaar.py b/i3pystatus/solaar.py new file mode 100644 index 0000000..04f2e69 --- /dev/null +++ b/i3pystatus/solaar.py @@ -0,0 +1,74 @@ +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 + + .. 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 numberOfDevice + raise DeviceNotFound() + + 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 batterystatus + elif line.count('offline'): + raise NoBatteryStatus('offline') + else: + raise NoBatteryStatus('unknown') + raise NoBatteryStatus('unknown/error') + + def run(self): + self.output = {} + + 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 + except NoBatteryStatus as e: + output = e.message + self.output['color'] = self.error_color + + self.output['full_text'] = output diff --git a/i3pystatus/spotify.py b/i3pystatus/spotify.py index 4404fe8..c12bc36 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""" @@ -74,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) @@ -86,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} @@ -93,6 +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 def playpause(self): """Pauses and plays spotify""" @@ -101,3 +105,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() diff --git a/i3pystatus/syncthing.py b/i3pystatus/syncthing.py new file mode 100644 index 0000000..8891c9e --- /dev/null +++ b/i3pystatus/syncthing.py @@ -0,0 +1,133 @@ +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): + # 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} + 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): + """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: + self.st_start_systemd() diff --git a/i3pystatus/taskwarrior.py b/i3pystatus/taskwarrior.py new file mode 100644 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 + } 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, + } diff --git a/i3pystatus/timer.py b/i3pystatus/timer.py new file mode 100644 index 0000000..4013ac2 --- /dev/null +++ b/i3pystatus/timer.py @@ -0,0 +1,182 @@ +import time + +from i3pystatus import IntervalModule +from i3pystatus.core.command import execute +from i3pystatus.core.util import TimeWrapper + + +class TimerState: + 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). + + .. 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 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()) 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/updates/__init__.py b/i3pystatus/updates/__init__.py index e135cae..eee9e90 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. @@ -19,6 +21,10 @@ class Updates(IntervalModule): .. 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 @@ -27,7 +33,7 @@ class Updates(IntervalModule): from i3pystatus import Status from i3pystatus.updates import pacman, cower - status = Status(standalone=True) + status = Status() status.register("updates", format = "Updates: {count}", @@ -42,12 +48,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,20 +63,50 @@ class Updates(IntervalModule): backends = None format = "Updates: {count}" format_no_updates = None + format_working = None color = "#00DD00" - color_no_updates = "#FFFFFF" + color_no_updates = None + 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 + } + 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): + 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, + } + 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 { @@ -77,10 +115,12 @@ 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, } + + def run(self): + with self.condition: + self.condition.notify() diff --git a/i3pystatus/updates/dnf.py b/i3pystatus/updates/dnf.py new file mode 100644 index 0000000..efd4bda --- /dev/null +++ b/i3pystatus/updates/dnf.py @@ -0,0 +1,25 @@ +from i3pystatus.core.command import run_through_shell +from i3pystatus.updates import Backend +from re import split + + +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: + 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 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 diff --git a/i3pystatus/utils/__init__.py b/i3pystatus/utils/__init__.py new file mode 100644 index 0000000..e69de29 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) diff --git a/i3pystatus/vk.py b/i3pystatus/vk.py new file mode 100644 index 0000000..ba24fcf --- /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", "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) + 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} diff --git a/i3pystatus/weather.py b/i3pystatus/weather.py deleted file mode 100644 index ed6d6e9..0000000 --- a/i3pystatus/weather.py +++ /dev/null @@ -1,113 +0,0 @@ -from i3pystatus import IntervalModule -from i3pystatus.core.util import internet, require - -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=*' - - -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 - - .. rubric:: Available formatters - - * `{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 - - """ - - interval = 20 - - settings = ( - ("location_code", "Location code from www.weather.com"), - ("colorize", "Enable color with temperature and UTF-8 icons."), - ("units", "Celsius (metric) or Fahrenheit (imperial)"), - "format", - ) - 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.''' - unit = '' if self.units == 'imperial' or self.units == '' else 'm' - url = WEATHER_COM_URL % (self.location_code, unit) - with urlopen(url) as handler: - try: - content_type = dict(handler.getheaders())['Content-Type'] - charset = re.search(r'charset=(.*)', content_type).group(1) - except AttributeError: - charset = 'utf-8' - xml = handler.read().decode(charset) - doc = ElementTree.XML(xml) - 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'), - ), - ) - - @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 - } diff --git a/i3pystatus/weather/__init__.py b/i3pystatus/weather/__init__.py new file mode 100644 index 0000000..8ac9eb8 --- /dev/null +++ b/i3pystatus/weather/__init__.py @@ -0,0 +1,148 @@ +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'\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'\u2600', '#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. + ''' + 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 or 't-storm' 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 \ + 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, + } diff --git a/i3pystatus/weather/weathercom.py b/i3pystatus/weather/weathercom.py new file mode 100644 index 0000000..5568a54 --- /dev/null +++ b/i3pystatus/weather/weathercom.py @@ -0,0 +1,137 @@ +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 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``). + + .. _weather-usage-weathercom: + + .. rubric:: Usage example + + .. code-block:: python + + from i3pystatus import Status + from i3pystatus.weather import weathercom + + 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'), + ('units', '\'metric\' or \'imperial\''), + ) + required = ('location_code',) + + location_code = None + + 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: + try: + content_type = dict(handler.getheaders())['Content-Type'] + charset = re.search(r'charset=(.*)', content_type).group(1) + except AttributeError: + 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( + 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'), + ) diff --git a/i3pystatus/weather/wunderground.py b/i3pystatus/weather/wunderground.py new file mode 100644 index 0000000..fcbb496 --- /dev/null +++ b/i3pystatus/weather/wunderground.py @@ -0,0 +1,240 @@ +from i3pystatus import IntervalModule +from i3pystatus.core.util import 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_QUERY_URL = 'http://api.wunderground.com/api/%s/%s/q/%s.json' + + +class Wunderground(IntervalModule): + ''' + 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 a developer API key free at + https://www.wunderground.com/weather/api/ + + 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: + + * **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`` 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 + + .. _weather-usage-wunderground: + + .. 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 wunderground.com'), + ('units', '\'metric\' or \'imperial\''), + ('use_pws', 'Set to False to use only airport stations'), + ('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' + use_pws = True + forecast = False + + # These will be set once weather data has been checked + station_id = None + forecast_url = None + + @require(internet) + 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 + + @require(internet) + 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 + + @require(internet) + def get_forecast(self): + ''' + 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_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) + + 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')) + except TypeError: + observation_time = 0 + + return dict( + city=_find('city', response['observation_location']), + 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), + 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('%'), + uv_index=_find('uv'), + ) 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, + } diff --git a/i3pystatus/xkblayout.py b/i3pystatus/xkblayout.py index d4ca783..a64c8b3 100644 --- a/i3pystatus/xkblayout.py +++ b/i3pystatus/xkblayout.py @@ -9,6 +9,9 @@ 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 @@ -20,7 +23,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 = self.kblayout() self.output = { "full_text": self.format.format(name=kblayout).upper(), @@ -29,12 +32,21 @@ 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 = self.kblayout() 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()) + + 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) diff --git a/setup.py b/setup.py index 5bc2d0d..a91b397 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", @@ -22,6 +22,7 @@ setup(name="i3pystatus", "i3pystatus.mail", "i3pystatus.pulseaudio", "i3pystatus.updates", + "i3pystatus.weather", ], entry_points={ "console_scripts": [ diff --git a/tests/test_core_modules.py b/tests/test_core_modules.py new file mode 100644 index 0000000..0ca7897 --- /dev/null +++ b/tests/test_core_modules.py @@ -0,0 +1,147 @@ +import time +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 +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' + + # 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 / 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") + + +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) diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py new file mode 100644 index 0000000..7225669 --- /dev/null +++ b/tests/test_lastfm.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 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() diff --git a/tests/test_plexstatus.py b/tests/test_plexstatus.py new file mode 100644 index 0000000..5399c62 --- /dev/null +++ b/tests/test_plexstatus.py @@ -0,0 +1,59 @@ +""" +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 + +# inline xml of stream info from plex server +STREAM = b''' + + +''' + + +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) + """ + 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()