Source code for i3pystatus.calendar

import inspect
import re
import threading
from abc import abstractmethod
from datetime import datetime, timedelta

from i3pystatus import IntervalModule, formatp, SettingsBase
from i3pystatus.core.color import ColorRangeModule
from i3pystatus.core.desktop import DesktopNotification

humanize_imported = False
try:
    import humanize
    humanize_imported = True
except ImportError:
    pass


def strip_microseconds(delta):
    return delta - timedelta(microseconds=delta.microseconds)


def formatter(func):
    """ Decorator to mark a CalendarEvent method as a formatter. """
    func.formatter = True
    return func


class CalendarEvent:
    """
    Simple class representing an Event. The attributes title, start, end and recurring are required as
    these will be used for the formatters. The id attribute is used to uniquely identify the event.

    If a backend wishes to provide extra formatters to the user, this can be done by adding additional
    methods and decorating them with the @formatter decorator. See the LightningCalendarEvent from the
    lightning module for an example of this.
    """

    # Unique identifier for this event
    id = None

    # The title of this event
    title = None

    # Datetime object representing when this event begins
    start = None

    # Datetime object representing when this event ends
    end = None

    # Whether or not this event is a recurring event
    recurring = False

    def formatters(self):
        """
        Build a dictionary containing all those key/value pairs that will be exposed to the user via formatters.
        """
        event_dict = dict(
            title=self.title,
            remaining=self.time_remaining,
            humanize_remaining=self.humanize_time_remaining,
        )

        def is_formatter(x):
            return inspect.ismethod(x) and hasattr(x, 'formatter') and getattr(x, 'formatter')

        for method_name, method in inspect.getmembers(self, is_formatter):
            event_dict[method_name] = method()
        return event_dict

    @property
    def time_remaining(self):
        return strip_microseconds(self.start - datetime.now(tz=self.start.tzinfo))

    @property
    def humanize_time_remaining(self):
        if humanize_imported:
            return humanize.naturaltime(datetime.now(tz=self.start.tzinfo) - self.start)

    def __str__(self):
        return "{}(title='{}', start={}, end={}, recurring={})" \
            .format(type(self).__name__,
                    self.title,
                    repr(self.start),
                    repr(self.end),
                    self.recurring)


class CalendarBackend(SettingsBase):
    """
    Base class for calendar backend. Subclasses should implement update and populate the events list.

    Optionally, subclasses can override on_click to perform actions on the current event when clicked.
    """

    def init(self):
        self.events = []

    @abstractmethod
    def update(self):
        """ Subclasses should implement this method and populate the events list with CalendarEvents."""

    def on_click(self, event):
        """ Override this method to do more interesting things with the event. """
        DesktopNotification(
            title=event.title,
            body="{} until {}!".format(event.time_remaining, event.title),
            icon='dialog-information',
            urgency=1,
            timeout=0,
        ).display()

    def __iter__(self):
        return iter(self.events)

    def __len__(self):
        return len(self.events)


[docs]class Calendar(IntervalModule, ColorRangeModule): """ Generic calendar module. Requires the PyPI package ``colour``. .. rubric:: Available formatters * {title} - the title or summary of the event * {remaining_time} - how long until this event is due * {humanize_remaining} - how long until this event is due in human readable format Additional formatters may be provided by the backend, consult their documentation for details. .. note:: Optionally requires `humanize` to display time in human readable format. """ settings = ( ('format', 'Format string to display in the bar'), ('backend', 'Backend to use for collecting calendar events'), ('skip_recurring', 'Whether or not to skip recurring events'), ('skip_all_day', 'Whether or not to skip all day events'), ('skip_regex', 'Skip events with titles that match this regex'), ('update_interval', "How often in seconds to call the backend's update method"), ('urgent_seconds', "When within this many seconds of the event, set the urgent flag"), ('urgent_blink', 'Whether or not to blink when within urgent_seconds of the event'), ('dynamic_color', 'Whether or not to change color as the event approaches'), 'color' ) required = ('backend',) skip_recurring = False skip_all_day = False skip_regex = None interval = 1 backend = None update_interval = 600 dynamic_color = True urgent_seconds = 300 urgent_blink = False color = None current_event = None urgent_acknowledged = False format = "{title} - {remaining}" on_rightclick = 'handle_click' on_leftclick = 'acknowledge' def init(self): if 'humanize_remaining' in self.format and not humanize_imported: raise ImportError('Missing humanize module') self.condition = threading.Condition() self.thread = threading.Thread(target=self.update_thread, daemon=True) self.thread.start() self.colors = self.get_hex_color_range(self.end_color, self.start_color, self.urgent_seconds * 2) def update_thread(self): self.refresh_events() while True: with self.condition: self.condition.wait(self.update_interval) self.refresh_events() def refresh_events(self): self.backend.update() def valid_event(ev): if self.skip_all_day and not isinstance(ev.start, datetime): return False if self.skip_recurring and ev.recurring: return False if self.skip_regex and re.search(self.skip_regex, ev.title) is not None: return False elif ev.time_remaining < timedelta(seconds=0): return False return True for event in self.backend: if valid_event(event): if self.current_event and self.current_event.id != event.id: self.urgent_acknowledged = False self.current_event = event return self.current_event = None def run(self): if self.current_event and self.current_event.time_remaining > timedelta(seconds=0): color = None if self.color is not None: color = self.color elif self.dynamic_color: color = self.get_color() self.output = { "full_text": formatp(self.format, **self.current_event.formatters()), "color": color, "urgent": self.is_urgent() } else: self.output = {} def handle_click(self): if self.current_event: self.backend.on_click(self.current_event) def get_color(self): if self.current_event.time_remaining.days > 0: color = self.colors[-1] else: p = self.percentage(self.current_event.time_remaining.seconds, self.urgent_seconds) color = self.get_gradient(p, self.colors) return color
[docs] def is_urgent(self): """ Determine whether or not to set the urgent flag. If urgent_blink is set, toggles urgent flag on and off every second. """ if not self.current_event: return False now = datetime.now(tz=self.current_event.start.tzinfo) alert_time = now + timedelta(seconds=self.urgent_seconds) urgent = alert_time > self.current_event.start if urgent and self.urgent_blink: urgent = now.second % 2 == 0 and not self.urgent_acknowledged return urgent
def acknowledge(self): self.urgent_acknowledged = not self.urgent_acknowledged