1
0
mirror of https://github.com/b4tman/sync_ics2gcal synced 2026-02-04 15:25:04 +00:00

4 Commits

Author SHA1 Message Date
60b0c839cc Merge branch 'develop' 2021-10-09 16:19:33 +03:00
7ac2de5824 Merge branch 'develop' 2021-05-01 18:30:29 +03:00
06f3776b1a Merge branch 'develop' 2021-05-01 18:11:56 +03:00
feba7ef258 Merge branch 'develop' 2020-02-19 13:21:56 +03:00
31 changed files with 977 additions and 2628 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
custom: ['https://boosty.to/0xffff']

View File

@@ -5,11 +5,5 @@ updates:
schedule:
interval: monthly
time: '02:00'
timezone: "Europe/Moscow"
day: "saturday"
groups:
"PyPi updates":
patterns:
- "*"
open-pull-requests-limit: 10
target-branch: develop

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -24,10 +24,10 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v1
# Override language selection by uncommenting this and choosing your languages
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1

View File

@@ -15,22 +15,29 @@ jobs:
runs-on: ubuntu-latest
strategy:
max-parallel: 3
max-parallel: 4
matrix:
python-version: ['3.11', '3.12', '3.13']
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Load cached Poetry installation
uses: actions/cache@v2
with:
path: ~/.local
key: poetry-0
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Install deps
run: poetry install --with dev
run: poetry install
- name: Install deps form requirements.txt
run: poetry run pip install -r requirements.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
@@ -39,21 +46,3 @@ jobs:
poetry run flake8 sync_ics2gcal --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
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: Check formatting with black
run: poetry run black --check --diff --color .
- name: Upload mypy report
uses: actions/upload-artifact@v4
with:
name: mypy_report-${{ matrix.python-version }}
path: mypy_report/

View File

@@ -2,27 +2,33 @@ name: Upload Python Package
on:
release:
types: [published]
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Load cached Poetry installation
uses: actions/cache@v2
with:
path: ~/.local
key: poetry-0
- name: Install Poetry
uses: snok/install-poetry@v1
- name: Install deps
run: poetry install --with dev
run: poetry install
- name: Build
run: poetry build
- name: Publish
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.pypi_token }}
REPO_USERNAME: __token__
REPO_PASSWORD: ${{ secrets.pypi_token }}
run: |
poetry publish -n
poetry publish -n -u $REPO_USERNAME -p $REPO_PASSWORD

View File

@@ -1,49 +0,0 @@
name: reviewdog
on:
pull_request:
branches:
- master
- develop
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
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 --with dev
- 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
- name: format with black
run: poetry run black .
- name: suggester / black
uses: reviewdog/action-suggester@v1
with:
tool_name: black

7
.gitignore vendored
View File

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

View File

@@ -1,16 +0,0 @@
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.12"
jobs:
post_create_environment:
- pip install poetry
post_install:
# VIRTUAL_ENV needs to be set manually for now.
# See https://github.com/readthedocs/readthedocs.org/pull/11152/
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs
sphinx:
configuration: docs/source/conf.py

19
.travis.yml Normal file
View File

@@ -0,0 +1,19 @@
language: python
python:
- "3.6"
- "3.7"
- "3.8"
- "3.9"
before_install:
- pip install poetry
install:
- poetry install
script:
# stop the build if there are Python syntax errors or undefined names
- poetry run flake8 sync_ics2gcal --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- poetry run flake8 sync_ics2gcal --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
# run tests
- poetry run pytest -v

View File

