mirror of
https://github.com/b4tman/sync_ics2gcal
synced 2026-02-05 07:44:59 +00:00
Compare commits
219 Commits
converter/
...
95208568cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95208568cb | ||
|
|
bfa8079560 | ||
|
|
914b69d81a | ||
|
|
ff9d82a5b1 | ||
|
|
228591580f | ||
|
|
4135802850 | ||
|
|
d6cc89456a | ||
|
|
6f93231a79 | ||
|
|
314ed34dc7 | ||
|
|
47015c3c5e | ||
|
|
60543837f7 | ||
|
|
391de7570b | ||
|
|
2d62495068 | ||
|
|
d0944b4a56 | ||
|
|
5bb33d1cb7 | ||
|
|
e7cbd72569 | ||
|
|
6b7f4ce5ff | ||
|
|
99b39b2abf | ||
|
|
5e6e173525 | ||
|
|
5fd1c75a25 | ||
|
|
4ba6a3cbd5 | ||
|
|
20db097fed | ||
|
|
f0363d7b0b | ||
|
|
6b7f1df64b | ||
|
|
cf85c3c3f9 | ||
|
|
bc5082e10e | ||
|
|
dcf5fc428a | ||
|
|
a8f887de52 | ||
|
|
49420f417f | ||
|
|
21fc0b9db4 | ||
|
|
79191247b4 | ||
|
|
fe70c3acd6 | ||
|
|
b3df47b8be | ||
|
|
3c98345014 | ||
|
|
ec45edb8f2 | ||
|
|
122b311b8a | ||
|
|
f36a5ad064 | ||
|
|
e11139d76f | ||
|
|
42ad4da28d | ||
|
|
550a73eb29 | ||
|
|
b1332b8d64 | ||
|
|
8a0904f68b | ||
|
|
ca6d0bf525 | ||
|
d2db9f9472
|
|||
|
|
657b0c0621 | ||
|
545cde2ccc
|
|||
|
b2c4136a92
|
|||
|
|
a65b544c8b | ||
|
|
52867a2f8e | ||
|
|
16e78dadbd | ||
|
|
ecaf9c4a39 | ||
|
|
17e28f5eab | ||
|
|
1d91cd37a2 | ||
|
|
f23adf3730 | ||
|
|
b5e34a7175 | ||
|
|
3709159f73 | ||
|
|
6cf5b936b1 | ||
|
|
830c6d6493 | ||
|
|
55ae6e70cc | ||
|
|
cc107eb295 | ||
|
|
14a3492ed5 | ||
|
|
12bb35ca33 | ||
|
|
dee0aad617 | ||
|
|
ac318237e0 | ||
|
|
c9f51c98c7 | ||
|
|
ab441a18e1 | ||
|
|
aad1b7d266 | ||
|
|
1c0074a308 | ||
|
|
2e684ca132 | ||
|
|
5a64414bb6 | ||
|
|
169faa4505 | ||
|
|
9a03984642 | ||
|
|
20343c8719 | ||
|
|
830feaf3fc | ||
|
|
9d38a8cde4 | ||
|
|
178c41c411 | ||
|
|
c2f59f9562 | ||
|
|
fe07b21e5d | ||
|
|
c806ec25e4 | ||
|
|
c426ecf06d | ||
| 5f0aa46e28 | |||
|
|
ee3e5ba613 | ||
|
|
45610c8bfe | ||
|
|
5591765911 | ||
|
|
b209fff2a1 | ||
|
|
69fc8560b6 | ||
|
|
3e00dfffa4 | ||
|
|
e64ede2472 | ||
|
|
a73d7048e4 | ||
|
|
e80b004a99 | ||
|
|
a806cc87c3 | ||
|
|
88eede3a00 | ||
|
|
7d605328c1 | ||
|
|
6561da777a | ||
|
|
a0429e24ab | ||
|
|
fa768c96ab | ||
|
|
f566c98065 | ||
|
|
c568dcd6ea | ||
|
|
111d773ff8 | ||
|
|
ac96a78280 | ||
|
|
5e845cf629 | ||
|
|
d43c906869 | ||
|
|
b685ed0c15 | ||
|
|
b6c6366b88 | ||
|
|
6ad8a6fa5e | ||
|
|
022a339157 | ||
|
|
f0c095dcc9 | ||
|
|
c6d47d2eba | ||
|
|
3e766d614c | ||
|
|
5c6c293d3b | ||
|
|
b9ac2de328 | ||
|
|
ebf0b2dc7c | ||
|
|
fb6a3c5503 | ||
|
|
2aee926d1e | ||
|
|
b1188c00a7 | ||
|
|
77e036975b | ||
|
|
c0b2863687 | ||
|
|
72e4659f8f | ||
|
|
27ba8192fd | ||
|
|
a209146c56 | ||
|
|
a60f8777f5 | ||
|
|
eafbd6bf59 | ||
|
|
9d02e01da0 | ||
|
|
1c1a1a220d | ||
|
|
3759992950 | ||
|
|
45b07dc5ab | ||
|
|
4f22594ddc | ||
|
|
dd2d87d38d | ||
|
|
ee4d6de9d5 | ||
|
|
353ffa1d0c | ||
|
|
3da56345b1 | ||
|
|
47808d92bd | ||
|
|
444e70942e | ||
|
|
0accf99f99 | ||
|
|
9eb3d964bd | ||
|
|
b51cec36e4 | ||
|
|
a2d51b1886 | ||
|
|
22d448c993 | ||
|
|
526be4de8a | ||
|
|
cc526e4172 | ||
|
|
f2e9b2cf87 | ||
|
|
0da36d5428 | ||
|
|
af8150197d | ||
|
|
72f474217a | ||
|
|
22b4cec62b | ||
|
|
defea7633b | ||
|
|
520fd15da8 | ||
|
|
f04c0c46fd | ||
|
|
74d2e18e91 | ||
|
5e4bed5604
|
|||
| 111d527c33 | |||
|
|
a18be3d079 | ||
|
c9b38cd29e
|
|||
|
c2c3a7a14d
|
|||
|
787e9df642
|
|||
|
80e15b0622
|
|||
|
ad634e9c6e
|
|||
|
6ba8106f3e
|
|||
|
e5abc3c218
|
|||
|
9ad97a158c
|
|||
|
47ac72c1fe
|
|||
|
c1c621cbf0
|
|||
|
bfdeaa17ff
|
|||
|
1eef4114cd
|
|||
|
e2d5548466
|
|||
|
0778fbe51f
|
|||
|
7b62e2c583
|
|||
|
3cbbbb1a1b
|
|||
|
29275d3f0e
|
|||
|
edd6c39dcb
|
|||
|
54146451c7
|
|||
|
5b4dca0ab9
|
|||
|
2f3a87f25f
|
|||
|
260f168077
|
|||
|
dc23acb7d2
|
|||
|
4a85424215
|
|||
|
8eaafc9925
|
|||
|
7582cba1dd
|
|||
|
|
6ac6a0fb6b | ||
|
|
e8c0c17a4c | ||
|
|
64a12c3754 | ||
|
|
aef5fd6491 | ||
|
|
6221cd02e9 | ||
|
|
6e0684372c | ||
|
|
903092835e | ||
|
|
f62f032a00 | ||
|
8c05b8319c
|
|||
|
adc61821cf
|
|||
|
25670a61ba
|
|||
|
|
6411241a9a | ||
| 912754b4c1 | |||
|
|
70278c1542 | ||
|
|
2372103807 | ||
| 3062d4323a | |||
| 7f8806e259 | |||
|
|
348dae9f60 | ||
|
f0a7668fcd
|
|||
|
38fe634a9f
|
|||
|
839a3ac0c3
|
|||
|
70a9dac822
|
|||
| e6cc7ad336 | |||
|
693c6c4359
|
|||
|
b09136747f
|
|||
|
89877bc6d8
|
|||
|
61d3186522
|
|||
|
3fa8334408
|
|||
|
|
809b0996c2 | ||
|
|
93840f9795 | ||
| 843ad37066 | |||
|
|
309969cc42 | ||
|
|
a3b3954f39 | ||
|
|
3838d66067 | ||
|
|
c3ab0bea08 | ||
|
|
5e55300448 | ||
|
|
69eea1b351 | ||
|
|
1a3ce7cda4 | ||
|
|
36c4825080 | ||
|
|
200ed88bd9 | ||
| a81192b96f |
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
# We must fetch at least the immediate parents so that if this is
|
# We must fetch at least the immediate parents so that if this is
|
||||||
# a pull request then we can checkout the head.
|
# a pull request then we can checkout the head.
|
||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v1
|
uses: github/codeql-action/init@v2
|
||||||
# Override language selection by uncommenting this and choosing your languages
|
# Override language selection by uncommenting this and choosing your languages
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v1
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|||||||
29
.github/workflows/pythonpackage.yml
vendored
29
.github/workflows/pythonpackage.yml
vendored
@@ -17,25 +17,20 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.6, 3.7, 3.8, 3.9]
|
python-version: ['3.8', '3.9', '3.10', '3.11']
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Upgrade pip
|
- name: Upgrade pip
|
||||||
run: python -m pip install --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
|
- name: Install Poetry
|
||||||
uses: snok/install-poetry@v1
|
uses: snok/install-poetry@v1
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: poetry install
|
run: poetry install --with dev
|
||||||
- name: Lint with flake8
|
- name: Lint with flake8
|
||||||
run: |
|
run: |
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
# stop the build if there are Python syntax errors or undefined names
|
||||||
@@ -44,3 +39,21 @@ 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: Check formatting with black
|
||||||
|
run: poetry run black --check --diff --color .
|
||||||
|
|
||||||
|
- name: Upload mypy report
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: mypy_report
|
||||||
|
path: mypy_report/
|
||||||
|
|||||||
9
.github/workflows/pythonpublish.yml
vendored
9
.github/workflows/pythonpublish.yml
vendored
@@ -8,22 +8,17 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
- name: Upgrade pip
|
- name: Upgrade pip
|
||||||
run: python -m pip install --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
|
- name: Install Poetry
|
||||||
uses: snok/install-poetry@v1
|
uses: snok/install-poetry@v1
|
||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: poetry install
|
run: poetry install --with dev
|
||||||
- name: Build
|
- name: Build
|
||||||
run: poetry build
|
run: poetry build
|
||||||
- name: Publish
|
- name: Publish
|
||||||
|
|||||||
49
.github/workflows/reviewdog.yml
vendored
Normal file
49
.github/workflows/reviewdog.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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 --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
7
.gitignore
vendored
@@ -4,8 +4,15 @@ 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/
|
||||||
|
docs/build/
|
||||||
|
|
||||||
|
|||||||
14
.readthedocs.yml
Normal file
14
.readthedocs.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
build:
|
||||||
|
os: ubuntu-22.04
|
||||||
|
tools:
|
||||||
|
python: "3.11"
|
||||||
|
jobs:
|
||||||
|
post_install:
|
||||||
|
- pip install poetry
|
||||||
|
- poetry config virtualenvs.create false
|
||||||
|
- poetry install --with=docs
|
||||||
|
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/source/conf.py
|
||||||
21
.travis.yml
21
.travis.yml
@@ -1,21 +0,0 @@
|
|||||||
language: python
|
|
||||||
os: linux
|
|
||||||
dist: focal
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# sync_ics2gcal
|
# sync_ics2gcal
|
||||||
|
|
||||||
[](https://badge.fury.io/py/sync-ics2gcal)
|
[](https://badge.fury.io/py/sync-ics2gcal)
|
||||||
[](https://travis-ci.org/b4tman/sync_ics2gcal)
|
|
||||||

|

|
||||||
|
[](https://sync-ics2gcal.readthedocs.io/en/latest/?badge=latest)
|
||||||
|
|
||||||
Python scripts for sync .ics file with Google calendar
|
Python scripts for sync .ics file with Google calendar
|
||||||
|
|
||||||
@@ -99,3 +99,5 @@ sync-ics2gcal
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Documentation is available at [sync-ics2gcal.readthedocs.io](https://sync-ics2gcal.readthedocs.io).
|
||||||
|
|||||||
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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)
|
||||||
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@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
|
||||||
40
docs/source/conf.py
Normal file
40
docs/source/conf.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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"]
|
||||||
BIN
docs/source/how-it-works.png
Normal file
BIN
docs/source/how-it-works.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
22
docs/source/index.rst
Normal file
22
docs/source/index.rst
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.. 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`
|
||||||
7
docs/source/modules.rst
Normal file
7
docs/source/modules.rst
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
sync_ics2gcal
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
sync_ics2gcal
|
||||||
3
docs/source/readme_link.md
Normal file
3
docs/source/readme_link.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
```{include} ../../README.md
|
||||||
|
```
|
||||||
|
|
||||||
6
docs/source/reference.rst
Normal file
6
docs/source/reference.rst
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-------------
|
||||||
|
Reference
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. include:: modules.rst
|
||||||
|
|
||||||
53
docs/source/sync_ics2gcal.rst
Normal file
53
docs/source/sync_ics2gcal.rst
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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:
|
||||||
1628
poetry.lock
generated
1628
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "sync_ics2gcal"
|
name = "sync_ics2gcal"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "Sync ics file with Google calendar"
|
description = "Sync ics file with Google calendar"
|
||||||
authors = ["Dmitry Belyaev <b4tm4n@mail.ru>"]
|
authors = ["Dmitry Belyaev <b4tm4n@mail.ru>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -11,27 +11,43 @@ 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.6',
|
|
||||||
'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',
|
||||||
|
'Programming Language :: Python :: 3.11',
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.6.1"
|
python = "^3.8"
|
||||||
google-auth = "2.2.1"
|
google-auth = "2.28.1"
|
||||||
google-api-python-client = "2.23.0"
|
google-api-python-client = "2.120.0"
|
||||||
icalendar = "4.0.7"
|
icalendar = "5.0.11"
|
||||||
pytz = "2021.1"
|
pytz = "2024.1"
|
||||||
PyYAML = "5.4.1"
|
PyYAML = "6.0.1"
|
||||||
fire = "0.4.0"
|
fire = "0.5.0"
|
||||||
pydantic = "^1.8.2"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.group.dev]
|
||||||
pytest = "^6.2.5"
|
optional = true
|
||||||
flake8 = "^3.9.2"
|
|
||||||
autopep8 = "^1.5.7"
|
[tool.poetry.group.docs]
|
||||||
|
optional = true
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
pytest = ">=7.4,<9.0"
|
||||||
|
flake8 = "^5.0.4"
|
||||||
|
black = ">=23.7,<25.0"
|
||||||
|
mypy = ">=0.960"
|
||||||
|
types-python-dateutil = "^2.8.19"
|
||||||
|
types-pytz = ">=2021.3.8"
|
||||||
|
types-PyYAML = "^6.0.12"
|
||||||
|
lxml = ">=4.9.3,<6.0.0"
|
||||||
|
|
||||||
|
[tool.poetry.group.docs.dependencies]
|
||||||
|
sphinx = ">=7.1,<7.2"
|
||||||
|
myst-parser = "^2.0.0"
|
||||||
|
sphinx-rtd-theme = ">=1.2.2,<3.0.0"
|
||||||
|
sphinx-copybutton = "^0.5.2"
|
||||||
|
sphinx-design = "^0.5.0"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
sync-ics2gcal = "sync_ics2gcal.sync_calendar:main"
|
sync-ics2gcal = "sync_ics2gcal.sync_calendar:main"
|
||||||
@@ -40,3 +56,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
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,18 +1,44 @@
|
|||||||
|
from .ical import CalendarConverter, EventConverter, DateDateTime
|
||||||
from .ical import (
|
|
||||||
CalendarConverter,
|
|
||||||
EventConverter,
|
|
||||||
DateDateTime
|
|
||||||
)
|
|
||||||
|
|
||||||
from .gcal import (
|
from .gcal import (
|
||||||
GoogleCalendarService,
|
GoogleCalendarService,
|
||||||
GoogleCalendar,
|
GoogleCalendar,
|
||||||
EventData,
|
EventData,
|
||||||
EventList,
|
EventList,
|
||||||
EventTuple
|
EventTuple,
|
||||||
|
EventDataKey,
|
||||||
|
EventDateOrDateTime,
|
||||||
|
EventDate,
|
||||||
|
EventDateTime,
|
||||||
|
EventsSearchResults,
|
||||||
|
ACLRule,
|
||||||
|
ACLScope,
|
||||||
|
CalendarData,
|
||||||
|
BatchRequestCallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .sync import (
|
from .sync import CalendarSync, ComparedEvents
|
||||||
CalendarSync
|
|
||||||
)
|
__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,47 +100,51 @@ 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 )
|
||||||
"""
|
"""
|
||||||
|
|
||||||
scopes = ['https://www.googleapis.com/auth/calendar']
|
scopes = ["https://www.googleapis.com/auth/calendar"]
|
||||||
credentials, _ = google.auth.default(scopes=scopes)
|
credentials, _ = google.auth.default(scopes=scopes)
|
||||||
service = discovery.build(
|
service = discovery.build(
|
||||||
'calendar', 'v3', credentials=credentials, cache_discovery=False)
|
"calendar", "v3", credentials=credentials, cache_discovery=False
|
||||||
|
)
|
||||||
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"]
|
||||||
credentials = service_account.Credentials.from_service_account_file(
|
credentials = service_account.Credentials.from_service_account_file(
|
||||||
service_account_file)
|
service_account_file
|
||||||
|
)
|
||||||
scoped_credentials = credentials.with_scopes(scopes)
|
scoped_credentials = credentials.with_scopes(scopes)
|
||||||
service = discovery.build(
|
service = discovery.build(
|
||||||
'calendar', 'v3', credentials=scoped_credentials,
|
"calendar", "v3", credentials=scoped_credentials, cache_discovery=False
|
||||||
cache_discovery=False)
|
)
|
||||||
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:
|
||||||
config -- config with keys:
|
|
||||||
|
**config** -- config with keys:
|
||||||
|
|
||||||
(optional) service_account: - service account filename
|
(optional) service_account: - service account filename
|
||||||
if key not in dict then default credentials will be used
|
if key not in dict then default credentials will be used
|
||||||
( https://developers.google.com/identity/protocols/application-default-credentials )
|
( 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:
|
if config is not None and "service_account" in config:
|
||||||
service = GoogleCalendarService.from_srv_acc_file(
|
service_account_filename: str = config["service_account"]
|
||||||
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,25 +160,26 @@ 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:
|
||||||
key = 'id'
|
key = "id"
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
class GoogleCalendar:
|
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, calendarId: Optional[str]):
|
def __init__(self, service: discovery.Resource, calendar_id: Optional[str]):
|
||||||
self.service: discovery.Resource = service
|
self.service: discovery.Resource = service
|
||||||
self.calendarId: str = calendarId
|
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:
|
||||||
@@ -105,184 +190,214 @@ 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(
|
||||||
'failed to %s event with %s: %s, exception: %s',
|
"failed to %s event with %s: %s, exception: %s",
|
||||||
action, key, event.get(key), str(exception)
|
action,
|
||||||
|
key,
|
||||||
|
event.get(key),
|
||||||
|
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
|
||||||
self.logger.info('event %s ok, %s: %s',
|
self.logger.info("event %s ok, %s: %s", action, key, event.get(key))
|
||||||
action, key, event.get(key))
|
|
||||||
|
|
||||||
return callback
|
return callback
|
||||||
|
|
||||||
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: str = "nextPageToken,items(id,iCalUID,updated)"
|
||||||
fields = 'nextPageToken,items(id,iCalUID,updated)'
|
events: EventList = []
|
||||||
events = []
|
page_token: Optional[str] = None
|
||||||
page_token = None
|
time_min: str = (
|
||||||
timeMin = utc.normalize(start.astimezone(utc)).replace(
|
utc.normalize(start.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z"
|
||||||
tzinfo=None).isoformat() + 'Z'
|
)
|
||||||
while True:
|
while True:
|
||||||
response = self.service.events().list(calendarId=self.calendarId,
|
response = (
|
||||||
|
self.service.events()
|
||||||
|
.list(
|
||||||
|
calendarId=self.calendar_id,
|
||||||
pageToken=page_token,
|
pageToken=page_token,
|
||||||
singleEvents=True,
|
singleEvents=True,
|
||||||
timeMin=timeMin,
|
timeMin=time_min,
|
||||||
fields=fields).execute()
|
fields=fields,
|
||||||
if 'items' in response:
|
)
|
||||||
events.extend(response['items'])
|
.execute()
|
||||||
page_token = response.get('nextPageToken')
|
)
|
||||||
|
if "items" in response:
|
||||||
|
events.extend(response["items"])
|
||||||
|
page_token = response.get("nextPageToken")
|
||||||
if not page_token:
|
if not page_token:
|
||||||
break
|
break
|
||||||
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:
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
'exception %s, while listing event with UID: %s',
|
"exception %s, while listing event with UID: %s",
|
||||||
str(exception), cur_event['iCalUID'])
|
str(exception),
|
||||||
|
cur_event["iCalUID"],
|
||||||
|
)
|
||||||
if found:
|
if found:
|
||||||
exists.append(
|
exists.append((cur_event, response["items"][0]))
|
||||||
(cur_event, response['items'][0]))
|
|
||||||
else:
|
else:
|
||||||
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(self.service.events().list(calendarId=self.calendarId,
|
batch.add(
|
||||||
iCalUID=event['iCalUID'],
|
self.service.events().list(
|
||||||
|
calendarId=self.calendar_id,
|
||||||
|
iCalUID=event["iCalUID"],
|
||||||
showDeleted=True,
|
showDeleted=True,
|
||||||
fields=fields
|
fields=fields,
|
||||||
),
|
),
|
||||||
request_id=str(i)
|
request_id=str(i),
|
||||||
)
|
)
|
||||||
i += 1
|
i += 1
|
||||||
batch.execute()
|
batch.execute()
|
||||||
self.logger.info('%d events exists, %d not found',
|
self.logger.info("%d events exists, %d not found", len(exists), len(not_found))
|
||||||
len(exists), len(not_found))
|
return EventsSearchResults(exists, not_found)
|
||||||
return 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(self.service.events().insert(
|
batch.add(
|
||||||
calendarId=self.calendarId, body=event, fields=fields),
|
self.service.events().insert(
|
||||||
request_id=str(i)
|
calendarId=self.calendar_id, body=event, fields=fields
|
||||||
|
),
|
||||||
|
request_id=str(i),
|
||||||
)
|
)
|
||||||
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
|
||||||
events_by_req.append(event_new)
|
events_by_req.append(event_new)
|
||||||
batch.add(self.service.events().patch(
|
batch.add(
|
||||||
calendarId=self.calendarId, eventId=event_old['id'],
|
self.service.events().patch(
|
||||||
body=event_new), fields=fields, request_id=str(i))
|
calendarId=self.calendar_id, eventId=event_old["id"], body=event_new
|
||||||
|
),
|
||||||
|
fields=fields,
|
||||||
|
request_id=str(i),
|
||||||
|
)
|
||||||
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
|
||||||
events_by_req.append(event_new)
|
events_by_req.append(event_new)
|
||||||
batch.add(self.service.events().update(
|
batch.add(
|
||||||
calendarId=self.calendarId, eventId=event_old['id'],
|
self.service.events().update(
|
||||||
body=event_new, fields=fields), request_id=str(i))
|
calendarId=self.calendar_id,
|
||||||
|
eventId=event_old["id"],
|
||||||
|
body=event_new,
|
||||||
|
fields=fields,
|
||||||
|
),
|
||||||
|
request_id=str(i),
|
||||||
|
)
|
||||||
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(self.service.events().delete(
|
batch.add(
|
||||||
calendarId=self.calendarId,
|
self.service.events().delete(
|
||||||
eventId=event['id']), request_id=str(i))
|
calendarId=self.calendar_id, eventId=event["id"]
|
||||||
|
),
|
||||||
|
request_id=str(i),
|
||||||
|
)
|
||||||
i += 1
|
i += 1
|
||||||
batch.execute()
|
batch.execute()
|
||||||
|
|
||||||
def create(self, summary: str, timeZone: Optional[str] = None) -> Any:
|
def create(self, summary: str, time_zone: Optional[str] = None) -> Any:
|
||||||
"""create calendar
|
"""create calendar
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
@@ -295,52 +410,35 @@ class GoogleCalendar:
|
|||||||
calendar Resource
|
calendar Resource
|
||||||
"""
|
"""
|
||||||
|
|
||||||
calendar = {'summary': summary}
|
calendar = CalendarData(summary=summary)
|
||||||
if timeZone is not None:
|
if time_zone is not None:
|
||||||
calendar['timeZone'] = timeZone
|
calendar["timeZone"] = time_zone
|
||||||
|
|
||||||
created_calendar = self.service.calendars().insert(
|
created_calendar = self.service.calendars().insert(body=calendar).execute()
|
||||||
body=calendar
|
self.calendar_id = created_calendar["id"]
|
||||||
).execute()
|
|
||||||
self.calendarId = 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.calendarId).execute()
|
self.service.calendars().delete(calendarId=self.calendar_id).execute()
|
||||||
|
|
||||||
def make_public(self):
|
def make_public(self) -> None:
|
||||||
"""make calendar puplic
|
"""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
|
||||||
},
|
|
||||||
'role': 'reader'
|
|
||||||
}
|
|
||||||
return self.service.acl().insert(
|
|
||||||
calendarId=self.calendarId,
|
|
||||||
body=rule_public
|
|
||||||
).execute()
|
).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,
|
|
||||||
},
|
|
||||||
'role': 'owner'
|
|
||||||
}
|
|
||||||
return self.service.acl().insert(
|
|
||||||
calendarId=self.calendarId,
|
|
||||||
body=rule_owner
|
|
||||||
).execute()
|
).execute()
|
||||||
|
|||||||
@@ -1,60 +1,22 @@
|
|||||||
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
|
||||||
|
|
||||||
import pydantic
|
from .gcal import (
|
||||||
|
EventData,
|
||||||
from .gcal import EventData, EventList
|
EventList,
|
||||||
|
EventDateOrDateTime,
|
||||||
|
EventDateTime,
|
||||||
|
EventDate,
|
||||||
|
EventDataKey,
|
||||||
|
)
|
||||||
|
|
||||||
DateDateTime = Union[datetime.date, datetime.datetime]
|
DateDateTime = Union[datetime.date, datetime.datetime]
|
||||||
|
|
||||||
|
|
||||||
class GCal_DateDateTime(pydantic.BaseModel):
|
|
||||||
date: Optional[str] = pydantic.Field(default=None)
|
|
||||||
date_time: Optional[str] = pydantic.Field(alias='dateTime', default=None)
|
|
||||||
timezone: Optional[str] = pydantic.Field(alias='timeZone', default=None)
|
|
||||||
|
|
||||||
@pydantic.root_validator(allow_reuse=True)
|
|
||||||
def check_only_date_or_datetime(cls, values):
|
|
||||||
date = values.get('date', None)
|
|
||||||
date_time = values.get('date_time', None)
|
|
||||||
assert (date is None) != (date_time is None), \
|
|
||||||
'only date or date_time must be provided'
|
|
||||||
return values
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_from(cls, value: DateDateTime) -> 'GCal_DateDateTime':
|
|
||||||
key: str = 'date'
|
|
||||||
str_value: str = ''
|
|
||||||
if type(value) is datetime.datetime:
|
|
||||||
key = 'date_time'
|
|
||||||
str_value = format_datetime_utc(value)
|
|
||||||
else:
|
|
||||||
str_value = value.isoformat()
|
|
||||||
return cls(**{key: str_value})
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
allow_population_by_field_name = True
|
|
||||||
|
|
||||||
|
|
||||||
class GCal_Event(pydantic.BaseModel):
|
|
||||||
created: Optional[str] = None
|
|
||||||
updated: Optional[str] = None
|
|
||||||
summary: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
location: Optional[str] = None
|
|
||||||
start: GCal_DateDateTime
|
|
||||||
end: GCal_DateDateTime
|
|
||||||
transparency: Optional[str] = None
|
|
||||||
ical_uid: str = pydantic.Field(alias='iCalUID')
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
allow_population_by_field_name = True
|
|
||||||
|
|
||||||
|
|
||||||
def format_datetime_utc(value: DateDateTime) -> str:
|
def format_datetime_utc(value: DateDateTime) -> str:
|
||||||
"""utc datetime as string from date or datetime value
|
"""utc datetime as string from date or datetime value
|
||||||
|
|
||||||
@@ -64,20 +26,17 @@ def format_datetime_utc(value: DateDateTime) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
utc datetime value as string in iso format
|
utc datetime value as string in iso format
|
||||||
"""
|
"""
|
||||||
if type(value) is datetime.date:
|
if not isinstance(value, datetime.datetime):
|
||||||
value = datetime.datetime(
|
value = datetime.datetime(value.year, value.month, value.day, tzinfo=utc)
|
||||||
value.year, value.month, value.day, tzinfo=utc)
|
|
||||||
value = value.replace(microsecond=1)
|
value = value.replace(microsecond=1)
|
||||||
|
|
||||||
return utc.normalize(
|
return utc.normalize(value.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z"
|
||||||
value.astimezone(utc)
|
|
||||||
).replace(tzinfo=None).isoformat() + 'Z'
|
|
||||||
|
|
||||||
|
|
||||||
def gcal_date_or_dateTime(value: DateDateTime,
|
def gcal_date_or_datetime(
|
||||||
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:
|
||||||
value: date or datetime
|
value: date or datetime
|
||||||
@@ -90,18 +49,18 @@ def gcal_date_or_dateTime(value: DateDateTime,
|
|||||||
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 )
|
||||||
"""
|
"""
|
||||||
@@ -110,13 +69,13 @@ class EventConverter(Event):
|
|||||||
"""decoded string property
|
"""decoded string property
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
prop - propperty name
|
prop - property name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
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
|
||||||
@@ -130,7 +89,7 @@ class EventConverter(Event):
|
|||||||
|
|
||||||
return format_datetime_utc(self.decoded(prop))
|
return format_datetime_utc(self.decoded(prop))
|
||||||
|
|
||||||
def _gcal_start(self) -> GCal_DateDateTime:
|
def _gcal_start(self) -> EventDateOrDateTime:
|
||||||
"""event start dict from icalendar event
|
"""event start dict from icalendar event
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -140,10 +99,10 @@ class EventConverter(Event):
|
|||||||
dict
|
dict
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = self.decoded('DTSTART')
|
value = self.decoded("DTSTART")
|
||||||
return GCal_DateDateTime.create_from(value)
|
return gcal_date_or_datetime(value)
|
||||||
|
|
||||||
def _gcal_end(self) -> GCal_DateDateTime:
|
def _gcal_end(self) -> EventDateOrDateTime:
|
||||||
"""event end dict from icalendar event
|
"""event end dict from icalendar event
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -152,31 +111,31 @@ class EventConverter(Event):
|
|||||||
dict
|
dict
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = None
|
result: EventDateOrDateTime
|
||||||
if 'DTEND' in self:
|
if "DTEND" in self:
|
||||||
value = self.decoded('DTEND')
|
value = self.decoded("DTEND")
|
||||||
result = GCal_DateDateTime.create_from(value)
|
result = gcal_date_or_datetime(value)
|
||||||
elif 'DURATION' in self:
|
elif "DURATION" in self:
|
||||||
start_val = self.decoded('DTSTART')
|
start_val = self.decoded("DTSTART")
|
||||||
duration = self.decoded('DURATION')
|
duration = self.decoded("DURATION")
|
||||||
end_val = start_val + duration
|
end_val = start_val + duration
|
||||||
if type(start_val) is datetime.date:
|
|
||||||
if type(end_val) is datetime.datetime:
|
|
||||||
end_val = datetime.date(
|
|
||||||
end_val.year, end_val.month, end_val.day)
|
|
||||||
|
|
||||||
result = GCal_DateDateTime.create_from(end_val)
|
result = gcal_date_or_datetime(end_val, check_value=start_val)
|
||||||
else:
|
else:
|
||||||
raise ValueError('no DTEND or DURATION')
|
raise ValueError("no DTEND or DURATION")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _put_to_gcal(self, gcal_event: EventData,
|
def _put_to_gcal(
|
||||||
prop: str, func: Callable[[str], str],
|
self,
|
||||||
ics_prop: Optional[str] = None):
|
gcal_event: EventData,
|
||||||
"""get property from ical event if exist, and put to gcal event
|
prop: EventDataKey,
|
||||||
|
func: Callable[[str], str],
|
||||||
|
ics_prop: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""get property from ical event if existed, and put to gcal event
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
gcal_event -- dest event
|
gcal_event -- destination event
|
||||||
prop -- property name
|
prop -- property name
|
||||||
func -- function to convert
|
func -- function to convert
|
||||||
ics_prop -- ical property name (default: {None})
|
ics_prop -- ical property name (default: {None})
|
||||||
@@ -187,69 +146,56 @@ 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 _get_prop(self, prop: str, func: Callable[[str], str]):
|
def convert(self) -> EventData:
|
||||||
"""get property from ical event if exist else None
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
prop -- property name
|
|
||||||
func -- function to convert
|
|
||||||
"""
|
|
||||||
|
|
||||||
if prop not in self:
|
|
||||||
return None
|
|
||||||
return func(prop)
|
|
||||||
|
|
||||||
def to_gcal(self) -> EventData:
|
|
||||||
"""Convert
|
"""Convert
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict - google calendar#event resource
|
dict - google calendar#event resource
|
||||||
"""
|
"""
|
||||||
|
|
||||||
kwargs = {
|
event: EventData = EventData(
|
||||||
'ical_uid': 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(),
|
||||||
'summary': self._get_prop('SUMMARY', self._str_prop),
|
)
|
||||||
'description': self._get_prop('DESCRIPTION', self._str_prop),
|
|
||||||
'location': self._get_prop('LOCATION', self._str_prop),
|
|
||||||
'created': self._get_prop('CREATED', self._datetime_str_prop),
|
|
||||||
'updated': self._get_prop('LAST-MODIFIED', self._datetime_str_prop),
|
|
||||||
'transparency': self._get_prop('TRANSP', lambda prop: self._str_prop(prop).lower()),
|
|
||||||
}
|
|
||||||
|
|
||||||
return GCal_Event(**kwargs).dict(by_alias=True, exclude_defaults=True)
|
self._put_to_gcal(event, "summary", self._str_prop)
|
||||||
|
self._put_to_gcal(event, "description", self._str_prop)
|
||||||
|
self._put_to_gcal(event, "location", self._str_prop)
|
||||||
|
self._put_to_gcal(event, "created", self._datetime_str_prop)
|
||||||
|
self._put_to_gcal(event, "updated", self._datetime_str_prop, "LAST-MODIFIED")
|
||||||
|
self._put_to_gcal(
|
||||||
|
event, "transparency", lambda prop: self._str_prop(prop).lower(), "TRANSP"
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
class CalendarConverter:
|
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):
|
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
|
||||||
self.logger.info('%d events readed', len(ics_events))
|
ics_events = calendar.walk(name="VEVENT")
|
||||||
|
self.logger.info("%d events read", len(ics_events))
|
||||||
|
|
||||||
result = list(
|
result = list(map(lambda event: EventConverter(event).convert(), ics_events))
|
||||||
map(lambda event: EventConverter(event).to_gcal(), ics_events))
|
self.logger.info("%d events converted", len(result))
|
||||||
self.logger.info('%d events converted', len(result))
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ 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)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
@@ -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:
|
||||||
@@ -31,8 +31,11 @@ class PropertyCommands:
|
|||||||
calendar_id: calendar id
|
calendar_id: calendar id
|
||||||
property_name: property key
|
property_name: property key
|
||||||
"""
|
"""
|
||||||
response = self._service.calendarList().get(calendarId=calendar_id,
|
response = (
|
||||||
fields=property_name).execute()
|
self._service.calendarList()
|
||||||
|
.get(calendarId=calendar_id, fields=property_name)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
print(response.get(property_name))
|
print(response.get(property_name))
|
||||||
|
|
||||||
def set(self, calendar_id: str, property_name: str, property_value: str) -> None:
|
def set(self, calendar_id: str, property_name: str, property_value: str) -> None:
|
||||||
@@ -44,22 +47,26 @@ class PropertyCommands:
|
|||||||
property_value: property value
|
property_value: property value
|
||||||
"""
|
"""
|
||||||
body = {property_name: 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)
|
print(response)
|
||||||
|
|
||||||
|
|
||||||
class Commands:
|
class Commands:
|
||||||
"""manage google calendars in service account"""
|
"""manage google calendars in service account"""
|
||||||
|
|
||||||
def __init__(self, config: str = 'config.yml'):
|
def __init__(self, config: str = "config.yml"):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config(str): config filename
|
config(str): config filename
|
||||||
"""
|
"""
|
||||||
self._config: Optional[Dict[str, Any]] = load_config(config)
|
self._config: Optional[Dict[str, Any]] = load_config(config)
|
||||||
if self._config is not None and 'logging' in self._config:
|
if self._config is not None and "logging" in self._config:
|
||||||
logging.config.dictConfig(self._config['logging'])
|
logging.config.dictConfig(self._config["logging"])
|
||||||
self._service = GoogleCalendarService.from_config(self._config)
|
self._service = GoogleCalendarService.from_config(self._config)
|
||||||
self.property = PropertyCommands(self._service)
|
self.property = PropertyCommands(self._service)
|
||||||
|
|
||||||
@@ -71,25 +78,28 @@ class Commands:
|
|||||||
show_deleted: show deleted calendars
|
show_deleted: show deleted calendars
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fields: str = 'nextPageToken,items(id,summary)'
|
fields: str = "nextPageToken,items(id,summary)"
|
||||||
calendars: List[Dict[str, Any]] = []
|
calendars: List[Dict[str, Any]] = []
|
||||||
page_token: Optional[str] = None
|
page_token: Optional[str] = None
|
||||||
while True:
|
while True:
|
||||||
calendars_api = self._service.calendarList()
|
calendars_api = self._service.calendarList()
|
||||||
response = calendars_api.list(fields=fields,
|
response = calendars_api.list(
|
||||||
|
fields=fields,
|
||||||
pageToken=page_token,
|
pageToken=page_token,
|
||||||
showHidden=show_hidden,
|
showHidden=show_hidden,
|
||||||
showDeleted=show_deleted
|
showDeleted=show_deleted,
|
||||||
).execute()
|
).execute()
|
||||||
if 'items' in response:
|
if "items" in response:
|
||||||
calendars.extend(response['items'])
|
calendars.extend(response["items"])
|
||||||
page_token = response.get('nextPageToken')
|
page_token = response.get("nextPageToken")
|
||||||
if page_token is None:
|
if page_token is None:
|
||||||
break
|
break
|
||||||
for calendar in calendars:
|
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
|
"""create calendar
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -101,7 +111,7 @@ class Commands:
|
|||||||
calendar.create(summary, timezone)
|
calendar.create(summary, timezone)
|
||||||
if public:
|
if public:
|
||||||
calendar.make_public()
|
calendar.make_public()
|
||||||
print('{}: {}'.format(summary, calendar.calendarId))
|
print("{}: {}".format(summary, calendar.calendar_id))
|
||||||
|
|
||||||
def add_owner(self, calendar_id: str, email: str) -> None:
|
def add_owner(self, calendar_id: str, email: str) -> None:
|
||||||
"""add owner to calendar
|
"""add owner to calendar
|
||||||
@@ -112,7 +122,7 @@ class Commands:
|
|||||||
"""
|
"""
|
||||||
calendar = GoogleCalendar(self._service, calendar_id)
|
calendar = GoogleCalendar(self._service, calendar_id)
|
||||||
calendar.add_owner(email)
|
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:
|
def remove(self, calendar_id: str) -> None:
|
||||||
"""remove calendar
|
"""remove calendar
|
||||||
@@ -122,7 +132,7 @@ class Commands:
|
|||||||
"""
|
"""
|
||||||
calendar = GoogleCalendar(self._service, calendar_id)
|
calendar = GoogleCalendar(self._service, calendar_id)
|
||||||
calendar.delete()
|
calendar.delete()
|
||||||
print('removed: {}'.format(calendar_id))
|
print("removed: {}".format(calendar_id))
|
||||||
|
|
||||||
def rename(self, calendar_id: str, summary: str) -> None:
|
def rename(self, calendar_id: str, summary: str) -> None:
|
||||||
"""rename calendar
|
"""rename calendar
|
||||||
@@ -131,14 +141,14 @@ class Commands:
|
|||||||
calendar_id: calendar id
|
calendar_id: calendar id
|
||||||
summary:
|
summary:
|
||||||
"""
|
"""
|
||||||
calendar = {'summary': summary}
|
calendar = {"summary": summary}
|
||||||
self._service.calendars().patch(body=calendar, calendarId=calendar_id).execute()
|
self._service.calendars().patch(body=calendar, calendarId=calendar_id).execute()
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
0
sync_ics2gcal/py.typed
Normal file
0
sync_ics2gcal/py.typed
Normal file
@@ -1,20 +1,35 @@
|
|||||||
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 CalendarSync:
|
class ComparedEvents(NamedTuple):
|
||||||
"""class for syncronize calendar with google
|
"""Compared events"""
|
||||||
"""
|
|
||||||
|
|
||||||
logger = logging.getLogger('CalendarSync')
|
added: EventList
|
||||||
|
changed: List[EventTuple]
|
||||||
|
deleted: EventList
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSync:
|
||||||
|
"""class for synchronize calendar with Google"""
|
||||||
|
|
||||||
|
logger = logging.getLogger("CalendarSync")
|
||||||
|
|
||||||
def __init__(self, gcalendar: GoogleCalendar, converter: CalendarConverter):
|
def __init__(self, gcalendar: GoogleCalendar, converter: CalendarConverter):
|
||||||
self.gcalendar: GoogleCalendar = gcalendar
|
self.gcalendar: GoogleCalendar = gcalendar
|
||||||
@@ -24,24 +39,22 @@ class CalendarSync:
|
|||||||
self.to_delete: EventList = []
|
self.to_delete: EventList = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _events_list_compare(items_src: EventList,
|
def _events_list_compare(
|
||||||
items_dst: EventList,
|
items_src: EventList, items_dst: EventList, key: EventDataKey = "iCalUID"
|
||||||
key: str = 'iCalUID') \
|
) -> ComparedEvents:
|
||||||
-> Tuple[EventList, List[EventTuple], EventList]:
|
|
||||||
"""compare list of events by key
|
"""compare list of events by key
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
items_src {list of dict} -- source events
|
items_src {list of dict} -- source events
|
||||||
items_dst {list of dict} -- dest events
|
items_dst {list of dict} -- destination events
|
||||||
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: return item[key]
|
def get_key(item: EventData) -> str:
|
||||||
|
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))
|
||||||
@@ -50,67 +63,68 @@ 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,
|
def items_by_keys(items: EventList, keys: Set[str]) -> EventList:
|
||||||
key_name: str,
|
return list(filter(lambda item: get_key(item) in keys, items))
|
||||||
keys: Set[str]) -> EventList:
|
|
||||||
return list(filter(lambda item: item[key_name] in keys, items))
|
|
||||||
|
|
||||||
items_to_insert = items_by_keys(items_src, key, keys_to_insert)
|
items_to_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:
|
||||||
new, old = event_tuple
|
new, old = event_tuple
|
||||||
new_date = dateutil.parser.parse(new['updated'])
|
if "updated" not in new or "updated" not in old:
|
||||||
old_date = dateutil.parser.parse(old['updated'])
|
return True
|
||||||
|
new_date = dateutil.parser.parse(new["updated"])
|
||||||
|
old_date = dateutil.parser.parse(old["updated"])
|
||||||
return new_date > old_date
|
return new_date > old_date
|
||||||
|
|
||||||
self.to_update = list(filter(filter_updated, self.to_update))
|
self.to_update = list(filter(filter_updated, self.to_update))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filter_events_by_date(events: EventList,
|
def _filter_events_by_date(
|
||||||
|
events: EventList,
|
||||||
date: DateDateTime,
|
date: DateDateTime,
|
||||||
op: Callable[[DateDateTime,
|
op: Callable[[DateDateTime, DateDateTime], bool],
|
||||||
DateDateTime], bool]) -> EventList:
|
) -> EventList:
|
||||||
"""filter events by start datetime
|
"""filter events by start datetime
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
events -- events list
|
events -- events list
|
||||||
date {datetime} -- datetime to compare
|
date {datetime} -- datetime to compare
|
||||||
op {operator} -- comparsion operator
|
op {operator} -- comparison operator
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list of filtred events
|
list of filtered events
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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(
|
||||||
event_date.year, event_date.month, event_date.day)
|
event_date.year, event_date.month, event_date.day
|
||||||
|
)
|
||||||
|
|
||||||
return op(event_date, date_cmp)
|
return op(event_date, date_cmp)
|
||||||
|
|
||||||
@@ -118,13 +132,13 @@ class CalendarSync:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _tz_aware_datetime(date: DateDateTime) -> datetime.datetime:
|
def _tz_aware_datetime(date: DateDateTime) -> datetime.datetime:
|
||||||
"""make tz aware datetime from datetime/date (utc if no tzinfo)
|
"""make tz aware datetime from datetime/date (utc if no tz-info)
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
date - date or datetime / with or without tzinfo
|
date - date or datetime / with or without tz-info
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
datetime with tzinfo
|
datetime with tz-info
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not isinstance(date, datetime.datetime):
|
if not isinstance(date, datetime.datetime):
|
||||||
@@ -134,7 +148,7 @@ class CalendarSync:
|
|||||||
return date
|
return date
|
||||||
|
|
||||||
def prepare_sync(self, start_date: DateDateTime) -> None:
|
def prepare_sync(self, start_date: DateDateTime) -> None:
|
||||||
"""prepare sync lists by comparsion of events
|
"""prepare sync lists by comparison of events
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
start_date -- date/datetime to start sync
|
start_date -- date/datetime to start sync
|
||||||
@@ -147,44 +161,47 @@ class CalendarSync:
|
|||||||
|
|
||||||
# divide source events by start datetime
|
# divide source events by start datetime
|
||||||
events_src_pending = CalendarSync._filter_events_by_date(
|
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_past = CalendarSync._filter_events_by_date(
|
||||||
events_src, start_date, operator.lt)
|
events_src, start_date, operator.lt
|
||||||
|
)
|
||||||
|
|
||||||
# first events comparsion
|
# first events comparison
|
||||||
self.to_insert, self.to_update, self.to_delete = CalendarSync._events_list_compare(
|
(
|
||||||
events_src_pending, events_dst)
|
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)
|
# find in events 'to_delete' past events from source, for update (move to past)
|
||||||
_, add_to_update, self.to_delete = CalendarSync._events_list_compare(
|
_, 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)
|
self.to_update.extend(add_to_update)
|
||||||
|
|
||||||
# find if events 'to_insert' exists in gcalendar, for update them
|
# find if events 'to_insert' exists in gcalendar, for update them
|
||||||
add_to_update, self.to_insert = self.gcalendar.find_exists(
|
add_to_update, self.to_insert = self.gcalendar.find_exists(self.to_insert)
|
||||||
self.to_insert)
|
|
||||||
self.to_update.extend(add_to_update)
|
self.to_update.extend(add_to_update)
|
||||||
|
|
||||||
# exclude outdated events from 'to_update' list, by 'updated' field
|
# exclude outdated events from 'to_update' list, by 'updated' field
|
||||||
self._filter_events_to_update()
|
self._filter_events_to_update()
|
||||||
|
|
||||||
self.logger.info(
|
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_insert),
|
||||||
len(self.to_update),
|
len(self.to_update),
|
||||||
len(self.to_delete)
|
len(self.to_delete),
|
||||||
)
|
)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
""" clear prepared sync lists (insert, update, delete)
|
"""clear prepared sync lists (insert, update, delete)"""
|
||||||
"""
|
|
||||||
self.to_insert.clear()
|
self.to_insert.clear()
|
||||||
self.to_update.clear()
|
self.to_update.clear()
|
||||||
self.to_delete.clear()
|
self.to_delete.clear()
|
||||||
|
|
||||||
def apply(self) -> None:
|
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.insert_events(self.to_insert)
|
||||||
self.gcalendar.update_events(self.to_update)
|
self.gcalendar.update_events(self.to_update)
|
||||||
@@ -192,4 +209,4 @@ class CalendarSync:
|
|||||||
|
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
self.logger.info('sync done')
|
self.logger.info("sync done")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any, Union
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -6,49 +6,48 @@ import dateutil.parser
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
from . import (
|
from . import CalendarConverter, GoogleCalendarService, GoogleCalendar, CalendarSync
|
||||||
CalendarConverter,
|
|
||||||
GoogleCalendarService,
|
ConfigDate = Union[str, datetime.datetime]
|
||||||
GoogleCalendar,
|
|
||||||
CalendarSync
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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_str: str) -> datetime.datetime:
|
def get_start_date(date: ConfigDate) -> datetime.datetime:
|
||||||
if 'now' == date_str:
|
if isinstance(date, datetime.datetime):
|
||||||
|
return date
|
||||||
|
if "now" == date:
|
||||||
result = datetime.datetime.utcnow()
|
result = datetime.datetime.utcnow()
|
||||||
else:
|
else:
|
||||||
result = dateutil.parser.parse(date_str)
|
result = dateutil.parser.parse(date)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main() -> None:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if 'logging' in config:
|
if "logging" in config:
|
||||||
logging.config.dictConfig(config['logging'])
|
logging.config.dictConfig(config["logging"])
|
||||||
|
|
||||||
calendarId: str = config['calendar']['google_id']
|
calendar_id: str = config["calendar"]["google_id"]
|
||||||
ics_filepath: str = config['calendar']['source']
|
ics_filepath: str = config["calendar"]["source"]
|
||||||
|
|
||||||
start = get_start_date(config['start_from'])
|
start = get_start_date(config["start_from"])
|
||||||
|
|
||||||
converter = CalendarConverter()
|
converter = CalendarConverter()
|
||||||
converter.load(ics_filepath)
|
converter.load(ics_filepath)
|
||||||
|
|
||||||
service = GoogleCalendarService.from_config(config)
|
service = GoogleCalendarService.from_config(config)
|
||||||
gcalendar = GoogleCalendar(service, calendarId)
|
gcalendar = GoogleCalendar(service, calendar_id)
|
||||||
|
|
||||||
sync = CalendarSync(gcalendar, converter)
|
sync = CalendarSync(gcalendar, converter)
|
||||||
sync.prepare_sync(start)
|
sync.prepare_sync(start)
|
||||||
sync.apply()
|
sync.apply()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
|
|
||||||
from typing import Iterable, List, Tuple, Union, Optional
|
|
||||||
from uuid import uuid4
|
|
||||||
import datetime
|
|
||||||
from itertools import islice
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import time
|
|
||||||
import statistics
|
|
||||||
import functools
|
|
||||||
|
|
||||||
from sync_ics2gcal import CalendarConverter
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class IcsTestEvent:
|
|
||||||
uid: str
|
|
||||||
start_date: Union[datetime.datetime, datetime.date]
|
|
||||||
end_date: Union[datetime.datetime, datetime.date, None] = None
|
|
||||||
duration: Optional[datetime.timedelta] = None
|
|
||||||
created: Union[datetime.datetime, datetime.date, None] = None
|
|
||||||
updated: Union[datetime.datetime, datetime.date, None] = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_datetime(value: Union[datetime.datetime, datetime.date]):
|
|
||||||
result: str = ''
|
|
||||||
if isinstance(value, datetime.datetime):
|
|
||||||
result += f'DATE-TIME:{value.strftime("%Y%m%dT%H%M%SZ")}'
|
|
||||||
else:
|
|
||||||
result += f'DATE:{value.strftime("%Y%m%d")}'
|
|
||||||
return result
|
|
||||||
|
|
||||||
def render(self) -> str:
|
|
||||||
result: str = ''
|
|
||||||
result += 'BEGIN:VEVENT\r\n'
|
|
||||||
result += f'UID:{self.uid}\r\n'
|
|
||||||
result += f'DTSTART;VALUE={IcsTestEvent._format_datetime(self.start_date)}\r\n'
|
|
||||||
if self.end_date is not None:
|
|
||||||
result += f'DTEND;VALUE={IcsTestEvent._format_datetime(self.end_date)}\r\n'
|
|
||||||
else:
|
|
||||||
result += f'DURATION:P{self.duration.days}D\r\n'
|
|
||||||
if self.created is not None:
|
|
||||||
result += f'CREATED:{self.created.strftime("%Y%m%dT%H%M%SZ")}\r\n'
|
|
||||||
if self.updated is not None:
|
|
||||||
result += f'LAST-MODIFIED:{self.updated.strftime("%Y%m%dT%H%M%SZ")}\r\n'
|
|
||||||
result += 'END:VEVENT\r\n'
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class IcsTestCalendar:
|
|
||||||
events: List[IcsTestEvent]
|
|
||||||
|
|
||||||
def render(self) -> str:
|
|
||||||
result: str = ''
|
|
||||||
result += 'BEGIN:VCALENDAR\r\n'
|
|
||||||
for event in self.events:
|
|
||||||
result += event.render()
|
|
||||||
result += 'END:VCALENDAR\r\n'
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def gen_test_calendar(events_count: int) -> IcsTestCalendar:
|
|
||||||
def gen_events() -> Iterable[IcsTestEvent]:
|
|
||||||
for i in range(10000000):
|
|
||||||
uid = f'{uuid4()}@test.com'
|
|
||||||
start_date = datetime.datetime.now() + datetime.timedelta(hours=i)
|
|
||||||
end_date = start_date + datetime.timedelta(hours=1)
|
|
||||||
event: IcsTestEvent = IcsTestEvent(
|
|
||||||
uid=uid, start_date=start_date, end_date=end_date, created=start_date, updated=start_date)
|
|
||||||
yield event
|
|
||||||
|
|
||||||
events: List[IcsTestEvent] = list(islice(gen_events(), events_count))
|
|
||||||
result: IcsTestCalendar = IcsTestCalendar(events)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
test_calendar: IcsTestCalendar = gen_test_calendar(1000)
|
|
||||||
ics_test_calendar: str = test_calendar.render()
|
|
||||||
converter = CalendarConverter()
|
|
||||||
converter.loads(ics_test_calendar)
|
|
||||||
|
|
||||||
|
|
||||||
def bench(num_iters=1000):
|
|
||||||
def make_wrapper(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def wrapper(*args, **kw):
|
|
||||||
times = []
|
|
||||||
for _ in range(num_iters):
|
|
||||||
t0 = time.perf_counter_ns()
|
|
||||||
result = func(*args, **kw)
|
|
||||||
t1 = time.perf_counter_ns()
|
|
||||||
times.append(t1 - t0)
|
|
||||||
best = min(times)
|
|
||||||
avg = round(sum(times) / num_iters, 2)
|
|
||||||
median = statistics.median(times)
|
|
||||||
print(
|
|
||||||
f'{func.__name__} x {num_iters} => best: {best} ns, \tavg: {avg} ns, \tmedian: {median} ns')
|
|
||||||
return result
|
|
||||||
return wrapper()
|
|
||||||
return make_wrapper
|
|
||||||
|
|
||||||
|
|
||||||
@bench(num_iters=500)
|
|
||||||
def events_to_gcal():
|
|
||||||
converter.events_to_gcal()
|
|
||||||
@@ -1,31 +1,52 @@
|
|||||||
from typing import Tuple
|
import datetime
|
||||||
|
from typing import Tuple, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytz import timezone, utc
|
||||||
|
|
||||||
from sync_ics2gcal import CalendarConverter
|
from sync_ics2gcal import CalendarConverter
|
||||||
|
from sync_ics2gcal.ical import format_datetime_utc
|
||||||
|
|
||||||
uid = "UID:uisgtr8tre93wewe0yr8wqy@test.com"
|
uid = "UID:uisgtr8tre93wewe0yr8wqy@test.com"
|
||||||
only_start_date = uid + """
|
only_start_date = (
|
||||||
|
uid
|
||||||
|
+ """
|
||||||
DTSTART;VALUE=DATE:20180215
|
DTSTART;VALUE=DATE:20180215
|
||||||
"""
|
"""
|
||||||
date_val = only_start_date + """
|
)
|
||||||
|
date_val = (
|
||||||
|
only_start_date
|
||||||
|
+ """
|
||||||
DTEND;VALUE=DATE:20180217
|
DTEND;VALUE=DATE:20180217
|
||||||
"""
|
"""
|
||||||
date_duration = only_start_date + """
|
)
|
||||||
|
date_duration = (
|
||||||
|
only_start_date
|
||||||
|
+ """
|
||||||
DURATION:P2D
|
DURATION:P2D
|
||||||
"""
|
"""
|
||||||
datetime_utc_val = uid + """
|
)
|
||||||
|
datetime_utc_val = (
|
||||||
|
uid
|
||||||
|
+ """
|
||||||
DTSTART;VALUE=DATE-TIME:20180319T092001Z
|
DTSTART;VALUE=DATE-TIME:20180319T092001Z
|
||||||
DTEND:20180321T102501Z
|
DTEND:20180321T102501Z
|
||||||
"""
|
"""
|
||||||
datetime_utc_duration = uid + """
|
)
|
||||||
|
datetime_utc_duration = (
|
||||||
|
uid
|
||||||
|
+ """
|
||||||
DTSTART;VALUE=DATE-TIME:20180319T092001Z
|
DTSTART;VALUE=DATE-TIME:20180319T092001Z
|
||||||
DURATION:P2DT1H5M
|
DURATION:P2DT1H5M
|
||||||
"""
|
"""
|
||||||
created_updated = date_val + """
|
)
|
||||||
|
created_updated = (
|
||||||
|
date_val
|
||||||
|
+ """
|
||||||
CREATED:20180320T071155Z
|
CREATED:20180320T071155Z
|
||||||
LAST-MODIFIED:20180326T120235Z
|
LAST-MODIFIED:20180326T120235Z
|
||||||
"""
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ics_test_cal(content: str) -> str:
|
def ics_test_cal(content: str) -> str:
|
||||||
@@ -36,60 +57,90 @@ 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):
|
||||||
converter.events_to_gcal()
|
converter.events_to_gcal()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=[
|
@pytest.fixture(
|
||||||
("date", ics_test_event(date_val), '2018-02-15', '2018-02-17'),
|
params=[
|
||||||
("date", ics_test_event(date_duration), '2018-02-15', '2018-02-17'),
|
("date", ics_test_event(date_val), "2018-02-15", "2018-02-17"),
|
||||||
("dateTime", ics_test_event(datetime_utc_val),
|
("date", ics_test_event(date_duration), "2018-02-15", "2018-02-17"),
|
||||||
'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')],
|
"dateTime",
|
||||||
ids=['date values', 'date duration',
|
ics_test_event(datetime_utc_val),
|
||||||
'datetime utc values', 'datetime utc duration']
|
"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):
|
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)
|
||||||
events = converter.events_to_gcal()
|
events = converter.events_to_gcal()
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
event = events[0]
|
event = events[0]
|
||||||
assert event['start'] == {
|
assert event["start"] == {date_type: start}
|
||||||
date_type: start
|
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()
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
event = events[0]
|
event = events[0]
|
||||||
assert event['created'] == '2018-03-20T07:11:55.000001Z'
|
assert event["created"] == "2018-03-20T07:11:55.000001Z"
|
||||||
assert event['updated'] == '2018-03-26T12:02:35.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
|
||||||
|
|||||||
@@ -3,95 +3,97 @@ 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(start: int,
|
def gen_events(
|
||||||
|
start: int,
|
||||||
stop: int,
|
stop: int,
|
||||||
start_time: Union[datetime.datetime, datetime.date],
|
start_time: DateDateTime,
|
||||||
no_time: bool = False) -> List[Dict[str, Union[str, Dict[str, str]]]]:
|
no_time: bool = False,
|
||||||
|
) -> EventList:
|
||||||
|
duration: datetime.timedelta
|
||||||
|
date_key: str
|
||||||
|
date_end: str
|
||||||
if no_time:
|
if no_time:
|
||||||
start_time = datetime.date(
|
start_time = datetime.date(start_time.year, start_time.month, start_time.day)
|
||||||
start_time.year, start_time.month, start_time.day)
|
duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
|
||||||
duration: datetime.timedelta = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
|
date_key = "date"
|
||||||
date_key: str = "date"
|
date_end = ""
|
||||||
date_end: str = ''
|
|
||||||
else:
|
else:
|
||||||
start_time = utc.normalize(
|
start_time = utc.normalize(start_time.astimezone(utc)).replace(tzinfo=None) # type: ignore
|
||||||
start_time.astimezone(utc)).replace(tzinfo=None)
|
duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
||||||
duration: datetime.timedelta = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
date_key = "dateTime"
|
||||||
date_key: str = "dateTime"
|
date_end = "Z"
|
||||||
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(event: Dict[str, Union[str, Dict[str, str]]]) -> Union[datetime.datetime, datetime.date]:
|
def get_start_date(event: EventData) -> DateDateTime:
|
||||||
event_start: Dict[str, str] = event['start']
|
event_start: EventDateOrDateTime = 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]
|
||||||
lst_dst = gen_list_to_compare(
|
lst_dst = gen_list_to_compare(1 + part_len, 1 + part_len * 3)
|
||||||
1 + part_len, 1 + part_len * 3)
|
|
||||||
|
|
||||||
lst_src_rnd = deepcopy(lst_src)
|
lst_src_rnd = deepcopy(lst_src)
|
||||||
lst_dst_rnd = deepcopy(lst_dst)
|
lst_dst_rnd = deepcopy(lst_dst)
|
||||||
@@ -99,15 +101,14 @@ def test_compare():
|
|||||||
shuffle(lst_src_rnd)
|
shuffle(lst_src_rnd)
|
||||||
shuffle(lst_dst_rnd)
|
shuffle(lst_dst_rnd)
|
||||||
|
|
||||||
to_ins, to_upd, to_del = CalendarSync._events_list_compare(
|
to_ins, to_upd, to_del = CalendarSync._events_list_compare(lst_src_rnd, lst_dst_rnd)
|
||||||
lst_src_rnd, lst_dst_rnd)
|
|
||||||
|
|
||||||
assert len(to_ins) == part_len
|
assert len(to_ins) == part_len
|
||||||
assert len(to_upd) == part_len
|
assert len(to_upd) == part_len
|
||||||
assert len(to_del) == part_len
|
assert len(to_del) == part_len
|
||||||
|
|
||||||
assert sorted(to_ins, key=lambda x: x['iCalUID']) == lst_src[: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_del, key=lambda x: x["iCalUID"]) == lst_dst[part_len:]
|
||||||
|
|
||||||
to_upd_ok = list(zip(lst_src[part_len:], 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)
|
assert len(to_upd) == len(to_upd_ok)
|
||||||
@@ -115,35 +116,29 @@ def test_compare():
|
|||||||
assert item in to_upd
|
assert item in to_upd
|
||||||
|
|
||||||
|
|
||||||
@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))
|
||||||
|
|
||||||
part_len = 5
|
part_len = 5
|
||||||
|
|
||||||
if no_time:
|
if no_time:
|
||||||
duration = datetime.date(
|
duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
|
||||||
1, 1, 2) - datetime.date(1, 1, 1)
|
|
||||||
else:
|
else:
|
||||||
duration = datetime.datetime(
|
duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
||||||
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 = datetime.date(date_cmp.year, date_cmp.month, date_cmp.day)
|
||||||
date_cmp.year, date_cmp.month, date_cmp.day)
|
|
||||||
|
|
||||||
events = gen_events(
|
events = gen_events(1, 1 + (part_len * 2), msk_now, no_time)
|
||||||
1, 1 + (part_len * 2), msk_now, no_time)
|
|
||||||
shuffle(events)
|
shuffle(events)
|
||||||
|
|
||||||
events_pending = CalendarSync._filter_events_by_date(
|
events_pending = CalendarSync._filter_events_by_date(events, date_cmp, operator.ge)
|
||||||
events, date_cmp, operator.ge)
|
events_past = CalendarSync._filter_events_by_date(events, date_cmp, operator.lt)
|
||||||
events_past = CalendarSync._filter_events_by_date(
|
|
||||||
events, date_cmp, operator.lt)
|
|
||||||
|
|
||||||
assert len(events_pending) == 1 + part_len
|
assert len(events_pending) == 1 + part_len
|
||||||
assert len(events_past) == part_len - 1
|
assert len(events_past) == part_len - 1
|
||||||
@@ -155,26 +150,52 @@ 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))
|
||||||
|
|
||||||
one_hour = datetime.datetime(
|
one_hour = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
||||||
1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
|
||||||
date_upd = msk_now + (one_hour * 5)
|
date_upd = msk_now + (one_hour * 5)
|
||||||
|
|
||||||
count = 10
|
count = 10
|
||||||
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()
|
||||||
|
|
||||||
assert len(sync1.to_update) == count
|
assert len(sync1.to_update) == count
|
||||||
assert sync2.to_update == []
|
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.utcnow()
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user