diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..082d6c2 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1 @@ +ref-names: $Format:%D$ \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..82bf71c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.git_archival.txt export-subst \ No newline at end of file diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000..9e383e8 --- /dev/null +++ b/.github/workflows/pythonpublish.yml @@ -0,0 +1,26 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools setuptools_scm setuptools_scm_git_archive wheel twine + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.pypi_token }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.gitignore b/.gitignore index 3fd4fef..d98c726 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ service-account.json *.pyc my-test*.ics .vscode/* +/dist/ +/*.egg-info/ +/build/ +/.eggs/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..346766b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include pyproject.toml + +# Include the README +include *.md + +# Include the license file +include LICENSE diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..47626ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel", "setuptools_scm"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..5ed97a9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +license_files = LICENSE + +[options] +setup_requires = + setuptools_scm + setuptools_scm_git_archive \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f77adb1 --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +import setuptools + +with open('README.md', 'r') as fh: + long_description = fh.read() + +setuptools.setup( + name='sync-ics2gcal', + author='Dmitry Belyaev', + author_email='b4tm4n@mail.ru', + license='MIT', + description='Sync ics file with Google calendar', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/b4tman/sync_ics2gcal', + use_scm_version={ + 'fallback_version': '0.1', + 'local_scheme': 'no-local-version' + }, + setup_requires=['setuptools_scm', 'setuptools_scm_git_archive'], + packages=setuptools.find_packages(exclude=['tests']), + classifiers=[ + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + ], + python_requires='>=3.5', + install_requires = [ + 'google-auth>=1.5.0', + 'google-api-python-client>=1.7.0', + 'icalendar>=4.0.1', + 'pytz', + 'PyYAML>=3.13' + ], + entry_points={ + "console_scripts": [ + "sync-ics2gcal = sync_ics2gcal.sync_calendar:main", + "manage-ics2gcal = sync_ics2gcal.manage_calendars:main", + ] + } +) \ No newline at end of file diff --git a/gcal_sync/__init__.py b/sync_ics2gcal/__init__.py similarity index 92% rename from gcal_sync/__init__.py rename to sync_ics2gcal/__init__.py index 5c5fcb1..24b73f5 100644 --- a/gcal_sync/__init__.py +++ b/sync_ics2gcal/__init__.py @@ -1,14 +1,14 @@ - -from .ical import ( - CalendarConverter, - EventConverter -) - -from .gcal import ( - GoogleCalendarService, - GoogleCalendar -) - -from .sync import ( - CalendarSync -) + +from .ical import ( + CalendarConverter, + EventConverter +) + +from .gcal import ( + GoogleCalendarService, + GoogleCalendar +) + +from .sync import ( + CalendarSync +) diff --git a/gcal_sync/gcal.py b/sync_ics2gcal/gcal.py similarity index 96% rename from gcal_sync/gcal.py rename to sync_ics2gcal/gcal.py index 4d02e14..8481a08 100644 --- a/gcal_sync/gcal.py +++ b/sync_ics2gcal/gcal.py @@ -1,283 +1,283 @@ -import logging -import sys - -import google.auth -from google.oauth2 import service_account -from googleapiclient import discovery -from pytz import utc - - -class GoogleCalendarService(): - """class for make google calendar service Resource - - Returns: - service Resource - """ - - @staticmethod - def from_srv_acc_file(service_account_file): - """make service Resource from service account filename (authorize) - - Returns: - service Resource - """ - - scopes = ['https://www.googleapis.com/auth/calendar'] - credentials = service_account.Credentials.from_service_account_file(service_account_file) - scoped_credentials = credentials.with_scopes(scopes) - service = discovery.build('calendar', 'v3', credentials=scoped_credentials) - return service - -def select_event_key(event): - """select event key for logging - - Arguments: - event -- event resource - - Returns: - key name or None if no key found - """ - - key = None - if 'iCalUID' in event: - key = 'iCalUID' - elif 'id' in event: - key = 'id' - return key - - -class GoogleCalendar(): - """class to interact with calendar on google - """ - - logger = logging.getLogger('GoogleCalendar') - - def __init__(self, service, calendarId): - self.service = service - self.calendarId = calendarId - - def _make_request_callback(self, action, events_by_req): - """make callback for log result of batch request - - Arguments: - action -- action name - events_by_req -- list of events ordered by request id - - Returns: - callback function - """ - - def callback(request_id, response, exception): - event = events_by_req[int(request_id)] - key = select_event_key(event) - - if exception is not None: - self.logger.error('failed to %s event with %s: %s, exception: %s', - action, key, event.get(key), str(exception)) - else: - resp_key = select_event_key(response) - if resp_key is not None: - event = response - key = resp_key - self.logger.info('event %s ok, %s: %s', - action, key, event.get(key)) - return callback - - def list_events_from(self, start): - """ list events from calendar, where start date >= start - """ - fields = 'nextPageToken,items(id,iCalUID,updated)' - events = [] - page_token = None - timeMin = utc.normalize(start.astimezone(utc)).replace( - tzinfo=None).isoformat() + 'Z' - while True: - response = self.service.events().list(calendarId=self.calendarId, pageToken=page_token, - singleEvents=True, timeMin=timeMin, fields=fields).execute() - if 'items' in response: - events.extend(response['items']) - page_token = response.get('nextPageToken') - if not page_token: - break - self.logger.info('%d events listed', len(events)) - return events - - def find_exists(self, events): - """ find existing events from list, by 'iCalUID' field - - Arguments: - events {list} -- list of events - - Returns: - tuple -- (events_exist, events_not_found) - events_exist - list of tuples: (new_event, exists_event) - """ - - fields = 'items(id,iCalUID,updated)' - events_by_req = [] - exists = [] - not_found = [] - - def list_callback(request_id, response, exception): - found = False - event = events_by_req[int(request_id)] - if exception is None: - found = ([] != response['items']) - else: - self.logger.error('exception %s, while listing event with UID: %s', str( - exception), event['iCalUID']) - if found: - exists.append( - (event, response['items'][0])) - else: - not_found.append(events_by_req[int(request_id)]) - - batch = self.service.new_batch_http_request(callback=list_callback) - i = 0 - for event in events: - events_by_req.append(event) - batch.add(self.service.events().list(calendarId=self.calendarId, - iCalUID=event['iCalUID'], showDeleted=True, fields=fields), request_id=str(i)) - i += 1 - batch.execute() - self.logger.info('%d events exists, %d not found', - len(exists), len(not_found)) - return exists, not_found - - def insert_events(self, events): - """ insert list of events - - Arguments: - events - events list - """ - - fields = 'id' - events_by_req = [] - - insert_callback = self._make_request_callback('insert', events_by_req) - batch = self.service.new_batch_http_request(callback=insert_callback) - i = 0 - for event in events: - events_by_req.append(event) - batch.add(self.service.events().insert( - calendarId=self.calendarId, body=event, fields=fields), request_id=str(i)) - i += 1 - batch.execute() - - def patch_events(self, event_tuples): - """ patch (update) events - - Arguments: - event_tuples -- list of tuples: (new_event, exists_event) - """ - - fields = 'id' - events_by_req = [] - - patch_callback = self._make_request_callback('patch', events_by_req) - batch = self.service.new_batch_http_request(callback=patch_callback) - i = 0 - for event_new, event_old in event_tuples: - if 'id' not in event_old: - continue - events_by_req.append(event_new) - batch.add(self.service.events().patch( - calendarId=self.calendarId, eventId=event_old['id'], body=event_new), fields=fields, request_id=str(i)) - i += 1 - batch.execute() - - def update_events(self, event_tuples): - """ update events - - Arguments: - event_tuples -- list of tuples: (new_event, exists_event) - """ - - fields = 'id' - events_by_req = [] - - update_callback = self._make_request_callback('update', events_by_req) - batch = self.service.new_batch_http_request(callback=update_callback) - i = 0 - for event_new, event_old in event_tuples: - if 'id' not in event_old: - continue - events_by_req.append(event_new) - batch.add(self.service.events().update( - calendarId=self.calendarId, eventId=event_old['id'], body=event_new, fields=fields), request_id=str(i)) - i += 1 - batch.execute() - - def delete_events(self, events): - """ delete events - - Arguments: - events -- list of events - """ - - events_by_req = [] - - delete_callback = self._make_request_callback('delete', events_by_req) - batch = self.service.new_batch_http_request(callback=delete_callback) - i = 0 - for event in events: - events_by_req.append(event) - batch.add(self.service.events().delete( - calendarId=self.calendarId, eventId=event['id']), request_id=str(i)) - i += 1 - batch.execute() - - def create(self, summary, timeZone=None): - """create calendar - - Arguments: - summary -- new calendar summary - - Keyword Arguments: - timeZone -- new calendar timezone as string (optional) - - Returns: - calendar Resource - """ - - calendar = {'summary': summary} - if timeZone is not None: - calendar['timeZone'] = timeZone - - created_calendar = self.service.calendars().insert(body=calendar).execute() - self.calendarId = created_calendar['id'] - return created_calendar - - def delete(self): - """delete calendar - """ - - self.service.calendars().delete(calendarId=self.calendarId).execute() - - def make_public(self): - """make calendar puplic - """ - - rule_public = { - 'scope': { - 'type': 'default', - }, - 'role': 'reader' - } - return self.service.acl().insert(calendarId=self.calendarId, body=rule_public).execute() - - def add_owner(self, email): - """add calendar owner by email - - Arguments: - email -- email to add - """ - - rule_owner = { - 'scope': { - 'type': 'user', - 'value': email, - }, - 'role': 'owner' - } - return self.service.acl().insert(calendarId=self.calendarId, body=rule_owner).execute() +import logging +import sys + +import google.auth +from google.oauth2 import service_account +from googleapiclient import discovery +from pytz import utc + + +class GoogleCalendarService(): + """class for make google calendar service Resource + + Returns: + service Resource + """ + + @staticmethod + def from_srv_acc_file(service_account_file): + """make service Resource from service account filename (authorize) + + Returns: + service Resource + """ + + scopes = ['https://www.googleapis.com/auth/calendar'] + credentials = service_account.Credentials.from_service_account_file(service_account_file) + scoped_credentials = credentials.with_scopes(scopes) + service = discovery.build('calendar', 'v3', credentials=scoped_credentials) + return service + +def select_event_key(event): + """select event key for logging + + Arguments: + event -- event resource + + Returns: + key name or None if no key found + """ + + key = None + if 'iCalUID' in event: + key = 'iCalUID' + elif 'id' in event: + key = 'id' + return key + + +class GoogleCalendar(): + """class to interact with calendar on google + """ + + logger = logging.getLogger('GoogleCalendar') + + def __init__(self, service, calendarId): + self.service = service + self.calendarId = calendarId + + def _make_request_callback(self, action, events_by_req): + """make callback for log result of batch request + + Arguments: + action -- action name + events_by_req -- list of events ordered by request id + + Returns: + callback function + """ + + def callback(request_id, response, exception): + event = events_by_req[int(request_id)] + key = select_event_key(event) + + if exception is not None: + self.logger.error('failed to %s event with %s: %s, exception: %s', + action, key, event.get(key), str(exception)) + else: + resp_key = select_event_key(response) + if resp_key is not None: + event = response + key = resp_key + self.logger.info('event %s ok, %s: %s', + action, key, event.get(key)) + return callback + + def list_events_from(self, start): + """ list events from calendar, where start date >= start + """ + fields = 'nextPageToken,items(id,iCalUID,updated)' + events = [] + page_token = None + timeMin = utc.normalize(start.astimezone(utc)).replace( + tzinfo=None).isoformat() + 'Z' + while True: + response = self.service.events().list(calendarId=self.calendarId, pageToken=page_token, + singleEvents=True, timeMin=timeMin, fields=fields).execute() + if 'items' in response: + events.extend(response['items']) + page_token = response.get('nextPageToken') + if not page_token: + break + self.logger.info('%d events listed', len(events)) + return events + + def find_exists(self, events): + """ find existing events from list, by 'iCalUID' field + + Arguments: + events {list} -- list of events + + Returns: + tuple -- (events_exist, events_not_found) + events_exist - list of tuples: (new_event, exists_event) + """ + + fields = 'items(id,iCalUID,updated)' + events_by_req = [] + exists = [] + not_found = [] + + def list_callback(request_id, response, exception): + found = False + event = events_by_req[int(request_id)] + if exception is None: + found = ([] != response['items']) + else: + self.logger.error('exception %s, while listing event with UID: %s', str( + exception), event['iCalUID']) + if found: + exists.append( + (event, response['items'][0])) + else: + not_found.append(events_by_req[int(request_id)]) + + batch = self.service.new_batch_http_request(callback=list_callback) + i = 0 + for event in events: + events_by_req.append(event) + batch.add(self.service.events().list(calendarId=self.calendarId, + iCalUID=event['iCalUID'], showDeleted=True, fields=fields), request_id=str(i)) + i += 1 + batch.execute() + self.logger.info('%d events exists, %d not found', + len(exists), len(not_found)) + return exists, not_found + + def insert_events(self, events): + """ insert list of events + + Arguments: + events - events list + """ + + fields = 'id' + events_by_req = [] + + insert_callback = self._make_request_callback('insert', events_by_req) + batch = self.service.new_batch_http_request(callback=insert_callback) + i = 0 + for event in events: + events_by_req.append(event) + batch.add(self.service.events().insert( + calendarId=self.calendarId, body=event, fields=fields), request_id=str(i)) + i += 1 + batch.execute() + + def patch_events(self, event_tuples): + """ patch (update) events + + Arguments: + event_tuples -- list of tuples: (new_event, exists_event) + """ + + fields = 'id' + events_by_req = [] + + patch_callback = self._make_request_callback('patch', events_by_req) + batch = self.service.new_batch_http_request(callback=patch_callback) + i = 0 + for event_new, event_old in event_tuples: + if 'id' not in event_old: + continue + events_by_req.append(event_new) + batch.add(self.service.events().patch( + calendarId=self.calendarId, eventId=event_old['id'], body=event_new), fields=fields, request_id=str(i)) + i += 1 + batch.execute() + + def update_events(self, event_tuples): + """ update events + + Arguments: + event_tuples -- list of tuples: (new_event, exists_event) + """ + + fields = 'id' + events_by_req = [] + + update_callback = self._make_request_callback('update', events_by_req) + batch = self.service.new_batch_http_request(callback=update_callback) + i = 0 + for event_new, event_old in event_tuples: + if 'id' not in event_old: + continue + events_by_req.append(event_new) + batch.add(self.service.events().update( + calendarId=self.calendarId, eventId=event_old['id'], body=event_new, fields=fields), request_id=str(i)) + i += 1 + batch.execute() + + def delete_events(self, events): + """ delete events + + Arguments: + events -- list of events + """ + + events_by_req = [] + + delete_callback = self._make_request_callback('delete', events_by_req) + batch = self.service.new_batch_http_request(callback=delete_callback) + i = 0 + for event in events: + events_by_req.append(event) + batch.add(self.service.events().delete( + calendarId=self.calendarId, eventId=event['id']), request_id=str(i)) + i += 1 + batch.execute() + + def create(self, summary, timeZone=None): + """create calendar + + Arguments: + summary -- new calendar summary + + Keyword Arguments: + timeZone -- new calendar timezone as string (optional) + + Returns: + calendar Resource + """ + + calendar = {'summary': summary} + if timeZone is not None: + calendar['timeZone'] = timeZone + + created_calendar = self.service.calendars().insert(body=calendar).execute() + self.calendarId = created_calendar['id'] + return created_calendar + + def delete(self): + """delete calendar + """ + + self.service.calendars().delete(calendarId=self.calendarId).execute() + + def make_public(self): + """make calendar puplic + """ + + rule_public = { + 'scope': { + 'type': 'default', + }, + 'role': 'reader' + } + return self.service.acl().insert(calendarId=self.calendarId, body=rule_public).execute() + + def add_owner(self, email): + """add calendar owner by email + + Arguments: + email -- email to add + """ + + rule_owner = { + 'scope': { + 'type': 'user', + 'value': email, + }, + 'role': 'owner' + } + return self.service.acl().insert(calendarId=self.calendarId, body=rule_owner).execute() diff --git a/gcal_sync/ical.py b/sync_ics2gcal/ical.py similarity index 96% rename from gcal_sync/ical.py rename to sync_ics2gcal/ical.py index ec21b8d..10f8784 100644 --- a/gcal_sync/ical.py +++ b/sync_ics2gcal/ical.py @@ -1,184 +1,184 @@ -import datetime -import logging - -from icalendar import Calendar, Event -from pytz import utc - - -def format_datetime_utc(value): - """utc datetime as string from date or datetime value - Arguments: - value -- date or datetime value - - Returns: - utc datetime value as string in iso format - """ - if not isinstance(value, datetime.datetime): - value = datetime.datetime( - value.year, value.month, value.day, tzinfo=utc) - value = value.replace(microsecond=1) - return utc.normalize(value.astimezone(utc)).replace(tzinfo=None).isoformat() + 'Z' - - -def gcal_date_or_dateTime(value, check_value=None): - """date or dateTime to gcal (start or end dict) - Arguments: - value -- date or datetime value - check_value - date or datetime to choise result type (if not None) - - Returns: - dict { 'date': ... } or { 'dateTime': ... } - """ - - if check_value is None: - check_value = value - - result = {} - if isinstance(check_value, datetime.datetime): - result['dateTime'] = format_datetime_utc(value) - else: - if isinstance(check_value, datetime.date): - if isinstance(value, datetime.datetime): - value = datetime.date(value.year, value.month, value.day) - result['date'] = value.isoformat() - return result - - -class EventConverter(Event): - """Convert icalendar event to google calendar resource - ( https://developers.google.com/calendar/v3/reference/events#resource-representations ) - """ - - def _str_prop(self, prop): - """decoded string property - - Arguments: - prop - propperty name - - Returns: - string value - """ - - return self.decoded(prop).decode(encoding='utf-8') - - def _datetime_str_prop(self, prop): - """utc datetime as string from property - - Arguments: - prop -- property name - - Returns: - utc datetime value as string in iso format - """ - - return format_datetime_utc(self.decoded(prop)) - - def _gcal_start(self): - """ event start dict from icalendar event - - Raises: - ValueError -- if DTSTART not date or datetime - - Returns: - dict - """ - - value = self.decoded('DTSTART') - return gcal_date_or_dateTime(value) - - def _gcal_end(self): - """event end dict from icalendar event - - Raises: - ValueError -- if no DTEND or DURATION - Returns: - dict - """ - - result = None - if 'DTEND' in self: - value = self.decoded('DTEND') - result = gcal_date_or_dateTime(value) - elif 'DURATION' in self: - start_val = self.decoded('DTSTART') - duration = self.decoded('DURATION') - end_val = start_val + duration - - result = gcal_date_or_dateTime(end_val, check_value=start_val) - else: - raise ValueError('no DTEND or DURATION') - return result - - def _put_to_gcal(self, gcal_event, prop, func, ics_prop=None): - """get property from ical event if exist, and put to gcal event - - Arguments: - gcal_event -- dest event - prop -- property name - func -- function to convert - ics_prop -- ical property name (default: {None}) - """ - - if not ics_prop: - ics_prop = prop - if ics_prop in self: - gcal_event[prop] = func(ics_prop) - - def to_gcal(self): - """Convert - - Returns: - dict - google calendar#event resource - """ - - event = { - 'iCalUID': self._str_prop('UID') - } - - event['start'] = self._gcal_start() - event['end'] = self._gcal_end() - - self._put_to_gcal(event, 'summary', self._str_prop) - self._put_to_gcal(event, 'description', self._str_prop) - self._put_to_gcal(event, 'location', self._str_prop) - self._put_to_gcal(event, 'created', self._datetime_str_prop) - self._put_to_gcal( - event, 'updated', self._datetime_str_prop, 'LAST-MODIFIED') - self._put_to_gcal( - event, 'transparency', lambda prop: self._str_prop(prop).lower(), 'TRANSP') - - return event - - -class CalendarConverter(): - """Convert icalendar events to google calendar resources - """ - - logger = logging.getLogger('CalendarConverter') - - def __init__(self, calendar=None): - self.calendar = calendar - - def load(self, filename): - """ load calendar from ics file - """ - with open(filename, 'r', encoding='utf-8') as f: - self.calendar = Calendar.from_ical(f.read()) - self.logger.info('%s loaded', filename) - - def loads(self, string): - """ load calendar from ics string - """ - self.calendar = Calendar.from_ical(string) - - def events_to_gcal(self): - """Convert events to google calendar resources - """ - - ics_events = self.calendar.walk(name='VEVENT') - self.logger.info('%d events readed', len(ics_events)) - - result = list( - map(lambda event: EventConverter(event).to_gcal(), ics_events)) - self.logger.info('%d events converted', len(result)) - return result +import datetime +import logging + +from icalendar import Calendar, Event +from pytz import utc + + +def format_datetime_utc(value): + """utc datetime as string from date or datetime value + Arguments: + value -- date or datetime value + + Returns: + utc datetime value as string in iso format + """ + if not isinstance(value, datetime.datetime): + value = datetime.datetime( + value.year, value.month, value.day, tzinfo=utc) + value = value.replace(microsecond=1) + return utc.normalize(value.astimezone(utc)).replace(tzinfo=None).isoformat() + 'Z' + + +def gcal_date_or_dateTime(value, check_value=None): + """date or dateTime to gcal (start or end dict) + Arguments: + value -- date or datetime value + check_value - date or datetime to choise result type (if not None) + + Returns: + dict { 'date': ... } or { 'dateTime': ... } + """ + + if check_value is None: + check_value = value + + result = {} + if isinstance(check_value, datetime.datetime): + result['dateTime'] = format_datetime_utc(value) + else: + if isinstance(check_value, datetime.date): + if isinstance(value, datetime.datetime): + value = datetime.date(value.year, value.month, value.day) + result['date'] = value.isoformat() + return result + + +class EventConverter(Event): + """Convert icalendar event to google calendar resource + ( https://developers.google.com/calendar/v3/reference/events#resource-representations ) + """ + + def _str_prop(self, prop): + """decoded string property + + Arguments: + prop - propperty name + + Returns: + string value + """ + + return self.decoded(prop).decode(encoding='utf-8') + + def _datetime_str_prop(self, prop): + """utc datetime as string from property + + Arguments: + prop -- property name + + Returns: + utc datetime value as string in iso format + """ + + return format_datetime_utc(self.decoded(prop)) + + def _gcal_start(self): + """ event start dict from icalendar event + + Raises: + ValueError -- if DTSTART not date or datetime + + Returns: + dict + """ + + value = self.decoded('DTSTART') + return gcal_date_or_dateTime(value) + + def _gcal_end(self): + """event end dict from icalendar event + + Raises: + ValueError -- if no DTEND or DURATION + Returns: + dict + """ + + result = None + if 'DTEND' in self: + value = self.decoded('DTEND') + result = gcal_date_or_dateTime(value) + elif 'DURATION' in self: + start_val = self.decoded('DTSTART') + duration = self.decoded('DURATION') + end_val = start_val + duration + + result = gcal_date_or_dateTime(end_val, check_value=start_val) + else: + raise ValueError('no DTEND or DURATION') + return result + + def _put_to_gcal(self, gcal_event, prop, func, ics_prop=None): + """get property from ical event if exist, and put to gcal event + + Arguments: + gcal_event -- dest event + prop -- property name + func -- function to convert + ics_prop -- ical property name (default: {None}) + """ + + if not ics_prop: + ics_prop = prop + if ics_prop in self: + gcal_event[prop] = func(ics_prop) + + def to_gcal(self): + """Convert + + Returns: + dict - google calendar#event resource + """ + + event = { + 'iCalUID': self._str_prop('UID') + } + + event['start'] = self._gcal_start() + event['end'] = self._gcal_end() + + self._put_to_gcal(event, 'summary', self._str_prop) + self._put_to_gcal(event, 'description', self._str_prop) + self._put_to_gcal(event, 'location', self._str_prop) + self._put_to_gcal(event, 'created', self._datetime_str_prop) + self._put_to_gcal( + event, 'updated', self._datetime_str_prop, 'LAST-MODIFIED') + self._put_to_gcal( + event, 'transparency', lambda prop: self._str_prop(prop).lower(), 'TRANSP') + + return event + + +class CalendarConverter(): + """Convert icalendar events to google calendar resources + """ + + logger = logging.getLogger('CalendarConverter') + + def __init__(self, calendar=None): + self.calendar = calendar + + def load(self, filename): + """ load calendar from ics file + """ + with open(filename, 'r', encoding='utf-8') as f: + self.calendar = Calendar.from_ical(f.read()) + self.logger.info('%s loaded', filename) + + def loads(self, string): + """ load calendar from ics string + """ + self.calendar = Calendar.from_ical(string) + + def events_to_gcal(self): + """Convert events to google calendar resources + """ + + ics_events = self.calendar.walk(name='VEVENT') + self.logger.info('%d events readed', len(ics_events)) + + result = list( + map(lambda event: EventConverter(event).to_gcal(), ics_events)) + self.logger.info('%d events converted', len(result)) + return result diff --git a/manage-calendars.py b/sync_ics2gcal/manage_calendars.py similarity index 95% rename from manage-calendars.py rename to sync_ics2gcal/manage_calendars.py index e528c76..b1236a2 100644 --- a/manage-calendars.py +++ b/sync_ics2gcal/manage_calendars.py @@ -1,104 +1,104 @@ -import argparse -import datetime -import logging.config - -import yaml -from pytz import utc - -from gcal_sync import GoogleCalendar, GoogleCalendarService - - -def parse_args(): - parser = argparse.ArgumentParser( - description="manage google calendars in service account") - command_subparsers = parser.add_subparsers(help='command', dest='command') - command_subparsers.add_parser('list', help='list calendars') - parser_create = command_subparsers.add_parser( - 'create', help='create calendar') - parser_create.add_argument( - 'summary', action='store', help='new calendar summary') - parser_create.add_argument('--timezone', action='store', - default=None, required=False, help='new calendar timezone') - parser_create.add_argument( - '--public', default=False, action='store_true', help='make calendar public') - parser_add_owner = command_subparsers.add_parser( - 'add_owner', help='add owner to calendar') - parser_add_owner.add_argument('id', action='store', help='calendar id') - parser_add_owner.add_argument( - 'owner_email', action='store', help='new owner email') - parser_remove = command_subparsers.add_parser( - 'remove', help='remove calendar') - parser_remove.add_argument( - 'id', action='store', help='calendar id to remove') - parser_rename = command_subparsers.add_parser( - 'rename', help='rename calendar') - parser_rename.add_argument( - 'id', action='store', help='calendar id') - parser_rename.add_argument( - 'summary', action='store', help='new summary') - - args = parser.parse_args() - if args.command is None: - parser.print_usage() - return args - - -def load_config(): - with open('config.yml', 'r', encoding='utf-8') as f: - result = yaml.safe_load(f) - return result - - -def list_calendars(service): - response = service.calendarList().list(fields='items(id,summary)').execute() - for calendar in response.get('items'): - print('{summary}: {id}'.format_map(calendar)) - - -def create_calendar(service, summary, timezone, public): - calendar = GoogleCalendar(service, None) - calendar.create(summary, timezone) - if public: - calendar.make_public() - print('{}: {}'.format(summary, calendar.calendarId)) - - -def add_owner(service, id, owner_email): - calendar = GoogleCalendar(service, id) - calendar.add_owner(owner_email) - print('to {} added owner: {}'.format(id, owner_email)) - - -def remove_calendar(service, id): - calendar = GoogleCalendar(service, id) - calendar.delete() - print('removed: {}'.format(id)) - -def rename_calendar(service, id, summary): - calendar = {'summary': summary} - service.calendars().patch(body=calendar, calendarId=id).execute() - print('{}: {}'.format(summary, id)) - -def main(): - args = parse_args() - config = load_config() - - if 'logging' in config: - logging.config.dictConfig(config['logging']) - - srv_acc_file = config['service_account'] - service = GoogleCalendarService.from_srv_acc_file(srv_acc_file) - - if 'list' == args.command: - list_calendars(service) - elif 'create' == args.command: - create_calendar(service, args.summary, args.timezone, args.public) - elif 'add_owner' == args.command: - add_owner(service, args.id, args.owner_email) - elif 'remove' == args.command: - remove_calendar(service, args.id) - elif 'rename' == args.command: - rename_calendar(service, args.id, args.summary) - -if __name__ == '__main__': - main() +import argparse +import datetime +import logging.config + +import yaml +from pytz import utc + +from . import GoogleCalendar, GoogleCalendarService + + +def parse_args(): + parser = argparse.ArgumentParser( + description="manage google calendars in service account") + command_subparsers = parser.add_subparsers(help='command', dest='command') + command_subparsers.add_parser('list', help='list calendars') + parser_create = command_subparsers.add_parser( + 'create', help='create calendar') + parser_create.add_argument( + 'summary', action='store', help='new calendar summary') + parser_create.add_argument('--timezone', action='store', + default=None, required=False, help='new calendar timezone') + parser_create.add_argument( + '--public', default=False, action='store_true', help='make calendar public') + parser_add_owner = command_subparsers.add_parser( + 'add_owner', help='add owner to calendar') + parser_add_owner.add_argument('id', action='store', help='calendar id') + parser_add_owner.add_argument( + 'owner_email', action='store', help='new owner email') + parser_remove = command_subparsers.add_parser( + 'remove', help='remove calendar') + parser_remove.add_argument( + 'id', action='store', help='calendar id to remove') + parser_rename = command_subparsers.add_parser( + 'rename', help='rename calendar') + parser_rename.add_argument( + 'id', action='store', help='calendar id') + parser_rename.add_argument( + 'summary', action='store', help='new summary') + + args = parser.parse_args() + if args.command is None: + parser.print_usage() + return args + + +def load_config(): + with open('config.yml', 'r', encoding='utf-8') as f: + result = yaml.safe_load(f) + return result + + +def list_calendars(service): + response = service.calendarList().list(fields='items(id,summary)').execute() + for calendar in response.get('items'): + print('{summary}: {id}'.format_map(calendar)) + + +def create_calendar(service, summary, timezone, public): + calendar = GoogleCalendar(service, None) + calendar.create(summary, timezone) + if public: + calendar.make_public() + print('{}: {}'.format(summary, calendar.calendarId)) + + +def add_owner(service, id, owner_email): + calendar = GoogleCalendar(service, id) + calendar.add_owner(owner_email) + print('to {} added owner: {}'.format(id, owner_email)) + + +def remove_calendar(service, id): + calendar = GoogleCalendar(service, id) + calendar.delete() + print('removed: {}'.format(id)) + +def rename_calendar(service, id, summary): + calendar = {'summary': summary} + service.calendars().patch(body=calendar, calendarId=id).execute() + print('{}: {}'.format(summary, id)) + +def main(): + args = parse_args() + config = load_config() + + if 'logging' in config: + logging.config.dictConfig(config['logging']) + + srv_acc_file = config['service_account'] + service = GoogleCalendarService.from_srv_acc_file(srv_acc_file) + + if 'list' == args.command: + list_calendars(service) + elif 'create' == args.command: + create_calendar(service, args.summary, args.timezone, args.public) + elif 'add_owner' == args.command: + add_owner(service, args.id, args.owner_email) + elif 'remove' == args.command: + remove_calendar(service, args.id) + elif 'rename' == args.command: + rename_calendar(service, args.id, args.summary) + +if __name__ == '__main__': + main() diff --git a/gcal_sync/sync.py b/sync_ics2gcal/sync.py similarity index 97% rename from gcal_sync/sync.py rename to sync_ics2gcal/sync.py index 6ca4fc8..de95836 100644 --- a/gcal_sync/sync.py +++ b/sync_ics2gcal/sync.py @@ -1,173 +1,173 @@ -import datetime -import dateutil.parser -import logging -import operator -from pytz import utc - - -class CalendarSync(): - """class for syncronize calendar with google - """ - - logger = logging.getLogger('CalendarSync') - - def __init__(self, gcalendar, converter): - self.gcalendar = gcalendar - self.converter = converter - - @staticmethod - def _events_list_compare(items_src, items_dst, key='iCalUID'): - """ compare list of events by key - - Arguments: - items_src {list of dict} -- source events - items_dst {list of dict} -- dest events - key {str} -- name of key to compare (default: {'iCalUID'}) - - Returns: - tuple -- (items_to_insert, - items_to_update, - items_to_delete) - """ - - def get_key(item): return item[key] - - keys_src = set(map(get_key, items_src)) - keys_dst = set(map(get_key, items_dst)) - - keys_to_insert = keys_src - keys_dst - keys_to_update = keys_src & keys_dst - keys_to_delete = keys_dst - keys_src - - def items_by_keys(items, key_name, keys): - return list(filter(lambda item: item[key_name] in keys, items)) - - items_to_insert = items_by_keys(items_src, key, keys_to_insert) - items_to_delete = items_by_keys(items_dst, key, keys_to_delete) - - to_upd_src = items_by_keys(items_src, key, keys_to_update) - to_upd_dst = items_by_keys(items_dst, key, keys_to_update) - to_upd_src.sort(key=get_key) - to_upd_dst.sort(key=get_key) - items_to_update = list(zip(to_upd_src, to_upd_dst)) - - return items_to_insert, items_to_update, items_to_delete - - def _filter_events_to_update(self): - """ filter 'to_update' events by 'updated' datetime - """ - - def filter_updated(event_tuple): - new, old = event_tuple - return dateutil.parser.parse(new['updated']) > dateutil.parser.parse(old['updated']) - - self.to_update = list(filter(filter_updated, self.to_update)) - - @staticmethod - def _filter_events_by_date(events, date, op): - """ filter events by start datetime - - Arguments: - events -- events list - date {datetime} -- datetime to compare - op {operator} -- comparsion operator - - Returns: - list of filtred events - """ - - def filter_by_date(event): - date_cmp = date - event_start = event['start'] - event_date = None - compare_dates = False - - if 'date' in event_start: - event_date = event_start['date'] - compare_dates = True - elif 'dateTime' in event_start: - event_date = event_start['dateTime'] - - event_date = dateutil.parser.parse(event_date) - if compare_dates: - date_cmp = datetime.date(date.year, date.month, date.day) - event_date = datetime.date(event_date.year, event_date.month, event_date.day) - - return op(event_date, date_cmp) - - return list(filter(filter_by_date, events)) - - @staticmethod - def _tz_aware_datetime(date): - """make tz aware datetime from datetime/date (utc if no tzinfo) - - Arguments: - date - date or datetime / with or without tzinfo - - Returns: - datetime with tzinfo - """ - - if not isinstance(date, datetime.datetime): - date = datetime.datetime(date.year, date.month, date.day) - if date.tzinfo is None: - date = date.replace(tzinfo=utc) - return date - - def prepare_sync(self, start_date): - """prepare sync lists by comparsion of events - - Arguments: - start_date -- date/datetime to start sync - """ - - start_date = CalendarSync._tz_aware_datetime(start_date) - - events_src = self.converter.events_to_gcal() - events_dst = self.gcalendar.list_events_from(start_date) - - # divide source events by start datetime - events_src_pending = CalendarSync._filter_events_by_date( - events_src, start_date, operator.ge) - events_src_past = CalendarSync._filter_events_by_date( - events_src, start_date, operator.lt) - - events_src = None - - # first events comparsion - self.to_insert, self.to_update, self.to_delete = CalendarSync._events_list_compare( - events_src_pending, events_dst) - - events_src_pending, events_dst = None, None - - # find in events 'to_delete' past events from source, for update (move to past) - _, add_to_update, self.to_delete = CalendarSync._events_list_compare( - events_src_past, self.to_delete) - self.to_update.extend(add_to_update) - - events_src_past = None - - # find if events 'to_insert' exists in gcalendar, for update them - add_to_update, self.to_insert = self.gcalendar.find_exists( - self.to_insert) - self.to_update.extend(add_to_update) - - add_to_update = None - - # exclude outdated events from 'to_update' list, by 'updated' field - self._filter_events_to_update() - - self.logger.info('prepared to sync: ( insert: %d, update: %d, delete: %d )', - len(self.to_insert), len(self.to_update), len(self.to_delete)) - - def apply(self): - """apply sync (insert, update, delete), using prepared lists of events - """ - - self.gcalendar.insert_events(self.to_insert) - self.gcalendar.update_events(self.to_update) - self.gcalendar.delete_events(self.to_delete) - - self.logger.info('sync done') - - self.to_insert, self.to_update, self.to_delete = [], [], [] +import datetime +import dateutil.parser +import logging +import operator +from pytz import utc + + +class CalendarSync(): + """class for syncronize calendar with google + """ + + logger = logging.getLogger('CalendarSync') + + def __init__(self, gcalendar, converter): + self.gcalendar = gcalendar + self.converter = converter + + @staticmethod + def _events_list_compare(items_src, items_dst, key='iCalUID'): + """ compare list of events by key + + Arguments: + items_src {list of dict} -- source events + items_dst {list of dict} -- dest events + key {str} -- name of key to compare (default: {'iCalUID'}) + + Returns: + tuple -- (items_to_insert, + items_to_update, + items_to_delete) + """ + + def get_key(item): return item[key] + + keys_src = set(map(get_key, items_src)) + keys_dst = set(map(get_key, items_dst)) + + keys_to_insert = keys_src - keys_dst + keys_to_update = keys_src & keys_dst + keys_to_delete = keys_dst - keys_src + + def items_by_keys(items, key_name, keys): + return list(filter(lambda item: item[key_name] in keys, items)) + + items_to_insert = items_by_keys(items_src, key, keys_to_insert) + items_to_delete = items_by_keys(items_dst, key, keys_to_delete) + + to_upd_src = items_by_keys(items_src, key, keys_to_update) + to_upd_dst = items_by_keys(items_dst, key, keys_to_update) + to_upd_src.sort(key=get_key) + to_upd_dst.sort(key=get_key) + items_to_update = list(zip(to_upd_src, to_upd_dst)) + + return items_to_insert, items_to_update, items_to_delete + + def _filter_events_to_update(self): + """ filter 'to_update' events by 'updated' datetime + """ + + def filter_updated(event_tuple): + new, old = event_tuple + return dateutil.parser.parse(new['updated']) > dateutil.parser.parse(old['updated']) + + self.to_update = list(filter(filter_updated, self.to_update)) + + @staticmethod + def _filter_events_by_date(events, date, op): + """ filter events by start datetime + + Arguments: + events -- events list + date {datetime} -- datetime to compare + op {operator} -- comparsion operator + + Returns: + list of filtred events + """ + + def filter_by_date(event): + date_cmp = date + event_start = event['start'] + event_date = None + compare_dates = False + + if 'date' in event_start: + event_date = event_start['date'] + compare_dates = True + elif 'dateTime' in event_start: + event_date = event_start['dateTime'] + + event_date = dateutil.parser.parse(event_date) + if compare_dates: + date_cmp = datetime.date(date.year, date.month, date.day) + event_date = datetime.date(event_date.year, event_date.month, event_date.day) + + return op(event_date, date_cmp) + + return list(filter(filter_by_date, events)) + + @staticmethod + def _tz_aware_datetime(date): + """make tz aware datetime from datetime/date (utc if no tzinfo) + + Arguments: + date - date or datetime / with or without tzinfo + + Returns: + datetime with tzinfo + """ + + if not isinstance(date, datetime.datetime): + date = datetime.datetime(date.year, date.month, date.day) + if date.tzinfo is None: + date = date.replace(tzinfo=utc) + return date + + def prepare_sync(self, start_date): + """prepare sync lists by comparsion of events + + Arguments: + start_date -- date/datetime to start sync + """ + + start_date = CalendarSync._tz_aware_datetime(start_date) + + events_src = self.converter.events_to_gcal() + events_dst = self.gcalendar.list_events_from(start_date) + + # divide source events by start datetime + events_src_pending = CalendarSync._filter_events_by_date( + events_src, start_date, operator.ge) + events_src_past = CalendarSync._filter_events_by_date( + events_src, start_date, operator.lt) + + events_src = None + + # first events comparsion + self.to_insert, self.to_update, self.to_delete = CalendarSync._events_list_compare( + events_src_pending, events_dst) + + events_src_pending, events_dst = None, None + + # find in events 'to_delete' past events from source, for update (move to past) + _, add_to_update, self.to_delete = CalendarSync._events_list_compare( + events_src_past, self.to_delete) + self.to_update.extend(add_to_update) + + events_src_past = None + + # find if events 'to_insert' exists in gcalendar, for update them + add_to_update, self.to_insert = self.gcalendar.find_exists( + self.to_insert) + self.to_update.extend(add_to_update) + + add_to_update = None + + # exclude outdated events from 'to_update' list, by 'updated' field + self._filter_events_to_update() + + self.logger.info('prepared to sync: ( insert: %d, update: %d, delete: %d )', + len(self.to_insert), len(self.to_update), len(self.to_delete)) + + def apply(self): + """apply sync (insert, update, delete), using prepared lists of events + """ + + self.gcalendar.insert_events(self.to_insert) + self.gcalendar.update_events(self.to_update) + self.gcalendar.delete_events(self.to_delete) + + self.logger.info('sync done') + + self.to_insert, self.to_update, self.to_delete = [], [], [] diff --git a/sync-calendar.py b/sync_ics2gcal/sync_calendar.py similarity index 93% rename from sync-calendar.py rename to sync_ics2gcal/sync_calendar.py index 6df9a79..37f5805 100644 --- a/sync-calendar.py +++ b/sync_ics2gcal/sync_calendar.py @@ -1,53 +1,52 @@ -import yaml - -import dateutil.parser -import datetime -import logging -import logging.config -from gcal_sync import ( - CalendarConverter, - GoogleCalendarService, - GoogleCalendar, - CalendarSync -) - - -def load_config(): - with open('config.yml', 'r', encoding='utf-8') as f: - result = yaml.safe_load(f) - return result - - -def get_start_date(date_str): - result = datetime.datetime(1,1,1) - if 'now' == date_str: - result = datetime.datetime.utcnow() - else: - result = dateutil.parser.parse(date_str) - return result - - -def main(): - config = load_config() - - if 'logging' in config: - logging.config.dictConfig(config['logging']) - - calendarId = config['calendar']['google_id'] - ics_filepath = config['calendar']['source'] - srv_acc_file = config['service_account'] - - start = get_start_date(config['start_from']) - - converter = CalendarConverter() - converter.load(ics_filepath) - - service = GoogleCalendarService.from_srv_acc_file(srv_acc_file) - gcalendar = GoogleCalendar(service, calendarId) - - sync = CalendarSync(gcalendar, converter) - sync.prepare_sync(start) - sync.apply() - -if __name__ == '__main__': - main() +import yaml + +import dateutil.parser +import datetime +import logging +import logging.config +from . import ( + CalendarConverter, + GoogleCalendarService, + GoogleCalendar, + CalendarSync +) + +def load_config(): + with open('config.yml', 'r', encoding='utf-8') as f: + result = yaml.safe_load(f) + return result + + +def get_start_date(date_str): + result = datetime.datetime(1,1,1) + if 'now' == date_str: + result = datetime.datetime.utcnow() + else: + result = dateutil.parser.parse(date_str) + return result + + +def main(): + config = load_config() + + if 'logging' in config: + logging.config.dictConfig(config['logging']) + + calendarId = config['calendar']['google_id'] + ics_filepath = config['calendar']['source'] + srv_acc_file = config['service_account'] + + start = get_start_date(config['start_from']) + + converter = CalendarConverter() + converter.load(ics_filepath) + + service = GoogleCalendarService.from_srv_acc_file(srv_acc_file) + gcalendar = GoogleCalendar(service, calendarId) + + sync = CalendarSync(gcalendar, converter) + sync.prepare_sync(start) + sync.apply() + +if __name__ == '__main__': + main() diff --git a/tests/test_converter.py b/tests/test_converter.py index c5bb563..2aadde6 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,6 +1,6 @@ import pytest -from gcal_sync import CalendarConverter +from sync_ics2gcal import CalendarConverter uid = "UID:uisgtr8tre93wewe0yr8wqy@test.com" only_start_date = uid + """ diff --git a/tests/test_sync.py b/tests/test_sync.py index 52c2099..3201929 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -8,7 +8,7 @@ import dateutil.parser import pytest from pytz import timezone, utc -from gcal_sync import CalendarSync +from sync_ics2gcal import CalendarSync def sha1(string):