mirror of
https://github.com/b4tman/sync_ics2gcal
synced 2025-01-21 07:28:24 +00:00
commit
a18be3d079
17
.github/workflows/pythonpackage.yml
vendored
17
.github/workflows/pythonpackage.yml
vendored
@ -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
41
.github/workflows/reviewdog.yml
vendored
Normal 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
5
.gitignore
vendored
@ -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
1837
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
@ -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",
|
||||||
|
]
|
||||||
|
@ -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()
|
|
||||||
)
|
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user