diff --git a/docs/configuration.rst b/docs/configuration.rst index 628f579..6fb4b6f 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -536,33 +536,83 @@ service that is not traditionally required for web browsing: Credentials ----------- -Settings that require credentials can utilize the keyring module to -keep sensitive information out of config files. To take advantage of -this feature, simply use the ``i3pystatus-setting-util`` script -installed along i3pystatus to set the credentials for a module. Once -this is done you can add the module to your config without specifying -the credentials, e.g.: +For modules which require credentials, i3pystatus supports credential +management using the keyring_ module from PyPI. + +.. important:: + Many distributions have keyring_ pre-packaged, available as + ``python-keyring``. Unless you have KWallet_ or SecretService_ available, + you will also most likely need to install keyrings.alt_, which contains + additional keyring backends for use by the keyring_ module. + + Both i3pystatus and ``i3pystatus-setting-util`` will abort with a + RuntimeError_ if keyring_ isinstalled but a usable keyring backend is not + present, so it is a good idea to install both if you plan to use a module + which supports credential handling. + +To store credentials in a keyring, use the ``i3pystatus-setting-util`` script +installed along i3pystatus. + +.. note:: + ``i3pystatus-setting-util`` will store credentials using the default + keyring backend. The method for determining which backend is the default + can be found :ref:`below `. If, for some reason, + it is necessary to use a keyring other than the default, then you will need + to override the default in your keyringrc.cfg_ for + ``i3pystatus-setting-util`` to successfully use it. + +Once you have successfully set up credentials, you can add the module to your +config file without specifying the credentials in the registration, e.g.: .. code:: python - # Use the default keyring to retrieve credentials. - # To determine which backend is the default on your system, run - # python -c 'import keyring; print(keyring.get_keyring())' + # Use the default keyring to retrieve credentials status.register('github') -If you don't want to use the default you can set a specific keyring like so: +i3pystatus will locate and set the credentials during the module loading +process. Currently supported credentials are ``password``, ``email`` and +``username``. -.. code:: python +.. _default-keyring-backend: - # Requires the keyrings.alt package - from keyrings.alt.file import PlaintextKeyring - status.register('github', keyring_backend=PlaintextKeyring()) +.. note:: + To determine which backend is the default on your system, run the + following: -i3pystatus will locate and set the credentials during the module -loading process. Currently supported credentials are "password", -"email" and "username". + .. code-block:: bash -.. note:: Credential handling requires the PyPI package - ``keyring``. Many distributions have it pre-packaged available as - ``python-keyring``. + python -c 'import keyring; print(keyring.get_keyring())' + If this command returns a ``keyring.backends.fail.Keyring`` object, none of + the keyrings supported out-of-the box by the keyring_ module are available, + and you will need to install the keyrings.alt_ Python module. keyrings.alt_ + provides an encrypted keyring which will be seen as the default if both + keyrings.alt_ and keyring_ are installed, and none of the keyrings + supported by keyring_ are present: + + .. code-block:: bash + + $ python -c 'import keyring; print(keyring.get_keyring())' + + +If the keyring backend you used to store credentials using +``i3pystatus-setting-util`` is not the default, then you can change which +keyring backend i3pystatus will use in one of two ways: + +#. Override the default in your keyringrc.cfg_ + +#. Import and instantiate a keyring backend class, and pass it as the + ``keyring_backend`` parameter when registering the module: + + .. code:: python + + # Requires the keyrings.alt package + from keyrings.alt.file import PlaintextKeyring + status.register('github', keyring_backend=PlaintextKeyring()) + +.. _KWallet: http://www.kde.org/ +.. _SecretService: https://specifications.freedesktop.org/secret-service/re01.html +.. _RuntimeError: https://docs.python.org/3/library/exceptions.html#RuntimeError +.. _keyring: https://pypi.python.org/pypi/keyring +.. _keyrings.alt: https://pypi.python.org/pypi/keyrings.alt +.. _keyringrc.cfg: http://pythonhosted.org/keyring/#customize-your-keyring-by-config-file diff --git a/i3pystatus/github.py b/i3pystatus/github.py index 3216f79..d786146 100644 --- a/i3pystatus/github.py +++ b/i3pystatus/github.py @@ -1,83 +1,632 @@ +import copy import json +import re +import threading +import time +from urllib.request import urlopen -import requests -from i3pystatus import IntervalModule -from i3pystatus import logger +from i3pystatus import IntervalModule, formatp from i3pystatus.core import ConfigError +from i3pystatus.core.desktop import DesktopNotification from i3pystatus.core.util import user_open, internet, require -from requests import Timeout, ConnectionError + +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +API_METHODS_URL = 'https://status.github.com/api.json' +STATUS_URL = 'https://status.github.com' +NOTIFICATIONS_URL = 'https://github.com/notifications' +ACCESS_TOKEN_AUTH_URL = 'https://api.github.com/notifications?access_token=%s' +BASIC_AUTH_URL = 'https://api.github.com/notifications' class Github(IntervalModule): - """ - Check GitHub for pending notifications. - Requires `requests` + ''' + This module checks the GitHub system status, and optionally the number of + unread notifications. - Availables authentication methods: + .. versionchanged:: 3.36 + Module now checks system status in addition to unread notifications. - * username + password - * access_token (manually generate a new token at https://github.com/settings/tokens) + .. note:: + For notification checking, the following is required: - See https://developer.github.com/v3/#authentication for more informations. + - The requests_ module must be installed. + - Either ``access_token`` (recommended) or ``username`` and + ``password`` must be used to authenticate to GitHub. - Formatters: + Using an access token is the recommended authentication method. Click + here__ to generate a new access token. Fill in the **Token + description** box, and enable the **notifications** scope by checking + the appropriate checkbox. Then, click the **Generate token** button. - * `{unread}` — contains the value of unread_marker when there are pending notifications - * `{unread_count}` — number of unread notifications, empty if 0 - """ + .. important:: + An access token is the only supported means of authentication for + this module, if `2-factor authentication`_ is enabled. - max_error_len = 50 - unread_marker = "●" - unread = '' - color = '#78EAF2' + .. _requests: https://pypi.python.org/pypi/requests + .. __: https://github.com/settings/tokens/new + .. _`2-factor authentication`: https://help.github.com/articles/about-two-factor-authentication/ + + See here__ for more information on GitHub's authentication API. + + .. __: https://developer.github.com/v3/#authentication + + If you would rather use a username and password pair, you can either + pass them as arguments when registering the module, or use i3pystatus' + :ref:`credential management ` support to store them in a + keyring. Keep in mind that if you do not pass a ``username`` or + ``password`` parameter when registering the module, i3pystatus will + still attempt to retrieve these values from a keyring if the keyring_ + Python module is installed. This could result in i3pystatus aborting + during startup if it cannot find a usable keyring backend. If you do + not plan to use credential management at all in i3pystatus, then you + should either ensure that A) keyring_ is not installed, or B) both + keyring_ and keyrings.alt_ are installed, to avoid this error. + + .. _keyring: https://pypi.python.org/pypi/keyring + .. _keyrings.alt: https://pypi.python.org/pypi/keyrings.alt + + + .. rubric:: Available formatters + + * `{status}` — Current GitHub status. This formatter can be different + depending on the current status (``good``, ``minor``, or ``major``). + The content displayed for each of these statuses is defined in the + **status** config option. + * `{unread}` — When there are unread notifications, this formatter will + contain the value of the **unread_marker** marker config option. + there are no unread notifications, it formatter will be an empty string. + * `{unread_count}` — The number of unread notifications + notifications, it will be an empty string. + * `{update_error}` — When an error is encountered updating this module, + this formatter will be set to the value of the **update_error** + config option. + + .. rubric:: Click events + + This module responds to 4 different click events: + + - **Left-click** — Forces an update of the module. + - **Right-click** — Triggers a desktop notification showing the most recent + update to the GitHub status. This is useful when the status changes when + you are away from your computer, so that the updated status can be seen + without visiting the `GitHub Status Dashboard`_. This click event + requires **notify_status** to be set to ``True``. + - **Double left-click** — Opens the GitHub `notifications page`_ in your web + browser. + - **Double right-click** — Opens the `GitHub Status Dashboard`_ in your web + browser. + + .. rubric:: Desktop notifications + + .. versionadded:: 3.36 + + If **notify_status** is set to ``True``, a notification will be displayed + when the status reported by the `GitHub Status API`_ changes. + + If **notify_unread** is set to ``True``, a notification will be displayed + when new unread notifications are found. Double-clicking the module will + launch the GitHub notifications dashboard in your browser. + + .. note:: + A notification will be displayed if there was a problem querying the + `GitHub Status API`_, irrespective of whether or not **notify_status** + or **notify_unread** is set to ``True``. + + .. rubric:: Example configuration + + The below example enables desktop notifications, enables Pango hinting for + differently-colored **update_error** and **refresh_icon** text, and alters + the both the status text and the colors used to visually denote the current + status level. It also sets the log level to debug, for troubleshooting + purposes. + + .. code-block:: python + + status.register( + 'github', + log_level=logging.DEBUG, + notify_status=True, + notify_unread=True, + access_token='0123456789abcdef0123456789abcdef01234567', + hints={'markup': 'pango'}, + update_error='!', + refresh_icon='', + status={ + 'good': '✓', + 'minor': '!', + 'major': '!!', + }, + colors={ + 'good': '#008700', + 'minor': '#d7ff00', + 'major': '#af0000', + }, + ) + + .. note:: + Setting debug logging and authenticating with an access token will + include the access token in the log file, as the notification URL is + logged at this level. + + .. _`GitHub Status API`: https://status.github.com/api + .. _`GitHub Status Dashboard`: https://status.github.com + .. _`notifications page`: https://github.com/notifications + + .. rubric:: Extended string formatting + + .. versionadded:: 3.36 + + This module supports the :ref:`formatp ` extended string format + syntax. This allows for values to be hidden when they evaluate as False. + The default ``format`` string value for this module makes use of this + syntax to conditionally show the value of the ``update_error`` config value + when the backend encounters an error during an update, but this can also + be used to only show the number of unread notifications when that number is + not **0**. The below example would show the unread count as **(3)** when + there are 3 unread notifications, but would show nothing when there are no + unread notifications. + + .. code-block:: python + + status.register( + 'github', + notify_status=True, + notify_unread=True, + access_token='0123456789abcdef0123456789abcdef01234567', + format='{status}[ ({unread_count})][ {update_error}]' + ) + ''' + settings = ( + ('format', 'format string'), + ('status', 'Dictionary mapping statuses to the text which represents ' + 'that status type. This defaults to ``GitHub`` for all ' + 'status types.'), + ('colors', 'Dictionary mapping statuses to the color used to display ' + 'the status text'), + ('refresh_icon', 'Text to display (in addition to any text currently ' + 'shown by the module) when refreshing the GitHub ' + 'status. **NOTE:** Depending on how quickly the ' + 'update is performed, the icon may not be displayed.'), + ('update_error', 'Value for the ``{update_error}`` formatter when an ' + 'error is encountered while checking GitHub status'), + ('keyring_backend', 'alternative keyring backend for retrieving ' + 'credentials'), + ('username', ''), + ('password', ''), + ('access_token', ''), + ('unread_marker', 'Defines the string that the ``{unread}`` formatter ' + 'shows when there are pending notifications'), + ('notify_status', 'Set to ``True`` to display a desktop notification ' + 'on status changes'), + ('notify_unread', 'Set to ``True`` to display a desktop notification ' + 'when new notifications are detected'), + ('unread_notification_template', + 'String with no more than one ``%d``, which will be replaced by ' + 'the number of new unread notifications. Useful for those with ' + 'non-English locales who would like the notification to be in ' + 'their native language. The ``%d`` can be omitted if desired.'), + ('api_methods_url', 'URL from which to retrieve the API endpoint URL ' + 'which this module will use to check the GitHub ' + 'Status'), + ('status_url', 'The URL to the status page (opened when the module is ' + 'double-clicked with the right mouse button'), + ('notifications_url', 'The URL to the GitHub notifications page ' + '(opened when the module is double-clicked with ' + 'the left mouse button'), + ) + + # Defaults for module configurables + _default_status = { + 'good': 'GitHub', + 'minor': 'GitHub', + 'major': 'GitHub', + } + _default_colors = { + 'good': '#2f895c', + 'minor': '#f29d50', + 'major': '#cc3300', + } + + # Module configurables + format = '{status}[ {unread}][ {update_error}]' + status = _default_status + colors = _default_colors + refresh_icon = '⟳' + update_error = '!' username = '' password = '' access_token = '' - format = '{unread}' + unread_marker = '•' + notify_status = False + notify_unread = False + unread_notification_template = 'You have %d new notification(s)' + api_methods_url = API_METHODS_URL + status_url = STATUS_URL + notifications_url = NOTIFICATIONS_URL + + # Global configurables interval = 600 + max_error_len = 50 keyring_backend = None - on_leftclick = 'open_github' + # Other + unread = '' + unknown_color = None + unknown_status = '?' + failed_update = False + previous_status = None + current_status = None + new_unread = None + previous_unread = None + current_unread = None + config_error = None + data = {'status': '', + 'unread': 0, + 'unread_count': '', + 'update_error': ''} + output = {'full_text': '', 'color': None} - settings = ( - ('format', 'format string'), - ('keyring_backend', 'alternative keyring backend for retrieving credentials'), - ('unread_marker', 'sets the string that the "unread" formatter shows when there are pending notifications'), - ("username", ""), - ("password", ""), - ("access_token", "see https://developer.github.com/v3/#authentication"), - ("color", "") - ) - - def open_github(self): - user_open('https://github.com/' + self.username) + # Click events + on_leftclick = ['perform_update'] + on_rightclick = ['show_status_notification'] + on_doubleleftclick = ['launch_notifications_url'] + on_doublerightclick = ['launch_status_url'] @require(internet) - def run(self): + def launch_status_url(self): + self.logger.debug('Launching %s in browser', self.status_url) + user_open(self.status_url) + + @require(internet) + def launch_notifications_url(self): + self.logger.debug('Launching %s in browser', self.notifications_url) + user_open(self.notifications_url) + + def init(self): + if self.status != self._default_status: + new_status = copy.copy(self._default_status) + new_status.update(self.status) + self.status = new_status + + if self.colors != self._default_colors: + new_colors = copy.copy(self._default_colors) + new_colors.update(self.colors) + self.colors = new_colors + + self.logger.debug('status = %s', self.status) + self.logger.debug('colors = %s', self.colors) + + self.condition = threading.Condition() + self.thread = threading.Thread(target=self.update_loop, daemon=True) + self.thread.start() + + def update_loop(self): + try: + self.perform_update() + while True: + with self.condition: + self.condition.wait(self.interval) + self.perform_update() + except Exception: + 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) + + @require(internet) + def status_api_request(self, url): + self.logger.debug('Making GitHub Status API request to %s', 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 = content.read().decode(charset).strip() + if not response_json: + self.logger.error('JSON response from %s was blank', url) + return {} + try: + response = json.loads(response_json) + except json.decoder.JSONDecodeError as exc: + self.logger.error('Error loading JSON: %s', exc) + self.logger.debug('JSON text that failed to load: %s', + response_json) + return {} + self.logger.log(5, 'API response: %s', response) + return response + except Exception as exc: + self.logger.error( + 'Failed to make API request to %s. Exception follows:', url, + exc_info=True + ) + return {} + + def detect_status_change(self, response=None): + if response is not None: + # Compare last update to current and exit without displaying a + # notification if one is not needed. + if self.previous_status is None: + # This is the first time status has been updated since + # i3pystatus was started. Set self.previous_status and exit. + self.previous_status = response + return + if response == self.previous_status: + # No change, so no notification + return + self.previous_status = response + + if self.previous_status is None: + # The only way this would happen is if we invoked the right-click + # event before we completed the initial status check. + return + + self.show_status_notification() + + @staticmethod + def notify(message): + return DesktopNotification(title='GitHub', body=message).display() + + def skip_notify(self, message): + self.logger.debug( + 'Desktop notifications turned off. Skipped notification: %s', + message + ) + return False + + def show_status_notification(self): + message = self.current_status.get( + 'body', + 'Missing \'body\' param in API response' + ) + return self.skip_notify(message) \ + if not self.notify_status \ + else self.notify(message) + + def show_unread_notification(self): + if '%d' not in self.unread_notification_template: + formatted = self.unread_notification_template + else: + try: + new_unread = len(self.new_unread) + except TypeError: + new_unread = 0 + try: + formatted = self.unread_notification_template % new_unread + except TypeError as exc: + self.logger.error( + 'Failed to format {0!r}: {1}'.format( + self.unread_notification_template, + exc + ) + ) + return False + return self.skip_notify(formatted) \ + if not self.notify_unread \ + else self.notify(formatted) + + @require(internet) + def perform_update(self): + self.output['full_text'] = \ + self.refresh_icon + self.output.get('full_text', '') + self.failed_update = False + + self.update_status() + try: + self.config_error = None + self.update_unread() + except ConfigError as exc: + self.config_error = exc + + self.data['update_error'] = self.update_error \ + if self.failed_update \ + else '' + self.refresh_display() + + @require(internet) + def update_status(self): + try: + # Get most recent update + if not hasattr(self, 'last_message_url'): + self.last_message_url = \ + self.status_api_request(API_METHODS_URL)['last_message_url'] + self.current_status = self.status_api_request(self.last_message_url) + if not self.current_status: + self.failed_update = True + return + + self.data['status'] = self.status.get( + self.current_status.get('status'), + self.unknown_status) + + if self.previous_status is not None: + if self.current_status != self.previous_status: + self.show_status_notification() + self.previous_status = self.current_status + + except Exception: + # Don't let an uncaught exception kill the update thread + self.logger.error( + 'Uncaught error occurred while checking GitHub status. ' + 'Exception follows:', exc_info=True + ) + self.failed_update = True + + @require(internet) + def update_unread(self): + # Reset the new_unread attribute to prevent spurious notifications + self.new_unread = None try: + if not self.username and not self.password and not self.access_token: + # Auth not configured + self.logger.debug( + 'No auth configured, notifications will not be checked') + return True + + if not HAS_REQUESTS: + self.logger.error( + 'The requests module is required to check GitHub notifications') + self.failed_update = True + return False + + self.logger.debug( + 'Checking unread notifications using %s', + 'access token' if self.access_token else 'username/password' + ) + + old_unread_url = None if self.access_token: - response = requests.get('https://api.github.com/notifications?access_token=' + self.access_token) + unread_url = ACCESS_TOKEN_AUTH_URL % self.access_token else: - response = requests.get('https://api.github.com/notifications', auth=(self.username, self.password)) - data = json.loads(response.text) - except (ConnectionError, Timeout) as e: - logger.warn(e) - data = [] + unread_url = BASIC_AUTH_URL - # Bad credentials - if isinstance(data, dict): - err_msg = data['message'] - raise ConfigError(err_msg) + self.current_unread = set() + page_num = 0 + while old_unread_url != unread_url: + old_unread_url = unread_url + page_num += 1 + self.logger.debug( + 'Reading page %d of notifications (%s)', + page_num, unread_url + ) + try: + if self.access_token: + response = requests.get(unread_url) + else: + response = requests.get( + unread_url, + auth=(self.username, self.password) + ) + self.logger.log( + 5, + 'Raw return from GitHub notification check: %s', + response.text) + unread_data = json.loads(response.text) + except (requests.ConnectionError, requests.Timeout) as exc: + self.logger.error( + 'Failed to check unread notifications: %s', exc) + self.failed_update = True + return False + except json.decoder.JSONDecodeError as exc: + self.logger.error('Error loading JSON: %s', exc) + self.logger.debug( + 'JSON text that failed to load: %s', response.text) + self.failed_update = True + return False - format_values = dict(unread_count='', unread='') - unread = len(data) - if unread > 0: - format_values['unread_count'] = unread - format_values['unread'] = self.unread_marker + # Bad credentials or some other error + if isinstance(unread_data, dict): + raise ConfigError( + unread_data.get( + 'message', + 'Unknown error encountered retrieving unread notifications' + ) + ) - self.data = format_values - self.output = { - 'full_text': self.format.format(**format_values), - 'color': self.color - } + # Update the current count of unread notifications + self.current_unread.update( + [x['id'] for x in unread_data if 'id' in x] + ) + + # Check 'Link' header for next page of notifications + # (https://tools.ietf.org/html/rfc5988#section-5) + self.logger.debug('Checking for next page of notifications') + try: + link_header = response.headers['Link'] + except AttributeError: + self.logger.error( + 'No headers present in response. This might be due to ' + 'an API change in the requests module.' + ) + self.failed_update = True + continue + except KeyError: + self.logger.debug('Only one page of notifications present') + continue + else: + # Process 'Link' header + try: + links = requests.utils.parse_header_links(link_header) + except Exception as exc: + self.logger.error( + 'Failed to parse \'Link\' header: %s', exc + ) + self.failed_update = True + continue + + for link in links: + try: + link_rel = link['rel'] + if link_rel != 'next': + # Link does not refer to the next page, skip it + continue + # Set the unread_url so that when we reach the top + # of the outer loop, we have a new URL to check. + unread_url = link['url'] + break + except TypeError: + # Malformed hypermedia link + self.logger.warning( + 'Malformed hypermedia link (%s) in \'Link\' ' + 'header (%s)', link, links + ) + continue + else: + self.logger.debug('No more pages of notifications remain') + + if self.failed_update: + return False + + self.data['unread_count'] = len(self.current_unread) + self.data['unread'] = self.unread_marker \ + if self.data['unread_count'] > 0 \ + else '' + + if self.previous_unread is not None: + if not self.current_unread.issubset(self.previous_unread): + self.new_unread = self.current_unread - self.previous_unread + if self.new_unread: + self.show_unread_notification() + self.previous_unread = self.current_unread + return True + except ConfigError as exc: + # This will be caught by the calling function + raise exc + except Exception as exc: + # Don't let an uncaught exception kill the update thread + self.logger.error( + 'Uncaught error occurred while checking GitHub notifications. ' + 'Exception follows:', exc_info=True + ) + self.failed_update = True + return False + + def refresh_display(self): + previous_color = self.output.get('color') + try: + if 'status' in self.current_status: + color = self.colors.get( + self.current_status['status'], + self.unknown_color) + else: + # Failed status update, keep the existing color + color = previous_color + except TypeError: + # Shouldn't get here, but this would happen if this function is + # called before we check the current status for the first time. + color = previous_color + self.output = {'full_text': formatp(self.format, **self.data).strip(), + 'color': color} + + def run(self): + if self.config_error is not None: + raise self.config_error