Repository: bergercookie/syncall (language: python)

Issue:
Sync as Google Tasks
It would be great to have options to be synced as Google Tasks, or Google Reminder, rather than as events


Discussion / hints:
There's no public API for Google reminders unfortunately, that was my original objective as well.
Wrt google tasks, sure, that would be a great feature to have.

Syncing over calendar events currently works fine for me so I'm not super keen on implementing it, at least not now.

It is a valid request though and it would be super useful to have so if somebody wants to work on it in the meantime, let me know ^_^ 
Hello, is there any progress on this feature request? It will be extremely useful for me especially since there is a shortcut (T) to add an email as a task. 

Update: or is there a way to link task to calendar and then sync with taskwarrior? I know that we can show google "Tasks" in the calendar by checking the box next to "Tasks". But when I try to use taskw_gcal_sync to pull tasks from the calendar with a name "Tasks", a new "Tasks" calendar is created in gcal. Am I doing something wrong? 
> a new "Tasks" calendar is created in gcal. Am I doing something wrong?

Even though Google tasks are displayed in your google calendar, it's just a view, and not an actual calendar, hence the tool creates a new calendar with the same name.

I currently have no plans of implementing syncing via tasks right now.
Hey, I would like to see this implemented. There were other apps using Tasks API, [example](https://tasksboard.com/), although I've no idea how cumbersome it would be to implement.

I've quite bit of Python proficiency, and a bit on bash as well, but I've never worked on a large project, rather on data-science pipelines or academical work. Nevertheless, I can try my best in case you can mentor the process @bergercookie .

Cheers
Hi @cenktaskin ,

This should definitely be possible. Feel free to have a go and let me know if you have any questions (e.g., start a thread in the [Discussions](https://github.com/bergercookie/taskwarrior-syncall/discussions)) :-)

See also this issue for potential sources of inspiration on creating this new synchronisation - https://github.com/bergercookie/taskwarrior-syncall/issues/61
> See also this issue for potential sources of inspiration on creating this new synchronisation - #61

Just saw the guide on the thread you linked, thanks for that! Looks really helpful. 


> Nevertheless, I can try my best in case you can mentor the process @bergercookie .

Unfortunately I have switched from google products a while ago, so I don't have such necessary means, and honestly the motivation to work on this. Hope someone else supports the effort!

Fix patch:
diff --git a/.gitignore b/.gitignore
index ef6af9f..3170b28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,3 +86,5 @@ test-tw-gcal-sync.json
 /test_dir/
 /fs_tests_dir/
 /a/
+/.taskrc
+/.envrc
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6abf929..0d70dc2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,6 +5,7 @@ repos:
     rev: "v4.4.0"
     hooks:
       - id: check-added-large-files
+        exclude: ^misc/
       - id: check-ast
       - id: check-merge-conflict
       - id: check-toml
diff --git a/README.md b/README.md
index dab1d3a..18c60ed 100644
--- a/README.md
+++ b/README.md
@@ -31,9 +31,10 @@ synchronization services/sides at hand.
 
 One of the main goals of `syncall` is to be extendable. Thus it should be easy
 to introduce support for either a new service / synchronization side (e.g.,
-ClickUp) or a new synchronization altogether (e.g., ClickUp <-> Google Keep)
-given that you [implement the corresponding synchronization
-sides and conversion methods](implement-a-new-synchronization.md). See also the
+[`ClickUp`](https://clickup.com/)) or a new synchronization altogether (e.g.,
+ClickUp <-> Google Keep) given that you [implement the corresponding
+synchronization sides and conversion
+methods](implement-a-new-synchronization.md). See also the
 [CONTRIBUTING](CONTRIBUTING.md) guide to get started.
 
 At the moment the list of supported synchronizations is the following:
@@ -47,6 +48,11 @@ At the moment the list of supported synchronizations is the following:
   </tr>
 </thead>
 <tbody>
+  <tr>
+    <td><a href="https://github.com/bergercookie/syncall/blob/master/readme-tw-gtasks.md">README</a></td>
+    <td> <a href="https://taskwarrior.org/">Taskwarrior</a> ⬄ <a href="https://support.google.com/tasks/answer/7675772">Google Calendar</a></td>
+    <td><tt>tw-gtasks-sync</tt></td>
+  </tr>
   <tr>
     <td><a href="https://github.com/bergercookie/syncall/blob/master/readme-tw-gcal.md">README</a></td>
     <td> <a href="https://taskwarrior.org/">Taskwarrior</a> ⬄ <a href="https://calendar.google.com/">Google Calendar</a></td>
@@ -169,6 +175,50 @@ Here's some of the available options for installing it:
 
 Here's the CLI help page for the synchronizations available.
 
+<!-- START sniff-and-replace tw_gtasks_sync --help START -->
+
+<details>
+ <summary><tt>tw_gtasks_sync --help</tt></summary>
+
+```
+Usage: tw_gtasks_sync [OPTIONS]
+
+  Synchronize lists from your Google Tasks with filters from Taskwarrior.
+
+  The list of TW tasks is determined by a combination of TW tags and a TW
+  project while the list in GTasks should be provided by their name. if it
+  doesn't exist it will be crated
+
+Options:
+  -l, --gtasks-list TEXT          Name of the Google Tasks list to synchronize
+                                  (will be created if not there)
+  --google-secret FILE            Override the client secret used for the
+                                  communication with the Google APIs
+  --oauth-port INTEGER            Port to use for OAuth Authentication with
+                                  Google Applications
+  -t, --taskwarrior-tags TEXT     Taskwarrior tags to synchronize
+  -p, --tw-project TEXT           Taskwarrior project to synchronize
+  --list-combinations             List the available named TW<->Google Tasks
+                                  combinations
+  --list-resolution-strategies    List all the available resolution strategies
+                                  and exit
+  -r, --resolution-strategy [MostRecentRS|LeastRecentRS|AlwaysFirstRS|AlwaysSecondRS]
+                                  Resolution strategy to use during conflicts
+  -b, --combination TEXT          Name of an already saved TW<->Google Tasks
+                                  combination
+  -s, --save-as TEXT              Save the given TW<->Google Tasks filters
+                                  combination using a specified custom name.
+  --prefer-scheduled-date         Prefer using the "scheduled" date field
+                                  instead of the "due" date if the former is
+                                  available
+  -v, --verbose
+  --version                       Show the version and exit.
+  --help                          Show this message and exit.
+```
+
+</details>
+
+<!-- END sniff-and-replace -->
 <!-- START sniff-and-replace tw_gcal_sync --help START -->
 
 <details>
diff --git a/completions/bash/tw_gtasks_sync.sh b/completions/bash/tw_gtasks_sync.sh
new file mode 100644
index 0000000..7e26493
--- /dev/null
+++ b/completions/bash/tw_gtasks_sync.sh
@@ -0,0 +1,28 @@
+_tw_gtasks_sync_completion() {
+    local IFS=$'\n'
+    local response
+
+    response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _TW_GTASKS_SYNC_COMPLETE=bash_complete $1)
+
+    for completion in $response; do
+        IFS=',' read type value <<< "$completion"
+
+        if [[ $type == 'dir' ]]; then
+            COMPREPLY=()
+            compopt -o dirnames
+        elif [[ $type == 'file' ]]; then
+            COMPREPLY=()
+            compopt -o default
+        elif [[ $type == 'plain' ]]; then
+            COMPREPLY+=($value)
+        fi
+    done
+
+    return 0
+}
+
+_tw_gtasks_sync_completion_setup() {
+    complete -o nosort -F _tw_gtasks_sync_completion tw_gtasks_sync
+}
+
+_tw_gtasks_sync_completion_setup;
diff --git a/completions/fish/fs_gkeep_sync.fish b/completions/fish/fs_gkeep_sync.fish
index a5cd613..6085b62 100644
--- a/completions/fish/fs_gkeep_sync.fish
+++ b/completions/fish/fs_gkeep_sync.fish
@@ -1,9 +1,5 @@
 function _fs_gkeep_sync_completion;
-    set -l response;
-
-    for value in (env _FS_GKEEP_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fs_gkeep_sync);
-        set response $response $value;
-    end;
+    set -l response (env _FS_GKEEP_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fs_gkeep_sync);
 
     for completion in $response;
         set -l metadata (string split "," $completion);
diff --git a/completions/fish/tw_asana_sync.fish b/completions/fish/tw_asana_sync.fish
index 8dd36b3..0ebe962 100644
--- a/completions/fish/tw_asana_sync.fish
+++ b/completions/fish/tw_asana_sync.fish
@@ -1,9 +1,5 @@
 function _tw_asana_sync_completion;
-    set -l response;
-
-    for value in (env _TW_ASANA_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_asana_sync);
-        set response $response $value;
-    end;
+    set -l response (env _TW_ASANA_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_asana_sync);
 
     for completion in $response;
         set -l metadata (string split "," $completion);
diff --git a/completions/fish/tw_caldav_sync.fish b/completions/fish/tw_caldav_sync.fish
index 222f3bd..5184257 100644
--- a/completions/fish/tw_caldav_sync.fish
+++ b/completions/fish/tw_caldav_sync.fish
@@ -1,9 +1,5 @@
 function _tw_caldav_sync_completion;
-    set -l response;
-
-    for value in (env _TW_CALDAV_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_caldav_sync);
-        set response $response $value;
-    end;
+    set -l response (env _TW_CALDAV_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_caldav_sync);
 
     for completion in $response;
         set -l metadata (string split "," $completion);
diff --git a/completions/fish/tw_gcal_sync.fish b/completions/fish/tw_gcal_sync.fish
index f8b2ca3..4da3dad 100644
--- a/completions/fish/tw_gcal_sync.fish
+++ b/completions/fish/tw_gcal_sync.fish
@@ -1,9 +1,5 @@
 function _tw_gcal_sync_completion;
-    set -l response;
-
-    for value in (env _TW_GCAL_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_gcal_sync);
-        set response $response $value;
-    end;
+    set -l response (env _TW_GCAL_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_gcal_sync);
 
     for completion in $response;
         set -l metadata (string split "," $completion);
diff --git a/completions/fish/tw_gkeep_sync.fish b/completions/fish/tw_gkeep_sync.fish
index 078bfe9..04543c4 100644
--- a/completions/fish/tw_gkeep_sync.fish
+++ b/completions/fish/tw_gkeep_sync.fish
@@ -1,9 +1,5 @@
 function _tw_gkeep_sync_completion;
-    set -l response;
-
-    for value in (env _TW_GKEEP_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_gkeep_sync);
-        set response $response $value;
-    end;
+    set -l response (env _TW_GKEEP_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_gkeep_sync);
 
     for completion in $response;
         set -l metadata (string split "," $completion);
diff --git a/completions/fish/tw_gtasks_sync.fish b/completions/fish/tw_gtasks_sync.fish
new file mode 100644
index 0000000..dab0210
--- /dev/null
+++ b/completions/fish/tw_gtasks_sync.fish
@@ -0,0 +1,17 @@
+function _tw_gtasks_sync_completion;
+    set -l response (env _TW_GTASKS_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_gtasks_sync);
+
+    for completion in $response;
+        set -l metadata (string split "," $completion);
+
+        if test $metadata[1] = "dir";
+            __fish_complete_directories $metadata[2];
+        else if test $metadata[1] = "file";
+            __fish_complete_path $metadata[2];
+        else if test $metadata[1] = "plain";
+            echo $metadata[2];
+        end;
+    end;
+end;
+
+complete --no-files --command tw_gtasks_sync --arguments "(_tw_gtasks_sync_completion)";
diff --git a/completions/fish/tw_notion_sync.fish b/completions/fish/tw_notion_sync.fish
index e9c9070..e04bcf5 100644
--- a/completions/fish/tw_notion_sync.fish
+++ b/completions/fish/tw_notion_sync.fish
@@ -1,9 +1,5 @@
 function _tw_notion_sync_completion;
-    set -l response;
-
-    for value in (env _TW_NOTION_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_notion_sync);
-        set response $response $value;
-    end;
+    set -l response (env _TW_NOTION_SYNC_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) tw_notion_sync);
 
     for completion in $response;
         set -l metadata (string split "," $completion);
diff --git a/completions/zsh/fs_gkeep_sync.sh b/completions/zsh/fs_gkeep_sync.sh
index 283a849..4501b45 100644
--- a/completions/zsh/fs_gkeep_sync.sh
+++ b/completions/zsh/fs_gkeep_sync.sh
@@ -31,4 +31,10 @@ _fs_gkeep_sync_completion() {
     fi
 }
 
-compdef _fs_gkeep_sync_completion fs_gkeep_sync;
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _fs_gkeep_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _fs_gkeep_sync_completion fs_gkeep_sync
+fi
diff --git a/completions/zsh/tw_asana_sync.sh b/completions/zsh/tw_asana_sync.sh
index 206383f..23a9a06 100644
--- a/completions/zsh/tw_asana_sync.sh
+++ b/completions/zsh/tw_asana_sync.sh
@@ -31,4 +31,10 @@ _tw_asana_sync_completion() {
     fi
 }
 
-compdef _tw_asana_sync_completion tw_asana_sync;
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _tw_asana_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _tw_asana_sync_completion tw_asana_sync
+fi
diff --git a/completions/zsh/tw_caldav_sync.sh b/completions/zsh/tw_caldav_sync.sh
index 99fe05a..7ae0926 100644
--- a/completions/zsh/tw_caldav_sync.sh
+++ b/completions/zsh/tw_caldav_sync.sh
@@ -31,4 +31,10 @@ _tw_caldav_sync_completion() {
     fi
 }
 
-compdef _tw_caldav_sync_completion tw_caldav_sync;
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _tw_caldav_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _tw_caldav_sync_completion tw_caldav_sync
+fi
diff --git a/completions/zsh/tw_gcal_sync.sh b/completions/zsh/tw_gcal_sync.sh
index 1b1528f..9123a91 100644
--- a/completions/zsh/tw_gcal_sync.sh
+++ b/completions/zsh/tw_gcal_sync.sh
@@ -31,4 +31,10 @@ _tw_gcal_sync_completion() {
     fi
 }
 
-compdef _tw_gcal_sync_completion tw_gcal_sync;
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _tw_gcal_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _tw_gcal_sync_completion tw_gcal_sync
+fi
diff --git a/completions/zsh/tw_gkeep_sync.sh b/completions/zsh/tw_gkeep_sync.sh
index ab0a1ab..71f3079 100644
--- a/completions/zsh/tw_gkeep_sync.sh
+++ b/completions/zsh/tw_gkeep_sync.sh
@@ -31,4 +31,10 @@ _tw_gkeep_sync_completion() {
     fi
 }
 
-compdef _tw_gkeep_sync_completion tw_gkeep_sync;
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _tw_gkeep_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _tw_gkeep_sync_completion tw_gkeep_sync
+fi
diff --git a/completions/zsh/tw_gtasks_sync.sh b/completions/zsh/tw_gtasks_sync.sh
new file mode 100644
index 0000000..0a38548
--- /dev/null
+++ b/completions/zsh/tw_gtasks_sync.sh
@@ -0,0 +1,40 @@
+#compdef tw_gtasks_sync
+
+_tw_gtasks_sync_completion() {
+    local -a completions
+    local -a completions_with_descriptions
+    local -a response
+    (( ! $+commands[tw_gtasks_sync] )) && return 1
+
+    response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _TW_GTASKS_SYNC_COMPLETE=zsh_complete tw_gtasks_sync)}")
+
+    for type key descr in ${response}; do
+        if [[ "$type" == "plain" ]]; then
+            if [[ "$descr" == "_" ]]; then
+                completions+=("$key")
+            else
+                completions_with_descriptions+=("$key":"$descr")
+            fi
+        elif [[ "$type" == "dir" ]]; then
+            _path_files -/
+        elif [[ "$type" == "file" ]]; then
+            _path_files -f
+        fi
+    done
+
+    if [ -n "$completions_with_descriptions" ]; then
+        _describe -V unsorted completions_with_descriptions -U
+    fi
+
+    if [ -n "$completions" ]; then
+        compadd -U -V unsorted -a completions
+    fi
+}
+
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _tw_gtasks_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _tw_gtasks_sync_completion tw_gtasks_sync
+fi
diff --git a/completions/zsh/tw_notion_sync.sh b/completions/zsh/tw_notion_sync.sh
index 42eb6c4..8b1f4d2 100644
--- a/completions/zsh/tw_notion_sync.sh
+++ b/completions/zsh/tw_notion_sync.sh
@@ -31,4 +31,10 @@ _tw_notion_sync_completion() {
     fi
 }
 
