mirror of
https://github.com/b4tman/sync_ics2gcal
synced 2025-07-22 21:03:33 +00:00
Compare commits
248 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
474f6ab58f | ||
3fcb4855d5 | |||
|
b34b1a8b20 | ||
|
1f270d65af | ||
|
5af61e92be | ||
|
d8feb095d8 | ||
|
56a84d8a65 | ||
|
6f4f34e1e1 | ||
|
30b01e3418 | ||
|
74faae48eb | ||
|
e68a04c1b3 | ||
|
119202ddcc | ||
|
de9b5fc174 | ||
|
bc9b4f9553 | ||
|
e86767b415 | ||
f1969a68a1 | |||
|
51ccf2fc88 | ||
eaaa3c9191
|
|||
526b75a895 | |||
|
290b86b765 | ||
4b290da71f
|
|||
f3b8cd958e
|
|||
68903924cf
|
|||
|
b6fab04662 | ||
|
6094d61d64 | ||
|
93dfcadc22 | ||
|
ae6d702f87 | ||
|
e02070c643 | ||
|
3219d01280 | ||
|
3fcc5eb2b7 | ||
|
4e24bfed47 | ||
|
1e087a7375 | ||
|
03cb931ff2 | ||
|
91bba6a568 | ||
|
993f2cac70 | ||
|
398d95f962 | ||
|
1ca4422379 | ||
|
820ab55de1 | ||
|
f3d37dca9e | ||
|
1d17e50d62 | ||
|
ab10c9eee0 | ||
|
fdb7a7f91f | ||
|
96801615f0 | ||
|
9869e966f7 | ||
|
8b09e37ebb | ||
|
e728d871ff | ||
|
3a95534d71 | ||
|
a68ebfd75b | ||
|
0120b313ec | ||
|
e1828ce304 | ||
|
20cd547ef3 | ||
|
c04a57719e | ||
|
d7b2559d2c | ||
|
a0cef6f010 | ||
|
0ae15d1cd3 | ||
|
e63bd7cca8 | ||
|
601ef8f0d7 | ||
|
e4725ffbf4 | ||
|
1fc572e427 | ||
|
b6f1b75c6b | ||
|
566ccedcc1 | ||
|
ecbb3ab459 | ||
|
fa228b5f70 | ||
|
7ac51ab562 | ||
|
091ff08579 | ||
|
b7d9df76d5 | ||
|
a14e56cfcd | ||
|
11f1777432 | ||
|
c874ca2199 | ||
|
fbd83ff497 | ||
f945cdc915
|
|||
217f284102 | |||
|
2d90ef289a | ||
|
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
|
|||
e5abc3c218
|
|||
9ad97a158c
|
|||
47ac72c1fe
|
|||
c1c621cbf0
|
|||
bfdeaa17ff
|
|||
1eef4114cd
|
|||
e2d5548466
|
|||
0778fbe51f
|
|||
7b62e2c583
|
|||
3cbbbb1a1b
|
|||
29275d3f0e
|
|||
edd6c39dcb
|
|||
54146451c7
|
|||
5b4dca0ab9
|
|||
2f3a87f25f
|
|||
260f168077
|
|||
dc23acb7d2
|
|||
4a85424215
|
.github
.gitignore.readthedocs.ymlREADME.mddocs
poetry.lockpyproject.tomlsync_ics2gcal
tests
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: ['https://boosty.to/0xffff', 'https://www.donationalerts.com/r/b4tman1']
|
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -4,6 +4,12 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
time: '02:00'
|
||||
time: '02:00'
|
||||
timezone: "Europe/Moscow"
|
||||
day: "saturday"
|
||||
groups:
|
||||
"PyPi updates":
|
||||
patterns:
|
||||
- "*"
|
||||
open-pull-requests-limit: 10
|
||||
target-branch: develop
|
||||
|
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
28
.github/workflows/pythonpackage.yml
vendored
28
.github/workflows/pythonpackage.yml
vendored
@@ -15,14 +15,14 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
python-version: ['3.7', '3.8', '3.9', '3.10']
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Upgrade pip
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
- name: Install deps
|
||||
run: poetry install
|
||||
run: poetry install --with dev
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
@@ -39,3 +39,21 @@ jobs:
|
||||
poetry run flake8 sync_ics2gcal --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Test with pytest
|
||||
run: poetry run pytest -v
|
||||
|
||||
- name: Check type annotations with mypy
|
||||
run: |
|
||||
mkdir mypy_report
|
||||
poetry run mypy --pretty --html-report mypy_report/ .
|
||||
|
||||
- name: Check type annotations with mypy strict mode (not failing)
|
||||
run: |
|
||||
poetry run mypy --strict --pretty . || true
|
||||
|
||||
- name: Check formatting with black
|
||||
run: poetry run black --check --diff --color .
|
||||
|
||||
- name: Upload mypy report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mypy_report-${{ matrix.python-version }}
|
||||
path: mypy_report/
|
||||
|
8
.github/workflows/pythonpublish.yml
vendored
8
.github/workflows/pythonpublish.yml
vendored
@@ -2,15 +2,15 @@ name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Upgrade pip
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
- name: Install deps
|
||||
run: poetry install
|
||||
run: poetry install --with dev
|
||||
- name: Build
|
||||
run: poetry build
|
||||
- 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@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
- name: Upgrade pip
|
||||
run: python -m pip install --upgrade pip
|
||||
|
||||
- name: Install Poetry
|
||||
uses: snok/install-poetry@v1
|
||||
|
||||
- name: Install deps
|
||||
run: poetry install --with dev
|
||||
|
||||
- name: setup mypy
|
||||
run: |
|
||||
mkdir tmp_bin/
|
||||
echo "#!/bin/sh" > tmp_bin/mypy
|
||||
echo "poetry run mypy \$@" >> tmp_bin/mypy
|
||||
chmod +x tmp_bin/mypy
|
||||
echo "$(pwd)/tmp_bin" >> $GITHUB_PATH
|
||||
|
||||
- uses: tsuyoshicho/action-mypy@v3
|
||||
with:
|
||||
reporter: github-pr-review
|
||||
level: warning
|
||||
|
||||
- name: format with black
|
||||
run: poetry run black .
|
||||
|
||||
- name: suggester / black
|
||||
uses: reviewdog/action-suggester@v1
|
||||
with:
|
||||
tool_name: black
|
7
.gitignore
vendored
7
.gitignore
vendored
@@ -4,8 +4,15 @@ service-account.json
|
||||
my-test*.ics
|
||||
.vscode/
|
||||
.idea/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
/dist/
|
||||
/*.egg-info/
|
||||
/build/
|
||||
/.eggs/
|
||||
venv/
|
||||
mypy_report/
|
||||
tmp_bin/
|
||||
docs/build/
|
||||
|
||||
|
16
.readthedocs.yml
Normal file
16
.readthedocs.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
jobs:
|
||||
post_create_environment:
|
||||
- pip install poetry
|
||||
post_install:
|
||||
# VIRTUAL_ENV needs to be set manually for now.
|
||||
# See https://github.com/readthedocs/readthedocs.org/pull/11152/
|
||||
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs
|
||||
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://badge.fury.io/py/sync-ics2gcal)
|
||||

|
||||
[](https://sync-ics2gcal.readthedocs.io/en/latest/?badge=latest)
|
||||
|
||||
Python scripts for sync .ics file with Google calendar
|
||||
|
||||
@@ -98,3 +99,5 @@ sync-ics2gcal
|
||||
## 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 ![]() (image error) 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:
|
2338
poetry.lock
generated
2338
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "sync_ics2gcal"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "Sync ics file with Google calendar"
|
||||
authors = ["Dmitry Belyaev <b4tm4n@mail.ru>"]
|
||||
license = "MIT"
|
||||
@@ -11,25 +11,42 @@ keywords = ["icalendar", "sync", "google", "calendar"]
|
||||
classifiers = [
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Programming Language :: Python :: 3.13',
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
google-auth = "2.6.6"
|
||||
google-api-python-client = "2.49.0"
|
||||
icalendar = "4.0.9"
|
||||
pytz = "2022.1"
|
||||
PyYAML = "6.0"
|
||||
fire = "0.4.0"
|
||||
python = "^3.11"
|
||||
google-auth = "2.40.3"
|
||||
google-api-python-client = "2.174.0"
|
||||
icalendar = "6.3.1"
|
||||
pytz = "2025.2"
|
||||
PyYAML = "6.0.2"
|
||||
fire = "0.7.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.2"
|
||||
flake8 = "^4.0.1"
|
||||
black = "^22.3.0"
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = ">=8.1,<9.0"
|
||||
flake8 = ">=7.0.4,<8.0.0"
|
||||
black = ">=25.0,<26.0"
|
||||
mypy = ">=1.16.1"
|
||||
types-python-dateutil = ">=2.9.0.20250516"
|
||||
types-pytz = ">=2025.2.0.20250516"
|
||||
types-PyYAML = "^6.0.12.20250516"
|
||||
lxml = ">=5.4.0,<7.0.0"
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
sphinx = ">=8.2,<9.0"
|
||||
myst-parser = ">=4,<5"
|
||||
sphinx-rtd-theme = ">=3.0.2,<4.0.0"
|
||||
sphinx-copybutton = "^0.5.2"
|
||||
sphinx-design = ">=0.6,<0.7"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
sync-ics2gcal = "sync_ics2gcal.sync_calendar:main"
|
||||
@@ -38,3 +55,12 @@ manage-ics2gcal = "sync_ics2gcal.manage_calendars:main"
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
'icalendar',
|
||||
'google.*',
|
||||
'googleapiclient',
|
||||
'fire'
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
@@ -6,6 +6,39 @@ from .gcal import (
|
||||
EventData,
|
||||
EventList,
|
||||
EventTuple,
|
||||
EventDataKey,
|
||||
EventDateOrDateTime,
|
||||
EventDate,
|
||||
EventDateTime,
|
||||
EventsSearchResults,
|
||||
ACLRule,
|
||||
ACLScope,
|
||||
CalendarData,
|
||||
BatchRequestCallback,
|
||||
)
|
||||
|
||||
from .sync import CalendarSync
|
||||
from .sync import CalendarSync, ComparedEvents
|
||||
|
||||
__all__ = [
|
||||
"ical",
|
||||
"gcal",
|
||||
"sync",
|
||||
"CalendarConverter",
|
||||
"EventConverter",
|
||||
"DateDateTime",
|
||||
"GoogleCalendarService",
|
||||
"GoogleCalendar",
|
||||
"EventData",
|
||||
"EventList",
|
||||
"EventTuple",
|
||||
"EventDataKey",
|
||||
"EventDateOrDateTime",
|
||||
"EventDate",
|
||||
"EventDateTime",
|
||||
"EventsSearchResults",
|
||||
"ACLRule",
|
||||
"ACLScope",
|
||||
"CalendarData",
|
||||
"CalendarSync",
|
||||
"ComparedEvents",
|
||||
]
|
||||
|
@@ -1,17 +1,97 @@
|
||||
import logging
|
||||
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
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient import discovery
|
||||
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]
|
||||
EventTuple = Tuple[EventData, EventData]
|
||||
|
||||
|
||||
class EventsSearchResults(NamedTuple):
|
||||
exists: List[EventTuple]
|
||||
new: List[EventData]
|
||||
|
||||
|
||||
BatchRequestCallback = Callable[[str, Any, Optional[Exception]], None]
|
||||
|
||||
|
||||
class GoogleCalendarService:
|
||||
"""class for make google calendar service Resource
|
||||
|
||||
@@ -20,7 +100,7 @@ class GoogleCalendarService:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def default():
|
||||
def default() -> discovery.Resource:
|
||||
"""make service Resource from default credentials (authorize)
|
||||
( https://developers.google.com/identity/protocols/application-default-credentials )
|
||||
( https://googleapis.dev/python/google-auth/latest/reference/google.auth.html#google.auth.default )
|
||||
@@ -34,7 +114,7 @@ class GoogleCalendarService:
|
||||
return service
|
||||
|
||||
@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)"""
|
||||
|
||||
scopes = ["https://www.googleapis.com/auth/calendar"]
|
||||
@@ -48,19 +128,23 @@ class GoogleCalendarService:
|
||||
return service
|
||||
|
||||
@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
|
||||
|
||||
Arguments:
|
||||
config -- config with keys:
|
||||
(optional) service_account: - service account filename
|
||||
if key not in dict then default credentials will be used
|
||||
( https://developers.google.com/identity/protocols/application-default-credentials )
|
||||
-- None: default credentials will be used
|
||||
|
||||
**config** -- config with keys:
|
||||
|
||||
(optional) service_account: - service account filename
|
||||
if key not in dict then default credentials will be used
|
||||
( https://developers.google.com/identity/protocols/application-default-credentials )
|
||||
|
||||
-- **None**: default credentials will be used
|
||||
"""
|
||||
|
||||
if config is not None and "service_account" in config:
|
||||
service = GoogleCalendarService.from_srv_acc_file(config["service_account"])
|
||||
service_account_filename: str = config["service_account"]
|
||||
service = GoogleCalendarService.from_srv_acc_file(service_account_filename)
|
||||
else:
|
||||
service = GoogleCalendarService.default()
|
||||
return service
|
||||
@@ -76,7 +160,7 @@ def select_event_key(event: EventData) -> Optional[str]:
|
||||
key name or None if no key found
|
||||
"""
|
||||
|
||||
key = None
|
||||
key: Optional[str] = None
|
||||
if "iCalUID" in event:
|
||||
key = "iCalUID"
|
||||
elif "id" in event:
|
||||
@@ -91,9 +175,11 @@ class GoogleCalendar:
|
||||
|
||||
def __init__(self, service: discovery.Resource, calendar_id: Optional[str]):
|
||||
self.service: discovery.Resource = service
|
||||
self.calendar_id: str = calendar_id
|
||||
self.calendar_id: str = str(calendar_id)
|
||||
|
||||
def _make_request_callback(self, action: str, events_by_req: EventList) -> Callable:
|
||||
def _make_request_callback(
|
||||
self, action: str, events_by_req: EventList
|
||||
) -> BatchRequestCallback:
|
||||
"""make callback for log result of batch request
|
||||
|
||||
Arguments:
|
||||
@@ -104,9 +190,12 @@ class GoogleCalendar:
|
||||
callback function
|
||||
"""
|
||||
|
||||
def callback(request_id, response, exception):
|
||||
event = events_by_req[int(request_id)]
|
||||
key = select_event_key(event)
|
||||
def callback(
|
||||
request_id: str, response: Any, exception: Optional[Exception]
|
||||
) -> None:
|
||||
event: EventData = events_by_req[int(request_id)]
|
||||
event_key: Optional[str] = select_event_key(event)
|
||||
key: str = event_key if event_key is not None else ""
|
||||
|
||||
if exception is not None:
|
||||
self.logger.error(
|
||||
@@ -117,7 +206,7 @@ class GoogleCalendar:
|
||||
str(exception),
|
||||
)
|
||||
else:
|
||||
resp_key = select_event_key(response)
|
||||
resp_key: Optional[str] = select_event_key(response)
|
||||
if resp_key is not None:
|
||||
event = response
|
||||
key = resp_key
|
||||
@@ -127,10 +216,10 @@ class GoogleCalendar:
|
||||
|
||||
def list_events_from(self, start: datetime) -> EventList:
|
||||
"""list events from calendar, where start date >= start"""
|
||||
fields = "nextPageToken,items(id,iCalUID,updated)"
|
||||
events = []
|
||||
page_token = None
|
||||
time_min = (
|
||||
fields: str = "nextPageToken,items(id,iCalUID,updated)"
|
||||
events: EventList = []
|
||||
page_token: Optional[str] = None
|
||||
time_min: str = (
|
||||
utc.normalize(start.astimezone(utc)).replace(tzinfo=None).isoformat() + "Z"
|
||||
)
|
||||
while True:
|
||||
@@ -153,25 +242,27 @@ class GoogleCalendar:
|
||||
self.logger.info("%d events listed", len(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
|
||||
|
||||
Arguments:
|
||||
events {list} -- list of events
|
||||
|
||||
Returns:
|
||||
tuple -- (events_exist, events_not_found)
|
||||
EventsSearchResults -- (events_exist, events_not_found)
|
||||
events_exist - list of tuples: (new_event, exists_event)
|
||||
"""
|
||||
|
||||
fields = "items(id,iCalUID,updated)"
|
||||
events_by_req = []
|
||||
exists = []
|
||||
not_found = []
|
||||
fields: str = "items(id,iCalUID,updated)"
|
||||
events_by_req: EventList = []
|
||||
exists: List[EventTuple] = []
|
||||
not_found: EventList = []
|
||||
|
||||
def list_callback(request_id, response, exception):
|
||||
found = False
|
||||
cur_event = events_by_req[int(request_id)]
|
||||
def list_callback(
|
||||
request_id: str, response: Any, exception: Optional[Exception]
|
||||
) -> None:
|
||||
found: bool = False
|
||||
cur_event: EventData = events_by_req[int(request_id)]
|
||||
if exception is None:
|
||||
found = [] != response["items"]
|
||||
else:
|
||||
@@ -186,7 +277,7 @@ class GoogleCalendar:
|
||||
not_found.append(events_by_req[int(request_id)])
|
||||
|
||||
batch = self.service.new_batch_http_request(callback=list_callback)
|
||||
i = 0
|
||||
i: int = 0
|
||||
for event in events:
|
||||
events_by_req.append(event)
|
||||
batch.add(
|
||||
@@ -201,21 +292,21 @@ class GoogleCalendar:
|
||||
i += 1
|
||||
batch.execute()
|
||||
self.logger.info("%d events exists, %d not found", len(exists), len(not_found))
|
||||
return exists, not_found
|
||||
return EventsSearchResults(exists, not_found)
|
||||
|
||||
def insert_events(self, events: EventList):
|
||||
def insert_events(self, events: EventList) -> None:
|
||||
"""insert list of events
|
||||
|
||||
Arguments:
|
||||
events - events list
|
||||
"""
|
||||
|
||||
fields = "id"
|
||||
events_by_req = []
|
||||
fields: str = "id"
|
||||
events_by_req: EventList = []
|
||||
|
||||
insert_callback = self._make_request_callback("insert", events_by_req)
|
||||
batch = self.service.new_batch_http_request(callback=insert_callback)
|
||||
i = 0
|
||||
i: int = 0
|
||||
for event in events:
|
||||
events_by_req.append(event)
|
||||
batch.add(
|
||||
@@ -227,19 +318,19 @@ class GoogleCalendar:
|
||||
i += 1
|
||||
batch.execute()
|
||||
|
||||
def patch_events(self, event_tuples: List[EventTuple]):
|
||||
def patch_events(self, event_tuples: List[EventTuple]) -> None:
|
||||
"""patch (update) events
|
||||
|
||||
Arguments:
|
||||
event_tuples -- list of tuples: (new_event, exists_event)
|
||||
"""
|
||||
|
||||
fields = "id"
|
||||
events_by_req = []
|
||||
fields: str = "id"
|
||||
events_by_req: EventList = []
|
||||
|
||||
patch_callback = self._make_request_callback("patch", events_by_req)
|
||||
batch = self.service.new_batch_http_request(callback=patch_callback)
|
||||
i = 0
|
||||
i: int = 0
|
||||
for event_new, event_old in event_tuples:
|
||||
if "id" not in event_old:
|
||||
continue
|
||||
@@ -254,19 +345,19 @@ class GoogleCalendar:
|
||||
i += 1
|
||||
batch.execute()
|
||||
|
||||
def update_events(self, event_tuples: List[EventTuple]):
|
||||
def update_events(self, event_tuples: List[EventTuple]) -> None:
|
||||
"""update events
|
||||
|
||||
Arguments:
|
||||
event_tuples -- list of tuples: (new_event, exists_event)
|
||||
"""
|
||||
|
||||
fields = "id"
|
||||
events_by_req = []
|
||||
fields: str = "id"
|
||||
events_by_req: EventList = []
|
||||
|
||||
update_callback = self._make_request_callback("update", events_by_req)
|
||||
batch = self.service.new_batch_http_request(callback=update_callback)
|
||||
i = 0
|
||||
i: int = 0
|
||||
for event_new, event_old in event_tuples:
|
||||
if "id" not in event_old:
|
||||
continue
|
||||
@@ -283,18 +374,18 @@ class GoogleCalendar:
|
||||
i += 1
|
||||
batch.execute()
|
||||
|
||||
def delete_events(self, events: EventList):
|
||||
def delete_events(self, events: EventList) -> None:
|
||||
"""delete events
|
||||
|
||||
Arguments:
|
||||
events -- list of events
|
||||
"""
|
||||
|
||||
events_by_req = []
|
||||
events_by_req: EventList = []
|
||||
|
||||
delete_callback = self._make_request_callback("delete", events_by_req)
|
||||
batch = self.service.new_batch_http_request(callback=delete_callback)
|
||||
i = 0
|
||||
i: int = 0
|
||||
for event in events:
|
||||
events_by_req.append(event)
|
||||
batch.add(
|
||||
@@ -319,7 +410,7 @@ class GoogleCalendar:
|
||||
calendar Resource
|
||||
"""
|
||||
|
||||
calendar = {"summary": summary}
|
||||
calendar = CalendarData(summary=summary)
|
||||
if time_zone is not None:
|
||||
calendar["timeZone"] = time_zone
|
||||
|
||||
@@ -327,42 +418,27 @@ class GoogleCalendar:
|
||||
self.calendar_id = created_calendar["id"]
|
||||
return created_calendar
|
||||
|
||||
def delete(self):
|
||||
def delete(self) -> None:
|
||||
"""delete calendar"""
|
||||
|
||||
self.service.calendars().delete(calendarId=self.calendar_id).execute()
|
||||
|
||||
def make_public(self):
|
||||
def make_public(self) -> None:
|
||||
"""make calendar public"""
|
||||
|
||||
rule_public = {
|
||||
"scope": {
|
||||
"type": "default",
|
||||
},
|
||||
"role": "reader",
|
||||
}
|
||||
return (
|
||||
self.service.acl()
|
||||
.insert(calendarId=self.calendar_id, body=rule_public)
|
||||
.execute()
|
||||
)
|
||||
rule_public = ACLRule(scope=ACLScope(type="default"), role="reader")
|
||||
self.service.acl().insert(
|
||||
calendarId=self.calendar_id, body=rule_public
|
||||
).execute()
|
||||
|
||||
def add_owner(self, email: str):
|
||||
def add_owner(self, email: str) -> None:
|
||||
"""add calendar owner by email
|
||||
|
||||
Arguments:
|
||||
email -- email to add
|
||||
"""
|
||||
|
||||
rule_owner = {
|
||||
"scope": {
|
||||
"type": "user",
|
||||
"value": email,
|
||||
},
|
||||
"role": "owner",
|
||||
}
|
||||
return (
|
||||
self.service.acl()
|
||||
.insert(calendarId=self.calendar_id, body=rule_owner)
|
||||
.execute()
|
||||
)
|
||||
rule_owner = ACLRule(scope=ACLScope(type="user", value=email), role="owner")
|
||||
self.service.acl().insert(
|
||||
calendarId=self.calendar_id, body=rule_owner
|
||||
).execute()
|
||||
|
@@ -1,11 +1,18 @@
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Union, Dict, Callable, Optional
|
||||
from typing import Union, Dict, Callable, Optional, Mapping, TypedDict
|
||||
|
||||
from icalendar import Calendar, Event
|
||||
from pytz import utc
|
||||
|
||||
from .gcal import EventData, EventList
|
||||
from .gcal import (
|
||||
EventData,
|
||||
EventList,
|
||||
EventDateOrDateTime,
|
||||
EventDateTime,
|
||||
EventDate,
|
||||
EventDataKey,
|
||||
)
|
||||
|
||||
DateDateTime = Union[datetime.date, datetime.datetime]
|
||||
|
||||
@@ -28,7 +35,7 @@ def format_datetime_utc(value: DateDateTime) -> str:
|
||||
|
||||
def gcal_date_or_datetime(
|
||||
value: DateDateTime, check_value: Optional[DateDateTime] = None
|
||||
) -> Dict[str, str]:
|
||||
) -> EventDateOrDateTime:
|
||||
"""date or datetime to gcal (start or end dict)
|
||||
|
||||
Arguments:
|
||||
@@ -42,18 +49,18 @@ def gcal_date_or_datetime(
|
||||
if check_value is None:
|
||||
check_value = value
|
||||
|
||||
result: Dict[str, str] = {}
|
||||
result: EventDateOrDateTime
|
||||
if isinstance(check_value, datetime.datetime):
|
||||
result["dateTime"] = format_datetime_utc(value)
|
||||
result = EventDateTime(dateTime=format_datetime_utc(value))
|
||||
else:
|
||||
if isinstance(check_value, datetime.date):
|
||||
if isinstance(value, datetime.datetime):
|
||||
value = datetime.date(value.year, value.month, value.day)
|
||||
result["date"] = value.isoformat()
|
||||
result = EventDate(date=value.isoformat())
|
||||
return result
|
||||
|
||||
|
||||
class EventConverter(Event):
|
||||
class EventConverter(Event): # type: ignore
|
||||
"""Convert icalendar event to google calendar resource
|
||||
( https://developers.google.com/calendar/v3/reference/events#resource-representations )
|
||||
"""
|
||||
@@ -68,7 +75,7 @@ class EventConverter(Event):
|
||||
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:
|
||||
"""utc datetime as string from property
|
||||
@@ -82,7 +89,7 @@ class EventConverter(Event):
|
||||
|
||||
return format_datetime_utc(self.decoded(prop))
|
||||
|
||||
def _gcal_start(self) -> Dict[str, str]:
|
||||
def _gcal_start(self) -> EventDateOrDateTime:
|
||||
"""event start dict from icalendar event
|
||||
|
||||
Raises:
|
||||
@@ -95,7 +102,7 @@ class EventConverter(Event):
|
||||
value = self.decoded("DTSTART")
|
||||
return gcal_date_or_datetime(value)
|
||||
|
||||
def _gcal_end(self) -> Dict[str, str]:
|
||||
def _gcal_end(self) -> EventDateOrDateTime:
|
||||
"""event end dict from icalendar event
|
||||
|
||||
Raises:
|
||||
@@ -104,7 +111,7 @@ class EventConverter(Event):
|
||||
dict
|
||||
"""
|
||||
|
||||
result: Dict[str, str]
|
||||
result: EventDateOrDateTime
|
||||
if "DTEND" in self:
|
||||
value = self.decoded("DTEND")
|
||||
result = gcal_date_or_datetime(value)
|
||||
@@ -121,10 +128,10 @@ class EventConverter(Event):
|
||||
def _put_to_gcal(
|
||||
self,
|
||||
gcal_event: EventData,
|
||||
prop: str,
|
||||
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:
|
||||
@@ -139,18 +146,18 @@ class EventConverter(Event):
|
||||
if ics_prop in self:
|
||||
gcal_event[prop] = func(ics_prop)
|
||||
|
||||
def to_gcal(self) -> EventData:
|
||||
def convert(self) -> EventData:
|
||||
"""Convert
|
||||
|
||||
Returns:
|
||||
dict - google calendar#event resource
|
||||
"""
|
||||
|
||||
event = {
|
||||
"iCalUID": self._str_prop("UID"),
|
||||
"start": self._gcal_start(),
|
||||
"end": self._gcal_end(),
|
||||
}
|
||||
event: EventData = EventData(
|
||||
iCalUID=self._str_prop("UID"),
|
||||
start=self._gcal_start(),
|
||||
end=self._gcal_end(),
|
||||
)
|
||||
|
||||
self._put_to_gcal(event, "summary", self._str_prop)
|
||||
self._put_to_gcal(event, "description", self._str_prop)
|
||||
@@ -172,22 +179,23 @@ class CalendarConverter:
|
||||
def __init__(self, calendar: Optional[Calendar] = None):
|
||||
self.calendar: Optional[Calendar] = calendar
|
||||
|
||||
def load(self, filename: str):
|
||||
def load(self, filename: str) -> None:
|
||||
"""load calendar from ics file"""
|
||||
with open(filename, "r", encoding="utf-8") as f:
|
||||
self.calendar = Calendar.from_ical(f.read())
|
||||
self.logger.info("%s loaded", filename)
|
||||
|
||||
def loads(self, string: str):
|
||||
def loads(self, string: str) -> None:
|
||||
"""load calendar from ics string"""
|
||||
self.calendar = Calendar.from_ical(string)
|
||||
|
||||
def events_to_gcal(self) -> EventList:
|
||||
"""Convert events to google calendar resources"""
|
||||
|
||||
ics_events = self.calendar.walk(name="VEVENT")
|
||||
calendar: Calendar = self.calendar
|
||||
ics_events = calendar.walk(name="VEVENT")
|
||||
self.logger.info("%d events read", len(ics_events))
|
||||
|
||||
result = list(map(lambda event: EventConverter(event).to_gcal(), ics_events))
|
||||
result = list(map(lambda event: EventConverter(event).convert(), ics_events))
|
||||
self.logger.info("%d events converted", len(result))
|
||||
return result
|
||||
|
@@ -8,7 +8,7 @@ from . import GoogleCalendar, GoogleCalendarService
|
||||
|
||||
|
||||
def load_config(filename: str) -> Optional[Dict[str, Any]]:
|
||||
result = None
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
with open(filename, "r", encoding="utf-8") as f:
|
||||
result = yaml.safe_load(f)
|
||||
@@ -21,7 +21,7 @@ def load_config(filename: str) -> Optional[Dict[str, Any]]:
|
||||
class PropertyCommands:
|
||||
"""get/set google calendar properties"""
|
||||
|
||||
def __init__(self, _service):
|
||||
def __init__(self, _service: Any) -> None:
|
||||
self._service = _service
|
||||
|
||||
def get(self, calendar_id: str, property_name: str) -> None:
|
||||
@@ -146,7 +146,7 @@ class Commands:
|
||||
print("{}: {}".format(summary, calendar_id))
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
fire.Fire(Commands, name="manage-ics2gcal")
|
||||
|
||||
|
||||
|
0
sync_ics2gcal/py.typed
Normal file
0
sync_ics2gcal/py.typed
Normal file
@@ -1,15 +1,31 @@
|
||||
import datetime
|
||||
import logging
|
||||
import operator
|
||||
from typing import List, Dict, Set, Tuple, Union, Callable
|
||||
from typing import List, Dict, Set, Tuple, Union, Callable, NamedTuple
|
||||
|
||||
import dateutil.parser
|
||||
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
|
||||
|
||||
|
||||
class ComparedEvents(NamedTuple):
|
||||
"""Compared events"""
|
||||
|
||||
added: EventList
|
||||
changed: List[EventTuple]
|
||||
deleted: EventList
|
||||
|
||||
|
||||
class CalendarSync:
|
||||
"""class for synchronize calendar with Google"""
|
||||
|
||||
@@ -24,8 +40,8 @@ class CalendarSync:
|
||||
|
||||
@staticmethod
|
||||
def _events_list_compare(
|
||||
items_src: EventList, items_dst: EventList, key: str = "iCalUID"
|
||||
) -> Tuple[EventList, List[EventTuple], EventList]:
|
||||
items_src: EventList, items_dst: EventList, key: EventDataKey = "iCalUID"
|
||||
) -> ComparedEvents:
|
||||
"""compare list of events by key
|
||||
|
||||
Arguments:
|
||||
@@ -34,13 +50,11 @@ class CalendarSync:
|
||||
key {str} -- name of key to compare (default: {'iCalUID'})
|
||||
|
||||
Returns:
|
||||
tuple -- (items_to_insert,
|
||||
items_to_update,
|
||||
items_to_delete)
|
||||
ComparedEvents -- (added, changed, deleted)
|
||||
"""
|
||||
|
||||
def get_key(item: EventData) -> str:
|
||||
return item[key]
|
||||
return str(item[key])
|
||||
|
||||
keys_src: Set[str] = set(map(get_key, items_src))
|
||||
keys_dst: Set[str] = set(map(get_key, items_dst))
|
||||
@@ -49,21 +63,21 @@ class CalendarSync:
|
||||
keys_to_update = keys_src & keys_dst
|
||||
keys_to_delete = keys_dst - keys_src
|
||||
|
||||
def items_by_keys(items: EventList, key_name: str, keys: Set[str]) -> EventList:
|
||||
return list(filter(lambda item: item[key_name] in keys, items))
|
||||
def items_by_keys(items: EventList, keys: Set[str]) -> EventList:
|
||||
return list(filter(lambda item: get_key(item) in keys, items))
|
||||
|
||||
items_to_insert = items_by_keys(items_src, key, keys_to_insert)
|
||||
items_to_delete = items_by_keys(items_dst, key, keys_to_delete)
|
||||
items_to_insert = items_by_keys(items_src, keys_to_insert)
|
||||
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_dst = items_by_keys(items_dst, key, keys_to_update)
|
||||
to_upd_src = items_by_keys(items_src, keys_to_update)
|
||||
to_upd_dst = items_by_keys(items_dst, keys_to_update)
|
||||
to_upd_src.sort(key=get_key)
|
||||
to_upd_dst.sort(key=get_key)
|
||||
items_to_update = list(zip(to_upd_src, to_upd_dst))
|
||||
|
||||
return items_to_insert, items_to_update, items_to_delete
|
||||
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"""
|
||||
|
||||
def filter_updated(event_tuple: EventTuple) -> bool:
|
||||
@@ -95,17 +109,17 @@ class CalendarSync:
|
||||
|
||||
def filter_by_date(event: EventData) -> bool:
|
||||
date_cmp = date
|
||||
event_start: Dict[str, str] = event["start"]
|
||||
event_start: EventDateOrDateTime = event["start"]
|
||||
event_date: Union[DateDateTime, str, None] = None
|
||||
compare_dates = False
|
||||
|
||||
if "date" in event_start:
|
||||
event_date = event_start["date"]
|
||||
event_date = event_start["date"] # type: ignore
|
||||
compare_dates = True
|
||||
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:
|
||||
date_cmp = datetime.date(date.year, date.month, date.day)
|
||||
event_date = datetime.date(
|
||||
|
@@ -14,20 +14,20 @@ ConfigDate = Union[str, datetime.datetime]
|
||||
def load_config() -> Dict[str, Any]:
|
||||
with open("config.yml", "r", encoding="utf-8") as f:
|
||||
result = yaml.safe_load(f)
|
||||
return result
|
||||
return result # type: ignore
|
||||
|
||||
|
||||
def get_start_date(date: ConfigDate) -> datetime.datetime:
|
||||
if isinstance(date, datetime.datetime):
|
||||
return date
|
||||
if "now" == date:
|
||||
result = datetime.datetime.utcnow()
|
||||
result = datetime.datetime.now(datetime.UTC)
|
||||
else:
|
||||
result = dateutil.parser.parse(date)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
def main() -> None:
|
||||
config = load_config()
|
||||
|
||||
if "logging" in config:
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import datetime
|
||||
from typing import Tuple
|
||||
from typing import Tuple, Any
|
||||
|
||||
import pytest
|
||||
from pytz import timezone, utc
|
||||
@@ -57,21 +57,21 @@ def ics_test_event(content: str) -> str:
|
||||
return ics_test_cal("BEGIN:VEVENT\r\n{}END:VEVENT\r\n".format(content))
|
||||
|
||||
|
||||
def test_empty_calendar():
|
||||
def test_empty_calendar() -> None:
|
||||
converter = CalendarConverter()
|
||||
converter.loads(ics_test_cal(""))
|
||||
evnts = converter.events_to_gcal()
|
||||
assert evnts == []
|
||||
|
||||
|
||||
def test_empty_event():
|
||||
def test_empty_event() -> None:
|
||||
converter = CalendarConverter()
|
||||
converter.loads(ics_test_event(""))
|
||||
with pytest.raises(KeyError):
|
||||
converter.events_to_gcal()
|
||||
|
||||
|
||||
def test_event_no_end():
|
||||
def test_event_no_end() -> None:
|
||||
converter = CalendarConverter()
|
||||
converter.loads(ics_test_event(only_start_date))
|
||||
with pytest.raises(ValueError):
|
||||
@@ -102,11 +102,11 @@ def test_event_no_end():
|
||||
"datetime utc duration",
|
||||
],
|
||||
)
|
||||
def param_events_start_end(request):
|
||||
def param_events_start_end(request: Any) -> Any:
|
||||
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
|
||||
converter = CalendarConverter()
|
||||
converter.loads(ics_str)
|
||||
@@ -117,7 +117,7 @@ def test_event_start_end(param_events_start_end: Tuple[str, str, str, str]):
|
||||
assert event["end"] == {date_type: end}
|
||||
|
||||
|
||||
def test_event_created_updated():
|
||||
def test_event_created_updated() -> None:
|
||||
converter = CalendarConverter()
|
||||
converter.loads(ics_test_event(created_updated))
|
||||
events = converter.events_to_gcal()
|
||||
@@ -142,5 +142,5 @@ def test_event_created_updated():
|
||||
],
|
||||
ids=["utc", "with timezone", "date"],
|
||||
)
|
||||
def test_format_datetime_utc(value: datetime.datetime, expected_str: str):
|
||||
def test_format_datetime_utc(value: datetime.datetime, expected_str: str) -> None:
|
||||
assert format_datetime_utc(value) == expected_str
|
||||
|
@@ -3,95 +3,93 @@ import hashlib
|
||||
import operator
|
||||
from copy import deepcopy
|
||||
from random import shuffle
|
||||
from typing import Union, List, Dict, Optional
|
||||
from typing import Union, List, Dict, Optional, AnyStr
|
||||
|
||||
import dateutil.parser
|
||||
import pytest
|
||||
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:
|
||||
if isinstance(string, str):
|
||||
string = string.encode("utf8")
|
||||
def sha1(s: AnyStr) -> str:
|
||||
h = hashlib.sha1()
|
||||
h.update(string)
|
||||
h.update(str(s).encode("utf8") if isinstance(s, str) else s)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def gen_events(
|
||||
start: 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]]]]:
|
||||
) -> EventList:
|
||||
duration: datetime.timedelta
|
||||
date_key: str
|
||||
date_end: str
|
||||
if no_time:
|
||||
start_time = datetime.date(start_time.year, start_time.month, start_time.day)
|
||||
duration: datetime.timedelta = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
|
||||
date_key: str = "date"
|
||||
date_end: str = ""
|
||||
duration = datetime.date(1, 1, 2) - datetime.date(1, 1, 1)
|
||||
date_key = "date"
|
||||
date_end = ""
|
||||
else:
|
||||
start_time = utc.normalize(start_time.astimezone(utc)).replace(tzinfo=None)
|
||||
duration: datetime.timedelta = datetime.datetime(
|
||||
1, 1, 1, 2
|
||||
) - datetime.datetime(1, 1, 1, 1)
|
||||
date_key: str = "dateTime"
|
||||
date_end: str = "Z"
|
||||
start_time = utc.normalize(start_time.astimezone(utc)).replace(tzinfo=None) # type: ignore
|
||||
duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
||||
date_key = "dateTime"
|
||||
date_end = "Z"
|
||||
|
||||
result: List[Dict[str, Union[str, Dict[str, str]]]] = []
|
||||
result: EventList = []
|
||||
for i in range(start, stop):
|
||||
event_start = start_time + (duration * i)
|
||||
event_end = event_start + duration
|
||||
|
||||
updated: Union[datetime.datetime, datetime.date] = event_start
|
||||
updated: DateDateTime = event_start
|
||||
if no_time:
|
||||
updated = datetime.datetime(
|
||||
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),
|
||||
"location": "la la la {}".format(i),
|
||||
"description": "test TEST -- test event {}".format(i),
|
||||
"iCalUID": "{}@test.com".format(sha1("test - event {}".format(i))),
|
||||
"updated": updated.isoformat() + "Z",
|
||||
"created": updated.isoformat() + "Z",
|
||||
"start": {date_key: event_start.isoformat() + date_end},
|
||||
"end": {date_key: event_end.isoformat() + date_end},
|
||||
"start": {date_key: event_start.isoformat() + date_end}, # type: ignore
|
||||
"end": {date_key: event_end.isoformat() + date_end}, # type: ignore
|
||||
}
|
||||
result.append(event)
|
||||
return result
|
||||
|
||||
|
||||
def gen_list_to_compare(start: int, stop: int) -> List[Dict[str, str]]:
|
||||
result: List[Dict[str, str]] = []
|
||||
def gen_list_to_compare(start: int, stop: int) -> EventList:
|
||||
result: EventList = []
|
||||
for i in range(start, stop):
|
||||
result.append({"iCalUID": "test{:06d}".format(i)})
|
||||
return result
|
||||
|
||||
|
||||
def get_start_date(
|
||||
event: Dict[str, Union[str, Dict[str, str]]]
|
||||
) -> Union[datetime.datetime, datetime.date]:
|
||||
event_start: Dict[str, str] = event["start"]
|
||||
def get_start_date(event: EventData) -> DateDateTime:
|
||||
event_start: EventDateOrDateTime = event["start"]
|
||||
start_date: Optional[str] = None
|
||||
is_date = False
|
||||
if "date" in event_start:
|
||||
start_date = event_start["date"]
|
||||
start_date = event_start["date"] # type: ignore
|
||||
is_date = True
|
||||
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:
|
||||
result = datetime.date(result.year, result.month, result.day)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def test_compare():
|
||||
part_len = 20
|
||||
def test_compare() -> None:
|
||||
part_len: int = 20
|
||||
# [1..2n]
|
||||
lst_src = gen_list_to_compare(1, 1 + part_len * 2)
|
||||
# [n..3n]
|
||||
@@ -119,9 +117,9 @@ def test_compare():
|
||||
|
||||
|
||||
@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")
|
||||
now = utc.localize(datetime.datetime.utcnow())
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
msk_now = msk.normalize(now.astimezone(msk))
|
||||
|
||||
part_len = 5
|
||||
@@ -131,7 +129,7 @@ def test_filter_events_by_date(no_time: bool):
|
||||
else:
|
||||
duration = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
||||
|
||||
date_cmp = msk_now + (duration * part_len)
|
||||
date_cmp: DateDateTime = msk_now + (duration * part_len)
|
||||
|
||||
if no_time:
|
||||
date_cmp = datetime.date(date_cmp.year, date_cmp.month, date_cmp.day)
|
||||
@@ -152,9 +150,9 @@ def test_filter_events_by_date(no_time: bool):
|
||||
assert get_start_date(event) < date_cmp
|
||||
|
||||
|
||||
def test_filter_events_to_update():
|
||||
def test_filter_events_to_update() -> None:
|
||||
msk = timezone("Europe/Moscow")
|
||||
now = utc.localize(datetime.datetime.utcnow())
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
msk_now = msk.normalize(now.astimezone(msk))
|
||||
|
||||
one_hour = datetime.datetime(1, 1, 1, 2) - datetime.datetime(1, 1, 1, 1)
|
||||
@@ -164,11 +162,11 @@ def test_filter_events_to_update():
|
||||
events_old = gen_events(1, 1 + count, msk_now)
|
||||
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._filter_events_to_update()
|
||||
|
||||
sync2 = CalendarSync(None, None)
|
||||
sync2 = CalendarSync(None, None) # type: ignore
|
||||
sync2.to_update = list(zip(events_old, events_new))
|
||||
sync2._filter_events_to_update()
|
||||
|
||||
@@ -176,12 +174,12 @@ def test_filter_events_to_update():
|
||||
assert sync2.to_update == []
|
||||
|
||||
|
||||
def test_filter_events_no_updated():
|
||||
def test_filter_events_no_updated() -> None:
|
||||
"""
|
||||
test filtering events that not have 'updated' field
|
||||
such events should always pass the filter
|
||||
"""
|
||||
now = datetime.datetime.utcnow()
|
||||
now = datetime.datetime.now()
|
||||
yesterday = now - datetime.timedelta(days=-1)
|
||||
|
||||
count = 10
|
||||
@@ -197,7 +195,7 @@ def test_filter_events_no_updated():
|
||||
del event["updated"]
|
||||
i += 1
|
||||
|
||||
sync = CalendarSync(None, None)
|
||||
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