Merge pull request #373 from terminalmage/scores

Add module to display sports scores
This commit is contained in:
enkore 2016-05-03 09:46:01 +02:00
commit b5a4fd2ab0
6 changed files with 1998 additions and 0 deletions

View File

@ -44,6 +44,15 @@ example configuration for the MaildirMail backend:
.. nothin'
.. _scorebackends:
Score Backends
--------------
.. autogen:: i3pystatus.scores SettingsBase
.. nothin'
.. _updatebackends:
Update Backends

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

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

@ -0,0 +1,302 @@
from i3pystatus.core.util import internet, require
from i3pystatus.scores import ScoresBackend
import copy
import json
import pytz
import re
import time
from datetime import datetime
from urllib.request import urlopen
LIVE_URL = 'https://www.nhl.com/gamecenter/%s'
SCOREBOARD_URL = 'https://www.nhl.com/scores'
API_URL = 'https://statsapi.web.nhl.com/api/v1/schedule?startDate=%04d-%02d-%02d&endDate=%04d-%02d-%02d&expand=schedule.teams,schedule.linescore,schedule.broadcasts.all&site=en_nhl&teamId='
class NHL(ScoresBackend):
'''
Backend to retrieve NHL scores. For usage examples, see :py:mod:`here
<.scores>`.
.. rubric:: Available formatters
* `{home_name}` Name of home team
* `{home_city}` Name of home team's city
* `{home_abbrev}` 3-letter abbreviation for home team's city
* `{home_score}` Home team's current score
* `{home_wins}` Home team's number of wins
* `{home_losses}` Home team's number of losses
* `{home_otl}` Home team's number of overtime losses
* `{home_favorite}` Displays the value for the :py:mod:`.scores` module's
``favorite`` attribute, if the home team is one of the teams being
followed. Otherwise, this formatter will be blank.
* `{home_empty_net}` Shows the value from the ``empty_net`` parameter
when the home team's net is empty.
* `{away_name}` Name of away team
* `{away_city}` Name of away team's city
* `{away_abbrev}` 2 or 3-letter abbreviation for away team's city
* `{away_score}` Away team's current score
* `{away_wins}` Away team's number of wins
* `{away_losses}` Away team's number of losses
* `{away_otl}` Away team's number of overtime losses
* `{away_favorite}` Displays the value for the :py:mod:`.scores` module's
``favorite`` attribute, if the away team is one of the teams being
followed. Otherwise, this formatter will be blank.
* `{away_empty_net}` Shows the value from the ``empty_net`` parameter
when the away team's net is empty.
* `{period}` Current period
* `{venue}` Name of arena where game is being played
* `{start_time}` Start time of game in system's localtime (supports
strftime formatting, e.g. `{start_time:%I:%M %p}`)
* `{overtime}` If the game ended in overtime or a shootout, this
formatter will show ``OT`` kor ``SO``. If the game ended in regulation,
or has not yet completed, this formatter will be blank.
.. rubric:: Team abbreviations
* **ANA** Anaheim Ducks
* **ARI** Arizona Coyotes
* **BOS** Boston Bruins
* **BUF** Buffalo Sabres
* **CAR** Carolina Hurricanes
* **CBJ** Columbus Blue Jackets
* **CGY** Calgary Flames
* **CHI** Chicago Blackhawks
* **COL** Colorado Avalanche
* **DAL** Dallas Stars
* **DET** Detroit Red Wings
* **EDM** Edmonton Oilers
* **FLA** Florida Panthers
* **LAK** Los Angeles Kings
* **MIN** Minnesota Wild
* **MTL** Montreal Canadiens
* **NJD** New Jersey Devils
* **NSH** Nashville Predators
* **NYI** New York Islanders
* **NYR** New York Rangers
* **OTT** Ottawa Senators
* **PHI** Philadelphia Flyers
* **PIT** Pittsburgh Penguins
* **SJS** San Jose Sharks
* **STL** St. Louis Blues
* **TBL** Tampa Bay Lightning
* **TOR** Toronto Maple Leafs
* **VAN** Vancouver Canucks
* **WPG** Winnipeg Jets
* **WSH** Washington Capitals
'''
interval = 300
settings = (
('favorite_teams', 'List of abbreviations of favorite teams. Games '
'for these teams will appear first in the scroll '
'list. A detailed description of how games are '
'ordered can be found '
':ref:`here <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 '')
_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