-compdef _tw_notion_sync_completion tw_notion_sync;
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
+    # autoload from fpath, call function directly
+    _tw_notion_sync_completion "$@"
+else
+    # eval/source/. command, register function for later
+    compdef _tw_notion_sync_completion tw_notion_sync
+fi
diff --git a/misc/tw_gtasks_sync.gif b/misc/tw_gtasks_sync.gif
new file mode 100644
index 0000000..c2f74fc
Binary files /dev/null and b/misc/tw_gtasks_sync.gif differ
diff --git a/poetry.lock b/poetry.lock
index b5c8cb1..ac12ca5 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -21,6 +21,17 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"]
 test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
 trio = ["trio (>=0.22)"]
 
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+optional = false
+python-versions = "*"
+files = [
+    {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+    {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
+
 [[package]]
 name = "asana"
 version = "1.0.0"
@@ -547,13 +558,13 @@ six = ">=1.11.0"
 
 [[package]]
 name = "google-api-core"
-version = "2.12.0"
+version = "2.14.0"
 description = "Google API client core library"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "google-api-core-2.12.0.tar.gz", hash = "sha256:c22e01b1e3c4dcd90998494879612c38d0a3411d1f7b679eb89e2abe3ce1f553"},
-    {file = "google_api_core-2.12.0-py3-none-any.whl", hash = "sha256:ec6054f7d64ad13b41e43d96f735acbd763b0f3b695dabaa2d579673f6a6e160"},
+    {file = "google-api-core-2.14.0.tar.gz", hash = "sha256:5368a4502b793d9bbf812a5912e13e4e69f9bd87f6efb508460c43f5bbd1ce41"},
+    {file = "google_api_core-2.14.0-py3-none-any.whl", hash = "sha256:de2fb50ed34d47ddbb2bd2dcf680ee8fead46279f4ed6b16de362aca23a18952"},
 ]
 
 [package.dependencies]
@@ -569,13 +580,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"]
 
 [[package]]
 name = "google-api-python-client"
-version = "2.106.0"
+version = "2.107.0"
 description = "Google API Client Library for Python"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "google-api-python-client-2.106.0.tar.gz", hash = "sha256:f6a3862be2f6e5e0536d7bd47b5af3f24ac0b9147c76c830cafb3329d71d5724"},
-    {file = "google_api_python_client-2.106.0-py2.py3-none-any.whl", hash = "sha256:c47c0dae5dd20aa43e4ea184566fe59d0c8fd0b86dd223b29040d8ea4f7ed6ea"},
+    {file = "google-api-python-client-2.107.0.tar.gz", hash = "sha256:ef6d4c1a17fe9ec0894fc6d4f61e751c4b859fb33f2ab5b881ceb0b80ba442ba"},
+    {file = "google_api_python_client-2.107.0-py2.py3-none-any.whl", hash = "sha256:51d7bf676f41a77b00b7b9c72ace0c1db3dd5a4dd392a13ae897cf4f571a3539"},
 ]
 
 [package.dependencies]
