Merged upstream master

This commit is contained in:
asmikhailov 2016-06-07 12:10:19 +03:00
commit 07dd1bb159
82 changed files with 5072 additions and 382 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ dist/*
*~
.i3pystatus-*
ci-build
docs/_build

View File

@ -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

View File

@ -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/

View File

@ -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,8 +43,27 @@ 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)
+++++++++++++++++

View File

@ -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

View File

@ -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-<pid-of-thread>``. 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-<pid-of-thread>`` 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.

View File

@ -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'

View File

@ -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))

View File

@ -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()

View File

@ -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):
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):
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))

View File

@ -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)])

View File

@ -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 <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,

View File

@ -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 <https://bitcoinaverage.com> while transaction data is pulled
from blockchain.info <https://blockchain.info/api/blockchain_api>.
Index API <https://bitcoinaverage.com> and it is possible to specify
the exchange to be monitored.
Transaction data is pulled from blockchain.info
<https://blockchain.info/api/blockchain_api>.
.. 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)

View File

@ -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 = {

View File

@ -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}

View File

@ -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(

View File

@ -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
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:]
else:
return cb, []
m_click = self.__multi_click
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
with m_click.lock:
double = m_click.check_double(button)
double_action = 'double%s' % action
if not cb:
log_event(self.__name__, button, None, None, "No callback attached")
return False
else:
cb, args = split_callback_and_args(cb)
if double:
action = double_action
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)
# 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:
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:

View File

@ -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:

View File

@ -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,15 +353,25 @@ 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
"""
address = ("google-public-dns-a.google.com", 53)
def __new__(cls):
try:
socket.create_connection(("google-public-dns-a.google.com", 53), 1).close()
socket.create_connection(cls.address, 1).close()
return True
except OSError:
return False
@ -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

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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):
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,

View File

@ -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

View File

@ -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,9 +35,15 @@ class DPMS(IntervalModule):
self.status = run_through_shell("xset -q | grep -q 'DPMS is Enabled'", True).rc == 0
if self.status:
self.output = {
"full_text": self.format.format(status='on' if self.status else 'off'),
"color": self.color if self.status else self.color_disabled
"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):

View File

@ -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

View File

@ -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

View File

@ -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

69
i3pystatus/gpu_mem.py Normal file
View File

@ -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
}

37
i3pystatus/gpu_temp.py Normal file
View File

@ -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,
}

94
i3pystatus/iinet.py Normal file
View File

@ -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)

View File

@ -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,
}

56
i3pystatus/lastfm.py Normal file
View File

@ -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
}

View File

@ -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,18 +32,28 @@ 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())
return self.last

View File

@ -38,6 +38,7 @@ class MakeWatch(IntervalModule):
"status": status
}
self.data = cdict
self.output = {
"full_text": self.format.format(**cdict),
"color": color

View File

@ -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

View File

@ -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."),

View File

@ -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()],

View File

@ -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:
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,6 +96,8 @@ class MPD(IntervalModule):
self.output = {
"full_text": ""
}
if hasattr(self, "data"):
del self.data
return
fdict = {
@ -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,

View File

@ -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,

View File

@ -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):

View File

@ -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'

View File

@ -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

View File

@ -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,

View File

@ -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,

85
i3pystatus/ping.py Normal file
View File

@ -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
}

View File

@ -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,

View File

@ -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

View File

@ -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])

View File

@ -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,

View File

@ -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/')

View File

@ -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 <scorebackends>`.
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 <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 <scorebackends>`
(``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='<span size="small" color="#F5FF00">★</span>',
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 <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='<span size="small" color="#F5FF00">★</span>',
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-<pid>`` where
``<pid>`` 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 <scorebackends>`\'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 = '<span color="%s">%s</span>' % (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

373
i3pystatus/scores/epl.py Normal file
View File

@ -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 <scores-game-order>`.'),
('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

321
i3pystatus/scores/mlb.py Normal file
View File

@ -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 <scores-game-order>`.'),
('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

332
i3pystatus/scores/nba.py Normal file
View File

@ -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 <scores-game-order>`.'),
('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

337
i3pystatus/scores/nhl.py Normal file
View File

@ -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='<span size="small" color="#F5FF00">★</span>',
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 <scores-game-order>`.'),
('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

56
i3pystatus/sge.py Normal file
View File

@ -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
}

74
i3pystatus/solaar.py Normal file
View File

@ -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

View File

@ -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()

133
i3pystatus/syncthing.py Normal file
View File

@ -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()

85
i3pystatus/taskwarrior.py Normal file
View File

@ -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
}

View File

@ -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,11 +22,13 @@ 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
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,

182
i3pystatus/timer.py Normal file
View File

@ -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

View File

@ -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)")
print(" - %s: unset" % credential)
return
choices = list(credential_modules.keys())

View File

@ -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),
}

View File

@ -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()

25
i3pystatus/updates/dnf.py Normal file
View File

@ -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

View File

@ -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

View File

43
i3pystatus/utils/gpu.py Normal file
View File

@ -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)

74
i3pystatus/vk.py Normal file
View File

@ -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}

View File

@ -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
}

View File

@ -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 <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 <weather-usage-weathercom>`
- :ref:`Weather Underground <weather-usage-wunderground>`
'''
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,
}

View File

@ -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 <weather-formatters>` 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'),
)

View File

@ -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 <weather-formatters>` 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'),
)

70
i3pystatus/weekcal.py Normal file
View File

@ -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,
}

View File

@ -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)

View File

@ -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": [

147
tests/test_core_modules.py Normal file
View File

@ -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)

41
tests/test_lastfm.py Normal file
View File

@ -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()

59
tests/test_plexstatus.py Normal file
View File

@ -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'''<?xml version="1.0" encoding="UTF-8"?>
<MediaContainer size="1">
<Video addedAt="1421001383" art="/library/metadata/46/art/1454407842" chapterSource="" contentRating="G" duration="596462" guid="com.plexapp.agents.themoviedb://10378?lang=en" key="/library/metadata/46" librarySectionID="1" originallyAvailableAt="2008-04-10" rating="6.7" ratingKey="46" sessionKey="146" studio="Blender Foundation" summary="Follow a day of the life of Big Buck Bunny when he meets three bullying rodents: Frank, Rinky, and Gamera. The rodents amuse themselves by harassing helpless creatures by throwing fruits, nuts and rocks at them. After the deaths of two of Bunny&apos;s favorite butterflies, and an offensive attack on Bunny himself, Bunny sets aside his gentle nature and orchestrates a complex plan for revenge." thumb="/library/metadata/46/thumb/1454407842" title="Big Buck Bunny" type="movie" updatedAt="1454407842" year="2008">
<Media aspectRatio="1.78" audioChannels="6" audioCodec="aac" audioProfile="lc" bitrate="9725" container="mov" duration="596462" has64bitOffsets="0" height="1080" id="46" optimizedForStreaming="1" videoCodec="h264" videoFrameRate="24p" videoProfile="main" videoResolution="1080" width="1920">
<Part audioProfile="lc" container="mov" duration="596462" file="Big Buck Bunny.mov" has64bitOffsets="0" id="46" indexes="sd" key="/library/parts/46/file.mov" optimizedForStreaming="1" size="725106140" videoProfile="main">
<Stream bitDepth="8" bitrate="9283" cabac="0" chromaSubsampling="4:2:0" codec="h264" codecID="avc1" colorRange="tv" colorSpace="bt709" default="1" duration="596458" frameRate="24.000" frameRateMode="cfr" hasScalingMatrix="0" height="1080" id="315" index="0" language="English" languageCode="eng" level="41" pixelFormat="yuv420p" profile="main" refFrames="2" scanType="progressive" streamIdentifier="1" streamType="1" width="1920" />
<Stream audioChannelLayout="5.1" bitrate="448" bitrateMode="cbr" channels="6" codec="aac" codecID="40" default="1" duration="596462" id="316" index="2" language="English" languageCode="eng" profile="lc" samplingRate="48000" selected="1" streamIdentifier="3" streamType="2" />
</Part>
</Media>
<Genre count="79" id="124" tag="Animation" />
<Genre count="209" id="177" tag="Comedy" />
<Director id="894" tag="Sacha Goedegebure" />
<Producer id="895" tag="Ton Roosendaal" />
<Country count="2" id="896" tag="Netherlands" />
<User id="1" thumb="https://plex.tv/users/a111111111a11111/avatar" title="user" />
<Player address="1.1.1.1" machineIdentifier="1aa1a11a-a1a1-1a1a-111a-1a1aa11a1111" platform="Chrome" product="Plex Web" state="playing" title="Plex Web (Chrome)" />
<TranscodeSession key="1aaa1a11aaa1aaa111a1aaaa11" throttled="1" progress="24.200000762939453" speed="0" duration="596000" remaining="2155" context="streaming" videoDecision="copy" audioDecision="transcode" protocol="http" container="mkv" videoCodec="h264" audioCodec="aac" audioChannels="2" />
</Video>
</MediaContainer>'''
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'<?xml version="1.0" encoding="UTF-8"?>\n<MediaContainer size="0">\n</MediaContainer>'
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()