1
0
mirror of https://github.com/b4tman/sync_ics2gcal synced 2025-01-21 07:28:24 +00:00

Merge pull request #98 from b4tman/mypy

type checking with mypy
This commit is contained in:
Dmitry Belyaev 2022-06-04 22:57:22 +03:00 committed by GitHub
commit a18be3d079
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1367 additions and 1010 deletions

View File

@ -17,7 +17,7 @@ jobs:
strategy: strategy:
max-parallel: 4 max-parallel: 4
matrix: matrix:
python-version: ['3.7', '3.8', '3.9', '3.10'] python-version: ['3.8', '3.9', '3.10']
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -39,3 +39,18 @@ jobs:
poetry run flake8 sync_ics2gcal --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics poetry run flake8 sync_ics2gcal --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest - name: Test with pytest
run: poetry run pytest -v run: poetry run pytest -v
- name: Check type annotations with mypy
run: |
mkdir mypy_report
poetry run mypy --pretty --html-report mypy_report/ .
- name: Check type annotations with mypy strict mode (not failing)
run: |
poetry run mypy --strict --pretty . || true
- name: Upload mypy report
uses: actions/upload-artifact@v2
with:
name: mypy_report
path: mypy_report/

41
.github/workflows/reviewdog.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: reviewdog
on:
pull_request:
branches:
- master
- develop
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.x
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Install deps
run: poetry install
- name: setup mypy
run: |
mkdir tmp_bin/
echo "#!/bin/sh" > tmp_bin/mypy
echo "poetry run mypy \$@" >> tmp_bin/mypy
chmod +x tmp_bin/mypy
echo "$(pwd)/tmp_bin" >> $GITHUB_PATH
- uses: tsuyoshicho/action-mypy@v3
with:
reporter: github-pr-review
level: warning

5
.gitignore vendored
View File