@@ -1,8 +1,8 @@
# sync_ics2gcal
[![PyPI version](https://badge.fury.io/py/sync-ics2gcal.svg)](https://badge.fury.io/py/sync-ics2gcal)
[![Build Status](https://travis-ci.org/b4tman/sync_ics2gcal.svg?branch=master)](https://travis-ci.org/b4tman/sync_ics2gcal)
![Python package status](https://github.com/b4tman/sync_ics2gcal/workflows/Python%20package/badge.svg)
[![Documentation Status](https://readthedocs.org/projects/sync-ics2gcal/badge/?version=latest)](https://sync-ics2gcal.readthedocs.io/en/latest/?badge=latest)
Python scripts for sync .ics file with Google calendar
@@ -99,5 +99,3 @@ sync-ics2gcal
## How it works
![How it works](how-it-works.png)
Documentation is available at [sync-ics2gcal.readthedocs.io](https://sync-ics2gcal.readthedocs.io).

View File

@@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= poetry run sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=poetry run sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@@ -1,39 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import importlib
from typing import List
project = "sync_ics2gcal"
copyright = "2023, b4tman"
author = "b4tman"
version = importlib.metadata.version("sync_ics2gcal")
release = version
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx_design",
"sphinx.ext.viewcode",
"sphinx_copybutton",
"sphinx.ext.githubpages",
]
templates_path = ["_templates"]
exclude_patterns: List[str] = []
source_suffix = [".rst", ".md"]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,22 +0,0 @@
.. sync_ics2gcal documentation master file, created by
sphinx-quickstart on Sat Aug 20 22:19:59 2023.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to sync_ics2gcal's documentation!
=========================================
.. toctree::
:maxdepth: 2
:caption: Contents:
readme_link
reference
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View File

@@ -1,7 +0,0 @@
sync_ics2gcal
=============
.. toctree::
:maxdepth: 4
sync_ics2gcal

View File

@@ -1,3 +0,0 @@
```{include} ../../README.md
```

View File

@@ -1,6 +0,0 @@
-------------
Reference
-------------
.. include:: modules.rst

View File

@@ -1,53 +0,0 @@
sync\_ics2gcal package
======================
Submodules
----------
sync\_ics2gcal.gcal module
--------------------------
.. automodule:: sync_ics2gcal.gcal
:members:
:undoc-members:
:show-inheritance:
sync\_ics2gcal.ical module
--------------------------
.. automodule:: sync_ics2gcal.ical
:members:
:undoc-members:
:show-inheritance:
sync\_ics2gcal.manage\_calendars module
---------------------------------------
.. automodule:: sync_ics2gcal.manage_calendars
:members:
:undoc-members:
:show-inheritance:
sync\_ics2gcal.sync module
--------------------------
.. automodule:: sync_ics2gcal.sync
:members:
:undoc-members:
:show-inheritance:
sync\_ics2gcal.sync\_calendar module
------------------------------------
.. automodule:: sync_ics2gcal.sync_calendar
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: sync_ics2gcal
:members:
:undoc-members:
:show-inheritance:

2081
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "sync_ics2gcal"
version = "0.1.5"
version = "0.1.3"
description = "Sync ics file with Google calendar"
authors = ["Dmitry Belyaev <b4tm4n@mail.ru>"]
license = "MIT"
@@ -11,42 +11,25 @@ keywords = ["icalendar", "sync", "google", "calendar"]
classifiers = [
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
]
[tool.poetry.dependencies]
python = "^3.11"
google-auth = "2.48.0"
google-api-python-client = "2.188.0"
icalendar = "6.3.2"
pytz = "2025.2"
PyYAML = "6.0.3"
fire = "0.7.1"
python = "^3.6"
google-auth = "2.2.1"
google-api-python-client = "2.23.0"
icalendar = "4.0.7"
pytz = "2021.1"
PyYAML = "5.4.1"
fire = "0.4.0"
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.dev.dependencies]
pytest = ">=8.1,<10.0"
flake8 = ">=7.0.4,<8.0.0"
black = ">=25.0,<27.0"
mypy = ">=1.16.1"
types-python-dateutil = ">=2.9.0.20250516"
types-pytz = ">=2025.2.0.20250516"
types-PyYAML = "^6.0.12.20250516"
lxml = ">=5.4.0,<7.0.0"
[tool.poetry.group.docs.dependencies]
sphinx = ">=8.2,<9.0"
myst-parser = ">=4,<6"
sphinx-rtd-theme = ">=3.0.2,<4.0.0"
sphinx-copybutton = "^0.5.2"
sphinx-design = ">=0.6,<0.8"
[tool.poetry.dev-dependencies]
pytest = "^6.2.5"
flake8 = "^3.9.2"
[tool.poetry.scripts]
sync-ics2gcal = "sync_ics2gcal.sync_calendar:main"
@@ -55,12 +38,3 @@ manage-ics2gcal = "sync_ics2gcal.manage_calendars:main"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[[tool.mypy.overrides]]
module = [
'icalendar',
'google.*',
'googleapiclient',
'fire'
]
ignore_missing_imports = true

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
google-auth==2.2.1
google-api-python-client==2.23.0
icalendar==4.0.7
pytz==2021.1
PyYAML==5.4.1
fire==0.4.0

View File

@@ -1,44 +1,18 @@
from .ical import CalendarConverter, EventConverter, DateDateTime
from .ical import (
CalendarConverter,
EventConverter,
DateDateTime
)
from .gcal import (
GoogleCalendarService,
GoogleCalendar,
EventData,
EventList,
EventTuple,
EventDataKey,
EventDateOrDateTime,
EventDate,
EventDateTime,
EventsSearchResults,
ACLRule,
ACLScope,
CalendarData,
BatchRequestCallback,
EventTuple
)
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",
]
from .sync import (
CalendarSync
)

View File

@@ -1,97 +1,17 @@
import logging
from datetime import datetime
from typing import (
List,
Dict,
Any,
Callable,
Tuple,
Optional,
Union,
TypedDict,
Literal,
NamedTuple,
)
from typing import List, Dict, Any, Callable, Tuple, Optional, Union
import google.auth
from google.oauth2 import service_account
from googleapiclient import discovery
from pytz import utc
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"],
]
EventData = Dict[str, Union[str, 'EventData', None]]
EventList = List[EventData]
EventTuple = Tuple[EventData, EventData]
class EventsSearchResults(NamedTuple):
exists: List[EventTuple]
new: List[EventData]
BatchRequestCallback = Callable[[str, Any, Optional[Exception]], None]
class GoogleCalendarService:
"""class for make google calendar service Resource
@@ -100,51 +20,47 @@ class GoogleCalendarService:
"""
@staticmethod
def default() -> discovery.Resource:
def default():
"""make service Resource from default credentials (authorize)
( https://developers.google.com/identity/protocols/application-default-credentials )
( https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.default )
"""
scopes = ["https://www.googleapis.com/auth/calendar"]
scopes = ['https://www.googleapis.com/auth/calendar']
credentials, _ = google.auth.default(scopes=scopes)
service = discovery.build(
"calendar", "v3", credentials=credentials, cache_discovery=False
)
'calendar', 'v3', credentials=credentials, cache_discovery=False)
return service
@staticmethod
def from_srv_acc_file(service_account_file: str) -> discovery.Resource:
"""make service Resource from service account filename (authorize)"""
def from_srv_acc_file(service_account_file: str):
"""make service Resource from service account filename (authorize)
"""
scopes = ["https://www.googleapis.com/auth/calendar"]
scopes = ['https://www.googleapis.com/auth/calendar']
credentials = service_account.Credentials.from_service_account_file(
service_account_file
)
service_account_file)
scoped_credentials = credentials.with_scopes(scopes)
service = discovery.build(
"calendar", "v3", credentials=scoped_credentials, cache_discovery=False
)
'calendar', 'v3', credentials=scoped_credentials,
cache_discovery=False)
return service
@staticmethod
def from_config(config: Optional[Dict[str, str]] = None) -> discovery.Resource:
def from_config(config: Optional[Dict[str, Optional[str]]] = None):
"""make service Resource from config dict
Arguments:
**config** -- config with keys:
config -- config with keys:
(optional) service_account: - service account filename
if key not in dict then default credentials will be used
( https://developers.google.com/identity/protocols/application-default-credentials )
-- **None**: default credentials will be used
-- None: default credentials will be used
"""
if config is not None and "service_account" in config:
service_account_filename: str = config["service_account"]
service = GoogleCalendarService.from_srv_acc_file(service_account_filename)
if config is not None and 'service_account' in config:
service = GoogleCalendarService.from_srv_acc_file(
config['service_account'])
else:
service = GoogleCalendarService.default()
return service
@@ -160,26 +76,25 @@ def select_event_key(event: EventData) -> Optional[str]:
key name or None if no key found
"""
key: Optional[str] = None
if "iCalUID" in event:
key = "iCalUID"
elif "id" in event:
key = "id"
key = None
if 'iCalUID' in event:
key = 'iCalUID'
elif 'id' in event:
key = 'id'
return key
class GoogleCalendar:
"""class to interact with calendar on Google"""
"""class to interact with calendar on google
"""
logger = logging.getLogger("GoogleCalendar")
logger = logging.getLogger('GoogleCalendar')
def __init__(self, service: discovery.Resource, calendar_id: Optional[str]):
def __init__(self, service: discovery.Resource, calendarId: Optional[str]):
self.service: discovery.Resource = service
self.calendar_id: str = str(calendar_id)
self.calendarId: str = calendarId
def _make_request_callback(
self, action: str, events_by_req: EventList
) -> BatchRequestCallback:
def _make_request_callback(self, action: str, events_by_req: EventList) -> Callable:
"""make callback for log result of batch request
Arguments:
@@ -190,214 +105,184 @@ class GoogleCalendar:
callback function
"""
def callback(
request_id: str, response: Any, exception: Optional[Exception]
) -> 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 ""
def callback(request_id, response, exception):
event = events_by_req[int(request_id)]
key = select_event_key(event)
if exception is not None:
self.logger.error(
"failed to %s event with %s: %s, exception: %s",
action,
key,
event.get(key),
str(exception),
'failed to %s event with %s: %s, exception: %s',
action, key, event.get(key), str(exception)
)
else:
resp_key: Optional[str] = select_event_key(response)
resp_key = select_event_key(response)
if resp_key is not None:
event = response
key = resp_key
self.logger.info("event %s ok, %s: %s", action, key, event.get(key))
self.logger.info('event %s ok, %s: %s',
action, key, event.get(key))
return callback
def list_events_from(self, start: datetime) -> EventList:
"""list events from calendar, where start date >= start"""
fields: str = "nextPageToken,items(id,iCalUID,updated)"
events: EventList = []
page_token: Optional[str] = None
time_min: str = (
utc.normalize(start.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z"
)
""" list events from calendar, where start date >= start
"""
fields = 'nextPageToken,items(id,iCalUID,updated)'
events = []
page_token = None
timeMin = utc.normalize(start.astimezone(utc)).replace(
tzinfo=None).isoformat() + 'Z'
while True:
response = (
self.service.events()
.list(
calendarId=self.calendar_id,
response = self.service.events().list(calendarId=self.calendarId,
pageToken=page_token,
singleEvents=True,
timeMin=time_min,
fields=fields,
)
.execute()
)
if "items" in response:
events.extend(response["items"])
page_token = response.get("nextPageToken")
timeMin=timeMin,
fields=fields).execute()
if 'items' in response:
events.extend(response['items'])
page_token = response.get('nextPageToken')
if not page_token:
break
self.logger.info("%d events listed", len(events))
self.logger.info('%d events listed', len(events))
return events
def find_exists(self, events: EventList) -> EventsSearchResults:
def find_exists(self, events: List) -> Tuple[List[EventTuple], EventList]:
""" find existing events from list, by 'iCalUID' field
Arguments:
events {list} -- list of events
Returns:
EventsSearchResults -- (events_exist, events_not_found)
tuple -- (events_exist, events_not_found)
events_exist - list of tuples: (new_event, exists_event)
"""
fields: str = "items(id,iCalUID,updated)"
events_by_req: EventList = []
exists: List[EventTuple] = []
not_found: EventList = []
fields = 'items(id,iCalUID,updated)'
events_by_req = []
exists = []
not_found = []
def list_callback(
request_id: str, response: Any, exception: Optional[Exception]
) -> None:
found: bool = False
cur_event: EventData = events_by_req[int(request_id)]
def list_callback(request_id, response, exception):
found = False
cur_event = events_by_req[int(request_id)]
if exception is None:
found = [] != response["items"]
found = ([] != response['items'])
else:
self.logger.error(
"exception %s, while listing event with UID: %s",
str(exception),
cur_event["iCalUID"],
)
'exception %s, while listing event with UID: %s',
str(exception), cur_event['iCalUID'])
if found:
exists.append((cur_event, response["items"][0]))
exists.append(
(cur_event, response['items'][0]))
else:
not_found.append(events_by_req[int(request_id)])
batch = self.service.new_batch_http_request(callback=list_callback)
i: int = 0
i = 0
for event in events:
events_by_req.append(event)
batch.add(
self.service.events().list(
calendarId=self.calendar_id,
iCalUID=event["iCalUID"],
batch.add(self.service.events().list(calendarId=self.calendarId,
iCalUID=event['iCalUID'],
showDeleted=True,
fields=fields,
fields=fields
),
request_id=str(i),
request_id=str(i)
)
i += 1
batch.execute()
self.logger.info("%d events exists, %d not found", len(exists), len(not_found))
return EventsSearchResults(exists, not_found)
self.logger.info('%d events exists, %d not found',
len(exists), len(not_found))
return exists, not_found
def insert_events(self, events: EventList) -> None:
def insert_events(self, events: EventList):
""" insert list of events
Arguments:
events - events list
"""
fields: str = "id"
events_by_req: EventList = []
fields = 'id'
events_by_req = []
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)
i: int = 0
i = 0
for event in events:
events_by_req.append(event)
batch.add(
self.service.events().insert(
calendarId=self.calendar_id, body=event, fields=fields
),
request_id=str(i),
batch.add(self.service.events().insert(
calendarId=self.calendarId, body=event, fields=fields),
request_id=str(i)
)
i += 1
batch.execute()
def patch_events(self, event_tuples: List[EventTuple]) -> None:
def patch_events(self, event_tuples: List[EventTuple]):
""" patch (update) events
Arguments:
event_tuples -- list of tuples: (new_event, exists_event)
"""
fields: str = "id"
events_by_req: EventList = []
fields = 'id'
events_by_req = []
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)
i: int = 0
i = 0
for event_new, event_old in event_tuples:
if "id" not in event_old:
if 'id' not in event_old:
continue
events_by_req.append(event_new)
batch.add(
self.service.events().patch(
calendarId=self.calendar_id, eventId=event_old["id"], body=event_new
),
fields=fields,
request_id=str(i),
)
batch.add(self.service.events().patch(
calendarId=self.calendarId, eventId=event_old['id'],
body=event_new), fields=fields, request_id=str(i))
i += 1
batch.execute()
def update_events(self, event_tuples: List[EventTuple]) -> None:
def update_events(self, event_tuples: List[EventTuple]):
""" update events
Arguments:
event_tuples -- list of tuples: (new_event, exists_event)
"""
fields: str = "id"
events_by_req: EventList = []
fields = 'id'
events_by_req = []
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)
i: int = 0
i = 0
for event_new, event_old in event_tuples:
if "id" not in event_old:
if 'id' not in event_old:
continue
events_by_req.append(event_new)
batch.add(
self.service.events().update(
calendarId=self.calendar_id,
eventId=event_old["id"],
body=event_new,
fields=fields,
),
request_id=str(i),
)
batch.add(self.service.events().update(
calendarId=self.calendarId, eventId=event_old['id'],
body=event_new, fields=fields), request_id=str(i))
i += 1
batch.execute()
def delete_events(self, events: EventList) -> None:
def delete_events(self, events: EventList):
""" delete events
Arguments:
events -- list of events
"""
events_by_req: EventList = []
events_by_req = []
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)
i: int = 0
i = 0
for event in events:
events_by_req.append(event)
batch.add(
self.service.events().delete(
calendarId=self.calendar_id, eventId=event["id"]
),
request_id=str(i),
)
batch.add(self.service.events().delete(
calendarId=self.calendarId,
eventId=event['id']), request_id=str(i))
i += 1
batch.execute()
def create(self, summary: str, time_zone: Optional[str] = None) -> Any:
def create(self, summary: str, timeZone: Optional[str] = None) -> Any:
"""create calendar
Arguments:
@@ -410,35 +295,52 @@ class GoogleCalendar:
calendar Resource
"""
calendar = CalendarData(summary=summary)
if time_zone is not None:
calendar["timeZone"] = time_zone
calendar = {'summary': summary}
if timeZone is not None:
calendar['timeZone'] = timeZone
created_calendar = self.service.calendars().insert(body=calendar).execute()
self.calendar_id = created_calendar["id"]
created_calendar = self.service.calendars().insert(
body=calendar
).execute()
self.calendarId = created_calendar['id']
return created_calendar
def delete(self) -> None:
"""delete calendar"""
def delete(self):
"""delete calendar
"""
self.service.calendars().delete(calendarId=self.calendar_id).execute()
self.service.calendars().delete(calendarId=self.calendarId).execute()
def make_public(self) -> None:
"""make calendar public"""
def make_public(self):
"""make calendar puplic
"""
rule_public = ACLRule(scope=ACLScope(type="default"), role="reader")
self.service.acl().insert(
calendarId=self.calendar_id, body=rule_public
rule_public = {
'scope': {
'type': 'default',
},
'role': 'reader'
}
return self.service.acl().insert(
calendarId=self.calendarId,
body=rule_public
).execute()
def add_owner(self, email: str) -> None:
def add_owner(self, email: str):
"""add calendar owner by email
Arguments:
email -- email to add
"""
rule_owner = ACLRule(scope=ACLScope(type="user", value=email), role="owner")
self.service.acl().insert(
calendarId=self.calendar_id, body=rule_owner
rule_owner = {
'scope': {
'type': 'user',
'value': email,
},
'role': 'owner'
}
return self.service.acl().insert(
calendarId=self.calendarId,
body=rule_owner
).execute()

View File

@@ -1,18 +1,11 @@
import datetime
import logging
from typing import Union, Dict, Callable, Optional, Mapping, TypedDict
from typing import Union, Dict, Callable, Optional
from icalendar import Calendar, Event
from pytz import utc
from .gcal import (
EventData,
EventList,
EventDateOrDateTime,
EventDateTime,
EventDate,
EventDataKey,
)
from .gcal import EventData, EventList
DateDateTime = Union[datetime.date, datetime.datetime]
@@ -27,16 +20,19 @@ def format_datetime_utc(value: DateDateTime) -> str:
utc datetime value as string in iso format
"""
if not isinstance(value, datetime.datetime):
value = datetime.datetime(value.year, value.month, value.day, tzinfo=utc)
value = datetime.datetime(
value.year, value.month, value.day, tzinfo=utc)
value = value.replace(microsecond=1)
return utc.normalize(value.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z"
return utc.normalize(
value.astimezone(utc)
).replace(tzinfo=None).isoformat() + 'Z'
def gcal_date_or_datetime(
value: DateDateTime, check_value: Optional[DateDateTime] = None
) -> EventDateOrDateTime:
"""date or datetime to gcal (start or end dict)
def gcal_date_or_dateTime(value: DateDateTime,
check_value: Optional[DateDateTime] = None) \
-> Dict[str, str]:
"""date or dateTime to gcal (start or end dict)
Arguments:
value: date or datetime
@@ -49,18 +45,18 @@ def gcal_date_or_datetime(
if check_value is None:
check_value = value
result: EventDateOrDateTime
result: Dict[str, str] = {}
if isinstance(check_value, datetime.datetime):
result = EventDateTime(dateTime=format_datetime_utc(value))
result['dateTime'] = format_datetime_utc(value)
else:
if isinstance(check_value, datetime.date):
if isinstance(value, datetime.datetime):
value = datetime.date(value.year, value.month, value.day)
result = EventDate(date=value.isoformat())
result['date'] = value.isoformat()
return result
class EventConverter(Event): # type: ignore
class EventConverter(Event):
"""Convert icalendar event to google calendar resource
( https://developers.google.com/calendar/v3/reference/events#resource-representations )
"""
@@ -69,13 +65,13 @@ class EventConverter(Event): # type: ignore
"""decoded string property
Arguments:
prop - property name
prop - propperty name
Returns:
string value
"""
return str(self.decoded(prop).decode(encoding="utf-8"))
return self.decoded(prop).decode(encoding='utf-8')
def _datetime_str_prop(self, prop: str) -> str:
"""utc datetime as string from property
@@ -89,7 +85,7 @@ class EventConverter(Event): # type: ignore
return format_datetime_utc(self.decoded(prop))
def _gcal_start(self) -> EventDateOrDateTime:
def _gcal_start(self) -> Dict[str, str]:
""" event start dict from icalendar event
Raises:
@@ -99,10 +95,10 @@ class EventConverter(Event): # type: ignore
dict
"""
value = self.decoded("DTSTART")
return gcal_date_or_datetime(value)
value = self.decoded('DTSTART')
return gcal_date_or_dateTime(value)
def _gcal_end(self) -> EventDateOrDateTime:
def _gcal_end(self) -> Dict[str, str]:
"""event end dict from icalendar event
Raises:
@@ -111,31 +107,27 @@ class EventConverter(Event): # type: ignore
dict
"""
result: EventDateOrDateTime
if "DTEND" in self:
value = self.decoded("DTEND")
result = gcal_date_or_datetime(value)
elif "DURATION" in self:
start_val = self.decoded("DTSTART")
duration = self.decoded("DURATION")
result = None
if 'DTEND' in self:
value = self.decoded('DTEND')
result = gcal_date_or_dateTime(value)
elif 'DURATION' in self:
start_val = self.decoded('DTSTART')
duration = self.decoded('DURATION')
end_val = start_val + duration
result = gcal_date_or_datetime(end_val, check_value=start_val)
result = gcal_date_or_dateTime(end_val, check_value=start_val)
else:
raise ValueError("no DTEND or DURATION")
raise ValueError('no DTEND or DURATION')
return result
def _put_to_gcal(
self,
gcal_event: EventData,
prop: EventDataKey,
func: Callable[[str], str],
ics_prop: Optional[str] = None,
) -> None:
"""get property from ical event if existed, and put to gcal event
def _put_to_gcal(self, gcal_event: EventData,
prop: str, func: Callable[[str], str],
ics_prop: Optional[str] = None):
"""get property from ical event if exist, and put to gcal event
Arguments:
gcal_event -- destination event
gcal_event -- dest event
prop -- property name
func -- function to convert
ics_prop -- ical property name (default: {None})
@@ -146,56 +138,62 @@ class EventConverter(Event): # type: ignore
if ics_prop in self:
gcal_event[prop] = func(ics_prop)
def convert(self) -> EventData:
def to_gcal(self) -> EventData:
"""Convert
Returns:
dict - google calendar#event resource
"""
event: EventData = EventData(
iCalUID=self._str_prop("UID"),
start=self._gcal_start(),
end=self._gcal_end(),
)
event = {
'iCalUID': self._str_prop('UID'),
'start': self._gcal_start(),
'end': self._gcal_end()
}
self._put_to_gcal(event, "summary", self._str_prop)
self._put_to_gcal(event, "description", self._str_prop)
self._put_to_gcal(event, "location", self._str_prop)
self._put_to_gcal(event, "created", self._datetime_str_prop)
self._put_to_gcal(event, "updated", self._datetime_str_prop, "LAST-MODIFIED")
self._put_to_gcal(event, 'summary', self._str_prop)
self._put_to_gcal(event, 'description', self._str_prop)
self._put_to_gcal(event, 'location', self._str_prop)
self._put_to_gcal(event, 'created', self._datetime_str_prop)
self._put_to_gcal(
event, "transparency", lambda prop: self._str_prop(prop).lower(), "TRANSP"
)
event, 'updated', self._datetime_str_prop, 'LAST-MODIFIED')
self._put_to_gcal(
event,
'transparency',
lambda prop: self._str_prop(prop).lower(), 'TRANSP')
return event
class CalendarConverter:
"""Convert icalendar events to google calendar resources"""
"""Convert icalendar events to google calendar resources
"""
logger = logging.getLogger("CalendarConverter")
logger = logging.getLogger('CalendarConverter')
def __init__(self, calendar: Optional[Calendar] = None):
self.calendar: Optional[Calendar] = calendar
def load(self, filename: str) -> None:
"""load calendar from ics file"""
with open(filename, "r", encoding="utf-8") as f:
def load(self, filename: str):
""" load calendar from ics file
"""
with open(filename, 'r', encoding='utf-8') as f:
self.calendar = Calendar.from_ical(f.read())
self.logger.info("%s loaded", filename)
self.logger.info('%s loaded', filename)
def loads(self, string: str) -> None:
"""load calendar from ics string"""
def loads(self, string: str):
""" load calendar from ics string
"""
self.calendar = Calendar.from_ical(string)
def events_to_gcal(self) -> EventList:
"""Convert events to google calendar resources"""
"""Convert events to google calendar resources
"""
calendar: Calendar = self.calendar
ics_events = calendar.walk(name="VEVENT")
self.logger.info("%d events read", len(ics_events))
ics_events = self.calendar.walk(name='VEVENT')
self.logger.info('%d events readed', len(ics_events))
result = list(map(lambda event: EventConverter(event).convert(), ics_events))
self.logger.info("%d events converted", len(result))
result = list(
map(lambda event: EventConverter(event).to_gcal(), ics_events))
self.logger.info('%d events converted', len(result))
return result

View File

@@ -8,9 +8,9 @@ from . import GoogleCalendar, GoogleCalendarService
def load_config(filename: str) -> Optional[Dict[str, Any]]:
result: Optional[Dict[str, Any]] = None
result = None
try:
with open(filename, "r", encoding="utf-8") as f:
with open(filename, 'r', encoding='utf-8') as f:
result = yaml.safe_load(f)
except FileNotFoundError:
pass
@@ -21,7 +21,7 @@ def load_config(filename: str) -> Optional[Dict[str, Any]]:
class PropertyCommands:
""" get/set google calendar properties """
def __init__(self, _service: Any) -> None:
def __init__(self, _service):
self._service = _service
def get(self, calendar_id: str, property_name: str) -> None:
@@ -31,11 +31,8 @@ class PropertyCommands:
calendar_id: calendar id
property_name: property key
"""
response = (
self._service.calendarList()
.get(calendarId=calendar_id, fields=property_name)
.execute()
)
response = self._service.calendarList().get(calendarId=calendar_id,
fields=property_name).execute()
print(response.get(property_name))
def set(self, calendar_id: str, property_name: str, property_value: str) -> None:
@@ -47,26 +44,22 @@ class PropertyCommands:
property_value: property value
"""
body = {property_name: property_value}
response = (
self._service.calendarList()
.patch(body=body, calendarId=calendar_id)
.execute()
)
response = self._service.calendarList().patch(body=body, calendarId=calendar_id).execute()
print(response)
class Commands:
""" manage google calendars in service account """
def __init__(self, config: str = "config.yml"):
def __init__(self, config: str = 'config.yml'):
"""
Args:
config(str): config filename
"""
self._config: Optional[Dict[str, Any]] = load_config(config)
if self._config is not None and "logging" in self._config:
logging.config.dictConfig(self._config["logging"])
if self._config is not None and 'logging' in self._config:
logging.config.dictConfig(self._config['logging'])
self._service = GoogleCalendarService.from_config(self._config)
self.property = PropertyCommands(self._service)
@@ -78,28 +71,25 @@ class Commands:
show_deleted: show deleted calendars
"""
fields: str = "nextPageToken,items(id,summary)"
fields: str = 'nextPageToken,items(id,summary)'
calendars: List[Dict[str, Any]] = []
page_token: Optional[str] = None
while True:
calendars_api = self._service.calendarList()
response = calendars_api.list(
fields=fields,
response = calendars_api.list(fields=fields,
pageToken=page_token,
showHidden=show_hidden,
showDeleted=show_deleted,
showDeleted=show_deleted
).execute()
if "items" in response:
calendars.extend(response["items"])
page_token = response.get("nextPageToken")
if 'items' in response:
calendars.extend(response['items'])
page_token = response.get('nextPageToken')
if page_token is None:
break
for calendar in calendars:
print("{summary}: {id}".format_map(calendar))
print('{summary}: {id}'.format_map(calendar))
def create(
self, summary: str, timezone: Optional[str] = None, public: bool = False
) -> None:
def create(self, summary: str, timezone: Optional[str] = None, public: bool = False) -> None:
""" create calendar
Args:
@@ -111,7 +101,7 @@ class Commands:
calendar.create(summary, timezone)
if public:
calendar.make_public()
print("{}: {}".format(summary, calendar.calendar_id))
print('{}: {}'.format(summary, calendar.calendarId))
def add_owner(self, calendar_id: str, email: str) -> None:
""" add owner to calendar
@@ -122,7 +112,7 @@ class Commands:
"""
calendar = GoogleCalendar(self._service, calendar_id)
calendar.add_owner(email)
print("to {} added owner: {}".format(calendar_id, email))
print('to {} added owner: {}'.format(calendar_id, email))
def remove(self, calendar_id: str) -> None:
""" remove calendar
@@ -132,7 +122,7 @@ class Commands:
"""
calendar = GoogleCalendar(self._service, calendar_id)
calendar.delete()
print("removed: {}".format(calendar_id))
print('removed: {}'.format(calendar_id))
def rename(self, calendar_id: str, summary: str) -> None:
""" rename calendar
@@ -141,14 +131,14 @@ class Commands:
calendar_id: calendar id
summary:
"""
calendar = {"summary": summary}
calendar = {'summary': summary}
self._service.calendars().patch(body=calendar, calendarId=calendar_id).execute()
print("{}: {}".format(summary, calendar_id))
print('{}: {}'.format(summary, calendar_id))
def main() -> None:
fire.Fire(Commands, name="manage-ics2gcal")
def main():
fire.Fire(Commands, name='manage-ics2gcal')
if __name__ == "__main__":
if __name__ == '__main__':
main()

View File

View File

@@ -1,35 +1,20 @@
import datetime
import logging
import operator
from typing import List, Dict, Set, Tuple, Union, Callable, NamedTuple
from typing import List, Dict, Set, Tuple, Union, Callable
import dateutil.parser
from pytz import utc
from .gcal import (
GoogleCalendar,
EventData,
EventList,
EventTuple,
EventDataKey,
EventDateOrDateTime,
EventDate,
)
from .gcal import GoogleCalendar, EventData, EventList, EventTuple
from .ical import CalendarConverter, DateDateTime
class ComparedEvents(NamedTuple):
"""Compared events"""
added: EventList
changed: List[EventTuple]
deleted: EventList
class CalendarSync:
"""class for synchronize calendar with Google"""
"""class for syncronize calendar with google
"""
logger = logging.getLogger("CalendarSync")
logger = logging.getLogger('CalendarSync')
def __init__(self, gcalendar: GoogleCalendar, converter: CalendarConverter):
self.gcalendar: GoogleCalendar = gcalendar
@@ -39,22 +24,24 @@ class CalendarSync:
self.to_delete: EventList = []
@staticmethod
def _events_list_compare(
items_src: EventList, items_dst: EventList, key: EventDataKey = "iCalUID"
) -> ComparedEvents:
def _events_list_compare(items_src: EventList,
items_dst: EventList,
key: str = 'iCalUID') \
-> Tuple[EventList, List[EventTuple], EventList]:
""" compare list of events by key
Arguments:
items_src {list of dict} -- source events
items_dst {list of dict} -- destination events
items_dst {list of dict} -- dest events
key {str} -- name of key to compare (default: {'iCalUID'})
Returns:
ComparedEvents -- (added, changed, deleted)
tuple -- (items_to_insert,
items_to_update,
items_to_delete)
"""
def get_key(item: EventData) -> str:
return str(item[key])
def get_key(item: EventData) -> str: return item[key]
keys_src: Set[str] = set(map(get_key, items_src))
keys_dst: Set[str] = set(map(get_key, items_dst))
@@ -63,68 +50,67 @@ class CalendarSync:
keys_to_update = keys_src & keys_dst
keys_to_delete = keys_dst - keys_src
def items_by_keys(items: EventList, keys: Set[str]) -> EventList:
return list(filter(lambda item: get_key(item) in keys, items))
def items_by_keys(items: EventList,
key_name: str,
keys: Set[str]) -> EventList:
return list(filter(lambda item: item[key_name] in keys, items))
items_to_insert = items_by_keys(items_src, keys_to_insert)
items_to_delete = items_by_keys(items_dst, keys_to_delete)
items_to_insert = items_by_keys(items_src, key, keys_to_insert)
items_to_delete = items_by_keys(items_dst, key, keys_to_delete)
to_upd_src = items_by_keys(items_src, keys_to_update)
to_upd_dst = items_by_keys(items_dst, keys_to_update)
to_upd_src = items_by_keys(items_src, key, keys_to_update)
to_upd_dst = items_by_keys(items_dst, key, keys_to_update)
to_upd_src.sort(key=get_key)
to_upd_dst.sort(key=get_key)
items_to_update = list(zip(to_upd_src, to_upd_dst))
return ComparedEvents(items_to_insert, items_to_update, items_to_delete)
return items_to_insert, items_to_update, items_to_delete
def _filter_events_to_update(self) -> None:
"""filter 'to_update' events by 'updated' datetime"""
def _filter_events_to_update(self):
""" filter 'to_update' events by 'updated' datetime
"""
def filter_updated(event_tuple: EventTuple) -> bool:
new, old = event_tuple
if "updated" not in new or "updated" not in old:
return True
new_date = dateutil.parser.parse(new["updated"])
old_date = dateutil.parser.parse(old["updated"])
new_date = dateutil.parser.parse(new['updated'])
old_date = dateutil.parser.parse(old['updated'])
return new_date > old_date
self.to_update = list(filter(filter_updated, self.to_update))
@staticmethod
def _filter_events_by_date(
events: EventList,
def _filter_events_by_date(events: EventList,
date: DateDateTime,
op: Callable[[DateDateTime, DateDateTime], bool],
) -> EventList:
op: Callable[[DateDateTime,
DateDateTime], bool]) -> EventList:
""" filter events by start datetime
Arguments:
events -- events list
date {datetime} -- datetime to compare
op {operator} -- comparison operator
op {operator} -- comparsion operator
Returns:
list of filtered events
list of filtred events
"""
def filter_by_date(event: EventData) -> bool:
date_cmp = date
event_start: EventDateOrDateTime = event["start"]
event_start: Dict[str, str] = event['start']
event_date: Union[DateDateTime, str, None] = None
compare_dates = False
if "date" in event_start:
event_date = event_start["date"] # type: ignore
if 'date' in event_start:
event_date = event_start['date']
compare_dates = True
elif "dateTime" in event_start:
event_date = event_start["dateTime"] # type: ignore
elif 'dateTime' in event_start:
event_date = event_start['dateTime']
event_date = dateutil.parser.parse(str(event_date))
event_date = dateutil.parser.parse(event_date)
if compare_dates:
date_cmp = datetime.date(date.year, date.month, date.day)
event_date = datetime.date(
event_date.year, event_date.month, event_date.day
)
event_date.year, event_date.month, event_date.day)
return op(event_date, date_cmp)
@@ -132,13 +118,13 @@ class CalendarSync:
@staticmethod
def _tz_aware_datetime(date: DateDateTime) -> datetime.datetime:
"""make tz aware datetime from datetime/date (utc if no tz-info)
"""make tz aware datetime from datetime/date (utc if no tzinfo)
Arguments:
date - date or datetime / with or without tz-info
date - date or datetime / with or without tzinfo
Returns:
datetime with tz-info
datetime with tzinfo
"""
if not isinstance(date, datetime.datetime):
@@ -148,7 +134,7 @@ class CalendarSync:
return date
def prepare_sync(self, start_date: DateDateTime) -> None:
"""prepare sync lists by comparison of events
"""prepare sync lists by comparsion of events
Arguments:
start_date -- date/datetime to start sync
@@ -161,47 +147,44 @@ class CalendarSync:
# divide source events by start datetime
events_src_pending = CalendarSync._filter_events_by_date(
events_src, start_date, operator.ge
)
events_src, start_date, operator.ge)
events_src_past = CalendarSync._filter_events_by_date(
events_src, start_date, operator.lt
)
events_src, start_date, operator.lt)
# first events comparison
(
self.to_insert,
self.to_update,
self.to_delete,
) = CalendarSync._events_list_compare(events_src_pending, events_dst)
# first events comparsion
self.to_insert, self.to_update, self.to_delete = CalendarSync._events_list_compare(
events_src_pending, events_dst)
# find in events 'to_delete' past events from source, for update (move to past)
_, add_to_update, self.to_delete = CalendarSync._events_list_compare(
events_src_past, self.to_delete
)
events_src_past, self.to_delete)
self.to_update.extend(add_to_update)
# find if events 'to_insert' exists in gcalendar, for update them
add_to_update, self.to_insert = self.gcalendar.find_exists(self.to_insert)
add_to_update, self.to_insert = self.gcalendar.find_exists(
self.to_insert)
self.to_update.extend(add_to_update)
# exclude outdated events from 'to_update' list, by 'updated' field
self._filter_events_to_update()
self.logger.info(
"prepared to sync: ( insert: %d, update: %d, delete: %d )",
'prepared to sync: ( insert: %d, update: %d, delete: %d )',
len(self.to_insert),
len(self.to_update),
len(self.to_delete),
len(self.to_delete)
)
def clear(self) -> None:
"""clear prepared sync lists (insert, update, delete)"""
""" clear prepared sync lists (insert, update, delete)
"""
self.to_insert.clear()
self.to_update.clear()
self.to_delete.clear()
def apply(self) -> None:
"""apply sync (insert, update, delete), using prepared lists of events"""
""" apply sync (insert, update, delete), using prepared lists of events
"""
self.gcalendar.insert_events(self.to_insert)
self.gcalendar.update_events(self.to_update)
@@ -209,4 +192,4 @@ class CalendarSync:
self.clear()
self.logger.info("sync done")
self.logger.info('sync done')

View File

@@ -1,4 +1,4 @@
from typing import Dict, Any, Union
from typing import Dict, Any
import yaml
@@ -6,48 +6,49 @@ import dateutil.parser
import datetime
import logging
import logging.config
from . import CalendarConverter, GoogleCalendarService, GoogleCalendar, CalendarSync
ConfigDate = Union[str, datetime.datetime]
from . import (
CalendarConverter,
GoogleCalendarService,
GoogleCalendar,
CalendarSync
)
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)
return result # type: ignore
def get_start_date(date: ConfigDate) -> datetime.datetime:
if isinstance(date, datetime.datetime):
return date
if "now" == date:
result = datetime.datetime.now(datetime.UTC)
else:
result = dateutil.parser.parse(date)
return result
def main() -> None:
def get_start_date(date_str: str) -> datetime.datetime:
if 'now' == date_str:
result = datetime.datetime.utcnow()
else:
result = dateutil.parser.parse(date_str)
return result
def main():
config = load_config()
if "logging" in config:
logging.config.dictConfig(config["logging"])
if 'logging' in config:
logging.config.dictConfig(config['logging'])
calendar_id: str = config["calendar"]["google_id"]
ics_filepath: str = config["calendar"]["source"]
calendarId: str = config['calendar']['google_id']
ics_filepath: str = config['calendar']['source']
start = get_start_date(config["start_from"])
start = get_start_date(config['start_from'])
converter = CalendarConverter()
converter.load(ics_filepath)
service = GoogleCalendarService.from_config(config)
gcalendar = GoogleCalendar(service, calendar_id)
gcalendar = GoogleCalendar(service, calendarId)
sync = CalendarSync(gcalendar, converter)
sync.prepare_sync(start)
sync.apply()
if __name__ == "__main__":
if __name__ == '__main__':
main()

