diff --git a/ChangeLog b/ChangeLog index 81a6e0d..5f61754 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,7 @@ v4.4.0 * Fix lots of bugs by switching from deprecated oauth2client to google_auth_oauthlib + * Friendlier help output when `import` command is missing vobject extra * Handle encoding/decoding errors more gracefully by replacing with placeholder chars instead of blowing up * Fix `--lineart` option failing with unicode errors diff --git a/gcalcli/gcal.py b/gcalcli/gcal.py index 5571e0a..09a3c42 100644 --- a/gcalcli/gcal.py +++ b/gcalcli/gcal.py @@ -18,10 +18,10 @@ from dateutil.tz import tzlocal from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore from googleapiclient.discovery import build -from googleapiclient.errors import HttpError # type: ignore +from googleapiclient.errors import HttpError from google.auth.transport.requests import Request # type: ignore -from . import actions, utils +from . import actions, ics, utils from ._types import Cache, CalendarListEntry from .actions import ACTIONS from .conflicts import ShowConflicts @@ -104,15 +104,6 @@ def _select_cals(self, selected_names): # operate against self.cals += matches - @staticmethod - def _localize_datetime(dt): - if not hasattr(dt, 'tzinfo'): # Why are we skipping these? - return dt - if dt.tzinfo is None: - return dt.replace(tzinfo=tzlocal()) - else: - return dt.astimezone(tzlocal()) - def _retry_with_backoff(self, method): for n in range(self.max_retries): try: @@ -1055,7 +1046,7 @@ def _GetAllEvents(self, cal, events, end): # all date events event['s'] = parse(event['start']['date']) - event['s'] = self._localize_datetime(event['s']) + event['s'] = utils.localize_datetime(event['s']) if 'dateTime' in event['end']: event['e'] = parse(event['end']['dateTime']) @@ -1063,7 +1054,7 @@ def _GetAllEvents(self, cal, events, end): # all date events event['e'] = parse(event['end']['date']) - event['e'] = self._localize_datetime(event['e']) + event['e'] = utils.localize_datetime(event['e']) # For all-day events, Google seems to assume that the event # time is based in the UTC instead of the local timezone. Here @@ -1428,129 +1419,21 @@ def Remind(self, minutes, command, use_reminders=False): def ImportICS(self, verbose=False, dump=False, reminders=None, icsFile=None): - - def CreateEventFromVOBJ(ve): - - event = {} - - if verbose: - print('+----------------+') - print('| Calendar Event |') - print('+----------------+') - - if hasattr(ve, 'summary'): - if verbose: - print('Event........%s' % ve.summary.value) - event['summary'] = ve.summary.value - - if hasattr(ve, 'location'): - if verbose: - print('Location.....%s' % ve.location.value) - event['location'] = ve.location.value - - if not hasattr(ve, 'dtstart') or not hasattr(ve, 'dtend'): - self.printer.err_msg( - 'Error: event does not have a dtstart and dtend!\n' - ) - return None - - if verbose: - if ve.dtstart.value: - print('Start........%s' % ve.dtstart.value.isoformat()) - if ve.dtend.value: - print('End..........%s' % ve.dtend.value.isoformat()) - if ve.dtstart.value: - print('Local Start..%s' % - self._localize_datetime(ve.dtstart.value) - ) - if ve.dtend.value: - print('Local End....%s' % - self._localize_datetime(ve.dtend.value) - ) - - if hasattr(ve, 'rrule'): - if verbose: - print('Recurrence...%s' % ve.rrule.value) - - event['recurrence'] = ['RRULE:' + ve.rrule.value] - - if hasattr(ve, 'dtstart') and ve.dtstart.value: - # XXX - # Timezone madness! Note that we're using the timezone for the - # calendar being added to. This is OK if the event is in the - # same timezone. This needs to be changed to use the timezone - # from the DTSTART and DTEND values. Problem is, for example, - # the TZID might be "Pacific Standard Time" and Google expects - # a timezone string like "America/Los_Angeles". Need to find a - # way in python to convert to the more specific timezone - # string. - # XXX - # print ve.dtstart.params['X-VOBJ-ORIGINAL-TZID'][0] - # print self.cals[0]['timeZone'] - # print dir(ve.dtstart.value.tzinfo) - # print vars(ve.dtstart.value.tzinfo) - - start = ve.dtstart.value.isoformat() - if isinstance(ve.dtstart.value, datetime): - event['start'] = {'dateTime': start, - 'timeZone': self.cals[0]['timeZone']} - else: - event['start'] = {'date': start} - - event = self._add_reminders(event, reminders) - - # Can only have an end if we have a start, but not the other - # way around apparently... If there is no end, use the start - if hasattr(ve, 'dtend') and ve.dtend.value: - end = ve.dtend.value.isoformat() - if isinstance(ve.dtend.value, datetime): - event['end'] = {'dateTime': end, - 'timeZone': self.cals[0]['timeZone']} - else: - event['end'] = {'date': end} - - else: - event['end'] = event['start'] - - if hasattr(ve, 'description') and ve.description.value.strip(): - descr = ve.description.value.strip() - if verbose: - print('Description:\n%s' % descr) - event['description'] = descr - - if hasattr(ve, 'organizer'): - if ve.organizer.value.startswith('MAILTO:'): - email = ve.organizer.value[7:] - else: - email = ve.organizer.value - if verbose: - print('organizer:\n %s' % email) - event['organizer'] = {'displayName': ve.organizer.name, - 'email': email} - - if hasattr(ve, 'attendee_list'): - if verbose: - print('attendees:') - event['attendees'] = [] - for attendee in ve.attendee_list: - if attendee.value.upper().startswith('MAILTO:'): - email = attendee.value[7:] - else: - email = attendee.value - if verbose: - print(' %s' % email) - - event['attendees'].append({'displayName': attendee.name, - 'email': email}) - - return event - - try: - import vobject - except ImportError: + if not ics.has_vobject_support(): self.printer.err_msg( - 'Python vobject module not installed!\n' + 'Python vobject module not installed!\n' ) + self.printer.msg( + 'To use the import command, you need to first install the ' + '"vobject" extra.\n' + 'For setup instructions, see ' + "https://github.com/insanum/gcalcli and documentation for the " + 'gcalcli package on your platform.\n') + sys_path_str = '\n '.join(sys.path) + self.printer.debug_msg( + 'Searched for vobject using python interpreter at ' + f'"{sys.executable}" with module search path:\n' + f" {sys_path_str}\n") sys.exit(1) if dump: @@ -1568,53 +1451,45 @@ def CreateEventFromVOBJ(ve): self.printer.err_msg('Error: ' + str(e) + '!\n') sys.exit(1) - while True: - try: - v = next(vobject.readComponents(f)) - except StopIteration: - break - - for ve in v.vevent_list: - event = CreateEventFromVOBJ(ve) - - if not event: - continue + events_to_import = ics.get_events( + f, + verbose=verbose, + default_tz=self.cals[0]['timeZone'], + printer=self.printer) + for event in events_to_import: + if not event: + continue - if dump: - continue + if dump: + continue - if not verbose: - new_event = self._retry_with_backoff( - self.get_events() - .insert( - calendarId=self.cals[0]['id'], - body=event - ) - ) - hlink = new_event.get('htmlLink') - self.printer.msg( - 'New event added: %s\n' % hlink, 'green' + self._add_reminders(event, reminders) + if not verbose: + new_event = self._retry_with_backoff( + self.get_events().insert( + calendarId=self.cals[0]['id'], body=event ) - continue + ) + hlink = new_event.get('htmlLink') + self.printer.msg('New event added: %s\n' % hlink, 'green') + continue - self.printer.msg('\n[S]kip [i]mport [q]uit: ', 'magenta') - val = input() - if not val or val.lower() == 's': - continue - if val.lower() == 'i': - new_event = self._retry_with_backoff( - self.get_events() - .insert( - calendarId=self.cals[0]['id'], - body=event - ) - ) - hlink = new_event.get('htmlLink') - self.printer.msg('New event added: %s\n' % hlink, 'green') - elif val.lower() == 'q': - sys.exit(0) - else: - self.printer.err_msg('Error: invalid input\n') - sys.exit(1) + self.printer.msg('\n[S]kip [i]mport [q]uit: ', 'magenta') + val = input() + if not val or val.lower() == 's': + continue + if val.lower() == 'i': + new_event = self._retry_with_backoff( + self.get_events().insert( + calendarId=self.cals[0]['id'], body=event + ) + ) + hlink = new_event.get('htmlLink') + self.printer.msg('New event added: %s\n' % hlink, 'green') + elif val.lower() == 'q': + sys.exit(0) + else: + self.printer.err_msg('Error: invalid input\n') + sys.exit(1) # TODO: return the number of events added return True diff --git a/gcalcli/ics.py b/gcalcli/ics.py new file mode 100644 index 0000000..413f0ad --- /dev/null +++ b/gcalcli/ics.py @@ -0,0 +1,137 @@ +"""Helpers for working with iCal/ics format.""" + +import importlib.util +import io +from datetime import datetime +from typing import Any, Dict, Iterable, Optional + +from gcalcli.printer import Printer +from gcalcli.utils import localize_datetime + +EventBody = Dict[str, Any] + + +def has_vobject_support() -> bool: + return importlib.util.find_spec('vobject') is not None + + +def get_events( + ics: io.TextIOBase, verbose: bool, default_tz: str, printer: Printer +) -> Iterable[Optional[EventBody]]: + import vobject + + for v in vobject.readComponents(ics): + for ve in v.vevent_list: + yield CreateEventFromVOBJ( + ve, verbose=verbose, default_tz=default_tz, printer=printer + ) + + +def CreateEventFromVOBJ( + ve, verbose: bool, default_tz: str, printer: Printer +) -> Optional[EventBody]: + event = {} + + if verbose: + print('+----------------+') + print('| Calendar Event |') + print('+----------------+') + + if hasattr(ve, 'summary'): + if verbose: + print('Event........%s' % ve.summary.value) + event['summary'] = ve.summary.value + + if hasattr(ve, 'location'): + if verbose: + print('Location.....%s' % ve.location.value) + event['location'] = ve.location.value + + if not hasattr(ve, 'dtstart') or not hasattr(ve, 'dtend'): + printer.err_msg('Error: event does not have a dtstart and dtend!\n') + return None + + if verbose: + if ve.dtstart.value: + print('Start........%s' % ve.dtstart.value.isoformat()) + if ve.dtend.value: + print('End..........%s' % ve.dtend.value.isoformat()) + if ve.dtstart.value: + print('Local Start..%s' % localize_datetime(ve.dtstart.value)) + if ve.dtend.value: + print('Local End....%s' % localize_datetime(ve.dtend.value)) + + if hasattr(ve, 'rrule'): + if verbose: + print('Recurrence...%s' % ve.rrule.value) + + event['recurrence'] = ['RRULE:' + ve.rrule.value] + + if hasattr(ve, 'dtstart') and ve.dtstart.value: + # XXX + # Timezone madness! Note that we're using the timezone for the + # calendar being added to. This is OK if the event is in the + # same timezone. This needs to be changed to use the timezone + # from the DTSTART and DTEND values. Problem is, for example, + # the TZID might be "Pacific Standard Time" and Google expects + # a timezone string like "America/Los_Angeles". Need to find a + # way in python to convert to the more specific timezone + # string. + # XXX + # print ve.dtstart.params['X-VOBJ-ORIGINAL-TZID'][0] + # print default_tz + # print dir(ve.dtstart.value.tzinfo) + # print vars(ve.dtstart.value.tzinfo) + + start = ve.dtstart.value.isoformat() + if isinstance(ve.dtstart.value, datetime): + event['start'] = {'dateTime': start, 'timeZone': default_tz} + else: + event['start'] = {'date': start} + + # NOTE: Reminders added by GoogleCalendarInterface caller. + + # Can only have an end if we have a start, but not the other + # way around apparently... If there is no end, use the start + if hasattr(ve, 'dtend') and ve.dtend.value: + end = ve.dtend.value.isoformat() + if isinstance(ve.dtend.value, datetime): + event['end'] = {'dateTime': end, 'timeZone': default_tz} + else: + event['end'] = {'date': end} + + else: + event['end'] = event['start'] + + if hasattr(ve, 'description') and ve.description.value.strip(): + descr = ve.description.value.strip() + if verbose: + print('Description:\n%s' % descr) + event['description'] = descr + + if hasattr(ve, 'organizer'): + if ve.organizer.value.startswith('MAILTO:'): + email = ve.organizer.value[7:] + else: + email = ve.organizer.value + if verbose: + print('organizer:\n %s' % email) + event['organizer'] = {'displayName': ve.organizer.name, 'email': email} + + if hasattr(ve, 'attendee_list'): + if verbose: + print('attendees:') + event['attendees'] = [] + for attendee in ve.attendee_list: + if attendee.value.upper().startswith('MAILTO:'): + email = attendee.value[7:] + else: + email = attendee.value + if verbose: + print(' %s' % email) + + event['attendees'].append( + {'displayName': attendee.name, 'email': email} + ) + + return event diff --git a/gcalcli/utils.py b/gcalcli/utils.py index de78d0d..353a6e1 100644 --- a/gcalcli/utils.py +++ b/gcalcli/utils.py @@ -151,3 +151,11 @@ def is_all_day(event): return (event['s'].hour == 0 and event['s'].minute == 0 and event['e'].hour == 0 and event['e'].minute == 0) + +def localize_datetime(dt): + if not hasattr(dt, 'tzinfo'): # Why are we skipping these? + return dt + if dt.tzinfo is None: + return dt.replace(tzinfo=tzlocal()) + else: + return dt.astimezone(tzlocal()) diff --git a/stubs/googleapiclient/discovery.pyi b/stubs/googleapiclient/discovery.pyi deleted file mode 100644 index ad346f5..0000000 --- a/stubs/googleapiclient/discovery.pyi +++ /dev/null @@ -1,26 +0,0 @@ -from _typeshed import Incomplete -from email.generator import Generator as BytesGenerator - -__all__ = ['build', 'build_from_document', 'fix_method_name', 'key2param'] - -class _BytesGenerator(BytesGenerator): ... - -def fix_method_name(name): ... -def key2param(key): ... -def build(serviceName, version, http: Incomplete | None = None, discoveryServiceUrl=..., developerKey: Incomplete | None = None, model: Incomplete | None = None, requestBuilder=..., credentials: Incomplete | None = None, cache_discovery: bool = True, cache: Incomplete | None = None): ... -def build_from_document(service, base: Incomplete | None = None, future: Incomplete | None = None, http: Incomplete | None = None, developerKey: Incomplete | None = None, model: Incomplete | None = None, requestBuilder=..., credentials: Incomplete | None = None): ... - -class ResourceMethodParameters: - argmap: Incomplete - required_params: Incomplete - repeated_params: Incomplete - pattern_params: Incomplete - query_params: Incomplete - path_params: Incomplete - param_types: Incomplete - enum_params: Incomplete - def __init__(self, method_desc) -> None: ... - def set_parameters(self, method_desc) -> None: ... - -class Resource: - def __init__(self, http, baseUrl, model, requestBuilder, developerKey, resourceDesc, rootDesc, schema) -> None: ... diff --git a/stubs/vobject/__init__.pyi b/stubs/vobject/__init__.pyi new file mode 100644 index 0000000..d43d76d --- /dev/null +++ b/stubs/vobject/__init__.pyi @@ -0,0 +1,6 @@ +# Fork of Typeshed's stubs to work around explicit export issue +# https://github.com/py-vobject/vobject/issues/53. +from .base import Component, readComponents as readComponents + +def iCalendar() -> Component: ... +def vCard() -> Component: ... diff --git a/tests/test_gcalcli.py b/tests/test_gcalcli.py index a065e0d..d68d086 100644 --- a/tests/test_gcalcli.py +++ b/tests/test_gcalcli.py @@ -2,11 +2,9 @@ import io import os +import re from datetime import datetime from json import load -import re - -from dateutil.tz import tzutc from gcalcli.argparsers import ( get_cal_query_parser, @@ -18,7 +16,6 @@ get_updates_parser, ) from gcalcli.cli import parse_cal_names -from gcalcli.gcal import GoogleCalendarInterface from gcalcli.utils import parse_reminder TEST_DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + '/data' @@ -331,15 +328,6 @@ def test_parse_cal_names(PatchedGCalI): assert gcal.AgendaQuery() == 0 -def test_localize_datetime(PatchedGCalI): - dt = GoogleCalendarInterface._localize_datetime(datetime.now()) - assert dt.tzinfo is not None - - dt = datetime.now(tzutc()) - dt = GoogleCalendarInterface._localize_datetime(dt) - assert dt.tzinfo is not None - - def test_iterate_events(capsys, PatchedGCalI): gcal = PatchedGCalI() assert gcal._iterate_events(gcal.now, []) == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py index 59ff1e3..715cce9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta -from dateutil.tz import UTC import pytest +from dateutil.tz import UTC, tzutc -import gcalcli.utils as utils +from gcalcli import utils def test_get_time_from_str(): @@ -72,3 +72,12 @@ def test_days_since_epoch(): def test_set_locale(): with pytest.raises(ValueError): utils.set_locale('not_a_real_locale') + + +def test_localize_datetime(PatchedGCalI): + dt = utils.localize_datetime(datetime.now()) + assert dt.tzinfo is not None + + dt = datetime.now(tzutc()) + dt = utils.localize_datetime(dt) + assert dt.tzinfo is not None diff --git a/tox.ini b/tox.ini index ff82454..81ec924 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,7 @@ description = run type checks skip_install = true deps = mypy >= 1.0 + google-api-python-client-stubs types-python-dateutil types-requests types-vobject