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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..700d735 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + time: '02:00' + open-pull-requests-limit: 10 + target-branch: develop diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..c72037a --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,38 @@ +name: "CodeQL" + +on: + push: + branches: [develop, ] + pull_request: + # The branches below must be a subset of the branches above + branches: [develop] + schedule: + - cron: '0 12 10 * *' + +jobs: + analyse: + name: Analyse + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + # Override language selection by uncommenting this and choosing your languages + with: + languages: python + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 55c17b1..30d2477 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -17,7 +17,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.5, 3.6, 3.7, 3.8] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v1 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..b5c5b91 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ config.yml service-account.json *.pyc my-test*.ics -.vscode/* +.vscode/ +.idea/ +/dist/ +/*.egg-info/ +/build/ +/.eggs/ +venv/ diff --git a/.travis.yml b/.travis.yml index f913379..09d1177 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ language: python python: - - "3.5" - "3.6" - "3.7" - - "3.8" + - "3.8" + - "3.9" script: - pytest -v 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/README.md b/README.md index 79d7c92..90dd467 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,96 @@ # sync_ics2gcal +[](https://badge.fury.io/py/sync-ics2gcal) [](https://travis-ci.org/b4tman/sync_ics2gcal) -[](https://dependabot.com) [](https://app.fossa.io/projects/git%2Bgithub.com%2Fb4tman%2Fsync_ics2gcal?ref=badge_shield)  Python scripts for sync .ics file with Google calendar +## Installation + +To install from [PyPI](https://pypi.org/project/sync-ics2gcal/) with [pip](https://pypi.python.org/pypi/pip), run: + +```sh +pip install sync-ics2gcal +``` + +Or download source code and install: + +```sh +python setup.py install +``` + +## Configuration + +### Create application in Google API Console + +1. Create a new project: [console.developers.google.com/project](https://console.developers.google.com/project) +2. Choose the new project from the top right project dropdown (only if another project is selected) +3. In the project Dashboard, choose "Library" +4. Find and Enable "Google Calendar API" +5. In the project Dashboard, choose "Credentials" +6. In the "Service Accounts" group, click to "Manage service accounts" +7. Click "Create service account" +8. Choose service account name and ID +9. Go back to "Service Accounts" group in "Credentials" +10. Edit service account and click "Create key", choose JSON and download key file. + +### Create working directory + +For example: `/home/user/myfolder`. + +1. Save service account key in file `service-account.json`. +2. Download [sample config](https://github.com/b4tman/sync_ics2gcal/blob/develop/sample-config.yml) and save to file `config.yml`. For example: + +```sh +wget https://raw.githubusercontent.com/b4tman/sync_ics2gcal/develop/sample-config.yml -O config.yml +``` + +3. *(Optional)* Place source `.ics` file, `my-calendar.ics` for example. + +### Configuration parameters + +* `start_from` - start date: + * full format datetime, `2018-04-03T13:23:25.000001Z` for example + * or just `now` +* *(Optional)* `service_account` - service account filename, remove it from config to use [default credentials](https://developers.google.com/identity/protocols/application-default-credentials) +* *(Optional)* `logging` - [config](https://docs.python.org/3.8/library/logging.config.html#dictionary-schema-details) to setup logging +* `google_id` - target google calendar id, `my-calendar@group.calendar.google.com` for example +* `source` - source `.ics` filename, `my-calendar.ics` for example + +## Usage + +### Manage calendars + +```sh +manage-ics2gcal GROUP | COMMAND +``` + +**GROUPS**: + +* **property** - get/set properties (see [CalendarList resource](https://developers.google.com/calendar/v3/reference/calendarList#resource)), subcommands: + - **get** - get calendar property + - **set** - set calendar property + +**COMMANDS**: + +* **list** - list calendars +* **create** - create calendar +* **add_owner** - add owner to calendar +* **remove** - remove calendar +* **rename** - rename calendar + + +Use **-h** for more info. + +### Sync calendar + +just type: + +```sh +sync-ics2gcal +``` + ## How it works  - - -## License -[](https://app.fossa.io/projects/git%2Bgithub.com%2Fb4tman%2Fsync_ics2gcal?ref=badge_large) \ No newline at end of file diff --git a/manage-calendars.py b/manage-calendars.py deleted file mode 100644 index e528c76..0000000 --- a/manage-calendars.py +++ /dev/null @@ -1,104 +0,0 @@ -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() 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/requirements.txt b/requirements.txt index 5369fe9..08659f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -google-auth==1.11.0 -google-api-python-client==1.7.11 -icalendar==4.0.4 -pytz==2019.3 -PyYAML==5.3 +google-auth==1.30.0 +google-api-python-client==2.3.0 +icalendar==4.0.7 +pytz==2021.1 +PyYAML==5.4.1 +fire==0.4.0 \ No newline at end of file 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..bd6dc4c --- /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.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], + python_requires='>=3.6', + 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 62% rename from gcal_sync/gcal.py rename to sync_ics2gcal/gcal.py index 4d02e14..6c96cbd 100644 --- a/gcal_sync/gcal.py +++ b/sync_ics2gcal/gcal.py @@ -1,283 +1,341 @@ -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 google.auth +from google.oauth2 import service_account +from googleapiclient import discovery +from pytz import utc +from datetime import datetime +from typing import List, Dict, Any, Callable, Tuple, Optional + + +class GoogleCalendarService: + """class for make google calendar service Resource + + Returns: + service Resource + """ + + @staticmethod + def default(): + """make service Resource from default credentials (authorize) + ( https://developers.google.com/identity/protocols/application-default-credentials ) + ( https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.default ) + """ + + scopes = ['https://www.googleapis.com/auth/calendar'] + credentials, _ = google.auth.default(scopes=scopes) + service = discovery.build( + 'calendar', 'v3', credentials=credentials, cache_discovery=False) + return service + + @staticmethod + def from_srv_acc_file(service_account_file: str): + """make service Resource from service account filename (authorize) + """ + + 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, + cache_discovery=False) + return service + + @staticmethod + def from_config(config: Optional[Dict[str, Optional[str]]] = None): + """make service Resource from config dict + + Arguments: + config -- config with keys: + (optional) service_account: - service account filename + if key not in dict then default credentials will be used + ( https://developers.google.com/identity/protocols/application-default-credentials ) + -- None: default credentials will be used + """ + + if config is not None and 'service_account' in config: + service = GoogleCalendarService.from_srv_acc_file( + config['service_account']) + else: + service = GoogleCalendarService.default() + return service + + +def select_event_key(event: Dict[str, Any]) -> Optional[str]: + """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: discovery.Resource, calendarId: Optional[str]): + self.service: discovery.Resource = service + self.calendarId: str = calendarId + + def _make_request_callback(self, action: str, events_by_req: List[Dict[str, Any]]) -> Callable: + """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: datetime) -> List[Dict[str, Any]]: + """ 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: List) -> Tuple[List[Tuple[Dict[str, Any], Dict[str, Any]]], List[Dict[str, Any]]]: + """ 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 + cur_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), cur_event['iCalUID']) + if found: + exists.append( + (cur_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: List[Dict[str, Any]]): + """ 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: List[Tuple[Dict[str, Any], Dict[str, Any]]]): + """ 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: List[Tuple[Dict[str, Any], Dict[str, Any]]]): + """ 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: List[Dict[str, Any]]): + """ 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: str, timeZone: Optional[str] = None) -> Any: + """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: str): + """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 73% rename from gcal_sync/ical.py rename to sync_ics2gcal/ical.py index ec21b8d..678c471 100644 --- a/gcal_sync/ical.py +++ b/sync_ics2gcal/ical.py @@ -1,184 +1,195 @@ -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 typing import Union, Dict, Any, Callable, Optional, List + +from icalendar import Calendar, Event +from pytz import utc + + +def format_datetime_utc(value: Union[datetime.date, datetime.datetime]) -> str: + """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: Union[datetime.date, datetime.datetime], + check_value: Union[datetime.date, datetime.datetime, None] = None)\ + -> Dict[str, str]: + """date or dateTime to gcal (start or end dict) + + Arguments: + value: date or datetime + check_value: optional for choose result type + + Returns: + { 'date': ... } or { 'dateTime': ... } + """ + + if check_value is None: + check_value = value + + result: Dict[str, str] = {} + 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: str) -> str: + """decoded string property + + Arguments: + prop - propperty name + + Returns: + string value + """ + + return self.decoded(prop).decode(encoding='utf-8') + + def _datetime_str_prop(self, prop: str) -> str: + """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) -> Dict[str, str]: + """ 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) -> Dict[str, str]: + """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: Dict[str, Any], + prop: str, func: Callable[[str], str], + ics_prop: Optional[str] = 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) -> Dict[str, Any]: + """Convert + + Returns: + dict - google calendar#event resource + """ + + event = { + 'iCalUID': self._str_prop('UID'), + 'start': self._gcal_start(), + '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: Optional[Calendar] = None): + self.calendar: Optional[Calendar] = calendar + + def load(self, filename: str): + """ 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: str): + """ load calendar from ics string + """ + self.calendar = Calendar.from_ical(string) + + def events_to_gcal(self) -> List[Dict[str, Any]]: + """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/sync_ics2gcal/manage_calendars.py b/sync_ics2gcal/manage_calendars.py new file mode 100644 index 0000000..f3e9cdc --- /dev/null +++ b/sync_ics2gcal/manage_calendars.py @@ -0,0 +1,144 @@ +import logging.config +from typing import Optional, Dict, Any, List + +import fire +import yaml + +from . import GoogleCalendar, GoogleCalendarService + + +def load_config(filename: str) -> Optional[Dict[str, Any]]: + result = None + try: + with open(filename, 'r', encoding='utf-8') as f: + result = yaml.safe_load(f) + except FileNotFoundError: + pass + + return result + + +class PropertyCommands: + """ get/set google calendar properties """ + + def __init__(self, _service): + self._service = _service + + def get(self, calendar_id: str, property_name: str) -> None: + """ get calendar property + + Args: + calendar_id: calendar id + property_name: property key + """ + response = self._service.calendarList().get(calendarId=calendar_id, + fields=property_name).execute() + print(response.get(property_name)) + + def set(self, calendar_id: str, property_name: str, property_value: str) -> None: + """ set calendar property + + Args: + calendar_id: calendar id + property_name: property key + property_value: property value + """ + body = {property_name: property_value} + response = self._service.calendarList().patch(body=body, calendarId=calendar_id).execute() + print(response) + + +class Commands: + """ manage google calendars in service account """ + + def __init__(self, config: str = 'config.yml'): + """ + + Args: + config(str): config filename + """ + self._config: Optional[Dict[str, Any]] = load_config(config) + if self._config is not None and 'logging' in self._config: + logging.config.dictConfig(self._config['logging']) + self._service = GoogleCalendarService.from_config(self._config) + self.property = PropertyCommands(self._service) + + def list(self, show_hidden: bool = False, show_deleted: bool = False) -> None: + """ list calendars + + Args: + show_hidden: show hidden calendars + show_deleted: show deleted calendars + """ + + fields: str = 'nextPageToken,items(id,summary)' + calendars: List[Dict[str, Any]] = [] + page_token: Optional[str] = None + while True: + calendars_api = self._service.calendarList() + response = calendars_api.list(fields=fields, + pageToken=page_token, + showHidden=show_hidden, + showDeleted=show_deleted + ).execute() + if 'items' in response: + calendars.extend(response['items']) + page_token = response.get('nextPageToken') + if page_token is None: + break + for calendar in calendars: + print('{summary}: {id}'.format_map(calendar)) + + def create(self, summary: str, timezone: Optional[str] = None, public: bool = False) -> None: + """ create calendar + + Args: + summary: new calendar summary + timezone: new calendar timezone + public: make calendar public + """ + calendar = GoogleCalendar(self._service, None) + calendar.create(summary, timezone) + if public: + calendar.make_public() + print('{}: {}'.format(summary, calendar.calendarId)) + + def add_owner(self, calendar_id: str, email: str) -> None: + """ add owner to calendar + + Args: + calendar_id: calendar id + email: new owner email + """ + calendar = GoogleCalendar(self._service, calendar_id) + calendar.add_owner(email) + print('to {} added owner: {}'.format(calendar_id, email)) + + def remove(self, calendar_id: str) -> None: + """ remove calendar + + Args: + calendar_id: calendar id + """ + calendar = GoogleCalendar(self._service, calendar_id) + calendar.delete() + print('removed: {}'.format(calendar_id)) + + def rename(self, calendar_id: str, summary: str) -> None: + """ rename calendar + + Args: + calendar_id: calendar id + summary: + """ + calendar = {'summary': summary} + self._service.calendars().patch(body=calendar, calendarId=calendar_id).execute() + print('{}: {}'.format(summary, calendar_id)) + + +def main(): + fire.Fire(Commands, name='manage-ics2gcal') + + +if __name__ == '__main__': + main() diff --git a/gcal_sync/sync.py b/sync_ics2gcal/sync.py similarity index 60% rename from gcal_sync/sync.py rename to sync_ics2gcal/sync.py index 6ca4fc8..475ad95 100644 --- a/gcal_sync/sync.py +++ b/sync_ics2gcal/sync.py @@ -1,173 +1,195 @@ -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 logging +import operator +from typing import List, Any, Dict, Set, Tuple, Union, Callable + +import dateutil.parser +from pytz import utc + +from .gcal import GoogleCalendar +from .ical import CalendarConverter + + +class CalendarSync: + """class for syncronize calendar with google + """ + + logger = logging.getLogger('CalendarSync') + + def __init__(self, gcalendar: GoogleCalendar, converter: CalendarConverter): + self.gcalendar: GoogleCalendar = gcalendar + self.converter: CalendarConverter = converter + self.to_insert: List[Dict[str, Any]] = [] + self.to_update: List[Tuple[Dict[str, Any], Dict[str, Any]]] = [] + self.to_delete: List[Dict[str, Any]] = [] + + @staticmethod + def _events_list_compare(items_src: List[Dict[str, Any]], + items_dst: List[Dict[str, Any]], + key: str = 'iCalUID') \ + -> Tuple[List[Dict[str, Any]], List[Tuple[Dict[str, Any], Dict[str, Any]]], List[Dict[str, Any]]]: + """ 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: Dict[str, Any]) -> str: return item[key] + + keys_src: Set[str] = set(map(get_key, items_src)) + keys_dst: Set[str] = 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: List[Dict[str, Any]], + key_name: str, + keys: Set[str]) -> List[Dict[str, Any]]: + 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: Tuple[Dict[str, Any], Dict[str, Any]]) -> bool: + new, old = event_tuple + new_date = dateutil.parser.parse(new['updated']) + old_date = dateutil.parser.parse(old['updated']) + return new_date > old_date + + self.to_update = list(filter(filter_updated, self.to_update)) + + @staticmethod + def _filter_events_by_date(events: List[Dict[str, Any]], + date: Union[datetime.date, datetime.datetime], + op: Callable[[Union[datetime.date, datetime.datetime], + Union[datetime.date, datetime.datetime]], bool]) -> List[Dict[str, Any]]: + """ 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: Dict[str, Any]) -> bool: + date_cmp = date + event_start: Dict[str, str] = event['start'] + event_date: Union[datetime.date, datetime.datetime, str, None] = 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: Union[datetime.date, datetime.datetime]) -> datetime.datetime: + """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: Union[datetime.date, datetime.datetime]) -> None: + """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) + + # first events comparsion + self.to_insert, self.to_update, self.to_delete = CalendarSync._events_list_compare( + events_src_pending, events_dst) + + # 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) + + # 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) + + # 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 clear(self) -> None: + """ clear prepared sync lists (insert, update, delete) + """ + self.to_insert.clear() + self.to_update.clear() + self.to_delete.clear() + + def apply(self) -> None: + """ 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.clear() + + self.logger.info('sync done') diff --git a/sync-calendar.py b/sync_ics2gcal/sync_calendar.py similarity index 69% rename from sync-calendar.py rename to sync_ics2gcal/sync_calendar.py index 6df9a79..2e90b6a 100644 --- a/sync-calendar.py +++ b/sync_ics2gcal/sync_calendar.py @@ -1,53 +1,54 @@ -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() +from typing import Dict, Any + +import yaml + +import dateutil.parser +import datetime +import logging +import logging.config +from . import ( + CalendarConverter, + GoogleCalendarService, + GoogleCalendar, + CalendarSync +) + + +def load_config() -> Dict[str, Any]: + with open('config.yml', 'r', encoding='utf-8') as f: + result = yaml.safe_load(f) + return result + + +def get_start_date(date_str: str) -> datetime.datetime: + 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: str = config['calendar']['google_id'] + ics_filepath: str = config['calendar']['source'] + + start = get_start_date(config['start_from']) + + converter = CalendarConverter() + converter.load(ics_filepath) + + service = GoogleCalendarService.from_config(config) + 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..37a9f97 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,6 +1,8 @@ +from typing import Tuple + import pytest -from gcal_sync import CalendarConverter +from sync_ics2gcal import CalendarConverter uid = "UID:uisgtr8tre93wewe0yr8wqy@test.com" only_start_date = uid + """ @@ -26,11 +28,11 @@ LAST-MODIFIED:20180326T120235Z """ -def ics_test_cal(content): +def ics_test_cal(content: str) -> str: return "BEGIN:VCALENDAR\r\n{}END:VCALENDAR\r\n".format(content) -def ics_test_event(content): +def ics_test_event(content: str) -> str: return ics_test_cal("BEGIN:VEVENT\r\n{}END:VEVENT\r\n".format(content)) @@ -68,7 +70,7 @@ def param_events_start_end(request): return request.param -def test_event_start_end(param_events_start_end): +def test_event_start_end(param_events_start_end: Tuple[str, str, str, str]): (date_type, ics_str, start, end) = param_events_start_end converter = CalendarConverter() converter.loads(ics_str) diff --git a/tests/test_sync.py b/tests/test_sync.py index 52c2099..f480260 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -3,15 +3,16 @@ import hashlib import operator from copy import deepcopy from random import shuffle +from typing import Union, List, Dict, Optional import dateutil.parser import pytest from pytz import timezone, utc -from gcal_sync import CalendarSync +from sync_ics2gcal import CalendarSync -def sha1(string): +def sha1(string: Union[str, bytes]) -> str: if isinstance(string, str): string = string.encode('utf8') h = hashlib.sha1() @@ -19,55 +20,57 @@ def sha1(string): return h.hexdigest() -def gen_events(start, stop, start_time, no_time=False): +def gen_events(start: int, + stop: int, + start_time: Union[datetime.datetime, datetime.date], + no_time: bool = False) -> List[Dict[str, Union[str, Dict[str, str]]]]: if no_time: start_time = datetime.date( start_time.year, start_time.month, start_time.day) - duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1) - date_key = "date" - suff = '' + duration: datetime.timedelta = datetime.date(1, 1, 2) - datetime.date(1, 1, 1) + date_key: str = "date" + date_end: str = '' else: start_time = utc.normalize( start_time.astimezone(utc)).replace(tzinfo=None) - duration = datetime.datetime( - 1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1) - date_key = "dateTime" - suff = 'Z' + duration: datetime.timedelta = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1) + date_key: str = "dateTime" + date_end: str = 'Z' - result = [] + result: List[Dict[str, Union[str, Dict[str, str]]]] = [] for i in range(start, stop): event_start = start_time + (duration * i) event_end = event_start + duration - updated = event_start + updated: Union[datetime.datetime, datetime.date] = event_start if no_time: updated = datetime.datetime( updated.year, updated.month, updated.day, 0, 0, 0, 1, tzinfo=utc) - event = { + event: Dict[str, Union[str, Dict[str, str]]] = { 'summary': 'test event __ {}'.format(i), 'location': 'la la la {}'.format(i), 'description': 'test TEST -- test event {}'.format(i), "iCalUID": "{}@test.com".format(sha1("test - event {}".format(i))), "updated": updated.isoformat() + 'Z', - "created": updated.isoformat() + 'Z' + "created": updated.isoformat() + 'Z', + 'start': {date_key: event_start.isoformat() + date_end}, + 'end': {date_key: event_end.isoformat() + date_end} } - event['start'] = {date_key: event_start.isoformat() + suff} - event['end'] = {date_key: event_end.isoformat() + suff} result.append(event) return result -def gen_list_to_compare(start, stop): - result = [] +def gen_list_to_compare(start: int, stop: int) -> List[Dict[str, str]]: + result: List[Dict[str, str]] = [] for i in range(start, stop): result.append({'iCalUID': 'test{:06d}'.format(i)}) return result -def get_start_date(event): - event_start = event['start'] - start_date = None +def get_start_date(event: Dict[str, Union[str, Dict[str, str]]]) -> Union[datetime.datetime, datetime.date]: + event_start: Dict[str, str] = event['start'] + start_date: Optional[str] = None is_date = False if 'date' in event_start: start_date = event_start['date'] @@ -113,7 +116,7 @@ def test_compare(): @pytest.mark.parametrize("no_time", [True, False], ids=['date', 'dateTime']) -def test_filter_events_by_date(no_time): +def test_filter_events_by_date(no_time: bool): msk = timezone('Europe/Moscow') now = utc.localize(datetime.datetime.utcnow()) msk_now = msk.normalize(now.astimezone(msk))