View File

@@ -1,11 +1,8 @@
import datetime
from typing import Tuple, Any
from typing import Tuple
import pytest
from pytz import timezone, utc
from sync_ics2gcal import CalendarConverter
from sync_ics2gcal.ical import format_datetime_utc
uid = "UID:uisgtr8tre93wewe0yr8wqy@test.com"
only_start_date = uid + """
@@ -39,90 +36,60 @@ def ics_test_event(content: str) -> str:
return ics_test_cal("BEGIN:VEVENT\r\n{}END:VEVENT\r\n".format(content))
def test_empty_calendar() -> None:
def test_empty_calendar():
converter = CalendarConverter()
converter.loads(ics_test_cal(""))
evnts = converter.events_to_gcal()
assert evnts == []
def test_empty_event() -> None:
def test_empty_event():
converter = CalendarConverter()
converter.loads(ics_test_event(""))
with pytest.raises(KeyError):
converter.events_to_gcal()
def test_event_no_end() -> None:
def test_event_no_end():
converter = CalendarConverter()
converter.loads(ics_test_event(only_start_date))
with pytest.raises(ValueError):
converter.events_to_gcal()
@pytest.fixture(
params=[
("date", ics_test_event(date_val), "2018-02-15", "2018-02-17"),
("date", ics_test_event(date_duration), "2018-02-15", "2018-02-17"),
(
"dateTime",
ics_test_event(datetime_utc_val),
"2018-03-19T09:20:01.000001Z",
"2018-03-21T10:25:01.000001Z",
),
(
"dateTime",
ics_test_event(datetime_utc_duration),
"2018-03-19T09:20:01.000001Z",
"2018-03-21T10:25:01.000001Z",
),
],
ids=[
"date values",
"date duration",
"datetime utc values",
"datetime utc duration",
],
@pytest.fixture(params=[
("date", ics_test_event(date_val), '2018-02-15', '2018-02-17'),
("date", ics_test_event(date_duration), '2018-02-15', '2018-02-17'),
("dateTime", ics_test_event(datetime_utc_val),
'2018-03-19T09:20:01.000001Z', '2018-03-21T10:25:01.000001Z'),
("dateTime", ics_test_event(datetime_utc_duration), '2018-03-19T09:20:01.000001Z', '2018-03-21T10:25:01.000001Z')],
ids=['date values', 'date duration',
'datetime utc values', 'datetime utc duration']
)
def param_events_start_end(request: Any) -> Any:
def param_events_start_end(request):
return request.param
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
def test_event_start_end(param_events_start_end: Tuple[str, str, str, str]):
(date_type, ics_str, start, end) = param_events_start_end
converter = CalendarConverter()
converter.loads(ics_str)
events = converter.events_to_gcal()
assert len(events) == 1
event = events[0]
assert event["start"] == {date_type: start}
assert event["end"] == {date_type: end}
assert event['start'] == {
date_type: start
}
assert event['end'] == {
date_type: end
}
def test_event_created_updated() -> None:
def test_event_created_updated():
converter = CalendarConverter()
converter.loads(ics_test_event(created_updated))
events = converter.events_to_gcal()
assert len(events) == 1
event = events[0]
assert event["created"] == "2018-03-20T07:11:55.000001Z"
assert event["updated"] == "2018-03-26T12:02:35.000001Z"
@pytest.mark.parametrize(
"value,expected_str",
[
(
datetime.datetime(2022, 6, 3, 13, 52, 15, 1, utc),
"2022-06-03T13:52:15.000001Z",
),
(
datetime.datetime(2022, 6, 3, 13, 52, 15, 1, timezone("Europe/Moscow")),
"2022-06-03T11:22:15.000001Z",
),
(datetime.date(2022, 6, 3), "2022-06-03T00:00:00.000001Z"),
],
ids=["utc", "with timezone", "date"],
)
def test_format_datetime_utc(value: datetime.datetime, expected_str: str) -> None:
assert format_datetime_utc(value) == expected_str
assert event['created'] == '2018-03-20T07:11:55.000001Z'
assert event['updated'] == '2018-03-26T12:02:35.000001Z'

View File

@@ -3,97 +3,95 @@ import hashlib
import operator
from copy import deepcopy
from random import shuffle
from typing import Union, List, Dict, Optional, AnyStr
from typing import Union, List, Dict, Optional
import dateutil.parser
import pytest
from pytz import timezone, utc
from sync_ics2gcal import CalendarSync, DateDateTime
from sync_ics2gcal.gcal import EventDateOrDateTime, EventData, EventList
from sync_ics2gcal import CalendarSync
def sha1(s: AnyStr) -> str:
def sha1(string: Union[str, bytes]) -> str:
if isinstance(string, str):
string = string.encode('utf8')
h = hashlib.sha1()
h.update(str(s).encode("utf8") if isinstance(s, str) else s)
h.update(string)
return h.hexdigest()
def gen_events(
start: int,
def gen_events(start: int,
stop: int,
start_time: DateDateTime,
no_time: bool = False,
) -> EventList:
duration: datetime.timedelta
date_key: str
date_end: str
start_time: Union[datetime.datetime, datetime.date],
no_time: bool = False) -> List[Dict[str, Union[str, Dict[str, str]]]]:
if no_time:
start_time = datetime.date(start_time.year, start_time.month, start_time.day)
duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
date_key = "date"
date_end = ""
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)
date_key: str = "date"
date_end: str = ''
else:
start_time = utc.normalize(start_time.astimezone(utc)).replace(tzinfo=None) # type: ignore
duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
date_key = "dateTime"
date_end = "Z"
start_time = utc.normalize(
start_time.astimezone(utc)).replace(tzinfo=None)
duration: datetime.timedelta = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
date_key: str = "dateTime"
date_end: str = 'Z'
result: EventList = []
result: List[Dict[str, Union[str, Dict[str, str]]]] = []
for i in range(start, stop):
event_start = start_time + (duration * i)
event_end = event_start + duration
updated: DateDateTime = event_start
updated: Union[datetime.datetime, datetime.date] = event_start
if no_time:
updated = datetime.datetime(
updated.year, updated.month, updated.day, 0, 0, 0, 1, tzinfo=utc
)
updated.year, updated.month, updated.day, 0, 0, 0, 1, tzinfo=utc)
event: EventData = {
"summary": "test event __ {}".format(i),
"location": "la la la {}".format(i),
"description": "test TEST -- test event {}".format(i),
event: Dict[str, Union[str, Dict[str, str]]] = {
'summary': 'test event __ {}'.format(i),
'location': 'la la la {}'.format(i),
'description': 'test TEST -- test event {}'.format(i),
"iCalUID": "{}@test.com".format(sha1("test - event {}".format(i))),
"updated": updated.isoformat() + "Z",
"created": updated.isoformat() + "Z",
"start": {date_key: event_start.isoformat() + date_end}, # type: ignore
"end": {date_key: event_end.isoformat() + date_end}, # type: ignore
"updated": updated.isoformat() + 'Z',
"created": updated.isoformat() + 'Z',
'start': {date_key: event_start.isoformat() + date_end},
'end': {date_key: event_end.isoformat() + date_end}
}
result.append(event)
return result
def gen_list_to_compare(start: int, stop: int) -> EventList:
result: EventList = []
def gen_list_to_compare(start: int, stop: int) -> List[Dict[str, str]]:
result: List[Dict[str, str]] = []
for i in range(start, stop):
result.append({"iCalUID": "test{:06d}".format(i)})
result.append({'iCalUID': 'test{:06d}'.format(i)})
return result
def get_start_date(event: EventData) -> DateDateTime:
event_start: EventDateOrDateTime = event["start"]
def get_start_date(event: Dict[str, Union[str, Dict[str, str]]]) -> Union[datetime.datetime, datetime.date]:
event_start: Dict[str, str] = event['start']
start_date: Optional[str] = None
is_date = False
if "date" in event_start:
start_date = event_start["date"] # type: ignore
if 'date' in event_start:
start_date = event_start['date']
is_date = True
if "dateTime" in event_start:
start_date = event_start["dateTime"] # type: ignore
if 'dateTime' in event_start:
start_date = event_start['dateTime']
result: DateDateTime = dateutil.parser.parse(str(start_date))
result = dateutil.parser.parse(start_date)
if is_date:
result = datetime.date(result.year, result.month, result.day)
return result
def test_compare() -> None:
part_len: int = 20
def test_compare():
part_len = 20
# [1..2n]
lst_src = gen_list_to_compare(1, 1 + part_len * 2)
# [n..3n]
lst_dst = gen_list_to_compare(1 + part_len, 1 + part_len * 3)
lst_dst = gen_list_to_compare(
1 + part_len, 1 + part_len * 3)
lst_src_rnd = deepcopy(lst_src)
lst_dst_rnd = deepcopy(lst_dst)
@@ -101,14 +99,15 @@ def test_compare() -> None:
shuffle(lst_src_rnd)
shuffle(lst_dst_rnd)
to_ins, to_upd, to_del = CalendarSync._events_list_compare(lst_src_rnd, lst_dst_rnd)
to_ins, to_upd, to_del = CalendarSync._events_list_compare(
lst_src_rnd, lst_dst_rnd)
assert len(to_ins) == part_len
assert len(to_upd) == part_len
assert len(to_del) == part_len
assert sorted(to_ins, key=lambda x: x["iCalUID"]) == lst_src[:part_len]
assert sorted(to_del, key=lambda x: x["iCalUID"]) == lst_dst[part_len:]
assert sorted(to_ins, key=lambda x: x['iCalUID']) == lst_src[:part_len]
assert sorted(to_del, key=lambda x: x['iCalUID']) == lst_dst[part_len:]
to_upd_ok = list(zip(lst_src[part_len:], lst_dst[:part_len]))
assert len(to_upd) == len(to_upd_ok)
@@ -116,29 +115,35 @@ def test_compare() -> None:
assert item in to_upd
@pytest.mark.parametrize("no_time", [True, False], ids=["date", "dateTime"])
def test_filter_events_by_date(no_time: bool) -> None:
msk = timezone("Europe/Moscow")
now = datetime.datetime.now(datetime.UTC)
@pytest.mark.parametrize("no_time", [True, False], ids=['date', 'dateTime'])
def test_filter_events_by_date(no_time: bool):
msk = timezone('Europe/Moscow')
now = utc.localize(datetime.datetime.utcnow())
msk_now = msk.normalize(now.astimezone(msk))
part_len = 5
if no_time:
duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
duration = datetime.date(
1, 1, 2) - datetime.date(1, 1, 1)
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: DateDateTime = msk_now + (duration * part_len)
date_cmp = msk_now + (duration * part_len)
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)
events = gen_events(1, 1 + (part_len * 2), msk_now, no_time)
events = gen_events(
1, 1 + (part_len * 2), msk_now, no_time)
shuffle(events)
events_pending = CalendarSync._filter_events_by_date(events, date_cmp, operator.ge)
events_past = CalendarSync._filter_events_by_date(events, date_cmp, operator.lt)
events_pending = CalendarSync._filter_events_by_date(
events, date_cmp, operator.ge)
events_past = CalendarSync._filter_events_by_date(
events, date_cmp, operator.lt)
assert len(events_pending) == 1 + part_len
assert len(events_past) == part_len - 1
@@ -150,52 +155,26 @@ def test_filter_events_by_date(no_time: bool) -> None:
assert get_start_date(event) < date_cmp
def test_filter_events_to_update() -> None:
msk = timezone("Europe/Moscow")
now = datetime.datetime.now(datetime.UTC)
def test_filter_events_to_update():
msk = timezone('Europe/Moscow')
now = utc.localize(datetime.datetime.utcnow())
msk_now = msk.normalize(now.astimezone(msk))
one_hour = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
one_hour = datetime.datetime(
1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
date_upd = msk_now + (one_hour * 5)
count = 10
events_old = gen_events(1, 1 + count, msk_now)
events_new = gen_events(1, 1 + count, date_upd)
sync1 = CalendarSync(None, None) # type: ignore
sync1 = CalendarSync(None, None)
sync1.to_update = list(zip(events_new, events_old))
sync1._filter_events_to_update()
sync2 = CalendarSync(None, None) # type: ignore
sync2 = CalendarSync(None, None)
sync2.to_update = list(zip(events_old, events_new))
sync2._filter_events_to_update()
assert len(sync1.to_update) == count
assert sync2.to_update == []
def test_filter_events_no_updated() -> None:
"""
test filtering events that not have 'updated' field
such events should always pass the filter
"""
now = datetime.datetime.now()
yesterday = now - datetime.timedelta(days=-1)
count = 10
events_old = gen_events(1, 1 + count, now)
events_new = gen_events(1, 1 + count, now)
# 1/2 updated=yesterday, 1/2 no updated field
i = 0
for event in events_new:
if 0 == i % 2:
event["updated"] = yesterday.isoformat() + "Z"
else:
del event["updated"]
i += 1
sync = CalendarSync(None, None) # type: ignore
sync.to_update = list(zip(events_old, events_new))
sync._filter_events_to_update()
assert len(sync.to_update) == count // 2