@@ -702,13 +713,13 @@ files = [
 
 [[package]]
 name = "httpcore"
-version = "1.0.1"
+version = "1.0.2"
 description = "A minimal low-level HTTP client."
 optional = true
 python-versions = ">=3.8"
 files = [
-    {file = "httpcore-1.0.1-py3-none-any.whl", hash = "sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0"},
-    {file = "httpcore-1.0.1.tar.gz", hash = "sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92"},
+    {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
+    {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
 ]
 
 [package.dependencies]
@@ -802,13 +813,13 @@ files = [
 
 [[package]]
 name = "importlib-resources"
-version = "6.1.0"
+version = "6.1.1"
 description = "Read resources from Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "importlib_resources-6.1.0-py3-none-any.whl", hash = "sha256:aa50258bbfa56d4e33fbd8aa3ef48ded10d1735f11532b8df95388cc6bdb7e83"},
-    {file = "importlib_resources-6.1.0.tar.gz", hash = "sha256:9d48dcccc213325e810fd723e7fbb45ccb39f6cf5c31f00cf2b965f5f10f3cb9"},
+    {file = "importlib_resources-6.1.1-py3-none-any.whl", hash = "sha256:e8bf90d8213b486f428c9c39714b920041cb02c184686a3dee24905aaa8105d6"},
+    {file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"},
 ]
 
 [package.dependencies]
@@ -1155,38 +1166,38 @@ test = ["pytest", "pytest-cov"]
 
 [[package]]
 name = "mypy"
-version = "1.6.1"
+version = "1.7.0"
 description = "Optional static typing for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mypy-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c"},
-    {file = "mypy-1.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb"},
-    {file = "mypy-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e"},
-    {file = "mypy-1.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f"},
-    {file = "mypy-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c"},
-    {file = "mypy-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5"},
-    {file = "mypy-1.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245"},
-    {file = "mypy-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183"},
-    {file = "mypy-1.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0"},
-    {file = "mypy-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7"},
-    {file = "mypy-1.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f"},
-    {file = "mypy-1.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660"},
-    {file = "mypy-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7"},
-    {file = "mypy-1.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71"},
-    {file = "mypy-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a"},
-    {file = "mypy-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169"},
-    {file = "mypy-1.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143"},
-    {file = "mypy-1.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46"},
-    {file = "mypy-1.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85"},
-    {file = "mypy-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45"},
-    {file = "mypy-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208"},
-    {file = "mypy-1.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd"},
-    {file = "mypy-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332"},
-    {file = "mypy-1.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f"},
-    {file = "mypy-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30"},
-    {file = "mypy-1.6.1-py3-none-any.whl", hash = "sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1"},
-    {file = "mypy-1.6.1.tar.gz", hash = "sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1"},
+    {file = "mypy-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5da84d7bf257fd8f66b4f759a904fd2c5a765f70d8b52dde62b521972a0a2357"},
+    {file = "mypy-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a3637c03f4025f6405737570d6cbfa4f1400eb3c649317634d273687a09ffc2f"},
+    {file = "mypy-1.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b633f188fc5ae1b6edca39dae566974d7ef4e9aaaae00bc36efe1f855e5173ac"},
+    {file = "mypy-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d6ed9a3997b90c6f891138e3f83fb8f475c74db4ccaa942a1c7bf99e83a989a1"},
+    {file = "mypy-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:1fe46e96ae319df21359c8db77e1aecac8e5949da4773c0274c0ef3d8d1268a9"},
+    {file = "mypy-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:df67fbeb666ee8828f675fee724cc2cbd2e4828cc3df56703e02fe6a421b7401"},
+    {file = "mypy-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a79cdc12a02eb526d808a32a934c6fe6df07b05f3573d210e41808020aed8b5d"},
+    {file = "mypy-1.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65f385a6f43211effe8c682e8ec3f55d79391f70a201575def73d08db68ead1"},
+    {file = "mypy-1.7.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e81ffd120ee24959b449b647c4b2fbfcf8acf3465e082b8d58fd6c4c2b27e46"},
+    {file = "mypy-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:f29386804c3577c83d76520abf18cfcd7d68264c7e431c5907d250ab502658ee"},
+    {file = "mypy-1.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:87c076c174e2c7ef8ab416c4e252d94c08cd4980a10967754f91571070bf5fbe"},
+    {file = "mypy-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cb8d5f6d0fcd9e708bb190b224089e45902cacef6f6915481806b0c77f7786d"},
+    {file = "mypy-1.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93e76c2256aa50d9c82a88e2f569232e9862c9982095f6d54e13509f01222fc"},
+    {file = "mypy-1.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cddee95dea7990e2215576fae95f6b78a8c12f4c089d7e4367564704e99118d3"},
+    {file = "mypy-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:d01921dbd691c4061a3e2ecdbfbfad029410c5c2b1ee88946bf45c62c6c91210"},
+    {file = "mypy-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:185cff9b9a7fec1f9f7d8352dff8a4c713b2e3eea9c6c4b5ff7f0edf46b91e41"},
+    {file = "mypy-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7a7b1e399c47b18feb6f8ad4a3eef3813e28c1e871ea7d4ea5d444b2ac03c418"},
+    {file = "mypy-1.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc9fe455ad58a20ec68599139ed1113b21f977b536a91b42bef3ffed5cce7391"},
+    {file = "mypy-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d0fa29919d2e720c8dbaf07d5578f93d7b313c3e9954c8ec05b6d83da592e5d9"},
+    {file = "mypy-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b53655a295c1ed1af9e96b462a736bf083adba7b314ae775563e3fb4e6795f5"},
+    {file = "mypy-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1b06b4b109e342f7dccc9efda965fc3970a604db70f8560ddfdee7ef19afb05"},
+    {file = "mypy-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf7a2f0a6907f231d5e41adba1a82d7d88cf1f61a70335889412dec99feeb0f8"},
+    {file = "mypy-1.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:551d4a0cdcbd1d2cccdcc7cb516bb4ae888794929f5b040bb51aae1846062901"},
+    {file = "mypy-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55d28d7963bef00c330cb6461db80b0b72afe2f3c4e2963c99517cf06454e665"},
+    {file = "mypy-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:870bd1ffc8a5862e593185a4c169804f2744112b4a7c55b93eb50f48e7a77010"},
+    {file = "mypy-1.7.0-py3-none-any.whl", hash = "sha256:96650d9a4c651bc2a4991cf46f100973f656d69edc7faf91844e87fe627f7e96"},
+    {file = "mypy-1.7.0.tar.gz", hash = "sha256:1e280b5697202efa698372d2f39e9a6713a0395a756b1c6bd48995f8d72690dc"},
 ]
 
 [package.dependencies]
@@ -1197,6 +1208,7 @@ typing-extensions = ">=4.1.0"
 [package.extras]
 dmypy = ["psutil (>=4.0)"]
 install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
 reports = ["lxml"]
 
 [[package]]
@@ -1309,13 +1321,13 @@ files = [
 
 [[package]]
 name = "platformdirs"
-version = "3.11.0"
+version = "4.0.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
-    {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
+    {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"},
+    {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"},
 ]
 
 [package.extras]
@@ -1339,13 +1351,13 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pre-commit"
-version = "2.21.0"
+version = "2.20.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"},
-    {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"},
+    {file = "pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"},
+    {file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"},
 ]
 
 [package.dependencies]
@@ -1353,7 +1365,8 @@ cfgv = ">=2.0.0"
 identify = ">=1.0.0"
 nodeenv = ">=0.11.1"
 pyyaml = ">=5.1"
-virtualenv = ">=20.10.0"
+toml = "*"
+virtualenv = ">=20.0.8"
 
 [[package]]
 name = "protobuf"
@@ -1560,13 +1573,13 @@ files = [
 
 [[package]]
 name = "pyright"
-version = "1.1.334"
+version = "1.1.335"
 description = "Command line wrapper for pyright"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "pyright-1.1.334-py3-none-any.whl", hash = "sha256:dcb13e8358e021189672c4d6ebcad192ab061e4c7225036973ec493183c6da68"},
-    {file = "pyright-1.1.334.tar.gz", hash = "sha256:3adaf10f1f4209575dc022f9c897f7ef024639b7ea5b3cbe49302147e6949cd4"},
+    {file = "pyright-1.1.335-py3-none-any.whl", hash = "sha256:1149d99d5cea3997010a5ac39611534e0426125d5090913ae5cb1e0e2c9fbca3"},
+    {file = "pyright-1.1.335.tar.gz", hash = "sha256:12c09c1644b223515cc342f7d383e55eefeedd730d7875e39a2cf338c2d99be4"},
 ]
 
 [package.dependencies]
@@ -1922,6 +1935,32 @@ files = [
     {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"},
 ]
 
+[[package]]
+name = "ruff"
+version = "0.1.5"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "ruff-0.1.5-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:32d47fc69261c21a4c48916f16ca272bf2f273eb635d91c65d5cd548bf1f3d96"},
+    {file = "ruff-0.1.5-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:171276c1df6c07fa0597fb946139ced1c2978f4f0b8254f201281729981f3c17"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17ef33cd0bb7316ca65649fc748acc1406dfa4da96a3d0cde6d52f2e866c7b39"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2c205827b3f8c13b4a432e9585750b93fd907986fe1aec62b2a02cf4401eee6"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb408e3a2ad8f6881d0f2e7ad70cddb3ed9f200eb3517a91a245bbe27101d379"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f20dc5e5905ddb407060ca27267c7174f532375c08076d1a953cf7bb016f5a24"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aafb9d2b671ed934998e881e2c0f5845a4295e84e719359c71c39a5363cccc91"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4894dddb476597a0ba4473d72a23151b8b3b0b5f958f2cf4d3f1c572cdb7af7"},
+    {file = "ruff-0.1.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00a7ec893f665ed60008c70fe9eeb58d210e6b4d83ec6654a9904871f982a2a"},
+    {file = "ruff-0.1.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8c11206b47f283cbda399a654fd0178d7a389e631f19f51da15cbe631480c5b"},
+    {file = "ruff-0.1.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fa29e67b3284b9a79b1a85ee66e293a94ac6b7bb068b307a8a373c3d343aa8ec"},
+    {file = "ruff-0.1.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9b97fd6da44d6cceb188147b68db69a5741fbc736465b5cea3928fdac0bc1aeb"},
+    {file = "ruff-0.1.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:721f4b9d3b4161df8dc9f09aa8562e39d14e55a4dbaa451a8e55bdc9590e20f4"},
+    {file = "ruff-0.1.5-py3-none-win32.whl", hash = "sha256:f80c73bba6bc69e4fdc73b3991db0b546ce641bdcd5b07210b8ad6f64c79f1ab"},
+    {file = "ruff-0.1.5-py3-none-win_amd64.whl", hash = "sha256:c21fe20ee7d76206d290a76271c1af7a5096bc4c73ab9383ed2ad35f852a0087"},
+    {file = "ruff-0.1.5-py3-none-win_arm64.whl", hash = "sha256:82bfcb9927e88c1ed50f49ac6c9728dab3ea451212693fe40d08d314663e412f"},
+    {file = "ruff-0.1.5.tar.gz", hash = "sha256:5cbec0ef2ae1748fb194f420fb03fb2c25c3258c86129af7172ff8f198f125ab"},
+]
+
 [[package]]
 name = "setuptools"
 version = "68.2.2"
@@ -2178,23 +2217,24 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.24.6"
+version = "20.4.7"
 description = "Virtual Python Environment builder"
 optional = false
-python-versions = ">=3.7"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
 files = [
-    {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"},
-    {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"},
+    {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"},
+    {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"},
 ]
 
 [package.dependencies]
-distlib = ">=0.3.7,<1"
-filelock = ">=3.12.2,<4"
-platformdirs = ">=3.9.1,<4"
+appdirs = ">=1.4.3,<2"
+distlib = ">=0.3.1,<1"
+filelock = ">=3.0.0,<4"
+six = ">=1.9.0,<2"
 
 [package.extras]
-docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "xonsh (>=0.9.16)"]
 
 [[package]]
 name = "vobject"
@@ -2242,86 +2282,81 @@ test = ["pytest"]
 
 [[package]]
 name = "wrapt"
-version = "1.15.0"
+version = "1.16.0"
 description = "Module for decorators, wrappers and monkey patching."
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.6"
 files = [
-    {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"},
-    {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"},
-    {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2"},
-    {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46"},
-    {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c"},
-    {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09"},
-    {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079"},
-    {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e"},
-    {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a"},
-    {file = "wrapt-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923"},
-    {file = "wrapt-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee"},
-    {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727"},
-    {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7"},
-    {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0"},
-    {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec"},
-    {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90"},
-    {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975"},
-    {file = "wrapt-1.15.0-cp310-cp310-win32.whl", hash = "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1"},
-    {file = "wrapt-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e"},
-    {file = "wrapt-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7"},
-    {file = "wrapt-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72"},
-    {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb"},
-    {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e"},
-    {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c"},
-    {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3"},
-    {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92"},
-    {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98"},
-    {file = "wrapt-1.15.0-cp311-cp311-win32.whl", hash = "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416"},
-    {file = "wrapt-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705"},
-    {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29"},
-    {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd"},
-    {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb"},
-    {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248"},
-    {file = "wrapt-1.15.0-cp35-cp35m-win32.whl", hash = "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559"},
-    {file = "wrapt-1.15.0-cp35-cp35m-win_amd64.whl", hash = "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639"},
-    {file = "wrapt-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba"},
-    {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752"},
-    {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364"},
-    {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475"},
-    {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8"},
-    {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418"},
-    {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2"},
-    {file = "wrapt-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1"},
-    {file = "wrapt-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420"},
-    {file = "wrapt-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317"},
-    {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e"},
-    {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e"},
-    {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0"},
-    {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019"},
-    {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034"},
-    {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653"},
-    {file = "wrapt-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0"},
-    {file = "wrapt-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e"},
-    {file = "wrapt-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145"},
-    {file = "wrapt-1.15.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f"},
-    {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd"},
-    {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b"},
-    {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f"},
-    {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6"},
-    {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094"},
-    {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7"},
-    {file = "wrapt-1.15.0-cp38-cp38-win32.whl", hash = "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b"},
-    {file = "wrapt-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1"},
-    {file = "wrapt-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86"},
-    {file = "wrapt-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c"},
-    {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d"},
-    {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc"},
-    {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29"},
-    {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a"},
-    {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8"},
-    {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9"},
-    {file = "wrapt-1.15.0-cp39-cp39-win32.whl", hash = "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff"},
-    {file = "wrapt-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6"},
-    {file = "wrapt-1.15.0-py3-none-any.whl", hash = "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640"},
-    {file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"},
+    {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"},
+    {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"},
+    {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"},
+    {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"},
+    {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"},
+    {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"},
+    {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"},
+    {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"},
+    {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"},
+    {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"},
+    {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"},
+    {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"},
+    {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"},
+    {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"},
+    {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"},
+    {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"},
+    {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"},
+    {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"},
+    {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"},
+    {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"},
+    {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"},
+    {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"},
+    {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"},
+    {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"},
+    {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"},
+    {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"},
+    {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"},
+    {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"},
+    {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"},
+    {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"},
+    {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"},
+    {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"},
+    {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"},
+    {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"},
+    {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"},
+    {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"},
+    {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"},
+    {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"},
+    {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"},
+    {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"},
+    {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"},
+    {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"},
+    {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"},
+    {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"},
+    {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"},
+    {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"},
+    {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"},
+    {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"},
+    {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"},
+    {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"},
+    {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"},
+    {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"},
+    {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"},
+    {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"},
+    {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"},
+    {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"},
+    {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"},
+    {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"},
+    {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"},
+    {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"},
+    {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"},
+    {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"},
+    {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"},
+    {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"},
+    {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"},
+    {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"},
+    {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"},
+    {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"},
+    {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"},
+    {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"},
 ]
 
 [[package]]
@@ -2426,11 +2461,11 @@ asana = ["asana"]
 caldav = ["caldav", "icalendar"]
 fs = ["xattr"]
 gkeep = ["gkeepapi"]
-google = ["google-api-python-client", "google-auth-oauthlib"]
+google = ["google-api-python-client", "google-auth-httplib2", "google-auth-oauthlib"]
 notion = ["notion-client"]
 tw = ["taskw"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "9e6f31d04c85c970b15e48c5f7c59a81f0afc3e308bfc5580ce2607ce0f07098"
+content-hash = "aa29f783fc44646ccde164dc620cc7cbe68151ed8164b03afd00764e30da253e"
diff --git a/pyproject.toml b/pyproject.toml
index feefb76..66fa595 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ keywords = [
   "gkeep",
   "google calendar",
   "google keep",
+  "google tasks",
   "google",
   "notion",
   "productivity",
@@ -45,6 +46,7 @@ tw_gkeep_sync = "syncall.scripts.tw_gkeep_sync:main"
 tw_notion_sync = "syncall.scripts.tw_notion_sync:main"
 fs_gkeep_sync = "syncall.scripts.fs_gkeep_sync:main"
 tw_caldav_sync = "syncall.scripts.tw_caldav_sync:main"
+tw_gtasks_sync = "syncall.scripts.tw_gtasks_sync:main"
 
 # end-user dependencies --------------------------------------------------------
 [tool.poetry.dependencies]
@@ -55,6 +57,7 @@ bidict = "^0.21.2"
 click = "^8.0"
 
 google-api-python-client = { version = "^2.1.0", optional = true }
+google-auth-httplib2 = { version = "^0.1.0", optional = true }
 google-auth-oauthlib = { version = "^0.4.4", optional = true }
 notion-client = { version = "^0.7.1", optional = true }
 gkeepapi = { version = "^0.13.7", optional = true }
@@ -75,7 +78,11 @@ item-synchronizer = "^1.1.4"
 bubop = { version = "0.1.11", allow-prereleases = true }
 
 [tool.poetry.extras]
-google = ["google-api-python-client", "google-auth-oauthlib"]
+google = [
+  "google-api-python-client",
+  "google-auth-oauthlib",
+  "google-auth-httplib2",
+]
 gkeep = ["gkeepapi"]
 notion = ["notion-client"]
 asana = ["asana"]
@@ -107,6 +114,10 @@ check-jsonschema = "^0.14.3"
 readline = "6.2.4.1"
 
 # isort ------------------------------------------------------------------------
+
+[tool.poetry.group.dev.dependencies]
+ruff = "^0.1.5"
+
 [tool.isort]
 include_trailing_comma = true
 line_length = 95
@@ -175,6 +186,11 @@ reportMissingTypeStubs = false
 pythonVersion = "3.8"
 pythonPlatform = "Linux"
 
+# ruff -------------------------------------------------------------------------
+[tool.ruff]
+line-length = 95
+target-version = "py38"
+
 # coverage.py ------------------------------------------------------------------
 [tool.coverage]
 [tool.coverage.run]
diff --git a/readme-fs-gkeep.md b/readme-fs-gkeep.md
index e8c92bc..3b979be 100644
--- a/readme-fs-gkeep.md
+++ b/readme-fs-gkeep.md
@@ -3,7 +3,7 @@
 ## Description
 
 Synchronize all the Google Keep Notes (optionally filtered with a label) with a
-List of files under a local directory. The taskwarrior _filter_ may be a task, a
+List of files under a local directory. The Taskwarrior _filter_ may be a task, a
 project or a combination of a project and arbitrary tags.
 
 Upon execution, `fs_gkeep_sync` will synchronize, and on subsequent runs of the
@@ -52,7 +52,7 @@ See the [Credentials](#credentials) section on how to authenticate with Google.
   supported. You also have to be consistent specifying the name of the said
   directory, i.e., always specify it as an absolute path or always add it as
   the same relative path. As the verbatim name provided is used for naming this
-  combination this will create two different combinatinons and will lead to
+  combination this will create two different combinations and will lead to
   duplication of entries.
 
   ```sh
diff --git a/readme-tw-caldav.md b/readme-tw-caldav.md
index bfb3187..a034d17 100644
--- a/readme-tw-caldav.md
+++ b/readme-tw-caldav.md
@@ -2,7 +2,7 @@
 
 ## Description
 
-Synchronize taskwarrior tasks to a generic caldav server.
+Synchronize Taskwarrior tasks to a generic caldav server.
 This service has been tested using a self-hosted [nextcloud](https://nextcloud.com/) server as well as , but should theoretically work with any server that implements the [caldav specification](https://www.rfc-editor.org/rfc/rfc4791)
 
 Upon execution, `tw_caldav_sync` will synchronize, and on subsequent runs of the
@@ -32,12 +32,12 @@ TW <-> Caldav will make the following mappings between items:
 
 ### Current limitations
 
-- No specific support for "waiting" tasks in taskwarrior, they will be treated like any other "needs-action" caldav task
+- No specific support for "waiting" tasks in Taskwarrior, they will be treated like any other "needs-action" caldav task
 - No support for recurring tasks sync in either direction
 
 ## Installation
 
-Install the `syncall` package from PyPI, enabling the `caldav` and `taskwarrior`
+Install the `syncall` package from PyPI, enabling the `caldav` and `Taskwarrior`
 extra:
 
 ```sh
@@ -73,13 +73,13 @@ The following flag is optional:
 
 With flags:
 
-```
+```sh
 tw_caldav_sync --caldav-url https://nextcloud.example.com/remote.php/dav --caldav-calendar MyCalendar --caldav-user myUser --caldav-passwd path/to/myPass --taskwarrior-tags sync
 ```
 
 With environment variables:
 
-```
+```sh
 CALDAV_USER=myUser CALDAV_PASSWD=myPass tw_caldav_sync --caldav-url https://nextcloud.example.com/remote.php/dav --caldav-calendar MyCalendar --taskwarrior-tags sync
 ```
 
diff --git a/readme-tw-gcal.md b/readme-tw-gcal.md
index 762df4b..4730d91 100644
--- a/readme-tw-gcal.md
+++ b/readme-tw-gcal.md
@@ -1,9 +1,16 @@
 # [Taskwarrior](https://taskwarrior.org/) ⬄ [Google Calendar](https://calendar.google.com/)
 
+> _UPDATE:_ This page describes the `tw_gcal_sync` app, which bi-directionally
+> synchronizes your Taskwarrior items with Google Calendar _events_. Since then a
+> new synchronization has been created which synchronizes your Taskwarrior items
+> with Google Tasks TODO items which in the majority of cases should be more
+> suitable for you to use. To use the latter, head over to the
+> [TW <> Google Tasks README](https://github.com/bergercookie/syncall/blob/master/readme-tw-gtasks.md)
+
 ## Description
 
 Given all the entries of a _Calendar_ in Google Calendar with all the tasks of a
-taskwarrior _filter_ (combination of tags and projects) synchronise all the
+Taskwarrior _filter_ (combination of tags and projects) synchronise all the
 addition/modification/deletion events between them.
 
 ## Demo - first run - populating calendar in GCal
@@ -92,7 +99,7 @@ If the item was created from Taskwarrior then there should be a
 
 `syncall` is aware of the `twgcalsyncduration` UDA:
 
-You can assign a custom duration to a taskwarrior task using something like the following:
+You can assign a custom duration to a Taskwarrior task using something like the following:
 
 ```sh
 # create a task ...
@@ -108,7 +115,7 @@ You can also edit the default event duration using the
 `--default-event-duration-mins INTEGER` flag (specify it in minutes)
 
 **Note:** To make it viewing the duration a bit more user friendly, e.g., for
-taskwarrior reports, the user can also add the following section to their
+Taskwarrior reports, the user can also add the following section to their
 `.taskrc`:
 
 ```sh
diff --git a/readme-tw-gkeep.md b/readme-tw-gkeep.md
index 4f1f1fa..94c7a21 100644
--- a/readme-tw-gkeep.md
+++ b/readme-tw-gkeep.md
@@ -3,7 +3,7 @@
 ## Description
 
 Synchronize all the items of a Google Keep list with a _filter_ in
-Taskwarrior. The taskwarrior _filter_ may be a tag, a project or a combination of
+Taskwarrior. The Taskwarrior _filter_ may be a tag, a project or a combination of
 a project and arbitrary tags.
 
 Upon execution, `tw_gkeep_sync` will synchronize, and on subsequent runs of the
diff --git a/readme-tw-gtasks.md b/readme-tw-gtasks.md
new file mode 100644
index 0000000..0147fb2
--- /dev/null
+++ b/readme-tw-gtasks.md
@@ -0,0 +1,100 @@
+# [Taskwarrior](https://taskwarrior.org/) ⬄ [Google Tasks](https://support.google.com/tasks/answer/7675772)
+
+## Description
+
+Given all tasks in your Google Task task list and the task list of a Taskwarrior
+_filter_ (combination of tags and projects) synchronise all the addition /
+modification / deletion events between them.
+
+## Demo - populating a list in Google Tasks (view from Google Calendar)
+
+![demo_gif](misc/tw_gtasks_sync.gif)
+
+## Motivation
+
+While Taskwarrior is an excellent tool when it comes to keeping TODO lists,
+keeping track of project goals etc., lacks the portability, simplicity and
+minimalistic design of Google Tasks. The latter also has the following
+advantages:
+
+- Automatic sync across all your devices
+- Comfortable addition/modification of events using voice commands
+- Actual reminding of events with a variety of mechanisms
+
+## Override Google Tasks API key
+
+At the moment in order to use this integration you have to register for your own
+developer account with the Google Tasks API with the following steps:
+
+Firstly, removed the `~/.gtasks_credentials.pickle` file on your system since that
+will be reused if found by the app.
+
+For creating your own Google Developer App:
+
+- Go to the Google developer console
+- Make a new project
+- From the sidebar go to `API & Services` and once there click the `ENABLE APIS AND SERVICES` button
+- Look for and Enable the `Tasks API`
+
+Your newly created app now has access to the Tasks API. We now have to create
+and download the credentials:
+
+- Again, from the sidebar under `API And Services` click `Credentials`
+- Enable the `Tasks API`
+  - You should give your app the following scopes for the `Tasks API`:
+    - `Create, edit, organize, and delete all your tasks`
+    - `View your tasks`
+- On the sidebar click `Credentials`, and once there click `CREATE CREDENTIALS`
+- Create a new `OAuth Client ID`. Set the type to `Desktop App` (app name is not
+  important).
+- Finally download the credentials in JSON form by clicking the download button
+  as shown below. This is the file you need to point to when running
+  `tw_gtasks_sync`.
+
+  ![download-btn](misc/gcal-json-btn.png)
+
+To specify your custom credentials JSON file use the `--google-secret` flag as follows:
+
+```sh
+tw_gtasks_sync -l "<list-name>" -t "<taskwarrior-tag>" --google-secret "<path/to/downloaded/json/file>"
+```
+
+## Usage Examples
+
+Run the `tw_gtasks_sync` to synchronise the Google Tasks list of your choice with
+the selected Taskwarrior tag(s). Run with `--help` for the list of options.
+
+```sh
+# Sync the +remindme Taskwarrior tag with the Google Tasks list named "TW Reminders"
+
+tw_gtasks_sync --help
+tw_gtasks_sync -t remindme -l "TW Reminders"
+```
+
+## Installation
+
+### Package Installation
+
+Install the `syncall` package from PyPI, enabling the `google` and `tw`
+extras:
+
+```sh
+pip3 install syncall[google,tw]
+```
+
+## Notes re this synchornization
+
+- Currently subtasks of a Google Tasks item are treated as completely
+  independent of the parent task when converted to Taskwarrior
+- It's not possible to get the time part of the "due" field of a task using the
+  Google Tasks API. Due to this restriction we currently do currently do sync
+  the date part (without the time) from Google Tasks to Taskwarrior, but in
+  order not to remove the time part when doing the inverse synchronization, we
+  don't sync the date at all from Taskwarrior to Google Tasks. More
+  information in [this ticket](https://issuetracker.google.com/u/1/issues/128979662)
+
+## FAQ
+
+### How do I mark an item as done from Google Tasks
+
+Simply mark them as completed.
diff --git a/readme-tw-notion.md b/readme-tw-notion.md
index ce9719f..9ad9f53 100644
--- a/readme-tw-notion.md
+++ b/readme-tw-notion.md
@@ -3,7 +3,7 @@
 ## Description
 
 Synchronize all the `to_do` blocks of a Notion page with a filter in
-Taskwarrior. The taskwarrior filter may be a task, a project or a combination of
+Taskwarrior. The Taskwarrior filter may be a task, a project or a combination of
 a project and arbitrary tags.
 
 Upon execution, `tw_notion_sync` will synchronize, and on subsequent runs of the
diff --git a/scripts/generate-shell-completions.sh b/scripts/generate-shell-completions.sh
index 5e33fbe..1ffdf64 100755
--- a/scripts/generate-shell-completions.sh
+++ b/scripts/generate-shell-completions.sh
@@ -54,7 +54,7 @@ completions_dir="${git_root_dir}/completions"
 mkdir -p $completions_dir
 
 # main loop --------------------------------------------------------------------
-for exec in tw_gkeep_sync tw_notion_sync tw_gcal_sync tw_asana_sync tw_caldav_sync fs_gkeep_sync; do
+for exec in tw_gkeep_sync tw_notion_sync tw_gcal_sync tw_asana_sync tw_caldav_sync fs_gkeep_sync tw_gtasks_sync; do
     tabs 4
     # Run the following, grab the output and dumpt it to the completions/ files...
     # _TW_GKEEP_SYNC_COMPLETE=fish_source tw_gkeep_sync
diff --git a/syncall/__init__.py b/syncall/__init__.py
index 1e6f594..2b4c8a9 100644
--- a/syncall/__init__.py
+++ b/syncall/__init__.py
@@ -31,6 +31,7 @@
     opt_gkeep_user_pass_path,
     opt_google_oauth_port,
     opt_google_secret_override,
+    opt_gtasks_list,
     opt_list_asana_workspaces,
     opt_list_combinations,
     opt_notion_page_id,
@@ -64,6 +65,7 @@
     "opt_combination",
     "opt_custom_combination_savename",
     "opt_gcal_calendar",
+    "opt_gtasks_list",
     "opt_gkeep_note",
     "opt_gkeep_passwd_pass_path",
     "opt_gkeep_user_pass_path",
@@ -134,6 +136,19 @@
         ]
     )
 
+# gtasks --------------------------------------------------------------------------------------
+try:
+    from syncall.google.gtasks_side import GTasksSide
+    from syncall.tw_gtasks_utils import convert_gtask_to_tw, convert_tw_to_gtask
+except ImportError:
+    __all__.extend(
+        [
+            "GTasksSide",
+            "convert_gtask_to_tw",
+            "convert_tw_to_gtask",
+        ]
+    )
+
 # gkeep ---------------------------------------------------------------------------------------
 try:
     from syncall.google.gkeep_note import GKeepNote
diff --git a/syncall/aggregator.py b/syncall/aggregator.py
index 53dcb94..7acc33a 100644
--- a/syncall/aggregator.py
+++ b/syncall/aggregator.py
@@ -136,8 +136,7 @@ def detect_changes(self, helper: SideHelper, items: Dict[ID, Item]) -> SideChang
         new = {
             item_id for item_id in item_ids if item_id not in self._get_ids_map(helper=helper)
         }
-        # Deleted items do not exist in the sync side but still yet exist in my IDs
-        # correspndences.
+        # Deleted items do not exist in the sync side but still exist in my ID correspndences.
         #
         # Exclude the already new ones determined in the earlier step
         deleted = {
diff --git a/syncall/caldav/caldav_side.py b/syncall/caldav/caldav_side.py
index 798cf32..3409305 100644
--- a/syncall/caldav/caldav_side.py
+++ b/syncall/caldav/caldav_side.py
@@ -28,7 +28,6 @@ class CaldavSide(SyncSide):
     ]
 
     _date_keys = ["end", "start", "last-modified"]
-    _date_format = "%Y-%m-%d"
 
     def __init__(self, client: caldav.DAVClient, calendar_name: str) -> None:
         super().__init__(name="caldav", fullname="Caldav")
diff --git a/syncall/cli.py b/syncall/cli.py
index 602949b..b2b9116 100644
--- a/syncall/cli.py
+++ b/syncall/cli.py
@@ -265,6 +265,15 @@ def opt_gcal_calendar():
     )
 
 
+def opt_gtasks_list():
+    return click.option(
+        "-l",
+        "--gtasks-list",
+        type=str,
+        help="Name of the Google Tasks list to synchronize (will be created if not there)",
+    )
+
+
 def opt_gkeep_labels():
     return click.option(
         "-k",
diff --git a/syncall/google/gcal_side.py b/syncall/google/gcal_side.py
index 5904e6f..52588a3 100644
--- a/syncall/google/gcal_side.py
+++ b/syncall/google/gcal_side.py
@@ -36,7 +36,6 @@ class GCalSide(GoogleSide):
     ]
 
     _date_keys = ["end", "start", "updated"]
-    _date_format = "%Y-%m-%d"
 
     def __init__(
         self,
@@ -58,24 +57,26 @@ def __init__(
         )
 
         self._calendar_summary = calendar_summary
-        self._calendar_id = None
+        self._calendar_id: str
         self._items_cache: Dict[str, dict] = {}
 
     def start(self):
         logger.debug("Connecting to Google Calendar...")
         creds = self._get_credentials()
         self._service = discovery.build("calendar", "v3", credentials=creds)
-        self._calendar_id = self._fetch_cal_id()
+        cal_id = self._fetch_cal_id()
 
         # Create calendar if not there --------------------------------------------------------
-        if self._calendar_id is None:
+        if cal_id is None:
             logger.info(f"Creating calendar {self._calendar_summary}")
             new_cal = {"summary": self._calendar_summary}
             ret = self._service.calendars().insert(body=new_cal).execute()  # type: ignore
             assert "id" in ret
-            new_cal_id = ret["id"]
+            new_cal_id: str = ret["id"]
             logger.info(f"Created calendar, id: {new_cal_id}")
             self._calendar_id = new_cal_id
+        else:
+            self._calendar_id = cal_id
 
         logger.debug("Connected to Google Calendar.")
 
@@ -100,12 +101,6 @@ def _fetch_cal_id(self) -> Optional[str]:
                 f'Multiple matching calendars for name -> "{self._calendar_summary}"'
             )
 
-    def _clear_all_calendar_entries(self):
-        """Clear all events from the current calendar."""
-        # TODO Currently not functional - returning "400 Bad Request"
-        logger.warning(f"Clearing all events from calendar {self._calendar_id}")
-        self._service.calendars().clear(calendarId=self._calendar_id).execute()
-
     def get_all_items(self, **kargs):
         """Get all the events for the calendar that we use.
 
diff --git a/syncall/google/gtasks_side.py b/syncall/google/gtasks_side.py
new file mode 100644
index 0000000..9aece7d
--- /dev/null
+++ b/syncall/google/gtasks_side.py
@@ -0,0 +1,311 @@
+import datetime
+import os
+from pathlib import Path
+from typing import Dict, List, Optional, Sequence, Union, cast
+
+import dateutil
+import pkg_resources
+import pytz
+from bubop import format_datetime_tz, logger
+from googleapiclient import discovery
+from googleapiclient.http import HttpError
+
+from syncall.google.google_side import GoogleSide
+from syncall.sync_side import SyncSide
+from syncall.types import GTasksItem, GTasksList
+
+DEFAULT_CLIENT_SECRET = pkg_resources.resource_filename(
+    "syncall", os.path.join("res", "gtasks_client_secret.json")
+)
+
+# API Reference: https://googleapis.github.io/google-api-python-client/docs/dyn/tasks_v1.html
+
+# NOTE(kisseliov): type hints supplied by google client library are
+# questionable, this is why you might see some # type: ignore comments
+
+
+class GTasksSide(GoogleSide):
+    """GTasksSide interacts with the Google Tasks API.
+
+
+    Adds, removes, and updates events on Google Tasks. Also handles the
+    OAuth2 user authentication workflow.
+    """
+
+    ID_KEY = "id"
+    TITLE_KEY = "title"
+    LAST_MODIFICATION_KEY = "updated"
+    _date_keys = ["updated"]
+
+    # don't put the "due" key for comparison
+    # due key holds the date but not the time that the use has set from the UI so we cannot
+    # really use it for bi-synchronization.
+    #
+    # https://stackoverflow.com/questions/65956873/google-task-api-due-field
+    _identical_comparison_keys = ["title", "notes", "status", *_date_keys]
+
+    def __init__(
+        self,
+        *,
+        task_list_title="TaskWarrior Reminders",
+        client_secret,
+        **kargs,
+    ):
+        if client_secret is None:
+            client_secret = DEFAULT_CLIENT_SECRET
+
+        super().__init__(
+            name="Gtasks",
+            fullname="Google Tasks",
+            scopes=["https://www.googleapis.com/auth/tasks"],
+            credentials_cache=Path.home() / ".gtasks_credentials.pickle",
+            client_secret=client_secret,  # type: ignore
+            **kargs,
+        )
+
+        self._task_list_title = task_list_title
+        self._task_list_id: Optional[str] = None
+        self._items_cache: Dict[str, dict] = {}
+
+    def start(self):
+        logger.debug("Connecting to Google Tasks...")
+        creds = self._get_credentials()
+        self._service = discovery.build("tasks", "v1", credentials=creds)
+        self._task_list_id = self._fetch_task_list_id()
+
+        # Create task list if not there --------------------------------------------------------
+        if self._task_list_id is None:
+            logger.info(f"Creating task list {self._task_list_title}")
+            new_task_list = {"title": self._task_list_title}
+            ret = self._service.tasklists().insert(body=new_task_list).execute()  # type: ignore
+            assert "id" in ret
+            new_task_list_id = ret["id"]
+            logger.info(f"Created task list, id: {new_task_list_id}")
+            self._task_list_id = new_task_list_id
+
+        logger.debug("Connected to Google Tasks.")
+
+    def _fetch_task_list_id(self) -> Optional[str]:
+        """Return the id of the task list based on the given Title.
+
+        :returns: id or None if that was not found
+        """
+        res = self._service.tasklists().list().execute()  # type: ignore
+        task_lists_list: List[GTasksList] = res["items"]  # type: ignore
+
+        matching_task_lists = [
+            list["id"] for list in task_lists_list if list["title"] == self._task_list_title
+        ]
+
+        if len(matching_task_lists) == 0:
+            return None
+        elif len(matching_task_lists) == 1:
+            return cast(str, matching_task_lists[0])
+        else:
+            raise RuntimeError(
+                f'Multiple matching task lists for title -> "{self._task_list_title}"'
+            )
+
+    def _clear_all_task_list_entries(self):
+        """Clear all tasks from the current task list."""
+        logger.warning(f"Clearing all tasks from task list {self._task_list_id}")
+        self._service.tasks().clear(tasklist=self._task_list_id).execute()  # type
+
+    def get_all_items(self, **kargs) -> Sequence[GTasksItem]:
+        """Get all tasks for the task list that we use.
+
+        :param kargs: Extra options for the call
+        """
+        # Get the ID of the task list of interest
+        tasks = []
+
+        if self._task_list_id is not None:
+            request = self._service.tasks().list(
+                tasklist=self._task_list_id,
+                # TL;DR Set showCompleted=True AND showHidden=True if you want to also get the
+                # items that the user has ticked from the app.
+                #
+                # From the ref: https://developers.google.com/tasks/reference/rest/v1/tasks/list
+                #
+                # Flag indicating whether completed tasks are returned in the result. Optional.
+                # The default is True. Note that showHidden must also be True to show tasks
+                # completed in first party clients, such as the web UI and Google's mobile
+                # apps.
+                showCompleted=True,
+                showDeleted=False,
+                showHidden=True,
+            )  # type: ignore
+        else:
+            raise RuntimeError("You have to provide valid task list ID")
+
+        # Loop until all pages have been processed.
+        while request is not None:
+            # Get the next page.
+            response = request.execute()
+
+            # Accessing the response like a dict object with an 'items' key
+            # returns a list of item objects (tasks).
+            tasks.extend(
+                [
+                    t
+                    for t in response.get("items", [])
+                    if t["status"] != "deleted" and len(t["title"]) > 0
+                ]
+            )
+
+            # Get the next request object by passing the previous request
+            # object to the list_next method.
+            request = self._service.tasks().list_next(request, response)  # type: ignore
+
+        # cache them
+        for t in tasks:
+            self._items_cache[t["id"]] = t
+
+        return tasks
+
+    def get_item(self, item_id: str, use_cached: bool = True) -> Optional[dict]:
+        item = self._items_cache.get(item_id)
+        if not use_cached or item is None:
+            item = self._get_item_refresh(item_id=item_id)
+
+        return item
+
+    def _get_item_refresh(self, item_id: str) -> Optional[dict]:
+        ret = None
+        try:
+            ret = (
+                self._service.tasks().get(tasklist=self._task_list_id, task=item_id).execute()
+            )
+            if ret["status"] == "deleted":
+                ret = None
+                self._items_cache.pop(item_id)
+            else:
+                self._items_cache[item_id] = ret
+        except HttpError:
+            pass
+        finally:
+            return ret
+
+    def update_item(self, item_id, **changes):
+        # Check if item is there
+        task = self._service.tasks().get(tasklist=self._task_list_id, task=item_id).execute()  # type: ignore
+        task.update(changes)
+        self._service.tasks().update(  # type: ignore
+            tasklist=self._task_list_id, task=task["id"], body=task
+        ).execute()
+
+    def add_item(self, item) -> dict:
+        task = self._service.tasks().insert(tasklist=self._task_list_id, body=item).execute()  # type: ignore
+        logger.debug(f'Task created -> {task.get("selfLink")}')
+
+        return task
+
+    def delete_single_item(self, item_id) -> None:
+        self._service.tasks().delete(tasklist=self._task_list_id, task=item_id).execute()  # type: ignore
+
+    @classmethod
+    def id_key(cls) -> str:
+        return cls.ID_KEY
+
+    @classmethod
+    def summary_key(cls) -> str:
+        return cls.TITLE_KEY
+
+    @classmethod
+    def last_modification_key(cls) -> str:
+        return cls.LAST_MODIFICATION_KEY
+
+    @staticmethod
+    def _parse_dt_or_none(item: GTasksItem, field: str) -> Optional[datetime.datetime]:
+        """Return the datetime on which task was completed in datetime format."""
+        if (dt := item.get(field)) is not None:
+            dt_dt = GTasksSide.parse_datetime(dt)
+            assert isinstance(dt_dt, datetime.datetime)
+            return dt_dt
+        else:
+            return None
+
+    @staticmethod
+    def get_task_due_time(item: GTasksItem) -> Optional[datetime.datetime]:
+        """Return the datetime on which task is due in datetime format."""
+        return GTasksSide._parse_dt_or_none(item=item, field="due")
+
+    @staticmethod
+    def get_task_completed_time(item: GTasksItem) -> Optional[datetime.datetime]:
+        """Return the datetime on which task was completed in datetime format."""
+        return GTasksSide._parse_dt_or_none(item=item, field="completed")
+
+    @staticmethod
+    def format_datetime(dt: datetime.datetime) -> str:
+        assert isinstance(dt, datetime.datetime)
+        return format_datetime_tz(dt)
+
+    @classmethod
+    def parse_datetime(cls, dt: Union[str, dict, datetime.datetime]) -> datetime.datetime:
+        """
+        Parse datetime given in the GTasks format(s):
+        - string with ('T', 'Z' separators).
+        - (dateTime, dateZone) dictionary
+        - datetime object
+
+        Usage::
+
+        >>> GTasksSide.parse_datetime("2019-03-05T00:03:09Z")
+        datetime.datetime(2019, 3, 5, 0, 3, 9)
+        >>> GTasksSide.parse_datetime("2019-03-05")
+        datetime.datetime(2019, 3, 5, 0, 0)
+        >>> GTasksSide.parse_datetime("2019-03-05T00:03:01.1234Z")
+        datetime.datetime(2019, 3, 5, 0, 3, 1, 123400)
+        >>> GTasksSide.parse_datetime("2019-03-08T00:29:06.602Z")
+        datetime.datetime(2019, 3, 8, 0, 29, 6, 602000)
+
+        >>> from tzlocal import get_localzone_name
+        >>> tz = get_localzone_name()
+        >>> a = GTasksSide.parse_datetime({"dateTime": "2021-11-14T22:07:49Z", "timeZone": tz})
+        >>> b = GTasksSide.parse_datetime({"dateTime": "2021-11-14T22:07:49.000000Z"})
+        >>> b
+        datetime.datetime(2021, 11, 14, 22, 7, 49)
+        >>> from bubop.time import is_same_datetime
+        >>> is_same_datetime(a, b) or (print(a) or print(b))
+        True
+        >>> GTasksSide.parse_datetime({"dateTime": "2021-11-14T22:07:49.123456"})
+        datetime.datetime(2021, 11, 14, 22, 7, 49, 123456)
+        >>> a = GTasksSide.parse_datetime({"dateTime": "2021-11-14T22:07:49Z", "timeZone": tz})
+        >>> GTasksSide.parse_datetime(a).isoformat() == a.isoformat()
+        True
+        """
+
+        if isinstance(dt, str):
+            return dateutil.parser.parse(dt).replace(tzinfo=None)  # type: ignore
+        elif isinstance(dt, dict):
+            date_time = dt.get("dateTime")
+            if date_time is None:
+                raise RuntimeError(f"Invalid structure dict: {dt}")
+            dt_dt = GTasksSide.parse_datetime(date_time)
+            time_zone = dt.get("timeZone")
+            if time_zone is not None:
+                timezone = pytz.timezone(time_zone)
+                dt_dt = timezone.localize(dt_dt)
+
+            return dt_dt
+        elif isinstance(dt, datetime.datetime):
+            return dt
+        else:
+            raise RuntimeError(
+                f"Unexpected type of a given date item, type: {type(dt)}, contents: {dt}"
+            )
+
+    @classmethod
+    def items_are_identical(cls, item1, item2, ignore_keys: Sequence[str] = []) -> bool:
+        for item in [item1, item2]:
+            for key in cls._date_keys:
+                if key not in item:
+                    continue
+
+                item[key] = cls.parse_datetime(item[key])
+
+        return SyncSide._items_are_identical(
+            item1,
+            item2,
+            keys=[k for k in cls._identical_comparison_keys if k not in ignore_keys],
+        )
diff --git a/syncall/scripts/tw_gtasks_sync.py b/syncall/scripts/tw_gtasks_sync.py
new file mode 100644
index 0000000..c5af9e6
--- /dev/null
+++ b/syncall/scripts/tw_gtasks_sync.py
@@ -0,0 +1,217 @@
+import sys
+from typing import List
+
+import click
+from bubop import (
+    check_optional_mutually_exclusive,
+    format_dict,
+    log_to_syslog,
+    logger,
+    loguru_tqdm_sink,
+)
+
+from syncall import inform_about_app_extras
+
+try:
+    from syncall import GTasksSide, TaskWarriorSide
+except ImportError:
+    inform_about_app_extras(["google", "tw"])
+
+from syncall import (
+    Aggregator,
+    __version__,
+    cache_or_reuse_cached_combination,
+    convert_gtask_to_tw,
+    convert_tw_to_gtask,
+    fetch_app_configuration,
+    get_resolution_strategy,
+    inform_about_combination_name_usage,
+    list_named_combinations,
+    report_toplevel_exception,
+)
+from syncall.cli import (
+    opt_combination,
+    opt_custom_combination_savename,
+    opt_google_oauth_port,
+    opt_google_secret_override,
+    opt_gtasks_list,
+    opt_list_combinations,
+    opt_list_resolution_strategies,
+    opt_prefer_scheduled_date,
+    opt_resolution_strategy,
+    opt_tw_project,
+    opt_tw_tags,
+)
+
+
+@click.command()
+# google tasks options ---------------------------------------------------------------------
+@opt_gtasks_list()
+@opt_google_secret_override()
+@opt_google_oauth_port()
+# taskwarrior options -------------------------------------------------------------------------
+@opt_tw_tags()
+@opt_tw_project()
+# misc options --------------------------------------------------------------------------------
+@opt_list_combinations("TW", "Google Tasks")
+@opt_list_resolution_strategies()
+@opt_resolution_strategy()
+@opt_combination("TW", "Google Tasks")
+@opt_custom_combination_savename("TW", "Google Tasks")
+@opt_prefer_scheduled_date()
+@click.option("-v", "--verbose", count=True)
+@click.version_option(__version__)
+def main(
+    gtasks_list: str,
+    google_secret: str,
+    oauth_port: int,
+    tw_tags: List[str],
+    tw_project: str,
+    resolution_strategy: str,
+    verbose: int,
+    combination_name: str,
+    custom_combination_savename: str,
+    do_list_combinations: bool,
+    list_resolution_strategies: bool,  # type: ignore
+    prefer_scheduled_date: bool,
+):
+    """Synchronize lists from your Google Tasks with filters from Taskwarrior.
+
+    The list of TW tasks is determined by a combination of TW tags and a TW project while the
+    list in GTasks should be provided by their name. if it doesn't exist it will be crated
+    """
+    # setup logger ----------------------------------------------------------------------------
+    loguru_tqdm_sink(verbosity=verbose)
+    log_to_syslog(name="tw_gtasks_sync")
+    logger.debug("Initialising...")
+    inform_about_config = False
+
+    if do_list_combinations:
+        list_named_combinations(config_fname="tw_gtasks_configs")
+        return 0
+
+    # cli validation --------------------------------------------------------------------------
+    check_optional_mutually_exclusive(combination_name, custom_combination_savename)
+    combination_of_tw_project_tags_and_gtasks_list = any(
+        [
+            tw_project,
+            tw_tags,
+            gtasks_list,
+        ]
+    )
+    check_optional_mutually_exclusive(
+        combination_name, combination_of_tw_project_tags_and_gtasks_list
+    )
+
+    # existing combination name is provided ---------------------------------------------------
+    if combination_name is not None:
+        app_config = fetch_app_configuration(
+            config_fname="tw_gtasks_configs", combination=combination_name
+        )
+        tw_tags = app_config["tw_tags"]
+        tw_project = app_config["tw_project"]
+        gtasks_list = app_config["gtasks_list"]
+
+    # combination manually specified ----------------------------------------------------------
+    else:
+        inform_about_config = True
+        combination_name = cache_or_reuse_cached_combination(
+            config_args={
+                "gtasks_list": gtasks_list,
+                "tw_project": tw_project,
+                "tw_tags": tw_tags,
+            },
+            config_fname="tw_gtasks_configs",
+            custom_combination_savename=custom_combination_savename,
+        )
+
+    # at least one of tw_tags, tw_project should be set ---------------------------------------
+    if not tw_tags and not tw_project:
+        logger.error(
+            "You have to provide at least one valid tag or a valid project ID to use for the"
+            " synchronization. You can do so either via CLI arguments or by specifying an"
+            " existing saved combination"
+        )
+        sys.exit(1)
+
+    # more checks -----------------------------------------------------------------------------
+    if gtasks_list is None:
+        logger.error(
+            "You have to provide the name of a Google Tasks list to synchronize events"
+            " to/from. You can do so either via CLI arguments or by specifying an existing"
+            " saved combination"
+        )
+        sys.exit(1)
+
+    # announce configuration ------------------------------------------------------------------
+    logger.info(
+        format_dict(
+            header="Configuration",
+            items={
+                "TW Tags": tw_tags,
+                "TW Project": tw_project,
+                "Google Tasks": gtasks_list,
+                "Prefer scheduled dates": prefer_scheduled_date,
+            },
+            prefix="\n\n",
+            suffix="\n",
+        )
+    )
+
+    # initialize sides ------------------------------------------------------------------------
+    tw_side = TaskWarriorSide(tags=tw_tags, project=tw_project)
+
+    gtask_side = GTasksSide(
+        task_list_title=gtasks_list, oauth_port=oauth_port, client_secret=google_secret
+    )
+
+    # take extra arguments into account -------------------------------------------------------
+    def convert_B_to_A(*args, **kargs):
+        return convert_tw_to_gtask(
+            *args,
+            **kargs,
+        )
+
+    convert_B_to_A.__doc__ = convert_tw_to_gtask.__doc__
+
+    def convert_A_to_B(*args, **kargs):
+        return convert_gtask_to_tw(
+            *args,
+            **kargs,
+            set_scheduled_date=prefer_scheduled_date,
+        )
+
+    convert_A_to_B.__doc__ = convert_gtask_to_tw.__doc__
+
+    # sync ------------------------------------------------------------------------------------
+    try:
+        with Aggregator(
+            side_A=gtask_side,
+            side_B=tw_side,
+            converter_B_to_A=convert_B_to_A,
+            converter_A_to_B=convert_A_to_B,
+            resolution_strategy=get_resolution_strategy(
+                resolution_strategy, side_A_type=type(gtask_side), side_B_type=type(tw_side)
+            ),
+            config_fname=combination_name,
+            ignore_keys=(
+                (),
+                (),
+            ),
+        ) as aggregator:
+            aggregator.sync()
+    except KeyboardInterrupt:
+        logger.error("Exiting...")
+        return 1
+    except:
+        report_toplevel_exception(is_verbose=verbose >= 1)
+        return 1
+
+    if inform_about_config:
+        inform_about_combination_name_usage(combination_name)
+
+    return 0
+
+
+if __name__ == "__main__":
+    main()
diff --git a/syncall/sync_side.py b/syncall/sync_side.py
index adbce84..05638ce 100644
--- a/syncall/sync_side.py
+++ b/syncall/sync_side.py
@@ -162,7 +162,7 @@ def _items_are_identical(item1: ItemType, item2: ItemType, keys: list) -> bool:
             if isinstance(item1[k], datetime.datetime) and isinstance(
                 item2[k], datetime.datetime
             ):
-                if is_same_datetime(item1[k], item2[k], tol=datetime.timedelta(minutes=10)):
+                if is_same_datetime(item1[k], item2[k], tol=datetime.timedelta(minutes=1)):
                     continue
                 else:
                     logger.opt(lazy=True).trace(
diff --git a/syncall/tw_gcal_utils.py b/syncall/tw_gcal_utils.py
index 6eb0f00..dfecd02 100644
--- a/syncall/tw_gcal_utils.py
+++ b/syncall/tw_gcal_utils.py
@@ -1,9 +1,6 @@
-import traceback
 from datetime import timedelta
-from typing import List, Optional, Tuple
 from uuid import UUID
 
-import dateutil
 from bubop import logger
 from item_synchronizer.types import Item
 
@@ -13,6 +10,12 @@
 )
 from syncall.taskwarrior.taskw_duration import duration_serialize as taskw_duration_serialize
 from syncall.taskwarrior.taskwarrior_side import tw_duration_key
+from syncall.tw_utils import (
+    extract_tw_fields_from_string,
+    get_tw_annotations_as_str,
+    get_tw_status_and_uuid_as_str,
+)
+from syncall.types import GCalItem
 
 _prefix_title_success_str = "✅"
 _prefix_title_failed_str = "❌"
@@ -49,11 +52,7 @@ def convert_tw_to_gcal(
     prefer_scheduled_date: bool = False,
     default_event_duration: timedelta = timedelta(hours=1),
 ) -> Item:
-    """TW -> GCal Converter.
-
-    .. note:: Do not convert the ID as that may change either manually or
-              after marking the task as "DONE"
-    """
+    """TW -> GCal conversion."""
     assert all(
         i in tw_item.keys() for i in ("description", "status", "uuid")
     ), "Missing keys in tw_item"
@@ -67,13 +66,9 @@ def convert_tw_to_gcal(
 
     # description
     gcal_item["description"] = "IMPORTED FROM TASKWARRIOR\n"
-    if "annotations" in tw_item.keys():
-        for i, annotation in enumerate(tw_item["annotations"]):
-            gcal_item["description"] += f"\n* Annotation {i + 1}: {annotation}"
-
-    gcal_item["description"] += "\n"
-    for k in ["status", "uuid"]:
-        gcal_item["description"] += f"\n* {k}: {tw_item[k]}"
+    gcal_item["description"] += "\n".join(
+        [get_tw_annotations_as_str(tw_item), get_tw_status_and_uuid_as_str(tw_item)]
+    )
 
     date_keys = ["scheduled", "due"] if prefer_scheduled_date else ["due", "scheduled"]
     # event duration --------------------------------------------------------------------------
@@ -119,7 +114,7 @@ def convert_tw_to_gcal(
 
 
 def convert_gcal_to_tw(
-    gcal_item: Item,
+    gcal_item: GCalItem,
     set_scheduled_date: bool = False,
 ) -> Item:
     """GCal -> TW Converter.
@@ -129,7 +124,12 @@ def convert_gcal_to_tw(
     """
 
     # Parse the description
-    annotations, status, uuid = _parse_gcal_item_desc(gcal_item)
+    annotations = []
+    status = "pending"
+    uuid = None
+    if (section := gcal_item.get("description")) is not None:
+        annotations, status, uuid = extract_tw_fields_from_string(section)
+
     assert isinstance(annotations, list)
     assert isinstance(status, str)
     assert isinstance(uuid, UUID) or uuid is None
@@ -138,14 +138,13 @@ def convert_gcal_to_tw(
     # annotations
     tw_item["annotations"] = annotations
 
-    # alias - make aliases dict?
     if status == "done":
         status = "completed"
 
     # Status
     if status not in ["pending", "completed", "deleted", "waiting", "recurring"]:
         logger.error(
-            "Invalid status {status} in GCal->TW conversion of item. Skipping status:"
+            f"Invalid status {status} in GCal->TW conversion of item. Skipping status:"
         )
     else:
         tw_item["status"] = status
@@ -181,49 +180,3 @@ def convert_gcal_to_tw(
     # them as new TW UDAs if needed
 
     return tw_item
-
-
-def _parse_gcal_item_desc(
-    gcal_item: Item,
-) -> Tuple[List[str], str, Optional[UUID]]:
-    """Parse and return the necessary TW fields off a Google Calendar Item."""
-    annotations: List[str] = []
-    status = "pending"
-    uuid = None
-
-    if "description" not in gcal_item.keys():
-        return annotations, status, uuid
-
-    gcal_desc = gcal_item["description"]
-    # strip whitespaces, empty lines
-    lines = [line.strip() for line in gcal_desc.split("\n") if line][1:]
-
-    # annotations
-    i = 0
-    for i, line in enumerate(lines):
-        parts = line.split(":", maxsplit=1)
-        if len(parts) == 2 and parts[0].lower().startswith("* annotation"):
-            annotations.append(parts[1].strip())
-        else:
-            break
-
-    if i == len(lines) - 1:
-        return annotations, status, uuid
-
-    # Iterate through rest of lines, find only the status and uuid ones
-    for line in lines[i:]:
-        parts = line.split(":", maxsplit=1)
-        if len(parts) == 2:
-            start = parts[0].lower()
-            if start.startswith("* status"):
-                status = parts[1].strip().lower()
-            elif start.startswith("* uuid"):
-                try:
-                    uuid = UUID(parts[1].strip())
-                except ValueError as err:
-                    logger.error(
-                        f'Invalid UUID "{err}" provided during GCal -> TW conversion,'
-                        f" Using None...\n\n{traceback.format_exc()}"
-                    )
-
-    return annotations, status, uuid
diff --git a/syncall/tw_gtasks_utils.py b/syncall/tw_gtasks_utils.py
new file mode 100644
index 0000000..4d632f0
--- /dev/null
+++ b/syncall/tw_gtasks_utils.py
@@ -0,0 +1,102 @@
+from bubop import logger
+from item_synchronizer.types import Item
+
+from syncall.google.gtasks_side import GTasksSide
+from syncall.tw_utils import extract_tw_fields_from_string, get_tw_annotations_as_str
+from syncall.types import GTasksItem
+
+
+def convert_tw_to_gtask(
+    tw_item: Item,
+) -> Item:
+    """TW -> GTasks conversion."""
+    assert all(
+        i in tw_item.keys() for i in ("description", "status", "uuid")
+    ), "Missing keys in tw_item"
+
+    gtasks_item = {}
+
+    # title
+    gtasks_item["title"] = tw_item["description"]
+
+    # status
+    gtasks_item["status"] = "needsAction" if tw_item["status"] == "pending" else "completed"
+
+    # notes
+    if annotations_str := get_tw_annotations_as_str(tw_item):
+        gtasks_item["notes"] = annotations_str
+
+    # update time
+    if "modified" in tw_item.keys():
+        gtasks_item["updated"] = GTasksSide.format_datetime(
+            GTasksSide.parse_datetime(tw_item["modified"])
+        )
+
+    return gtasks_item
+
+
+def convert_gtask_to_tw(
+    gtasks_item: GTasksItem,
+    set_scheduled_date: bool = False,
+) -> Item:
+    """GTasks -> TW Converter.
+
+    If set_scheduled_date, then it will set the "scheduled" date of the produced TW task
+    instead of the "due" date
+    """
+
+    # Parse the description
+    annotations = []
+    uuid = None
+    if (section := gtasks_item.get("notes")) is not None:
+        annotations, _, uuid = extract_tw_fields_from_string(section)
+
+    tw_item: Item = {}
+    # annotations
+    tw_item["annotations"] = annotations
+
+    gtasks_to_tw_status_corrs = {
+        "completed": "completed",
+        "needsAction": "pending",
+    }
+
+    status_gtask = gtasks_item["status"]
+    status_tw = gtasks_to_tw_status_corrs.get(status_gtask)
+    if status_tw is None:
+        logger.error(
+            f"Unknown Google Task status {status_gtask} for google task item {gtasks_item}."
+            " Setting it to pending"
+        )
+        status_tw = "pending"
+
+    # status
+    tw_item["status"] = status_tw
+
+    # uuid - may just be created -, thus not there
+    if uuid is not None:
+        tw_item["uuid"] = uuid
+
+    # Description
+    tw_item["description"] = gtasks_item["title"]
+
+    # don't meddle with the 'entry' field
+    if set_scheduled_date:
+        date_key = "scheduled"
+    else:
+        date_key = "due"
+
+    # due/scheduled date
+    due_date = GTasksSide.get_task_due_time(gtasks_item)
+    if due_date is not None:
+        tw_item[date_key] = due_date
+
+    # end date
+    end_date = GTasksSide.get_task_completed_time(gtasks_item)
+    if end_date is not None:
+        tw_item["end"] = end_date
+
+    # update time
+    if "updated" in gtasks_item.keys():
+        tw_item["modified"] = GTasksSide.parse_datetime(gtasks_item["updated"])
+
+    return tw_item
diff --git a/syncall/tw_utils.py b/syncall/tw_utils.py
new file mode 100644
index 0000000..bd3c982
--- /dev/null
+++ b/syncall/tw_utils.py
@@ -0,0 +1,85 @@
+"""Miscellaneous TW related utilities.
+
+At the time of writing, these are primarily used in the TW<>Gtasks and TW<>Gcal integrations.
+"""
+
+import traceback
+from typing import Optional, Sequence, Tuple
+from uuid import UUID
+
+from bubop import logger
+
+from syncall.types import TwItem
+
+
+def get_tw_annotations_as_str(tw_item: TwItem) -> str:
+    """
+    Return all the annotations of the given object in a single string. Put each annotation
+    in its own line and prefix it with "Annotation X" where X is an increasing integer id.
+
+    Return an empty string if there are no annotations.
+    """
+    if "annotations" in tw_item.keys() and len(tw_item["annotations"]) > 0:
+        annotations_li = [
+            f"* Annotation {i + 1}: {annotation}"
+            for i, annotation in enumerate(tw_item["annotations"])
+        ]
+
+        return "\n".join(annotations_li)
+
+    else:
+        return ""
+
+
+def get_tw_status_and_uuid_as_str(tw_item: TwItem) -> str:
+    """
+    Return the UUID and status of the given Taskwarrior item in a single string.
+    """
+    return "\n".join(
+        [
+            f"{k}: {tw_item[k]}"
+            for k in [
+                "status",
+                "uuid",
+            ]
+        ]
+    )
+
+
+def extract_tw_fields_from_string(s: str) -> Tuple[Sequence[str], str, Optional[UUID]]:
+    """Parse the TW annotations, status, and UUID fields from the given string."""
+
+    annotations = []
+    status = "pending"
+    uuid = None
+
+    # strip whitespaces, empty lines
+    lines = [line.strip() for line in s.split("\n") if line][0:]
+    i = 0
+    for i, line in enumerate(lines):
+        parts = line.split(":", maxsplit=1)
+        if len(parts) == 2 and parts[0].lower().startswith("* annotation"):
+            annotations.append(parts[1].strip())
+        else:
+            break
+
+    if i == len(lines) - 1:
+        return annotations, status, uuid
+
+    # Iterate through rest of lines, find only the status and uuid ones
+    for line in lines[i:]:
+        parts = line.split(":", maxsplit=1)
+        if len(parts) == 2:
+            start = parts[0].lower()
+            if start.startswith("* status"):
+                status = parts[1].strip().lower()
+            elif start.startswith("* uuid"):
+                try:
+                    uuid = UUID(parts[1].strip())
+                except ValueError as err:
+                    logger.error(
+                        f'Invalid UUID "{err}" provided during the conversion to taskwarrior,'
+                        f" Using None...\n\n{traceback.format_exc()}"
+                    )
+
+    return annotations, status, uuid
diff --git a/syncall/types.py b/syncall/types.py
index 29037be..4475d44 100644
--- a/syncall/types.py
+++ b/syncall/types.py
@@ -42,6 +42,95 @@ class TaskwarriorRawItem(TypedDict, total=False):
 # Google Calendar -----------------------------------------------------------------------------
 GCalItem = Dict[str, Any]
 
+# ---------------------------------------------------------------------------------------------
+# Google Tasks
+
+
+class GTaskLink(TypedDict):
+    description: str
+    link: str
+    type: str  # "email"
+
+
+class GTasksList(TypedDict):
+    etag: str  # ETag of the resource.
+    id: str  # Task list identifier.
+    kind: str  # Type of the resource. This is always "tasks#taskList".
+    selfLink: str  # URL pointing to this task list. Used to retrieve, update, or delete this task list.
+    title: str  # Title of the task list.
+    updated: str  # Last modification time of the task list (as a RFC 3339 timestamp).
+
+
+class GTasksItem(TypedDict):
+    """
+    Dict part of an item as returned from the Google Tasks Python API on `tasks().get_task()`.
+
+    Example:
+
+       {
+        'id': 'Yl9GSzNDVWluTk9heE1sUQ',
+        'kind': 'tasks#task',
+        'status': 'completed',
+        'etag': '"LTc5ODEwNzk2Mg"',
+        'title': 'Simple completed item',
+        'updated': '2021-12-04T15:07:00.000Z',
+        'selfLink': 'https://www.googleapis.com/tasks/v1/lists/YUFLWXdFQ3NLczVKalZsWg/tasks/Yl9GSzNDVWluTk9heE1sUQ',
+        'position': '00000000000000000001',
+        'notes': '''
+            * Annotation: Testing adding annotation in Google Tasks\n
+
+            * status: done\n
+            * uuid: 542f5dbc-b1b7-4a85-b55e-10f5f1d31847
+        ''',
+        'due': '2021-12-04T18:07:00.000Z',
+        "completed": "2021-12-04T15:07:00.000Z",
+        'links': []
+    }
+    """
+
+    # Completion date of the task (as a RFC 3339 timestamp). This field is omitted if the task
+    # has not been completed.
+    completed: Optional[str]
+    # Flag indicating whether the task has been deleted. The default is False.
+    deleted: bool
+    # Due date of the task (as a RFC 3339 timestamp). Optional. The due date only records date
+    # information; the time portion of the timestamp is discarded when setting the due date. It
+    # isn't possible to read or write the time that a task is due via the API.
+    due: Optional[str]
+    # ETag of the resource.
+    etag: str
+    # Flag indicating whether the task is hidden. This is the case if the task had been marked
+    # completed when the task list was last cleared. The default is False. This field is
+    # read-only.
+    hidden: bool
+    # Task identifier.
+    id: str
+    # Type of the resource. This is always "tasks#task".
+    kind: str
+    # Collection of links. This collection is read-only.
+    links: List[GTaskLink]
+    # Notes describing the task. Optional.
+    notes: Optional[str]
+    # Parent task identifier. This field is omitted if it is a top-level task. This field is
+    # read-only. Use the "move" method to move the task under a different parent or to the top
+    # level.
+    parent: Optional[str]
+    # String indicating the position of the task among its sibling tasks under the same parent
+    # task or at the top level. If this string is greater than another task's corresponding
+    # position string according to lexicographical ordering, the task is positioned after the
+    # other task under the same parent task (or at the top level). This field is read-only. Use
+    # the "move" method to move the task to another position.
+    position: str
+    # URL pointing to this task. Used to retrieve, update, or delete this task.
+    selfLink: str
+    # Status of the task. This is either "needsAction" or "completed".
+    status: str
+    # Title of the task.
+    title: str
+    # Last modification time of the task (as a RFC 3339 timestamp).
+    updated: str
+
+
 # ---------------------------------------------------------------------------------------------
 # Notion
 NotionID = ID


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 8f99bd9..fe81d76 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,6 +9,7 @@
 from .conftest_fs import *
 from .conftest_gcal import *
 from .conftest_gkeep import *
+from .conftest_gtasks import *
 from .conftest_notion import *
 from .conftest_tw import *
 
diff --git a/tests/conftest_gtasks.py b/tests/conftest_gtasks.py
new file mode 100644
index 0000000..e6721ad
--- /dev/null
+++ b/tests/conftest_gtasks.py
@@ -0,0 +1,157 @@
+from typing import cast
+
+import pytest
+
+from syncall.types import GTasksItem
+
+# API Reference: https://googleapis.github.io/google-api-python-client/docs/dyn/tasks_v1.html
+
+
+@pytest.fixture()
+def gtask(request: pytest.FixtureRequest) -> GTasksItem:
+    """Fixture to parametrize on."""
+    param = request.param  # type: ignore
+    return cast(GTasksItem, request.getfixturevalue(param))
+
+
+@pytest.fixture()
+def gtasks_simple_done_item():
+    return {
+        "id": "Yl9GSzNDVWluTk9heE1sUQ",
+        "kind": "tasks#task",
+        "status": "completed",
+        "etag": '"LTc5ODEwNzk2Mg"',
+        "title": "Simple completed item",
+        "updated": "2021-12-04T15:07:00.000Z",
+        "selfLink": "https://www.googleapis.com/tasks/v1/lists/YUFLWXdFQ3NLczVKalZsWg/tasks/Yl9GSzNDVWluTk9heE1sUQ",
+        "position": "00000000000000000001",
+        "notes": """
+            * Annotation: Testing adding annotation in Google Tasks\n
+
+            * status: done\n
+            * uuid: 542f5dbc-b1b7-4a85-b55e-10f5f1d31847
+        """,
+        "due": "2021-12-04T18:07:00.000Z",
+        "completed": "2021-12-04T15:07:00.000Z",
+        "links": [],
+    }
+
+
+@pytest.fixture()
+def gtasks_simple_pending_item():
+    return {
+        "id": "yl9gszndvwlutk9hee1suq",
+        "kind": "tasks#task",
+        "status": "needsAction",
+        "etag": '"ltc5odewnzk2mg"',
+        "title": "Simple pending item",
+        "updated": "2021-12-04T15:07:00.000Z",
+        "selflink": "https://www.googleapis.com/tasks/v1/lists/yuflwxdfq3nlczvkalzswg/tasks/yl9gszndvwlutk9hee1suq",
+        "position": "00000000000000000001",
+        "notes": """
+            * annotation: testing adding annotation in google tasks\n
+
+            * status: pending\n
+            * uuid: 542f5dbc-b1b7-4a85-b55e-10f5f1d31847
+        """,
+        "due": "2021-12-04t18:07:00.000z",
+        "links": [],
+    }
+
+
+@pytest.fixture()
+def gtasks_deleted_pending_item():
+    return {
+        "id": "yl9gszndvwlutk9hee1suq",
+        "deleted": True,
+        "kind": "tasks#task",
+        "status": "needsAction",
+        "etag": '"ltc5odewnzk2mg"',
+        "title": "test creating a task in google tasks",
+        "updated": "2021-12-04T15:07:00.000Z",
+        "selflink": "https://www.googleapis.com/tasks/v1/lists/yuflwxdfq3nlczvkalzswg/tasks/yl9gszndvwlutk9hee1suq",
+        "position": "00000000000000000001",
+        "notes": """
+            * annotation: testing adding annotation in google tasks\n
+
+            * status: pending\n
+            * uuid: 542f5dbc-b1b7-4a85-b55e-10f5f1d31847
+        """,
+        "due": "2021-12-04T18:07:00.000Z",
+        "links": [],
+    }
+
+
+@pytest.fixture()
+def gtasks_completed_hidden_item():
+    return {
+        "id": "yl9gszndvwlutk9hee1suq",
+        "deleted": False,
+        "hidden": True,
+        "kind": "tasks#task",
+        "status": "completed",
+        "etag": '"ltc5odewnzk2mg"',
+        "title": "Hidden item",
+        "updated": "2021-12-04T15:07:00.000Z",
+        "selflink": "https://www.googleapis.com/tasks/v1/lists/yuflwxdfq3nlczvkalzswg/tasks/yl9gszndvwlutk9hee1suq",
+        "position": "00000000000000000001",
+        "notes": """
+            * annotation: testing adding annotation in google tasks\n
+
+            * status: done\n
+            * uuid: 542f5dbc-b1b7-4a85-b55e-10f5f1d31847
+        """,
+        "due": "2021-12-04T18:07:00.000Z",
+        "links": [],
+    }
+
+
+@pytest.fixture()
+def gtasks_deleted_completed_item():
+    return {
+        "id": "yl9gszndvwlutk9hee1suq",
+        "deleted": True,
+        "status": "completed",
+        "kind": "tasks#task",
+        "etag": '"ltc5odewnzk2mg"',
+        "title": "test creating a task in google tasks",
+        "updated": "2021-12-04t15:07:00.000z",
+        "selflink": "https://www.googleapis.com/tasks/v1/lists/yuflwxdfq3nlczvkalzswg/tasks/yl9gszndvwlutk9hee1suq",
+        "position": "00000000000000000001",
+        "notes": """
+            * annotation: testing adding annotation in google tasks\n
+
+            * status: completed\n
+            * uuid: 542f5dbc-b1b7-4a85-b55e-10f5f1d31847
+        """,
+        "due": "2021-12-04T18:07:00.000Z",
+        "links": [],
+    }
+
+
+@pytest.fixture()
+def gtasks_note_empty():
+    return {
+        "id": "yl9gszndvwlutk9hee1suq",
+        "kind": "tasks#task",
+        "status": "needsAction",
+        "etag": '"ltc5odewnzk2mg"',
+        "title": "test creating a task in google tasks",
+        "updated": "2021-12-04T15:07:00.000Z",
+        "selflink": "https://www.googleapis.com/tasks/v1/lists/yuflwxdfq3nlczvkalzswg/tasks/yl9gszndvwlutk9hee1suq",
+        "position": "00000000000000000001",
+        "due": "2021-12-04T18:07:00.000Z",
+        "links": [],
+    }
+
+
+@pytest.fixture()
+def gtasks_list0():
+    return {
+        "etag": '"ltc5odewnzk2mg"',
+        "id": "yl9gszndvwlutk9hee1suq",
+        "kind": "tasks#taskList",
+        "selfLink": "https://www.googleapis.com/tasks/v1/lists/yuflwxdfq3nlczvkalzswg",
+        "title": "Taskwarrior Reminders",
+        "updated": "2021-12-04T15:07:00.000Z",
+    }
diff --git a/tests/test_tw_gtasks_conversions.py b/tests/test_tw_gtasks_conversions.py
new file mode 100644
index 0000000..a7e6cb0
--- /dev/null
+++ b/tests/test_tw_gtasks_conversions.py
@@ -0,0 +1,37 @@
+import pytest
+
+from syncall.tw_gtasks_utils import convert_gtask_to_tw, convert_tw_to_gtask
+from syncall.types import GTasksItem, TwItem, TwRawItem
+
+
+# test conversions ----------------------------------------------------------------------------
+def compare_items(gtasks_item: GTasksItem, tw_item: TwItem):
+    assert gtasks_item["title"] == tw_item["description"]
+    if "status" in tw_item.keys():
+        if gtasks_item["status"] == "completed":
+            assert tw_item["status"] == "completed"
+        else:
+            assert tw_item["status"] != "completed"
+
+
+@pytest.mark.parametrize(
+    "gtask",
+    [
+        "gtasks_simple_done_item",
+        "gtasks_simple_pending_item",
+    ],
+    indirect=True,
+)
+def test_convert_gtask_to_tw(gtask: GTasksItem):
+    tw_task = convert_gtask_to_tw(gtask)
+    compare_items(gtask, tw_task)
+
+
+@pytest.mark.parametrize(
+    "tw_task",
+    ["tw_pending_task", "tw_completed_task"],
+    indirect=True,
+)
+def test_convert_tw_to_gtask(tw_task: TwItem):
+    gtask_item = convert_tw_to_gtask(tw_task)
+    compare_items(gtask_item, tw_task)