@ -4,8 +4,13 @@ service-account.json
my-test*.ics my-test*.ics
.vscode/ .vscode/
.idea/ .idea/
.venv/
.pytest_cache/
.mypy_cache/
/dist/ /dist/
/*.egg-info/ /*.egg-info/
/build/ /build/
/.eggs/ /.eggs/
venv/ venv/
mypy_report/
tmp_bin/

1837
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,13 @@ keywords = ["icalendar", "sync", "google", "calendar"]
classifiers = [ classifiers = [
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.10',
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.8"
google-auth = "2.6.6" google-auth = "2.6.6"
google-api-python-client = "2.49.0" google-api-python-client = "2.49.0"
icalendar = "4.0.9" icalendar = "4.0.9"
@ -30,6 +29,11 @@ fire = "0.4.0"
pytest = "^7.1.2" pytest = "^7.1.2"
flake8 = "^4.0.1" flake8 = "^4.0.1"
black = "^22.3.0" black = "^22.3.0"
mypy = ">=0.960"
types-python-dateutil = "^2.8.17"
types-pytz = ">=2021.3.8"
types-PyYAML = "^6.0.8"
lxml = "^4.9.0"
[tool.poetry.scripts] [tool.poetry.scripts]
sync-ics2gcal = "sync_ics2gcal.sync_calendar:main" sync-ics2gcal = "sync_ics2gcal.sync_calendar:main"
@ -38,3 +42,12 @@ manage-ics2gcal = "sync_ics2gcal.manage_calendars:main"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[[tool.mypy.overrides]]
module = [
'icalendar',
'google.*',
'googleapiclient',
'fire'
]
ignore_missing_imports = true

View File

@ -6,6 +6,39 @@ from .gcal import (
EventData, EventData,
EventList, EventList,
EventTuple, EventTuple,
EventDataKey,
EventDateOrDateTime,
EventDate,
EventDateTime,
EventsSearchResults,
ACLRule,
ACLScope,
CalendarData,
BatchRequestCallback,
) )
from .sync import CalendarSync from .sync import CalendarSync, ComparedEvents
__all__ = [
"ical",
"gcal",
"sync",
"CalendarConverter",
"EventConverter",
"DateDateTime",
"GoogleCalendarService",
"GoogleCalendar",
"EventData",
"EventList",
"EventTuple",
"EventDataKey",
"EventDateOrDateTime",
"EventDate",
"EventDateTime",
"EventsSearchResults",
"ACLRule",
"ACLScope",
"CalendarData",
"CalendarSync",
"ComparedEvents",
]

View File

@ -1,17 +1,97 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any, Callable, Tuple, Optional, Union from typing import (
List,
Dict,
Any,
Callable,
Tuple,
Optional,
Union,
TypedDict,
Literal,
NamedTuple,
)
import google.auth import google.auth
from google.oauth2 import service_account from google.oauth2 import service_account
from googleapiclient import discovery from googleapiclient import discovery
from pytz import utc from pytz import utc
EventData = Dict[str, Union[str, "EventData", None]]
class EventDate(TypedDict, total=False):
date: str
timeZone: str
class EventDateTime(TypedDict, total=False):
dateTime: str
timeZone: str
EventDateOrDateTime = Union[EventDate, EventDateTime]
class ACLScope(TypedDict, total=False):
type: str
value: str
class ACLRule(TypedDict, total=False):
scope: ACLScope
role: str
class CalendarData(TypedDict, total=False):
id: str
summary: str
description: str
timeZone: str
class EventData(TypedDict, total=False):
id: str
summary: str
description: str
start: EventDateOrDateTime
end: EventDateOrDateTime
iCalUID: str
location: str
status: str
created: str
updated: str
sequence: int
transparency: str
visibility: str
EventDataKey = Union[
Literal["id"],
Literal["summary"],
Literal["description"],
Literal["start"],
Literal["end"],
Literal["iCalUID"],
Literal["location"],
Literal["status"],
Literal["created"],
Literal["updated"],
Literal["sequence"],
Literal["transparency"],
Literal["visibility"],
]
EventList = List[EventData] EventList = List[EventData]
EventTuple = Tuple[EventData, EventData] EventTuple = Tuple[EventData, EventData]
class EventsSearchResults(NamedTuple):
exists: List[EventTuple]
new: List[EventData]
BatchRequestCallback = Callable[[str, Any, Optional[Exception]], None]
class GoogleCalendarService: class GoogleCalendarService:
"""class for make google calendar service Resource """class for make google calendar service Resource
@ -20,7 +100,7 @@ class GoogleCalendarService:
""" """
@staticmethod @staticmethod
def default(): def default() -> discovery.Resource:
"""make service Resource from default credentials (authorize) """make service Resource from default credentials (authorize)
( https://developers.google.com/identity/protocols/application-default-credentials ) ( https://developers.google.com/identity/protocols/application-default-credentials )
( https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.default ) ( https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.default )
@ -34,7 +114,7 @@ class GoogleCalendarService:
return service return service
@staticmethod @staticmethod
def from_srv_acc_file(service_account_file: str): def from_srv_acc_file(service_account_file: str) -> discovery.Resource:
"""make service Resource from service account filename (authorize)""" """make service Resource from service account filename (authorize)"""
scopes = ["https://www.googleapis.com/auth/calendar"] scopes = ["https://www.googleapis.com/auth/calendar"]
@ -48,7 +128,7 @@ class GoogleCalendarService:
return service return service
@staticmethod @staticmethod
def from_config(config: Optional[Dict[str, Optional[str]]] = None): def from_config(config: Optional[Dict[str, str]] = None) -> discovery.Resource:
"""make service Resource from config dict """make service Resource from config dict
Arguments: Arguments:
@ -60,7 +140,8 @@ class GoogleCalendarService:
""" """
if config is not None and "service_account" in config: if config is not None and "service_account" in config:
service = GoogleCalendarService.from_srv_acc_file(config["service_account"]) service_account_filename: str = config["service_account"]
service = GoogleCalendarService.from_srv_acc_file(service_account_filename)
else: else:
service = GoogleCalendarService.default() service = GoogleCalendarService.default()
return service return service
@ -76,7 +157,7 @@ def select_event_key(event: EventData) -> Optional[str]:
key name or None if no key found key name or None if no key found
""" """
key = None key: Optional[str] = None
if "iCalUID" in event: if "iCalUID" in event:
key = "iCalUID" key = "iCalUID"
elif "id" in event: elif "id" in event:
@ -91,9 +172,11 @@ class GoogleCalendar:
def __init__(self, service: discovery.Resource, calendar_id: Optional[str]): def __init__(self, service: discovery.Resource, calendar_id: Optional[str]):
self.service: discovery.Resource = service self.service: discovery.Resource = service
self.calendar_id: str = calendar_id self.calendar_id: str = str(calendar_id)
def _make_request_callback(self, action: str, events_by_req: EventList) -> Callable: def _make_request_callback(
self, action: str, events_by_req: EventList
) -> BatchRequestCallback:
"""make callback for log result of batch request """make callback for log result of batch request
Arguments: Arguments:
@ -104,9 +187,12 @@ class GoogleCalendar:
callback function callback function
""" """
def callback(request_id, response, exception): def callback(
event = events_by_req[int(request_id)] request_id: str, response: Any, exception: Optional[Exception]
key = select_event_key(event) ) -> None:
event: EventData = events_by_req[int(request_id)]
event_key: Optional[str] = select_event_key(event)
key: str = event_key if event_key is not None else ""
if exception is not None: if exception is not None:
self.logger.error( self.logger.error(
@ -117,7 +203,7 @@ class GoogleCalendar:
str(exception), str(exception),
) )
else: else:
resp_key = select_event_key(response) resp_key: Optional[str] = select_event_key(response)
if resp_key is not None: if resp_key is not None:
event = response event = response
key = resp_key key = resp_key
@ -127,10 +213,10 @@ class GoogleCalendar:
def list_events_from(self, start: datetime) -> EventList: def list_events_from(self, start: datetime) -> EventList:
"""list events from calendar, where start date >= start""" """list events from calendar, where start date >= start"""
fields = "nextPageToken,items(id,iCalUID,updated)" fields: str = "nextPageToken,items(id,iCalUID,updated)"
events = [] events: EventList = []
page_token = None page_token: Optional[str] = None
time_min = ( time_min: str = (
utc.normalize(start.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z" utc.normalize(start.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z"
) )
while True: while True:
@ -153,25 +239,27 @@ class GoogleCalendar:
self.logger.info("%d events listed", len(events)) self.logger.info("%d events listed", len(events))
return events return events
def find_exists(self, events: List) -> Tuple[List[EventTuple], EventList]: def find_exists(self, events: EventList) -> EventsSearchResults:
"""find existing events from list, by 'iCalUID' field """find existing events from list, by 'iCalUID' field
Arguments: Arguments:
events {list} -- list of events events {list} -- list of events
Returns: Returns:
tuple -- (events_exist, events_not_found) EventsSearchResults -- (events_exist, events_not_found)
events_exist - list of tuples: (new_event, exists_event) events_exist - list of tuples: (new_event, exists_event)
""" """
fields = "items(id,iCalUID,updated)" fields: str = "items(id,iCalUID,updated)"
events_by_req = [] events_by_req: EventList = []
exists = [] exists: List[EventTuple] = []
not_found = [] not_found: EventList = []
def list_callback(request_id, response, exception): def list_callback(
found = False request_id: str, response: Any, exception: Optional[Exception]
cur_event = events_by_req[int(request_id)] ) -> None:
found: bool = False
cur_event: EventData = events_by_req[int(request_id)]
if exception is None: if exception is None:
found = [] != response["items"] found = [] != response["items"]
else: else:
@ -186,7 +274,7 @@ class GoogleCalendar:
not_found.append(events_by_req[int(request_id)]) not_found.append(events_by_req[int(request_id)])
batch = self.service.new_batch_http_request(callback=list_callback) batch = self.service.new_batch_http_request(callback=list_callback)
i = 0 i: int = 0
for event in events: for event in events:
events_by_req.append(event) events_by_req.append(event)
batch.add( batch.add(
@ -201,21 +289,21 @@ class GoogleCalendar:
i += 1 i += 1
batch.execute() batch.execute()
self.logger.info("%d events exists, %d not found", len(exists), len(not_found)) self.logger.info("%d events exists, %d not found", len(exists), len(not_found))
return exists, not_found return EventsSearchResults(exists, not_found)
def insert_events(self, events: EventList): def insert_events(self, events: EventList) -> None:
"""insert list of events """insert list of events
Arguments: Arguments:
events - events list events - events list
""" """
fields = "id" fields: str = "id"
events_by_req = [] events_by_req: EventList = []
insert_callback = self._make_request_callback("insert", events_by_req) insert_callback = self._make_request_callback("insert", events_by_req)
batch = self.service.new_batch_http_request(callback=insert_callback) batch = self.service.new_batch_http_request(callback=insert_callback)
i = 0 i: int = 0
for event in events: for event in events:
events_by_req.append(event) events_by_req.append(event)
batch.add( batch.add(
@ -227,19 +315,19 @@ class GoogleCalendar:
i += 1 i += 1
batch.execute() batch.execute()
def patch_events(self, event_tuples: List[EventTuple]): def patch_events(self, event_tuples: List[EventTuple]) -> None:
"""patch (update) events """patch (update) events
Arguments: Arguments:
event_tuples -- list of tuples: (new_event, exists_event) event_tuples -- list of tuples: (new_event, exists_event)
""" """
fields = "id" fields: str = "id"
events_by_req = [] events_by_req: EventList = []
patch_callback = self._make_request_callback("patch", events_by_req) patch_callback = self._make_request_callback("patch", events_by_req)
batch = self.service.new_batch_http_request(callback=patch_callback) batch = self.service.new_batch_http_request(callback=patch_callback)
i = 0 i: int = 0
for event_new, event_old in event_tuples: for event_new, event_old in event_tuples:
if "id" not in event_old: if "id" not in event_old:
continue continue
@ -254,19 +342,19 @@ class GoogleCalendar:
i += 1 i += 1
batch.execute() batch.execute()
def update_events(self, event_tuples: List[EventTuple]): def update_events(self, event_tuples: List[EventTuple]) -> None:
"""update events """update events
Arguments: Arguments:
event_tuples -- list of tuples: (new_event, exists_event) event_tuples -- list of tuples: (new_event, exists_event)
""" """
fields = "id" fields: str = "id"
events_by_req = [] events_by_req: EventList = []
update_callback = self._make_request_callback("update", events_by_req) update_callback = self._make_request_callback("update", events_by_req)
batch = self.service.new_batch_http_request(callback=update_callback) batch = self.service.new_batch_http_request(callback=update_callback)
i = 0 i: int = 0
for event_new, event_old in event_tuples: for event_new, event_old in event_tuples:
if "id" not in event_old: if "id" not in event_old:
continue continue
@ -283,18 +371,18 @@ class GoogleCalendar:
i += 1 i += 1
batch.execute() batch.execute()
def delete_events(self, events: EventList): def delete_events(self, events: EventList) -> None:
"""delete events """delete events
Arguments: Arguments:
events -- list of events events -- list of events
""" """
events_by_req = [] events_by_req: EventList = []
delete_callback = self._make_request_callback("delete", events_by_req) delete_callback = self._make_request_callback("delete", events_by_req)
batch = self.service.new_batch_http_request(callback=delete_callback) batch = self.service.new_batch_http_request(callback=delete_callback)
i = 0 i: int = 0
for event in events: for event in events:
events_by_req.append(event) events_by_req.append(event)
batch.add( batch.add(
@ -319,7 +407,7 @@ class GoogleCalendar:
calendar Resource calendar Resource
""" """
calendar = {"summary": summary} calendar = CalendarData(summary=summary)
if time_zone is not None: if time_zone is not None:
calendar["timeZone"] = time_zone calendar["timeZone"] = time_zone
@ -327,42 +415,27 @@ class GoogleCalendar:
self.calendar_id = created_calendar["id"] self.calendar_id = created_calendar["id"]
return created_calendar return created_calendar
def delete(self): def delete(self) -> None:
"""delete calendar""" """delete calendar"""
self.service.calendars().delete(calendarId=self.calendar_id).execute() self.service.calendars().delete(calendarId=self.calendar_id).execute()
def make_public(self): def make_public(self) -> None:
"""make calendar public""" """make calendar public"""
rule_public = { rule_public = ACLRule(scope=ACLScope(type="default"), role="reader")
"scope": { self.service.acl().insert(
"type": "default", calendarId=self.calendar_id, body=rule_public
}, ).execute()
"role": "reader",
}
return (
self.service.acl()
.insert(calendarId=self.calendar_id, body=rule_public)
.execute()
)
def add_owner(self, email: str): def add_owner(self, email: str) -> None:
"""add calendar owner by email """add calendar owner by email
Arguments: Arguments:
email -- email to add email -- email to add
""" """
rule_owner = { rule_owner = ACLRule(scope=ACLScope(type="user", value=email), role="owner")
"scope": { self.service.acl().insert(
"type": "user", calendarId=self.calendar_id, body=rule_owner
"value": email, ).execute()
},
"role": "owner",
}
return (
self.service.acl()
.insert(calendarId=self.calendar_id, body=rule_owner)
.execute()
)

View File

@ -1,11 +1,18 @@
import datetime import datetime
import logging import logging
from typing import Union, Dict, Callable, Optional from typing import Union, Dict, Callable, Optional, Mapping, TypedDict
from icalendar import Calendar, Event from icalendar import Calendar, Event
from pytz import utc from pytz import utc
from .gcal import EventData, EventList from .gcal import (
EventData,
EventList,
EventDateOrDateTime,
EventDateTime,
EventDate,
EventDataKey,
)
DateDateTime = Union[datetime.date, datetime.datetime] DateDateTime = Union[datetime.date, datetime.datetime]
@ -28,7 +35,7 @@ def format_datetime_utc(value: DateDateTime) -> str:
def gcal_date_or_datetime( def gcal_date_or_datetime(
value: DateDateTime, check_value: Optional[DateDateTime] = None value: DateDateTime, check_value: Optional[DateDateTime] = None
) -> Dict[str, str]: ) -> EventDateOrDateTime:
"""date or datetime to gcal (start or end dict) """date or datetime to gcal (start or end dict)
Arguments: Arguments:
@ -42,18 +49,18 @@ def gcal_date_or_datetime(
if check_value is None: if check_value is None:
check_value = value check_value = value
result: Dict[str, str] = {} result: EventDateOrDateTime
if isinstance(check_value, datetime.datetime): if isinstance(check_value, datetime.datetime):
result["dateTime"] = format_datetime_utc(value) result = EventDateTime(dateTime=format_datetime_utc(value))
else: else:
if isinstance(check_value, datetime.date): if isinstance(check_value, datetime.date):
if isinstance(value, datetime.datetime): if isinstance(value, datetime.datetime):
value = datetime.date(value.year, value.month, value.day) value = datetime.date(value.year, value.month, value.day)
result["date"] = value.isoformat() result = EventDate(date=value.isoformat())
return result return result
class EventConverter(Event): class EventConverter(Event): # type: ignore
"""Convert icalendar event to google calendar resource """Convert icalendar event to google calendar resource
( https://developers.google.com/calendar/v3/reference/events#resource-representations ) ( https://developers.google.com/calendar/v3/reference/events#resource-representations )
""" """
@ -68,7 +75,7 @@ class EventConverter(Event):
string value string value
""" """
return self.decoded(prop).decode(encoding="utf-8") return str(self.decoded(prop).decode(encoding="utf-8"))
def _datetime_str_prop(self, prop: str) -> str: def _datetime_str_prop(self, prop: str) -> str:
"""utc datetime as string from property """utc datetime as string from property
@ -82,7 +89,7 @@ class EventConverter(Event):
return format_datetime_utc(self.decoded(prop)) return format_datetime_utc(self.decoded(prop))
def _gcal_start(self) -> Dict[str, str]: def _gcal_start(self) -> EventDateOrDateTime:
"""event start dict from icalendar event """event start dict from icalendar event
Raises: Raises:
@ -95,7 +102,7 @@ class EventConverter(Event):
value = self.decoded("DTSTART") value = self.decoded("DTSTART")
return gcal_date_or_datetime(value) return gcal_date_or_datetime(value)
def _gcal_end(self) -> Dict[str, str]: def _gcal_end(self) -> EventDateOrDateTime:
"""event end dict from icalendar event """event end dict from icalendar event
Raises: Raises:
@ -104,7 +111,7 @@ class EventConverter(Event):
dict dict
""" """
result: Dict[str, str] result: EventDateOrDateTime
if "DTEND" in self: if "DTEND" in self:
value = self.decoded("DTEND") value = self.decoded("DTEND")
result = gcal_date_or_datetime(value) result = gcal_date_or_datetime(value)
@ -121,10 +128,10 @@ class EventConverter(Event):
def _put_to_gcal( def _put_to_gcal(
self, self,
gcal_event: EventData, gcal_event: EventData,
prop: str, prop: EventDataKey,
func: Callable[[str], str], func: Callable[[str], str],
ics_prop: Optional[str] = None, ics_prop: Optional[str] = None,
): ) -> None:
"""get property from ical event if existed, and put to gcal event """get property from ical event if existed, and put to gcal event
Arguments: Arguments:
@ -139,18 +146,18 @@ class EventConverter(Event):
if ics_prop in self: if ics_prop in self:
gcal_event[prop] = func(ics_prop) gcal_event[prop] = func(ics_prop)
def to_gcal(self) -> EventData: def convert(self) -> EventData:
"""Convert """Convert
Returns: Returns:
dict - google calendar#event resource dict - google calendar#event resource
""" """
event = { event: EventData = EventData(
"iCalUID": self._str_prop("UID"), iCalUID=self._str_prop("UID"),
"start": self._gcal_start(), start=self._gcal_start(),
"end": self._gcal_end(), end=self._gcal_end(),
} )
self._put_to_gcal(event, "summary", self._str_prop) self._put_to_gcal(event, "summary", self._str_prop)
self._put_to_gcal(event, "description", self._str_prop) self._put_to_gcal(event, "description", self._str_prop)
@ -172,22 +179,23 @@ class CalendarConverter:
def __init__(self, calendar: Optional[Calendar] = None): def __init__(self, calendar: Optional[Calendar] = None):
self.calendar: Optional[Calendar] = calendar self.calendar: Optional[Calendar] = calendar
def load(self, filename: str): def load(self, filename: str) -> None:
"""load calendar from ics file""" """load calendar from ics file"""
with open(filename, "r", encoding="utf-8") as f: with open(filename, "r", encoding="utf-8") as f:
self.calendar = Calendar.from_ical(f.read()) self.calendar = Calendar.from_ical(f.read())
self.logger.info("%s loaded", filename) self.logger.info("%s loaded", filename)
def loads(self, string: str): def loads(self, string: str) -> None:
"""load calendar from ics string""" """load calendar from ics string"""
self.calendar = Calendar.from_ical(string) self.calendar = Calendar.from_ical(string)
def events_to_gcal(self) -> EventList: def events_to_gcal(self) -> EventList:
"""Convert events to google calendar resources""" """Convert events to google calendar resources"""
ics_events = self.calendar.walk(name="VEVENT") calendar: Calendar = self.calendar
ics_events = calendar.walk(name="VEVENT")
self.logger.info("%d events read", len(ics_events)) self.logger.info("%d events read", len(ics_events))
result = list(map(lambda event: EventConverter(event).to_gcal(), ics_events)) result = list(map(lambda event: EventConverter(event).convert(), ics_events))
self.logger.info("%d events converted", len(result)) self.logger.info("%d events converted", len(result))
return result return result

View File

@ -8,7 +8,7 @@ from . import GoogleCalendar, GoogleCalendarService
def load_config(filename: str) -> Optional[Dict[str, Any]]: def load_config(filename: str) -> Optional[Dict[str, Any]]:
result = None result: Optional[Dict[str, Any]] = None
try: try:
with open(filename, "r", encoding="utf-8") as f: with open(filename, "r", encoding="utf-8") as f:
result = yaml.safe_load(f) result = yaml.safe_load(f)
@ -21,7 +21,7 @@ def load_config(filename: str) -> Optional[Dict[str, Any]]:
class PropertyCommands: class PropertyCommands:
"""get/set google calendar properties""" """get/set google calendar properties"""
def __init__(self, _service): def __init__(self, _service: Any) -> None:
self._service = _service self._service = _service
def get(self, calendar_id: str, property_name: str) -> None: def get(self, calendar_id: str, property_name: str) -> None:
@ -146,7 +146,7 @@ class Commands:
print("{}: {}".format(summary, calendar_id)) print("{}: {}".format(summary, calendar_id))
def main(): def main() -> None:
fire.Fire(Commands, name="manage-ics2gcal") fire.Fire(Commands, name="manage-ics2gcal")

View File

@ -1,15 +1,31 @@
import datetime import datetime
import logging import logging
import operator import operator
from typing import List, Dict, Set, Tuple, Union, Callable from typing import List, Dict, Set, Tuple, Union, Callable, NamedTuple
import dateutil.parser import dateutil.parser
from pytz import utc from pytz import utc
from .gcal import GoogleCalendar, EventData, EventList, EventTuple from .gcal import (
GoogleCalendar,
EventData,
EventList,
EventTuple,
EventDataKey,
EventDateOrDateTime,
EventDate,
)
from .ical import CalendarConverter, DateDateTime from .ical import CalendarConverter, DateDateTime
class ComparedEvents(NamedTuple):
"""Compared events"""
added: EventList
changed: List[EventTuple]
deleted: EventList
class CalendarSync: class CalendarSync:
"""class for synchronize calendar with Google""" """class for synchronize calendar with Google"""
@ -24,8 +40,8 @@ class CalendarSync:
@staticmethod @staticmethod
def _events_list_compare( def _events_list_compare(
items_src: EventList, items_dst: EventList, key: str = "iCalUID" items_src: EventList, items_dst: EventList, key: EventDataKey = "iCalUID"
) -> Tuple[EventList, List[EventTuple], EventList]: ) -> ComparedEvents:
"""compare list of events by key """compare list of events by key
Arguments: Arguments:
@ -34,13 +50,11 @@ class CalendarSync:
key {str} -- name of key to compare (default: {'iCalUID'}) key {str} -- name of key to compare (default: {'iCalUID'})
Returns: Returns:
tuple -- (items_to_insert, ComparedEvents -- (added, changed, deleted)
items_to_update,
items_to_delete)
""" """
def get_key(item: EventData) -> str: def get_key(item: EventData) -> str:
return item[key] return str(item[key])
keys_src: Set[str] = set(map(get_key, items_src)) keys_src: Set[str] = set(map(get_key, items_src))
keys_dst: Set[str] = set(map(get_key, items_dst)) keys_dst: Set[str] = set(map(get_key, items_dst))
@ -49,21 +63,21 @@ class CalendarSync:
keys_to_update = keys_src & keys_dst keys_to_update = keys_src & keys_dst
keys_to_delete = keys_dst - keys_src keys_to_delete = keys_dst - keys_src
def items_by_keys(items: EventList, key_name: str, keys: Set[str]) -> EventList: def items_by_keys(items: EventList, keys: Set[str]) -> EventList:
return list(filter(lambda item: item[key_name] in keys, items)) return list(filter(lambda item: get_key(item) in keys, items))
items_to_insert = items_by_keys(items_src, key, keys_to_insert) items_to_insert = items_by_keys(items_src, keys_to_insert)
items_to_delete = items_by_keys(items_dst, key, keys_to_delete) items_to_delete = items_by_keys(items_dst, keys_to_delete)
to_upd_src = items_by_keys(items_src, key, keys_to_update) to_upd_src = items_by_keys(items_src, keys_to_update)
to_upd_dst = items_by_keys(items_dst, key, keys_to_update) to_upd_dst = items_by_keys(items_dst, keys_to_update)
to_upd_src.sort(key=get_key) to_upd_src.sort(key=get_key)
to_upd_dst.sort(key=get_key) to_upd_dst.sort(key=get_key)
items_to_update = list(zip(to_upd_src, to_upd_dst)) items_to_update = list(zip(to_upd_src, to_upd_dst))
return items_to_insert, items_to_update, items_to_delete return ComparedEvents(items_to_insert, items_to_update, items_to_delete)
def _filter_events_to_update(self): def _filter_events_to_update(self) -> None:
"""filter 'to_update' events by 'updated' datetime""" """filter 'to_update' events by 'updated' datetime"""
def filter_updated(event_tuple: EventTuple) -> bool: def filter_updated(event_tuple: EventTuple) -> bool:
@ -95,17 +109,17 @@ class CalendarSync:
def filter_by_date(event: EventData) -> bool: def filter_by_date(event: EventData) -> bool:
date_cmp = date date_cmp = date
event_start: Dict[str, str] = event["start"] event_start: EventDateOrDateTime = event["start"]
event_date: Union[DateDateTime, str, None] = None event_date: Union[DateDateTime, str, None] = None
compare_dates = False compare_dates = False
if "date" in event_start: if "date" in event_start:
event_date = event_start["date"] event_date = event_start["date"] # type: ignore
compare_dates = True compare_dates = True
elif "dateTime" in event_start: elif "dateTime" in event_start:
event_date = event_start["dateTime"] event_date = event_start["dateTime"] # type: ignore
event_date = dateutil.parser.parse(event_date) event_date = dateutil.parser.parse(str(event_date))
if compare_dates: if compare_dates:
date_cmp = datetime.date(date.year, date.month, date.day) date_cmp = datetime.date(date.year, date.month, date.day)
event_date = datetime.date( event_date = datetime.date(

View File

@ -14,7 +14,7 @@ ConfigDate = Union[str, datetime.datetime]
def load_config() -> Dict[str, Any]: def load_config() -> Dict[str, Any]:
with open("config.yml", "r", encoding="utf-8") as f: with open("config.yml", "r", encoding="utf-8") as f:
result = yaml.safe_load(f) result = yaml.safe_load(f)
return result return result # type: ignore
def get_start_date(date: ConfigDate) -> datetime.datetime: def get_start_date(date: ConfigDate) -> datetime.datetime:
@ -27,7 +27,7 @@ def get_start_date(date: ConfigDate) -> datetime.datetime:
return result return result
def main(): def main() -> None:
config = load_config() config = load_config()
if "logging" in config: if "logging" in config:

View File

@ -1,5 +1,5 @@
import datetime import datetime
from typing import Tuple from typing import Tuple, Any
import pytest import pytest
from pytz import timezone, utc from pytz import timezone, utc
@ -57,21 +57,21 @@ def ics_test_event(content: str) -> str:
return ics_test_cal("BEGIN:VEVENT\r\n{}END:VEVENT\r\n".format(content)) return ics_test_cal("BEGIN:VEVENT\r\n{}END:VEVENT\r\n".format(content))
def test_empty_calendar(): def test_empty_calendar() -> None:
converter = CalendarConverter() converter = CalendarConverter()
converter.loads(ics_test_cal("")) converter.loads(ics_test_cal(""))
evnts = converter.events_to_gcal() evnts = converter.events_to_gcal()
assert evnts == [] assert evnts == []
def test_empty_event(): def test_empty_event() -> None:
converter = CalendarConverter() converter = CalendarConverter()
converter.loads(ics_test_event("")) converter.loads(ics_test_event(""))
with pytest.raises(KeyError): with pytest.raises(KeyError):
converter.events_to_gcal() converter.events_to_gcal()
def test_event_no_end(): def test_event_no_end() -> None:
converter = CalendarConverter() converter = CalendarConverter()
converter.loads(ics_test_event(only_start_date)) converter.loads(ics_test_event(only_start_date))
with pytest.raises(ValueError): with pytest.raises(ValueError):
@ -102,11 +102,11 @@ def test_event_no_end():
"datetime utc duration", "datetime utc duration",
], ],
) )
def param_events_start_end(request): def param_events_start_end(request: Any) -> Any:
return request.param return request.param
def test_event_start_end(param_events_start_end: Tuple[str, str, str, str]): def test_event_start_end(param_events_start_end: Tuple[str, str, str, str]) -> None:
(date_type, ics_str, start, end) = param_events_start_end (date_type, ics_str, start, end) = param_events_start_end
converter = CalendarConverter() converter = CalendarConverter()
converter.loads(ics_str) converter.loads(ics_str)
@ -117,7 +117,7 @@ def test_event_start_end(param_events_start_end: Tuple[str, str, str, str]):
assert event["end"] == {date_type: end} assert event["end"] == {date_type: end}
def test_event_created_updated(): def test_event_created_updated() -> None:
converter = CalendarConverter() converter = CalendarConverter()
converter.loads(ics_test_event(created_updated)) converter.loads(ics_test_event(created_updated))
events = converter.events_to_gcal() events = converter.events_to_gcal()
@ -142,5 +142,5 @@ def test_event_created_updated():
], ],
ids=["utc", "with timezone", "date"], ids=["utc", "with timezone", "date"],
) )
def test_format_datetime_utc(value: datetime.datetime, expected_str: str): def test_format_datetime_utc(value: datetime.datetime, expected_str: str) -> None:
assert format_datetime_utc(value) == expected_str assert format_datetime_utc(value) == expected_str

View File

@ -3,95 +3,93 @@ import hashlib
import operator import operator
from copy import deepcopy from copy import deepcopy
from random import shuffle from random import shuffle
from typing import Union, List, Dict, Optional from typing import Union, List, Dict, Optional, AnyStr
import dateutil.parser import dateutil.parser
import pytest import pytest
from pytz import timezone, utc from pytz import timezone, utc
from sync_ics2gcal import CalendarSync from sync_ics2gcal import CalendarSync, DateDateTime
from sync_ics2gcal.gcal import EventDateOrDateTime, EventData, EventList
def sha1(string: Union[str, bytes]) -> str: def sha1(s: AnyStr) -> str:
if isinstance(string, str):
string = string.encode("utf8")
h = hashlib.sha1() h = hashlib.sha1()
h.update(string) h.update(str(s).encode("utf8") if isinstance(s, str) else s)
return h.hexdigest() return h.hexdigest()
def gen_events( def gen_events(
start: int, start: int,
stop: int, stop: int,
start_time: Union[datetime.datetime, datetime.date], start_time: DateDateTime,
no_time: bool = False, no_time: bool = False,
) -> List[Dict[str, Union[str, Dict[str, str]]]]: ) -> EventList:
duration: datetime.timedelta
date_key: str
date_end: str
if no_time: if no_time:
start_time = datetime.date(start_time.year, start_time.month, start_time.day) start_time = datetime.date(start_time.year, start_time.month, start_time.day)
duration: datetime.timedelta = datetime.date(1, 1, 2) - datetime.date(1, 1, 1) duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
date_key: str = "date" date_key = "date"
date_end: str = "" date_end = ""
else: else:
start_time = utc.normalize(start_time.astimezone(utc)).replace(tzinfo=None) start_time = utc.normalize(start_time.astimezone(utc)).replace(tzinfo=None) # type: ignore
duration: datetime.timedelta = datetime.datetime( duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
1, 1, 1, 2 date_key = "dateTime"
) - datetime.datetime(1, 1, 1, 1) date_end = "Z"
date_key: str = "dateTime"
date_end: str = "Z"
result: List[Dict[str, Union[str, Dict[str, str]]]] = [] result: EventList = []
for i in range(start, stop): for i in range(start, stop):
event_start = start_time + (duration * i) event_start = start_time + (duration * i)
event_end = event_start + duration event_end = event_start + duration
updated: Union[datetime.datetime, datetime.date] = event_start updated: DateDateTime = event_start
if no_time: if no_time:
updated = datetime.datetime( updated = datetime.datetime(
updated.year, updated.month, updated.day, 0, 0, 0, 1, tzinfo=utc updated.year, updated.month, updated.day, 0, 0, 0, 1, tzinfo=utc
) )
event: Dict[str, Union[str, Dict[str, str]]] = { event: EventData = {
"summary": "test event __ {}".format(i), "summary": "test event __ {}".format(i),
"location": "la la la {}".format(i), "location": "la la la {}".format(i),
"description": "test TEST -- test event {}".format(i), "description": "test TEST -- test event {}".format(i),
"iCalUID": "{}@test.com".format(sha1("test - event {}".format(i))), "iCalUID": "{}@test.com".format(sha1("test - event {}".format(i))),
"updated": updated.isoformat() + "Z", "updated": updated.isoformat() + "Z",
"created": updated.isoformat() + "Z", "created": updated.isoformat() + "Z",
"start": {date_key: event_start.isoformat() + date_end}, "start": {date_key: event_start.isoformat() + date_end}, # type: ignore
"end": {date_key: event_end.isoformat() + date_end}, "end": {date_key: event_end.isoformat() + date_end}, # type: ignore
} }
result.append(event) result.append(event)
return result return result
def gen_list_to_compare(start: int, stop: int) -> List[Dict[str, str]]: def gen_list_to_compare(start: int, stop: int) -> EventList:
result: List[Dict[str, str]] = [] result: EventList = []
for i in range(start, stop): for i in range(start, stop):
result.append({"iCalUID": "test{:06d}".format(i)}) result.append({"iCalUID": "test{:06d}".format(i)})
return result return result
def get_start_date( def get_start_date(event: EventData) -> DateDateTime:
event: Dict[str, Union[str, Dict[str, str]]] event_start: EventDateOrDateTime = event["start"]
) -> Union[datetime.datetime, datetime.date]:
event_start: Dict[str, str] = event["start"]
start_date: Optional[str] = None start_date: Optional[str] = None
is_date = False is_date = False
if "date" in event_start: if "date" in event_start:
start_date = event_start["date"] start_date = event_start["date"] # type: ignore
is_date = True is_date = True
if "dateTime" in event_start: if "dateTime" in event_start:
start_date = event_start["dateTime"] start_date = event_start["dateTime"] # type: ignore
result = dateutil.parser.parse(start_date) result: DateDateTime = dateutil.parser.parse(str(start_date))
if is_date: if is_date:
result = datetime.date(result.year, result.month, result.day) result = datetime.date(result.year, result.month, result.day)
return result return result
def test_compare(): def test_compare() -> None:
part_len = 20 part_len: int = 20
# [1..2n] # [1..2n]
lst_src = gen_list_to_compare(1, 1 + part_len * 2) lst_src = gen_list_to_compare(1, 1 + part_len * 2)
# [n..3n] # [n..3n]
@ -119,7 +117,7 @@ def test_compare():
@pytest.mark.parametrize("no_time", [True, False], ids=["date", "dateTime"]) @pytest.mark.parametrize("no_time", [True, False], ids=["date", "dateTime"])
def test_filter_events_by_date(no_time: bool): def test_filter_events_by_date(no_time: bool) -> None:
msk = timezone("Europe/Moscow") msk = timezone("Europe/Moscow")
now = utc.localize(datetime.datetime.utcnow()) now = utc.localize(datetime.datetime.utcnow())
msk_now = msk.normalize(now.astimezone(msk)) msk_now = msk.normalize(now.astimezone(msk))
@ -131,7 +129,7 @@ def test_filter_events_by_date(no_time: bool):
else: else:
duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1) duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
date_cmp = msk_now + (duration * part_len) date_cmp: DateDateTime = msk_now + (duration * part_len)
if no_time: if no_time:
date_cmp = datetime.date(date_cmp.year, date_cmp.month, date_cmp.day) date_cmp = datetime.date(date_cmp.year, date_cmp.month, date_cmp.day)
@ -152,7 +150,7 @@ def test_filter_events_by_date(no_time: bool):
assert get_start_date(event) < date_cmp assert get_start_date(event) < date_cmp
def test_filter_events_to_update(): def test_filter_events_to_update() -> None:
msk = timezone("Europe/Moscow") msk = timezone("Europe/Moscow")
now = utc.localize(datetime.datetime.utcnow()) now = utc.localize(datetime.datetime.utcnow())
msk_now = msk.normalize(now.astimezone(msk)) msk_now = msk.normalize(now.astimezone(msk))
@ -164,11 +162,11 @@ def test_filter_events_to_update():
events_old = gen_events(1, 1 + count, msk_now) events_old = gen_events(1, 1 + count, msk_now)
events_new = gen_events(1, 1 + count, date_upd) events_new = gen_events(1, 1 + count, date_upd)
sync1 = CalendarSync(None, None) sync1 = CalendarSync(None, None) # type: ignore
sync1.to_update = list(zip(events_new, events_old)) sync1.to_update = list(zip(events_new, events_old))
sync1._filter_events_to_update() sync1._filter_events_to_update()
sync2 = CalendarSync(None, None) sync2 = CalendarSync(None, None) # type: ignore
sync2.to_update = list(zip(events_old, events_new)) sync2.to_update = list(zip(events_old, events_new))
sync2._filter_events_to_update() sync2._filter_events_to_update()
@ -176,7 +174,7 @@ def test_filter_events_to_update():
assert sync2.to_update == [] assert sync2.to_update == []
def test_filter_events_no_updated(): def test_filter_events_no_updated() -> None:
""" """
test filtering events that not have 'updated' field test filtering events that not have 'updated' field
such events should always pass the filter such events should always pass the filter
@ -197,7 +195,7 @@ def test_filter_events_no_updated():
del event["updated"] del event["updated"]
i += 1 i += 1
sync = CalendarSync(None, None) sync = CalendarSync(None, None) # type: ignore
sync.to_update = list(zip(events_old, events_new)) sync.to_update = list(zip(events_old, events_new))
sync._filter_events_to_update() sync._filter_events_to_update()
assert len(sync.to_update) == count // 2 assert len(sync.to_update) == count // 2