From d29c09f08723215d6e1ff2ca1b47a1d29b7cb0ed Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 01/15] adding type hints for calendarwidget and khal/ui --- khal/ui/__init__.py | 72 +++------ khal/ui/calendarwidget.py | 306 +++++++++++++++++++++----------------- 2 files changed, 194 insertions(+), 184 deletions(-) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 3643e5b96..2043aa444 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -23,7 +23,7 @@ import logging import signal import sys -from typing import Optional +from typing import Optional, Tuple import click import urwid @@ -99,11 +99,10 @@ def render(self, size, focus=False): class DateHeader(SelectableText): - def __init__(self, day, dateformat, conf): + def __init__(self, day: dt.date, dateformat: str, conf): """ - :type day: datetime.date - :type dateformat: format to print `day` in - :type dateformat: str + :param day: the date that is represented by this DateHeader instance + :param dateformat: format to print `day` in """ self._day = day self._dateformat = dateformat @@ -118,14 +117,11 @@ def update_date_line(self): """ self.set_text(self.relative_day(self._day, self._dateformat)) - def relative_day(self, day, dtformat): + def relative_day(self, day: dt.date, dtformat: str) -> str: """convert day into a string with its weekday and relative distance to today :param day: day to be converted - :type: day: datetime.day :param dtformat: the format day is to be printed in, passed to strftime - :type dtformat: str - :rtype: str """ weekday = day.strftime('%A') @@ -393,15 +389,11 @@ def update_events_ondate(self, day): assert self[offset].date == day self[offset] = self._get_events(day) - def refresh_titles(self, start, end, everything): + def refresh_titles(self, start: dt.date, end: dt.date, everything: bool): """refresh events' titles if `everything` is True, reset all titles, otherwise only those between `start` and `end` - - :type start: datetime.date - :type end: datetime.date - :type bool: bool """ start = start.date() if isinstance(start, dt.datetime) else start end = end.date() if isinstance(end, dt.datetime) else end @@ -418,12 +410,8 @@ def refresh_titles(self, start, end, everything): for index in range(offset, offset + length + 1): self[index].refresh_titles() - def update_range(self, start, end, everything=False): - """refresh contents of all days between start and end (inclusive) - - :type start: datetime.date - :type end: datetime.date - """ + def update_range(self, start: dt.date, end: dt.date, everything: bool=False): + """refresh contents of all days between start and end (inclusive)""" start = start.date() if isinstance(start, dt.datetime) else start end = end.date() if isinstance(end, dt.datetime) else end @@ -467,11 +455,8 @@ def _autoprepend(self): pile = self._get_events(self._first_day) self.insert(0, pile) - def _get_events(self, day): - """get all events on day, return a DateListBox of `U_Event()`s - - :type day: datetime.date - """ + def _get_events(self, day: dt.date) -> urwid.Widget: + """get all events on day, return a DateListBox of `U_Event()`s """ event_list = [] date_header = DateHeader( day=day, @@ -523,25 +508,18 @@ def update_events_ondate(self, day): """refresh the contents of the day's DateListBox""" self[0] = self._get_events(day) - def refresh_titles(self, start, end, everything): + def refresh_titles(self, start: dt.date, end: dt.date, everything: bool) -> None: """refresh events' titles if `everything` is True, reset all titles, otherwise only those between `start` and `end` - - :type start: datetime.date - :type end: datetime.date - :type bool: bool """ + # TODO: why are we not using the arguments? for one in self: one.refresh_titles() - def update_range(self, start, end, everything=False): - """refresh contents of all days between start and end (inclusive) - - :type start: datetime.date - :type end: datetime.date - """ + def update_range(self, start: dt.date, end: dt.date, everything: bool=False): + """refresh contents of all days between start and end (inclusive)""" start = start.date() if isinstance(start, dt.datetime) else start end = end.date() if isinstance(end, dt.datetime) else end @@ -584,11 +562,10 @@ def render(self, size, focus): def reset_style(self): self.body[0].set_attr_map({None: 'date header'}) - def set_selected_date(self, day): + def set_selected_date(self, day: dt.date) -> None: """Mark `day` as selected :param day: day to mark as selected - :type day: datetime.date """ DateListBox.selected_date = day # we need to touch the title's content to make sure @@ -688,13 +665,12 @@ def update_date_line(self): """refresh titles in DateListBoxes""" self.dlistbox.update_date_line() - def edit(self, event, always_save=False, external_edit=False): + def edit(self, event, always_save: bool=False, external_edit: bool=False) -> None: """create an EventEditor and display it :param event: event to edit :type event: khal.event.Event :param always_save: even save the event if it hasn't changed - :type always_save: bool """ if event.readonly: self.pane.window.alert( @@ -710,12 +686,10 @@ def edit(self, event, always_save=False, external_edit=False): else: original_end = event.end_local - def update_colors(new_start, new_end, everything=False): + def update_colors(new_start: dt.date, new_end: dt.date, everything: bool=False): """reset colors in the calendar widget and dates in DayWalker between min(new_start, original_start) - :type new_start: datetime.date - :type new_end: datetime.date :param everything: set to True if event is a recurring one, than everything gets reseted """ @@ -840,7 +814,7 @@ def delete_all(_): event.event.start_local, event.event.end_local, event.event.recurring) event.set_title() # if we are in search results, refresh_titles doesn't work properly - def duplicate(self): + def duplicate(self) -> None: """duplicate the event in focus""" # TODO copying from birthday calendars is currently problematic # because their title is determined by X-BIRTHDAY and X-FNAME properties @@ -864,11 +838,11 @@ def duplicate(self): except IndexError: pass - def new(self, date: dt.date, end: Optional[dt.date]=None): + def new(self, date: dt.date, end: Optional[dt.date]=None) -> None: """create a new event on `date` at the next full hour and edit it :param date: default date for new event - :param end: optional, date the event ends on (inclusive) + :param end: date the event ends on (inclusive) """ dtstart: dt.date dtend: dt.date @@ -1188,15 +1162,13 @@ def new_event(self, date, end): self.eventscolumn.original_widget.new(date, end) -def _urwid_palette_entry(name, color, hmethod): +def _urwid_palette_entry( + name: str, color: str, hmethod: str) -> Tuple[str, str, str, str, str, str]: """Create an urwid compatible palette entry. :param name: name of the new attribute in the palette - :type name: string :param color: color for the new attribute - :type color: string :returns: an urwid palette entry - :rtype: tuple """ from ..terminal import COLORS if color == '' or color in COLORS or color is None: diff --git a/khal/ui/calendarwidget.py b/khal/ui/calendarwidget.py index da2df441e..2f6e065ff 100644 --- a/khal/ui/calendarwidget.py +++ b/khal/ui/calendarwidget.py @@ -26,22 +26,29 @@ import calendar import datetime as dt -from collections import defaultdict from locale import LC_ALL, LC_TIME, getlocale, setlocale +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypedDict, Union import urwid from khal.utils import get_month_abbr_len + +# Some custom types +class MarkType(TypedDict): + date: dt.date + pos: Tuple[int, int] +OnPressType = Dict[str, Callable[[dt.date, Optional[dt.date]], Optional[str]]] +GetStylesSignature = Callable[[dt.date, bool], Optional[Union[str, Tuple[str, str]]]] + + setlocale(LC_ALL, '') -def getweeknumber(day): +def getweeknumber(day: dt.date) -> int: """return iso week number for datetime.date object :param day: date - :type day: datetime.date() :return: weeknumber - :rtype: int """ return dt.date.isocalendar(day)[1] @@ -50,20 +57,20 @@ class DatePart(urwid.Text): """used in the Date widget (single digit)""" - def __init__(self, digit): + def __init__(self, digit: str): super().__init__(digit) @classmethod - def selectable(cls): + def selectable(cls: type) -> bool: return True - def keypress(self, _, key): + def keypress(self, size: Tuple[int], key: str) -> str: return key - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: Tuple[int]) -> Tuple[int, int]: return 1, 0 - def render(self, size, focus=False): + def render(self, size: Tuple[int], focus: bool=False) -> urwid.Canvas: canv = super().render(size, focus) if focus: canv = urwid.CompositeCanvas(canv) @@ -75,7 +82,7 @@ class Date(urwid.WidgetWrap): """used in the main calendar for dates (a number)""" - def __init__(self, date, get_styles=None): + def __init__(self, date: dt.date, get_styles: GetStylesSignature): dstr = str(date.day).rjust(2) self.halves = [urwid.AttrMap(DatePart(dstr[:1]), None, None), urwid.AttrMap(DatePart(dstr[1:]), None, None)] @@ -83,7 +90,7 @@ def __init__(self, date, get_styles=None): self._get_styles = get_styles super().__init__(urwid.Columns(self.halves)) - def set_styles(self, styles): + def set_styles(self, styles: Union[None, str, Tuple[str, str]]) -> None: """If single string, sets the same style for both halves, if two strings, sets different style for each half. """ @@ -98,21 +105,21 @@ def set_styles(self, styles): self.halves[0].set_focus_map({None: styles}) self.halves[1].set_focus_map({None: styles}) - def reset_styles(self, focus=False): + def reset_styles(self, focus: bool=False) -> None: self.set_styles(self._get_styles(self.date, focus)) @property - def marked(self): + def marked(self) -> bool: if 'mark' in [self.halves[0].attr_map[None], self.halves[1].attr_map[None]]: return True else: return False @classmethod - def selectable(cls): + def selectable(cls) -> bool: return True - def keypress(self, _, key): + def keypress(self, _: Any, key: str) -> str: return key @@ -129,24 +136,34 @@ class DateCColumns(urwid.Columns): # TODO only call on_date_change when we change our date ourselves, # not if it gets changed by an (external) call to set_focus_date() - def __init__(self, widget_list, on_date_change, on_press, keybindings, - get_styles=None, **kwargs): + def __init__(self, + widget_list, + on_date_change: Callable[[dt.date], None], + on_press: OnPressType, + keybindings: Dict[str, List[str]], + get_styles: GetStylesSignature, + **kwargs): self.on_date_change = on_date_change self.on_press = on_press self.keybindings = keybindings self.get_styles = get_styles - self._init = True + self._init: bool = True super().__init__(widget_list, **kwargs) - def __repr__(self): + def __repr__(self) -> str: return f'' - def _clear_cursor(self): - old_pos = self.focus_position + def _clear_cursor(self) -> None: + old_pos: int = self.focus_position self.contents[old_pos][0].set_styles( self.get_styles(self.contents[old_pos][0].date, False)) - @urwid.Columns.focus_position.setter + @property + def focus_position(self) -> int: + """returns the current focus position""" + return urwid.Columns.focus_position.fget(self) + + @focus_position.setter def focus_position(self, position: int) -> None: """calls on_date_change before calling super()._set_focus_position""" # do not call when building up the interface, lots of potentially @@ -160,14 +177,14 @@ def focus_position(self, position: int) -> None: self.on_date_change(self.contents[position][0].date) urwid.Columns.focus_position.fset(self, position) - def set_focus_date(self, a_date): + def set_focus_date(self, a_date: dt.date) -> None: for num, day in enumerate(self.contents[1:8], 1): if day[0].date == a_date: self._set_focus_position(num) return None raise ValueError('%s not found in this week' % a_date) - def get_date_column(self, a_date): + def get_date_column(self, a_date: dt.date) -> int: """return the column `a_date` is in, raises ValueError if `a_date` cannot be found """ @@ -176,7 +193,7 @@ def get_date_column(self, a_date): return num raise ValueError('%s not found in this week' % a_date) - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: str) -> str: """only leave calendar area on pressing 'tab' or 'enter'""" if key in self.keybindings['left']: @@ -202,7 +219,7 @@ def keypress(self, size, key): self.focus_position = 7 exit_row = True key = 'up' - elif key in self.keybindings['view']: # XXX make this more generic + elif key in self.keybindings['view']: # TODO make this more generic self.focus_position = old_pos key = 'right' elif key in ['up', 'down']: @@ -213,23 +230,29 @@ def keypress(self, size, key): return key - class CListBox(urwid.ListBox): """our custom version of ListBox containing a CalendarWalker instance it should contain a `CalendarWalker` instance which it autoextends on rendering, if needed """ - def __init__(self, walker): - self._init = True + def __init__(self, walker: 'CalendarWalker'): + self._init: bool = True self.keybindings = walker.keybindings self.on_press = walker.on_press - self._marked = False - self._pos_old = False - + self._marked: Optional[MarkType] = None + self._pos_old: Optional[Tuple[int, int]] = None super().__init__(walker) - def render(self, size, focus=False): + @property + def focus_position(self) -> int: + return super().focus_position + + @focus_position.setter + def focus_position(self, position: int) -> None: + super().set_focus(position) + + def render(self, size: Tuple[int], focus: bool=False) -> urwid.Canvas: while 'bottom' in self.ends_visible(size): self.body._autoextend() if self._init: @@ -246,28 +269,32 @@ def mouse_event(self, *args): self.focus.get_styles(self.body.focus_date, False)) return super().mouse_event(*args) - def _date(self, row, column): + def _date(self, row: int, column: int) -> dt.date: """return the date at row `row` and column `column`""" return self.body[row].contents[column][0].date - def _unmark_one(self, row, column): + def _unmark_one(self, row: int, column: int) -> None: """remove attribute *mark* from the date at row `row` and column `column` returning it to the attributes defined by self._get_color() """ self.body[row].contents[column][0].reset_styles() - def _mark_one(self, row, column): + def _mark_one(self, row: int, column: int) -> None: """set attribute *mark* on the date at row `row` and column `column`""" self.body[row].contents[column][0].set_styles('mark') - def _mark(self, a_date=None): + def _mark(self, a_date: Optional[dt.date]=None) -> None: """make sure everything between the marked entry and `a_date` is visually marked, and nothing else""" + assert self._marked is not None + if a_date is None: a_date = self.body.focus_date - def toggle(row, column): + def toggle(row: int, column: int) -> None: + """toggle the mark attribute on the date at row `row` and column + `column`""" if self.body[row].contents[column][0].marked: self._mark_one(row, column) else: @@ -291,25 +318,28 @@ def toggle(row, column): toggle(self.focus_position, self.focus.focus_col) self._pos_old = self.focus_position, self.focus.focus_col - def _unmark_all(self): - start = min(self._marked['pos'][0], self.focus_position, self._pos_old[0]) - end = max(self._marked['pos'][0], self.focus_position, self._pos_old[0]) + 1 - for row in range(start, end): - for col in range(1, 8): - self._unmark_one(row, col) - - def set_focus_date(self, a_day): + def _unmark_all(self) -> None: + """remove attribute *mark* from all dates""" + if self._marked and self._pos_old: + start = min(self._marked['pos'][0], self.focus_position, self._pos_old[0]) + end = max(self._marked['pos'][0], self.focus_position, self._pos_old[0]) + 1 + for row in range(start, end): + for col in range(1, 8): + self._unmark_one(row, col) + + def set_focus_date(self, a_day: dt.date) -> None: + """set focus to the date `a_day`""" self.focus.focus.set_styles(self.focus.get_styles(self.body.focus_date, False)) if self._marked: self._unmark_all() self._mark(a_day) self.body.set_focus_date(a_day) - def keypress(self, size, key): + def keypress(self, size: bool, key: str) -> Optional[str]: if key in self.keybindings['mark'] + ['esc'] and self._marked: self._unmark_all() - self._marked = False - return + self._marked = None + return None if key in self.keybindings['mark']: self._marked = {'date': self.body.focus_date, 'pos': (self.focus_position, self.focus.focus_col)} @@ -319,7 +349,6 @@ def keypress(self, size, key): 'pos': (self.focus_position, self.focus.focus_col)} self.focus.focus_col = col self.focus_position = row - if key in self.on_press: if self._marked: start = min(self.body.focus_date, self._marked['date']) @@ -342,9 +371,16 @@ def keypress(self, size, key): class CalendarWalker(urwid.SimpleFocusListWalker): - def __init__(self, on_date_change, on_press, keybindings, firstweekday=0, - weeknumbers=False, monthdisplay='firstday', get_styles=None, - initial=None): + def __init__(self, + on_date_change: Callable[[dt.date], None], + on_press: Dict[str, Callable[[dt.date, Optional[dt.date]], Optional[str]]], + keybindings: Dict[str, List[str]], + get_styles: GetStylesSignature, + firstweekday: int = 0, + weeknumbers: Literal['left', 'right', False]=False, + monthdisplay: Literal['firstday', 'firstfullweek']='firstday', + initial: Optional[dt.date]=None, + ) -> None: if initial is None: initial = dt.date.today() self.firstweekday = firstweekday @@ -357,43 +393,37 @@ def __init__(self, on_date_change, on_press, keybindings, firstweekday=0, weeks = self._construct_month(initial.year, initial.month) urwid.SimpleFocusListWalker.__init__(self, weeks) - def set_focus(self, position): + def set_focus(self, position: int) -> None: """set focus by item number""" while position >= len(self) - 1: self._autoextend() while position <= 0: no_additional_weeks = self._autoprepend() position += no_additional_weeks - return urwid.SimpleFocusListWalker.set_focus(self, position) + urwid.SimpleFocusListWalker.set_focus(self, position) @property - def focus_date(self): - """return the date the focus is currently set to - - :rtype: datetime.date - """ + def focus_date(self) -> dt.date: + """return the date the focus is currently set to""" return self[self.focus].focus.date - def set_focus_date(self, a_day): - """set the focus to `a_day` - - :type: a_day: datetime.date - """ + def set_focus_date(self, a_day: dt.date) -> None: + """set the focus to `a_day`""" row, column = self.get_date_pos(a_day) self.set_focus(row) self[self.focus]._set_focus_position(column) @property - def earliest_date(self): + def earliest_date(self) -> dt.date: """return earliest day that is already loaded into the CalendarWidget""" return self[0][1].date @property - def latest_date(self): + def latest_date(self) -> dt.date: """return latest day that is already loaded into the CalendarWidget""" return self[-1][7].date - def reset_styles_range(self, min_date, max_date): + def reset_styles_range(self, min_date: dt.date, max_date: dt.date) -> None: """reset styles for all (displayed) dates between min_date and max_date""" minr, minc = self.get_date_pos(max(min_date, self.earliest_date)) maxr, maxc = self.get_date_pos(min(max_date, self.latest_date)) @@ -404,12 +434,8 @@ def reset_styles_range(self, min_date, max_date): focus = ((row, column) == focus_pos) self[row][column].reset_styles(focus) - def get_date_pos(self, a_day): - """get row and column where `a_day` is located - - :type: a_day: datetime.date - :rtype: tuple(int, int) - """ + def get_date_pos(self, a_day: dt.date) -> Tuple[int, int]: + """get row and column where `a_day` is located""" # rough estimate of difference in lines, i.e. full weeks, we might be # off by as much as one week though week_diff = int((self.focus_date - a_day).days / 7) @@ -429,12 +455,12 @@ def get_date_pos(self, a_day): self._autoextend() column = self[row].get_date_column(a_day) return row, column - except ValueError: + except (ValueError, IndexError): pass # we didn't find the date we were looking for... raise ValueError('something is wrong') - def _autoextend(self): + def _autoextend(self) -> None: """appends the next month""" date_last_month = self[-1][1].date # a date from the last month last_month = date_last_month.month @@ -444,11 +470,10 @@ def _autoextend(self): weeks = self._construct_month(year, month, clean_first_row=True) self.extend(weeks) - def _autoprepend(self): + def _autoprepend(self) -> int: """prepends the previous month :returns: number of weeks prepended - :rtype: int """ try: date_first_month = self[0][-1].date # a date from the first month @@ -469,16 +494,14 @@ def _autoprepend(self): self.insert(0, one) return len(weeks) - def _construct_week(self, week): + def _construct_week(self, week: List[dt.date]) -> DateCColumns: """ constructs a CColumns week from a week of datetime.date objects. Also prepends the month name if the first day of the month is included in that week. :param week: list of datetime.date objects - :returns: the week as an CColumns object and True or False depending on - if today is in this week - :rtype: tuple(urwid.CColumns, bool) + :returns: the week as an CColumns object """ if self.monthdisplay == 'firstday' and 1 in (day.day for day in week): month_name = calendar.month_abbr[week[-1].month].ljust(4) @@ -493,6 +516,7 @@ def _construct_week(self, week): month_name = ' ' attr = None + this_week: List[Tuple[int, Union[Date, urwid.AttrMap]]] this_week = [(get_month_abbr_len(), urwid.AttrMap(urwid.Text(month_name), attr))] for _number, day in enumerate(week): new_date = Date(day, self.get_styles) @@ -511,64 +535,65 @@ def _construct_week(self, week): return week def _construct_month(self, - year=dt.date.today().year, - month=dt.date.today().month, - clean_first_row=False, - clean_last_row=False): + year: int=dt.date.today().year, + month: int=dt.date.today().month, + clean_first_row: bool=False, + clean_last_row: bool=False, + ) -> List[DateCColumns]: """construct one month of DateCColumns :param year: the year this month is set in - :type year: int :param month: the number of the month to be constructed - :type month: int (1-12) - :param clean_first_row: makes sure that the first element returned is - completely in `month` and not partly in the one - before (which might lead to that line occurring - twice - :type clean_first_row: bool - :param clean_last_row: makes sure that the last element returned is - completely in `month` and not partly in the one - after (which might lead to that line occurring - twice - :type clean_last_row: bool + :param clean_first_row: if set, makes sure that the first element + returned is completely in `month` and not partly in the one before + (which might lead to that line occurring twice + :param clean_last_row: if set, makes sure that the last element returned + is completely in `month` and not partly in the one after (which + might lead to that line occurring twice) :returns: list of DateCColumns and the number of the list element which contains today (or None if it isn't in there) - :rtype: tuple(list(dateCColumns, int or None)) """ - plain_weeks = calendar.Calendar( - self.firstweekday).monthdatescalendar(year, month) + plain_weeks = calendar.Calendar(self.firstweekday).monthdatescalendar(year, month) weeks = [] for _number, week in enumerate(plain_weeks): - week = self._construct_week(week) - weeks.append(week) + weeks.append(self._construct_week(week)) if clean_first_row and weeks[0][1].date.month != weeks[0][7].date.month: return weeks[1:] - elif clean_last_row and \ - weeks[-1][1].date.month != weeks[-1][7].date.month: + elif clean_last_row and weeks[-1][1].date.month != weeks[-1][7].date.month: return weeks[:-1] else: return weeks class CalendarWidget(urwid.WidgetWrap): - def __init__(self, on_date_change, keybindings, on_press, firstweekday=0, - weeknumbers=False, monthdisplay='firstday', get_styles=None, initial=None): - """ + def __init__(self, + on_date_change: Callable[[dt.date], None], + keybindings: Dict[str, List[str]], + on_press: Optional[OnPressType]=None, + firstweekday: int=0, + weeknumbers: Literal['left', 'right', False]=False, + monthdisplay: Literal['firstday', 'firstfullweek']='firstday', + get_styles: Optional[GetStylesSignature]=None, + initial: Optional[dt.date]=None, + ): + """A calendar widget that can be used in urwid applications + :param on_date_change: a function that is called every time the selected - date is changed with the newly selected date as a first (and only - argument) - :type on_date_change: function + date is changed with the newly selected date as an argument :param keybindings: bind keys to specific functionionality, keys are the available commands, values are lists of keys that should be bound to those commands. See below for the defaults. Available commands: - 'left', 'right', 'up', 'down': move cursor in direction + 'left', 'right', 'up', 'down': move cursor in that direction 'today': refocus on today 'mark': toggles selection mode - :type keybindings: dict + 'other': toggles between selecting the earlier and the later end + of a selection + 'view': returns the key `right` to the widget containing the + CalendarWidget :param on_press: dictonary of functions that are called when the key is - pressed and is not already bound to one of the internal functionions + pressed and is not already bound to one of the internal functions via `keybindings`. These functions must accept two arguments, in normal mode the first argument is the currently selected date (datetime.date) and the second is `None`. When a date range is @@ -576,33 +601,43 @@ def __init__(self, on_date_change, keybindings, on_press, firstweekday=0, is the later date. The function's return values are interpreted as pressed keys, which are handed to the widget containing the CalendarWidget. - :type on_press: dict + :param firstweekday: the first day of the week, 0 for Monday, 6 for + :param weeknumbers: display weeknumbers on the left or right side of + the calendar. + :param monthdisplay: display the month name in the row of the 1st of the + month or in the first row that only contains days of the current month. + :param get_styles: a function that returns a list of styles for a given date + :param initial: the date that is selected when the widget is first rendered """ if initial is None: self._initial = dt.date.today() else: self._initial = initial - default_keybindings = { + if on_press is None: + on_press = {} + + default_keybindings: Dict[str, List[str]] = { 'left': ['left'], 'down': ['down'], 'right': ['right'], 'up': ['up'], 'today': ['t'], 'view': [], 'mark': ['v'], + 'other': ['%'], } - on_press = defaultdict(lambda: lambda x: x, on_press) + default_keybindings.update(keybindings) calendar.setfirstweekday(firstweekday) try: - mylocale = '.'.join(getlocale(LC_TIME)) + mylocale: str = '.'.join(getlocale(LC_TIME)) # type: ignore except TypeError: # language code and encoding may be None mylocale = 'C' - _calendar = calendar.LocaleTextCalendar(firstweekday, mylocale) + _calendar = calendar.LocaleTextCalendar(firstweekday, mylocale) # type: ignore weekheader = _calendar.formatweekheader(2) dnames = weekheader.split(' ') - def _get_styles(date, focus): + def _get_styles(date: dt.date, focus: bool) -> Optional[str]: if focus: if date == dt.date.today(): return 'today focus' @@ -619,36 +654,39 @@ def _get_styles(date, focus): if weeknumbers == 'right': dnames.append('#w') month_names_length = get_month_abbr_len() - dnames = urwid.Columns( + cnames = urwid.Columns( [(month_names_length, urwid.Text(' ' * month_names_length))] + [(2, urwid.AttrMap(urwid.Text(name), 'dayname')) for name in dnames], dividechars=1) self.walker = CalendarWalker( - on_date_change, on_press, default_keybindings, firstweekday, - weeknumbers, monthdisplay, - get_styles, initial=self._initial) + on_date_change=on_date_change, + on_press=on_press, + keybindings=default_keybindings, + firstweekday=firstweekday, + weeknumbers=weeknumbers, + monthdisplay=monthdisplay, + get_styles=get_styles, + initial=self._initial, + ) self.box = CListBox(self.walker) - frame = urwid.Frame(self.box, header=dnames) + frame = urwid.Frame(self.box, header=cnames) urwid.WidgetWrap.__init__(self, frame) self.set_focus_date(self._initial) - def focus_today(self): + def focus_today(self) -> None: self.set_focus_date(dt.date.today()) - def reset_styles_range(self, min_date, max_date): + def reset_styles_range(self, min_date: dt.date, max_date: dt.date) -> None: self.walker.reset_styles_range(min_date, max_date) @classmethod - def selectable(cls): + def selectable(cls) -> bool: return True @property - def focus_date(self): + def focus_date(self) -> dt.date: return self.walker.focus_date - def set_focus_date(self, a_day): - """set the focus to `a_day` - - :type a_day: datetime.date - """ + def set_focus_date(self, a_day: dt.date) -> None: + """set the focus to `a_day`""" self.box.set_focus_date(a_day) From 84da85648eb90d03459f1a35824783994d33b38d Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 02/15] more typing in controllers.py --- khal/controllers.py | 47 ++++++++++++++++++++++++-------------------- khal/custom_types.py | 5 ++++- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/khal/controllers.py b/khal/controllers.py index 64f43e6ef..de6b9ec10 100644 --- a/khal/controllers.py +++ b/khal/controllers.py @@ -33,7 +33,7 @@ from click import confirm, echo, prompt, style from khal import __productname__, __version__, calendar_display, parse_datetime, utils -from khal.custom_types import EventCreationTypes, LocaleConfiguration +from khal.custom_types import EventCreationTypes, LocaleConfiguration, WeekNumbersType, MonthDisplayType from khal.exceptions import DateTimeParseError, FatalError from khal.khalendar import CalendarCollection from khal.khalendar.event import Event @@ -48,7 +48,7 @@ logger = logging.getLogger('khal') -def format_day(day, format_string: str, locale, attributes=None): +def format_day(day: dt.date, format_string: str, locale, attributes=None): if attributes is None: attributes = {} @@ -68,23 +68,28 @@ def format_day(day, format_string: str, locale, attributes=None): raise KeyError("cannot format day with: %s" % format_string) -def calendar(collection, agenda_format=None, notstarted=False, once=False, daterange=None, - day_format=None, - locale=None, - conf=None, - firstweekday=0, - weeknumber=False, - monthdisplay='firstday', - hmethod='fg', - default_color='', - multiple='', - multiple_on_overflow=False, - color='', - highlight_event_days=0, - full=False, - bold_for_light_color=True, - env=None, - ): +def calendar( + collection: CalendarCollection, + agenda_format=None, + notstarted: bool=False, + once=False, + daterange=None, + day_format=None, + locale=None, + conf=None, + firstweekday: int=0, + weeknumber: WeekNumbersType=False, + monthdisplay: MonthDisplayType='firstday', + hmethod: str='fg', + default_color: str='', + multiple='', + multiple_on_overflow: bool=False, + color='', + highlight_event_days=0, + full=False, + bold_for_light_color: bool=True, + env=None, + ): term_width, _ = get_terminal_size() lwidth = 27 if conf['locale']['weeknumbers'] == 'right' else 25 rwidth = term_width - lwidth - 4 @@ -163,7 +168,7 @@ def get_events_between( agenda_format: str, notstarted: bool, env: dict, - width, + width: Optional[int], seen, original_start: dt.datetime, ) -> List[str]: @@ -230,7 +235,7 @@ def khal_list( day_format: Optional[str]=None, once=False, notstarted: bool = False, - width: bool = False, + width: Optional[int] = None, env=None, datepoint=None, ): diff --git a/khal/custom_types.py b/khal/custom_types.py index 80f88d7df..29f2a3cf9 100644 --- a/khal/custom_types.py +++ b/khal/custom_types.py @@ -1,6 +1,6 @@ import datetime as dt import os -from typing import List, Optional, Protocol, Tuple, TypedDict, Union +from typing import List, Optional, Protocol, Tuple, TypedDict, Union, Literal import pytz @@ -77,3 +77,6 @@ class EventCreationTypes(TypedDict): PathLike = Union[str, os.PathLike] + +WeekNumbersType = Literal['left', 'right', False] +MonthDisplayType = Literal['firstday', 'firstfullweek'] From c8c2fc9cb403727da05f9d11f9955dc5221df559 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 03/15] more typing in khalendar and below icalendar.py, parse_datetime.py, backend.py, parse_datetime.py --- khal/icalendar.py | 45 ++++++++++++++++++++----------------- khal/khalendar/backend.py | 4 ++-- khal/khalendar/khalendar.py | 6 ++--- khal/parse_datetime.py | 3 --- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/khal/icalendar.py b/khal/icalendar.py index 5ae178749..cfe9811eb 100644 --- a/khal/icalendar.py +++ b/khal/icalendar.py @@ -38,13 +38,12 @@ logger = logging.getLogger('khal') -def split_ics(ics: str, random_uid: bool=False, default_timezone=None): +def split_ics(ics: str, random_uid: bool=False, default_timezone=None) -> List: """split an ics string into several according to VEVENT's UIDs and sort the right VTIMEZONEs accordingly ignores all other ics components :param random_uid: assign random uids to all events - :rtype list: """ cal = cal_from_ics(ics) tzs = {} @@ -217,7 +216,10 @@ def ics_from_list( return calendar.to_ical().decode('utf-8') -def expand(vevent, href=''): +def expand( + vevent: icalendar.Event, + href: str='', +) -> Optional[List[Tuple[dt.datetime, dt.datetime]]]: """ Constructs a list of start and end dates for all recurring instances of the event defined in vevent. @@ -229,12 +231,9 @@ def expand(vevent, href=''): the function still returns a tuple of start and end (date)times. :param vevent: vevent to be expanded - :type vevent: icalendar.cal.Event :param href: the href of the vevent, used for more informative logging and nothing else - :type href: str :returns: list of start and end (date)times of the expanded event - :rtype: list(tuple(datetime, datetime)) """ # we do this now and than never care about the "real" end time again if 'DURATION' in vevent: @@ -249,7 +248,7 @@ def expand(vevent, href=''): events_tz = getattr(vevent['DTSTART'].dt, 'tzinfo', None) allday = not isinstance(vevent['DTSTART'].dt, dt.datetime) - def sanitize_datetime(date): + def sanitize_datetime(date: dt.date) -> dt.date: if allday and isinstance(date, dt.datetime): date = date.date() if events_tz is not None: @@ -274,20 +273,24 @@ def sanitize_datetime(date): ignoretz=True, ) - if rrule._until is None: + # telling mypy, that _until exists + # we are very sure (TM) that rrulestr always returns a rrule, not a + # rruleset (which wouldn't have a _until attribute) + if rrule._until is None: # type: ignore # rrule really doesn't like to calculate all recurrences until # eternity, so we only do it until 2037, because a) I'm not sure # if python can deal with larger datetime values yet and b) pytz # doesn't know any larger transition times - rrule._until = dt.datetime(2037, 12, 31) + rrule._until = dt.datetime(2037, 12, 31) # type: ignore else: if events_tz and 'Z' in rrule_param.to_ical().decode(): - rrule._until = pytz.UTC.localize( - rrule._until).astimezone(events_tz).replace(tzinfo=None) + assert isinstance(rrule._until, dt.datetime) # type: ignore + rrule._until = pytz.UTC.localize( # type: ignore + rrule._until).astimezone(events_tz).replace(tzinfo=None) # type: ignore # rrule._until and dtstart could be dt.date or dt.datetime. They # need to be the same for comparison - testuntil = rrule._until + testuntil = rrule._until # type: ignore if (type(dtstart) == dt.date and type(testuntil) == dt.datetime): testuntil = testuntil.date() teststart = dtstart @@ -298,15 +301,15 @@ def sanitize_datetime(date): logger.warning( f'{href}: Unsupported recurrence. UNTIL is before DTSTART.\n' 'This event will not be available in khal.') - return False + return None if rrule.count() == 0: logger.warning( f'{href}: Recurrence defined but will never occur.\n' 'This event will not be available in khal.') - return False + return None - rrule = map(sanitize_datetime, rrule) + rrule = map(sanitize_datetime, rrule) # type: ignore logger.debug(f'calculating recurrence dates for {href}, this might take some time.') @@ -362,24 +365,24 @@ def assert_only_one_uid(cal: icalendar.Calendar): return True -def sanitize(vevent, default_timezone, href='', calendar=''): +def sanitize( + vevent: icalendar.Event, + default_timezone: pytz.BaseTzInfo, + href: str='', + calendar: str='', +) -> icalendar.Event: """ clean up vevents we do not understand :param vevent: the vevent that needs to be cleaned - :type vevent: icalendar.cal.Event :param default_timezone: timezone to apply to start and/or end dates which were supposed to be localized but which timezone was not understood by icalendar - :type timezone: pytz.timezone :param href: used for logging to inform user which .ics files are problematic - :type href: str :param calendar: used for logging to inform user which .ics files are problematic - :type calendar: str :returns: clean vevent - :rtype: icalendar.cal.Event """ # convert localized datetimes with timezone information we don't # understand to the default timezone diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py index 25b3b29d9..c6c3c61b7 100644 --- a/khal/khalendar/backend.py +++ b/khal/khalendar/backend.py @@ -36,7 +36,7 @@ from dateutil import parser from .. import utils -from ..custom_types import EventTuple +from ..custom_types import EventTuple, LocaleConfiguration from ..icalendar import assert_only_one_uid, cal_from_ics from ..icalendar import expand as expand_vevent from ..icalendar import sanitize as sanitize_vevent @@ -75,7 +75,7 @@ class SQLiteDb: def __init__(self, calendars: Iterable[str], db_path: Optional[str], - locale: Dict[str, str], + locale: LocaleConfiguration, ) -> None: assert db_path is not None self.calendars: List[str] = list(calendars) diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py index 5814c2592..fc22f28b0 100644 --- a/khal/khalendar/khalendar.py +++ b/khal/khalendar/khalendar.py @@ -32,7 +32,7 @@ import os.path from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union # noqa -from ..custom_types import CalendarConfiguration, EventCreationTypes +from ..custom_types import CalendarConfiguration, EventCreationTypes, LocaleConfiguration from ..icalendar import new_vevent from . import backend from .event import Event @@ -69,10 +69,10 @@ def __init__(self, color: str='', priority: int=10, highlight_event_days: bool=False, - locale: Optional[Dict[str, Any]]=None, + locale: Optional[LocaleConfiguration]=None, dbpath: Optional[str]=None, ) -> None: - locale = locale or {} + assert locale assert dbpath is not None assert calendars is not None diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py index 34b3784f9..c3915b201 100644 --- a/khal/parse_datetime.py +++ b/khal/parse_datetime.py @@ -287,8 +287,6 @@ def guesstimedeltafstr(delta_string: str) -> dt.timedelta: """parses a timedelta from a string :param delta_string: string encoding time-delta, e.g. '1h 15m' - :type delta_string: str - :rtype: datetime.timedelta """ tups = re.split(r'(-?\d+)', delta_string) @@ -330,7 +328,6 @@ def guessrangefstr(daterange: Union[str, List[str]], """parses a range string :param daterange: date1 [date2 | timedelta] - :type daterange: str or list :param locale: :returns: start and end of the date(time) range and if this is an all-day time range or not, From 8742beec51ec63fec93c90f31998a883ad94348a Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 04/15] more typing in ui/ --- khal/ui/__init__.py | 108 +++++++++++++++++++++++--------------------- khal/ui/editor.py | 13 ++++-- khal/ui/widgets.py | 12 ++--- 3 files changed, 70 insertions(+), 63 deletions(-) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 2043aa444..e8b77675e 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -23,12 +23,14 @@ import logging import signal import sys -from typing import Optional, Tuple +from typing import Optional, Tuple, Union, Dict, List +from enum import IntEnum import click import urwid from .. import utils +from ..khalendar import CalendarCollection from ..khalendar.exceptions import ReadOnlyCalendarError from . import colors from .base import Pane, Window @@ -72,8 +74,9 @@ # │ └─────────────────┘ └──────────────────────────────────┘ │ # └───────────────────────────────────────────────────────────┘ -ALL = 1 -INSTANCES = 2 +class DeletionType(IntEnum): + ALL = 0 + INSTANCES = 1 class DateConversionError(Exception): @@ -173,7 +176,7 @@ def __init__(self, event, conf, delete_status, this_date=None, relative=True): super().__init__('', wrap='clip') self.set_title() - def get_cursor_coords(self, size): + def get_cursor_coords(self, size) -> Tuple[int, int]: return 0, 0 def render(self, size, focus=False): @@ -184,20 +187,24 @@ def render(self, size, focus=False): return canv @classmethod - def selectable(cls): + def selectable(cls) -> bool: return True @property - def uid(self): + def uid(self) -> str: return self.event.calendar + '\n' + \ str(self.event.href) + '\n' + str(self.event.etag) @property - def recuid(self): + def recuid(self) -> Tuple[str, str]: return (self.uid, self.event.recurrence_id) - def set_title(self, mark=' '): - mark = {ALL: 'D', INSTANCES: 'd', False: ''}[self.delete_status(self.recuid)] + def set_title(self, mark: str=' ') -> None: + mark = { + DeletionType.ALL: 'D', + DeletionType.INSTANCES: 'd', + None: '', + }[self.delete_status(self.recuid)] if self.relative: format_ = self._conf['view']['agenda_event_format'] else: @@ -216,7 +223,7 @@ def set_title(self, mark=' '): self.set_text(mark + ' ' + text.replace('\n', newline)) - def keypress(self, _, key): + def keypress(self, _, key: str) -> str: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' @@ -251,7 +258,7 @@ def keypress(self, size, key): return super().keypress(size, key) @property - def focus_event(self): + def focus_event(self) -> Optional[U_Event]: if self.focus is None: return None else: @@ -290,15 +297,14 @@ def clean(self): if self._old_focus is not None: self.body[self._old_focus].body[0].set_attr_map({None: 'date'}) - def ensure_date(self, day): + def ensure_date(self, day: dt.date) -> None: """ensure an entry for `day` exists and bring it into focus""" try: self._old_focus = self.focus_position except IndexError: pass - rval = self.body.ensure_date(day) + self.body.ensure_date(day) self.clean() - return rval def keypress(self, size, key): if key in self._conf['keybindings']['up']: @@ -332,7 +338,7 @@ def focus_event(self): return self.body.focus_event @property - def current_date(self): + def current_date(self) -> dt.date: return self.body.current_day def refresh_titles(self, start, end, recurring): @@ -475,23 +481,23 @@ def _get_events(self, day: dt.date) -> urwid.Widget: (len(event_list) + 1) if self.events else 1 ) - def selectable(self): + def selectable(self) -> bool: """mark this widget as selectable""" return True @property - def focus_event(self): + def focus_event(self) -> U_Event: return self[self.focus].original_widget.focus_event @property - def current_day(self): + def current_day(self) -> dt.date: return self[self.focus].original_widget.date class StaticDayWalker(DayWalker): """Only show events for a fixed number of days.""" - def ensure_date(self, day): + def ensure_date(self, day: dt.date) -> None: """make sure a DateListBox for `day` exists, update it and bring it into focus""" # TODO cache events for each day and update as needed num_days = max(1, self._conf['default']['timedelta'].days) @@ -536,8 +542,9 @@ def set_focus(self, position): class DateListBox(urwid.ListBox): - """A ListBox container for a SimpleFocusListWalker, that contains one day - worth of events""" + """A ListBox container containing all events for one specific date + used with a SimpleFocusListWalker + """ selected_date = None @@ -602,7 +609,7 @@ def __init__(self, elistbox, pane): self._conf = pane._conf self.divider = urwid.Divider('─') self.editor = False - self._current_date = None + self._last_focused_date: Optional[dt.date] = None self._eventshown = False self.event_width = int(self.pane._conf['view']['event_view_weighting']) self.delete_status = pane.delete_status @@ -633,12 +640,12 @@ def set_focus_date(self, date): self.focus_date = date @property - def focus_date(self): - return self._current_date + def focus_date(self) -> dt.date: + return self.dlistbox.current_date @focus_date.setter - def focus_date(self, date): - self._current_date = date + def focus_date(self, date: dt.date) -> None: + self._last_focused_date = date self.dlistbox.ensure_date(date) def update(self, min_date, max_date, everything): @@ -791,9 +798,9 @@ def delete_all(_): return status = self.delete_status(event.recuid) refresh = True - if status == ALL: + if status == DeletionType.ALL: self.toggle_delete_all(event.recuid) - elif status == INSTANCES: + elif status == DeletionType.INSTANCES: self.toggle_delete_instance(event.recuid) elif event.event.recurring: # FIXME if in search results, original pane is used for overlay, not search results @@ -1013,13 +1020,13 @@ class ClassicView(Pane): on the right """ - def __init__(self, collection, conf=None, title='', description=''): + def __init__(self, collection, conf=None, title: str='', description: str=''): self.init = True # Will be set when opening the view inside a Window self.window = None self._conf = conf self.collection = collection - self._deleted = {ALL: [], INSTANCES: []} + self._deleted: Dict[int, List[str]] = {DeletionType.ALL: [], DeletionType.INSTANCES: []} ContainerWidget = linebox[self._conf['view']['frame']] if self._conf['view']['dynamic_days']: @@ -1063,26 +1070,26 @@ def __init__(self, collection, conf=None, title='', description=''): ) Pane.__init__(self, columns, title=title, description=description) - def delete_status(self, uid): - if uid[0] in self._deleted[ALL]: - return ALL - elif uid in self._deleted[INSTANCES]: - return INSTANCES + def delete_status(self, uid: str) -> Optional[DeletionType]: + if uid[0] in self._deleted[DeletionType.ALL]: + return DeletionType.ALL + elif uid in self._deleted[DeletionType.INSTANCES]: + return DeletionType.INSTANCES else: - return False + return None - def toggle_delete_all(self, recuid): + def toggle_delete_all(self, recuid: Tuple[str, str]) -> None: uid, _ = recuid - if uid in self._deleted[ALL]: - self._deleted[ALL].remove(uid) + if uid in self._deleted[DeletionType.ALL]: + self._deleted[DeletionType.ALL].remove(uid) else: - self._deleted[ALL].append(uid) + self._deleted[DeletionType.ALL].append(uid) - def toggle_delete_instance(self, uid): - if uid in self._deleted[INSTANCES]: - self._deleted[INSTANCES].remove(uid) + def toggle_delete_instance(self, uid: str) -> None: + if uid in self._deleted[DeletionType.INSTANCES]: + self._deleted[DeletionType.INSTANCES].remove(uid) else: - self._deleted[INSTANCES].append(uid) + self._deleted[DeletionType.INSTANCES].append(uid) def cleanup(self, data): """delete all events marked for deletion""" @@ -1092,16 +1099,16 @@ def cleanup(self, data): # We therefore keep track of the etags of the events we already # deleted. updated_etags = {} - for part in self._deleted[ALL]: + for part in self._deleted[DeletionType.ALL]: account, href, etag = part.split('\n', 2) self.collection.delete(href, etag, account) - for part, rec_id in self._deleted[INSTANCES]: + for part, rec_id in self._deleted[DeletionType.INSTANCES]: account, href, etag = part.split('\n', 2) etag = updated_etags.get(href) or etag event = self.collection.delete_instance(href, etag, account, rec_id) updated_etags[event.href] = event.etag - def keypress(self, size, key): + def keypress(self, size, key: str): binds = self._conf['keybindings'] if key in binds['search']: self.search() @@ -1117,7 +1124,7 @@ def search(self): height=None) self.window.open(overlay) - def _search(self, search_term): + def _search(self, search_term: str): """search for events matching `search_term""" self.window.backtrack() events = sorted(self.collection.search(search_term)) @@ -1228,15 +1235,12 @@ def _urwid_palette_entry( return (name, '', '', '', '', color) -def _add_calendar_colors(palette, collection): +def _add_calendar_colors(palette: List, collection: CalendarCollection) -> List: """Add the colors for the defined calendars to the palette. :param palette: the base palette - :type palette: list :param collection: - :type collection: CalendarCollection :returns: the modified palette - :rtype: list """ for cal in collection.calendars: if cal['color'] == '': diff --git a/khal/ui/editor.py b/khal/ui/editor.py index 72ad105d6..b12cfa6db 100644 --- a/khal/ui/editor.py +++ b/khal/ui/editor.py @@ -20,6 +20,7 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import datetime as dt +from typing import Callable, Dict, List, Optional, Literal import urwid @@ -103,10 +104,14 @@ class DateEdit(urwid.WidgetWrap): """ def __init__( - self, startdt=None, dateformat='%Y-%m-%d', - on_date_change=lambda _: None, - weeknumbers=False, firstweekday=0, monthdisplay='firstday', - keybindings=None, + self, + startdt: dt.date, + dateformat: str='%Y-%m-%d', + on_date_change: Callable=lambda _: None, + weeknumbers: Literal['left', 'right', False]=False, + firstweekday: int=0, + monthdisplay: Literal['firstday', 'firstfullweek']='firstday', + keybindings: Optional[Dict[str, List[str]]] = None, ): datewidth = len(startdt.strftime(dateformat)) + 1 self._dateformat = dateformat diff --git a/khal/ui/widgets.py b/khal/ui/widgets.py index 1fe62fdc8..e4c08f2a4 100644 --- a/khal/ui/widgets.py +++ b/khal/ui/widgets.py @@ -26,6 +26,7 @@ """ import datetime as dt import re +from typing import Tuple, Optional import urwid @@ -76,7 +77,7 @@ def goto_end_of_line(text): class ExtendedEdit(urwid.Edit): """A text editing widget supporting some more editing commands""" - def keypress(self, size, key): + def keypress(self, size, key: str) -> Optional[Tuple[Tuple[int, int], str]]: if key == 'ctrl w': self._delete_word() elif key == 'ctrl u': @@ -121,7 +122,7 @@ def _goto_end_of_line(self): class DateTimeWidget(ExtendedEdit): - def __init__(self, dateformat, on_date_change=lambda x: None, **kwargs): + def __init__(self, dateformat: str, on_date_change=lambda x: None, **kwargs): self.dateformat = dateformat self.on_date_change = on_date_change super().__init__(wrap='any', **kwargs) @@ -165,11 +166,8 @@ def _crease(self, fun): except DateConversionError: pass - def set_value(self, new_date): - """set a new value for this widget - - :type new_date: datetime.date - """ + def set_value(self, new_date: dt.date): + """set a new value for this widget""" self.set_edit_text(new_date.strftime(self.dateformat)) From 5698b3452aee69de14dfb8bc2cbc616a52bb7d8e Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 05/15] improve performance of DayWalker for long jumps --- khal/ui/__init__.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index e8b77675e..d7aaa660e 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -364,12 +364,21 @@ def __init__(self, this_date, eventcolumn, conf, collection, delete_status): super().__init__([]) self.ensure_date(this_date) - def ensure_date(self, day): + + def reset(self): + """delete all events contained in this DayWalker""" + self.clear() + self._last_day = None + self._first_day = None + + + def ensure_date(self, day: dt.date) -> None: """make sure a DateListBox for `day` exists, update it and bring it into focus""" # TODO this function gets called twice on every date change, not necessary but # isn't very costly either + if self.days_to_next_already_loaded(day) > 200: # arbitrary number + self.reset() item_no = None - if len(self) == 0: pile = self._get_events(day) self.append(pile) @@ -389,6 +398,19 @@ def ensure_date(self, day): self[item_no].set_selected_date(day) self.set_focus(item_no) + def days_to_next_already_loaded(self, day: dt.date) -> int: + """return number of days until `day` is already loaded into the CalendarWidget""" + if len(self) == 0: + return 0 + elif self[0].date <= day <= self[-1].date: + return 0 + elif day <= self[0].date: + return (self[0].date - day).days + elif self[-1].date <= day: + return (day - self[-1].date).days + else: + raise ValueError("This should not happen") + def update_events_ondate(self, day): """refresh the contents of the day's DateListBox""" offset = (day - self[0].date).days From 508fda577e234b94f3219d9523e3d3e3f20dd320 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 06/15] fix tests icalendar_helpers.expand now returns None (not False) if event does not contain any recurrence instances. --- tests/khalendar_utils_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/khalendar_utils_test.py b/tests/khalendar_utils_test.py index c7cd1236c..b9904f608 100644 --- a/tests/khalendar_utils_test.py +++ b/tests/khalendar_utils_test.py @@ -690,14 +690,14 @@ def test_event_dt_rrule_until_before_start(self): vevent = _get_vevent(_get_text('event_dt_rrule_until_before_start')) dtstart = icalendar_helpers.expand(vevent, berlin) # TODO test for logging message - assert dtstart is False + assert dtstart is None def test_event_invalid_rrule(self): """test handling if an event with RRULE will never occur""" vevent = _get_vevent(_get_text('event_rrule_no_occurence')) dtstart = icalendar_helpers.expand(vevent, berlin) # TODO test for logging message - assert dtstart is False + assert dtstart is None simple_rdate = """BEGIN:VEVENT From 3d0574fa2c57390b7bba521bb0bbe8aeddcd7e61 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 07/15] rename tests/ui/tests_walker.py tests/ui/test_walker.py --- tests/ui/{tests_walker.py => test_walker.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/ui/{tests_walker.py => test_walker.py} (100%) diff --git a/tests/ui/tests_walker.py b/tests/ui/test_walker.py similarity index 100% rename from tests/ui/tests_walker.py rename to tests/ui/test_walker.py From 872f43445565991bcdc300a317e06d19d3f2329e Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 08/15] typing in tests --- tests/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 875829502..7f2322f10 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,6 +6,7 @@ from khal.khalendar import CalendarCollection from khal.khalendar.vdir import Vdir +from khal.custom_types import LocaleConfiguration CollVdirType = Tuple[CalendarCollection, Dict[str, Vdir]] @@ -26,7 +27,7 @@ BOGOTA = pytz.timezone('America/Bogota') -LOCALE_BERLIN = { +LOCALE_BERLIN: LocaleConfiguration = { 'default_timezone': BERLIN, 'local_timezone': BERLIN, 'dateformat': '%d.%m.', @@ -39,7 +40,7 @@ 'weeknumbers': False, } -LOCALE_NEW_YORK = { +LOCALE_NEW_YORK: LocaleConfiguration = { 'default_timezone': NEW_YORK, 'local_timezone': NEW_YORK, 'timeformat': '%H:%M', From 4c86b23085f081b942dde92fcef743f724f52dcc Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 09/15] optimziation for CalendarWidget do not extend the calendar when setting the focus to a far away date but instead reset it and start from the new date. This can save a lot of CPU time, because coloring the calendar is costly, because we need several database queries for each day to color. --- khal/ui/calendarwidget.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/khal/ui/calendarwidget.py b/khal/ui/calendarwidget.py index 2f6e065ff..87c7435ad 100644 --- a/khal/ui/calendarwidget.py +++ b/khal/ui/calendarwidget.py @@ -242,6 +242,7 @@ def __init__(self, walker: 'CalendarWalker'): self.on_press = walker.on_press self._marked: Optional[MarkType] = None self._pos_old: Optional[Tuple[int, int]] = None + self.body: 'CalendarWalker' super().__init__(walker) @property @@ -381,8 +382,6 @@ def __init__(self, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', initial: Optional[dt.date]=None, ) -> None: - if initial is None: - initial = dt.date.today() self.firstweekday = firstweekday self.weeknumbers = weeknumbers self.monthdisplay = monthdisplay @@ -390,6 +389,11 @@ def __init__(self, self.on_press = on_press self.keybindings = keybindings self.get_styles = get_styles + self.reset(initial) + + def reset(self, initial: Optional[dt.date]=None) -> None: + if initial is None: + initial = dt.date.today() weeks = self._construct_month(initial.year, initial.month) urwid.SimpleFocusListWalker.__init__(self, weeks) @@ -402,6 +406,19 @@ def set_focus(self, position: int) -> None: position += no_additional_weeks urwid.SimpleFocusListWalker.set_focus(self, position) + def days_to_next_already_loaded(self, day: dt.date) -> int: + """return the number of weeks from the focus to the next week that is already loaded""" + if len(self) == 0: + return 0 + elif self.earliest_date <= day <= self.latest_date: + return 0 + elif day <= self.earliest_date: + return (self.earliest_date - day).days + elif self.latest_date <= day: + return (day - self.latest_date).days + else: + raise ValueError("This should not happen") + @property def focus_date(self) -> dt.date: """return the date the focus is currently set to""" @@ -409,6 +426,8 @@ def focus_date(self) -> dt.date: def set_focus_date(self, a_day: dt.date) -> None: """set the focus to `a_day`""" + if self.days_to_next_already_loaded(a_day) > 200: # arbitrary number + self.reset(a_day) row, column = self.get_date_pos(a_day) self.set_focus(row) self[self.focus]._set_focus_position(column) From 7310a80d95f6d4a8cafa22781807656145935d2d Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 10/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- khal/controllers.py | 7 ++++++- khal/custom_types.py | 2 +- khal/khalendar/backend.py | 2 +- khal/ui/__init__.py | 2 +- khal/ui/editor.py | 2 +- khal/ui/widgets.py | 2 +- tests/utils.py | 2 +- 7 files changed, 12 insertions(+), 7 deletions(-) diff --git a/khal/controllers.py b/khal/controllers.py index de6b9ec10..cb789301c 100644 --- a/khal/controllers.py +++ b/khal/controllers.py @@ -33,7 +33,12 @@ from click import confirm, echo, prompt, style from khal import __productname__, __version__, calendar_display, parse_datetime, utils -from khal.custom_types import EventCreationTypes, LocaleConfiguration, WeekNumbersType, MonthDisplayType +from khal.custom_types import ( + EventCreationTypes, + LocaleConfiguration, + MonthDisplayType, + WeekNumbersType, +) from khal.exceptions import DateTimeParseError, FatalError from khal.khalendar import CalendarCollection from khal.khalendar.event import Event diff --git a/khal/custom_types.py b/khal/custom_types.py index 29f2a3cf9..97000076e 100644 --- a/khal/custom_types.py +++ b/khal/custom_types.py @@ -1,6 +1,6 @@ import datetime as dt import os -from typing import List, Optional, Protocol, Tuple, TypedDict, Union, Literal +from typing import List, Literal, Optional, Protocol, Tuple, TypedDict, Union import pytz diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py index c6c3c61b7..4ef898d83 100644 --- a/khal/khalendar/backend.py +++ b/khal/khalendar/backend.py @@ -28,7 +28,7 @@ import sqlite3 from enum import IntEnum from os import makedirs, path -from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union +from typing import Any, Iterable, Iterator, List, Optional, Tuple, Union import icalendar import icalendar.cal diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index d7aaa660e..1df8cd75b 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -23,8 +23,8 @@ import logging import signal import sys -from typing import Optional, Tuple, Union, Dict, List from enum import IntEnum +from typing import Dict, List, Optional, Tuple import click import urwid diff --git a/khal/ui/editor.py b/khal/ui/editor.py index b12cfa6db..67318615d 100644 --- a/khal/ui/editor.py +++ b/khal/ui/editor.py @@ -20,7 +20,7 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import datetime as dt -from typing import Callable, Dict, List, Optional, Literal +from typing import Callable, Dict, List, Literal, Optional import urwid diff --git a/khal/ui/widgets.py b/khal/ui/widgets.py index e4c08f2a4..3d3a8fceb 100644 --- a/khal/ui/widgets.py +++ b/khal/ui/widgets.py @@ -26,7 +26,7 @@ """ import datetime as dt import re -from typing import Tuple, Optional +from typing import Optional, Tuple import urwid diff --git a/tests/utils.py b/tests/utils.py index 7f2322f10..cfcf636a9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,9 +4,9 @@ import icalendar import pytz +from khal.custom_types import LocaleConfiguration from khal.khalendar import CalendarCollection from khal.khalendar.vdir import Vdir -from khal.custom_types import LocaleConfiguration CollVdirType = Tuple[CalendarCollection, Dict[str, Vdir]] From ca32ec31158dea11b6453bac19e2c09c44a2677f Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 11/15] fix issue after reset of eventlist --- khal/ui/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 1df8cd75b..842442031 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -295,7 +295,11 @@ def render(self, size, focus=False): def clean(self): """reset event most recently in focus""" if self._old_focus is not None: - self.body[self._old_focus].body[0].set_attr_map({None: 'date'}) + try: + self.body[self._old_focus].body[0].set_attr_map({None: 'date'}) + except IndexError: + # after reseting the EventList, the old focus might not exist + pass def ensure_date(self, day: dt.date) -> None: """ensure an entry for `day` exists and bring it into focus""" From 70d72579531191ae7139cec4a418b6597a0b2b6e Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 12/15] more typing --- khal/ui/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 842442031..23afd9e25 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -113,7 +113,7 @@ def __init__(self, day: dt.date, dateformat: str, conf): super().__init__('', wrap='clip') self.update_date_line() - def update_date_line(self): + def update_date_line(self) -> None: """update self, so that the timedelta is accurate to be called after a date change @@ -144,7 +144,7 @@ def relative_day(self, day: dt.date, dtformat: str) -> str: day=daystr, ) - def keypress(self, _, key): + def keypress(self, _, key: str) -> str: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' @@ -244,8 +244,8 @@ def __init__( delete_status, toggle_delete_instance, toggle_delete_all, set_focus_date_callback=None, **kwargs): - self._init = True - self.parent = parent + self._init: bool = True + self.parent: 'ClassicView' = parent self.delete_status = delete_status self.toggle_delete_instance = toggle_delete_instance self.toggle_delete_all = toggle_delete_all @@ -641,7 +641,7 @@ def __init__(self, elistbox, pane): self.delete_status = pane.delete_status self.toggle_delete_all = pane.toggle_delete_all self.toggle_delete_instance = pane.toggle_delete_instance - self.dlistbox = elistbox + self.dlistbox: DateListBox = elistbox self.container = urwid.Pile([self.dlistbox]) urwid.WidgetWrap.__init__(self, self.container) From 06945a0ee8dd61fae3ecc48e6d0ec5d815db21e5 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 13/15] add todo --- khal/ui/editor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/khal/ui/editor.py b/khal/ui/editor.py index 67318615d..1063a4dba 100644 --- a/khal/ui/editor.py +++ b/khal/ui/editor.py @@ -756,6 +756,8 @@ def _rebuild_edit(self): @property def changed(self): + # TODO this often gives false positives which leads to redraws of all + # events shown return self._rrule != self.rrule() # TODO do this properly def rrule(self): From c4cd982416e729aaa837d66f751e6be61644a5a4 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 14/15] fix an issue with updating the eventlist after editing After we edited an event, the event list would get updated between the first and last date shown in calendar, not between the first and last date shown in the event list. This would often lead to not updating the right dates. This *should* only be an issue when the recurrence rules change, but there also is an issue where the RecurrenceEditor spuriously claims it has been edited (when it has not been edited). --- khal/ui/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 23afd9e25..5f5df56b2 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -519,6 +519,13 @@ def focus_event(self) -> U_Event: def current_day(self) -> dt.date: return self[self.focus].original_widget.date + @property + def first_date(self) -> dt.date: + return self[0].original_widget.date + + @property + def last_date(self) -> dt.date: + return self[-1].original_widget.date class StaticDayWalker(DayWalker): """Only show events for a fixed number of days.""" @@ -684,6 +691,9 @@ def update(self, min_date, max_date, everything): min_date = self.pane.calendar.base_widget.walker.earliest_date max_date = self.pane.calendar.base_widget.walker.latest_date self.pane.base_widget.calendar.base_widget.reset_styles_range(min_date, max_date) + if everything: + min_date = self.dlistbox.body.first_date + max_date = self.dlistbox.body.last_date self.dlistbox.body.update_range(min_date, max_date) def refresh_titles(self, min_date, max_date, everything): From d56b15ac4e2a7a7aa8e3b89d628259108a272868 Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Sat, 17 Jun 2023 17:55:57 +0200 Subject: [PATCH 15/15] CHANGELOG --- CHANGELOG.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 970695ae5..789168ec5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,13 @@ Package maintainers and users who have to manually update their installation may want to subscribe to `GitHub's tag feed `_. +0.11.3 +====== +not released yet + +* optimization in ikhal when editing events in the far future or past +* FIX an issue in ikhal with updating the view of the event list after editing + an event 0.11.2 ======