From a6b5159e7e65fd4abfd2e1b1e412f47ee61dafe4 Mon Sep 17 00:00:00 2001 From: Nikos Koukis Date: Thu, 15 Aug 2024 10:47:20 +0300 Subject: [PATCH] Fix all-day events entered in Google Cal - Reduce duplication with Gtasks Fixes #126 --- syncall/google/common.py | 40 +++++++++++ syncall/google/gcal_side.py | 45 ++---------- syncall/google/gtasks_side.py | 70 ++----------------- syncall/tw_gcal_utils.py | 12 ++-- syncall/tw_gtasks_utils.py | 9 ++- tests/{test_gcal.py => test_google_common.py} | 9 +-- 6 files changed, 62 insertions(+), 123 deletions(-) create mode 100644 syncall/google/common.py rename tests/{test_gcal.py => test_google_common.py} (89%) diff --git a/syncall/google/common.py b/syncall/google/common.py new file mode 100644 index 0000000..bf48230 --- /dev/null +++ b/syncall/google/common.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, cast + +import dateutil +from bubop import assume_local_tz_if_none + +if TYPE_CHECKING: + from syncall.types import GoogleDateT + + +def parse_google_datetime(dt: GoogleDateT) -> datetime.datetime: + """Parse datetime given in format(s) returned by the Google API: + - string with ('T', 'Z' separators). + - (dateTime, timeZone) dictionary + - datetime object + + The output datetime is always in local timezone. + """ + if isinstance(dt, str): + dt_dt = dateutil.parser.parse(dt) # type: ignore + return parse_google_datetime(dt_dt) + + if isinstance(dt, dict): + for key in "dateTime", "date": + if key in dt: + date_time = cast(str, dt.get(key)) + break + else: + raise RuntimeError(f"Invalid structure dict: {dt}") + + return parse_google_datetime(date_time) + + if isinstance(dt, datetime.datetime): + return assume_local_tz_if_none(dt) + + raise TypeError( + f"Unexpected type of a given date item, type: {type(dt)}, contents: {dt}", + ) diff --git a/syncall/google/gcal_side.py b/syncall/google/gcal_side.py index a0b40fe..4127ed8 100644 --- a/syncall/google/gcal_side.py +++ b/syncall/google/gcal_side.py @@ -2,20 +2,17 @@ import datetime from pathlib import Path -from typing import TYPE_CHECKING, Literal, Sequence, cast +from typing import Literal, Sequence, cast -import dateutil import pkg_resources -from bubop import assume_local_tz_if_none, format_datetime_tz, logger +from bubop import logger from googleapiclient import discovery from googleapiclient.http import HttpError +from syncall.google.common import parse_google_datetime from syncall.google.google_side import GoogleSide from syncall.sync_side import SyncSide -if TYPE_CHECKING: - from syncall.types import GoogleDateT - DEFAULT_CLIENT_SECRET = pkg_resources.resource_filename( "syncall", "res/gcal_client_secret.json", @@ -216,39 +213,7 @@ def get_event_time(item: dict, t: str) -> datetime.datetime: if isinstance(item[t], datetime.datetime): return item[t] - return GCalSide.parse_datetime(item[t][GCalSide.get_date_key(item[t])]) - - @staticmethod - def format_datetime(dt: datetime.datetime) -> str: - assert isinstance(dt, datetime.datetime) - return format_datetime_tz(dt) - - @classmethod - def parse_datetime(cls, dt: GoogleDateT) -> datetime.datetime: - """Parse datetime given in the GCal format(s): - - string with ('T', 'Z' separators). - - (dateTime, dateZone) dictionary - - datetime object - - The output datetime is always in local timezone. - """ - if isinstance(dt, str): - dt_dt = dateutil.parser.parse(dt) # type: ignore - return cls.parse_datetime(dt_dt) - - if isinstance(dt, dict): - date_time = dt.get("dateTime") - if date_time is None: - raise RuntimeError(f"Invalid structure dict: {dt}") - - return cls.parse_datetime(date_time) - - if isinstance(dt, datetime.datetime): - return assume_local_tz_if_none(dt) - - raise TypeError( - f"Unexpected type of a given date item, type: {type(dt)}, contents: {dt}", - ) + return parse_google_datetime(item[t][GCalSide.get_date_key(item[t])]) @classmethod def items_are_identical(cls, item1, item2, ignore_keys: Sequence[str] = []) -> bool: @@ -257,7 +222,7 @@ def items_are_identical(cls, item1, item2, ignore_keys: Sequence[str] = []) -> b if key not in item: continue - item[key] = cls.parse_datetime(item[key]) + item[key] = parse_google_datetime(item[key]) return SyncSide._items_are_identical( item1, diff --git a/syncall/google/gtasks_side.py b/syncall/google/gtasks_side.py index 8c2aa65..acca563 100644 --- a/syncall/google/gtasks_side.py +++ b/syncall/google/gtasks_side.py @@ -4,16 +4,16 @@ from pathlib import Path from typing import TYPE_CHECKING, Sequence, cast -import dateutil import pkg_resources -import pytz -from bubop import format_datetime_tz, logger +from bubop import logger from googleapiclient import discovery from googleapiclient.http import HttpError from syncall.google.google_side import GoogleSide from syncall.sync_side import SyncSide +from .common import parse_google_datetime + if TYPE_CHECKING: from syncall.types import GTasksItem, GTasksList @@ -229,7 +229,7 @@ def last_modification_key(cls) -> str: def _parse_dt_or_none(item: GTasksItem, field: str) -> datetime.datetime | None: """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) + dt_dt = parse_google_datetime(dt) assert isinstance(dt_dt, datetime.datetime) return dt_dt @@ -245,66 +245,6 @@ def get_task_completed_time(item: GTasksItem) -> datetime.datetime | None: """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: 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 - - if 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 - - if isinstance(dt, datetime.datetime): - return dt - - raise TypeError( - 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]: @@ -312,7 +252,7 @@ def items_are_identical(cls, item1, item2, ignore_keys: Sequence[str] = []) -> b if key not in item: continue - item[key] = cls.parse_datetime(item[key]) + item[key] = parse_google_datetime(item[key]) return SyncSide._items_are_identical( item1, diff --git a/syncall/tw_gcal_utils.py b/syncall/tw_gcal_utils.py index 3099832..fa7bad3 100644 --- a/syncall/tw_gcal_utils.py +++ b/syncall/tw_gcal_utils.py @@ -1,7 +1,7 @@ from datetime import timedelta from uuid import UUID -from bubop import logger +from bubop import format_datetime_tz, logger from item_synchronizer.types import Item from syncall.google.gcal_side import GCalSide @@ -84,11 +84,9 @@ def convert_tw_to_gcal( f'Using "{date_key}" date for {tw_item["uuid"]} for setting the end date of' " the event", ) - dt_gcal = GCalSide.format_datetime(tw_item[date_key]) + dt_gcal = format_datetime_tz(tw_item[date_key]) gcal_item["start"] = { - "dateTime": GCalSide.format_datetime( - tw_item[date_key] - tw_item[tw_duration_key], - ), + "dateTime": format_datetime_tz(tw_item[date_key] - tw_item[tw_duration_key]), } gcal_item["end"] = {"dateTime": dt_gcal} break @@ -98,12 +96,12 @@ def convert_tw_to_gcal( " event", ) entry_dt = tw_item["entry"] - entry_dt_gcal_str = GCalSide.format_datetime(entry_dt) + entry_dt_gcal_str = format_datetime_tz(entry_dt) gcal_item["start"] = {"dateTime": entry_dt_gcal_str} gcal_item["end"] = { - "dateTime": GCalSide.format_datetime(entry_dt + tw_item[tw_duration_key]), + "dateTime": format_datetime_tz(entry_dt + tw_item[tw_duration_key]), } return gcal_item diff --git a/syncall/tw_gtasks_utils.py b/syncall/tw_gtasks_utils.py index 387a55e..f680283 100644 --- a/syncall/tw_gtasks_utils.py +++ b/syncall/tw_gtasks_utils.py @@ -1,6 +1,7 @@ -from bubop import logger +from bubop import format_datetime_tz, logger from item_synchronizer.types import Item +from syncall.google.common import parse_google_datetime 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 @@ -28,9 +29,7 @@ def convert_tw_to_gtask( # update time if "modified" in tw_item.keys(): - gtasks_item["updated"] = GTasksSide.format_datetime( - GTasksSide.parse_datetime(tw_item["modified"]), - ) + gtasks_item["updated"] = format_datetime_tz(parse_google_datetime(tw_item["modified"])) return gtasks_item @@ -92,6 +91,6 @@ def convert_gtask_to_tw( # update time if "updated" in gtasks_item.keys(): - tw_item["modified"] = GTasksSide.parse_datetime(gtasks_item["updated"]) + tw_item["modified"] = parse_google_datetime(gtasks_item["updated"]) return tw_item diff --git a/tests/test_gcal.py b/tests/test_google_common.py similarity index 89% rename from tests/test_gcal.py rename to tests/test_google_common.py index f4c2c0d..7f875cc 100644 --- a/tests/test_gcal.py +++ b/tests/test_google_common.py @@ -1,8 +1,8 @@ import datetime -import syncall.google.gcal_side as side from bubop import is_same_datetime from dateutil.tz import gettz, tzutc +from syncall.google import common from syncall.types import GoogleDateT localzone = gettz("Europe/Athens") @@ -13,12 +13,9 @@ def assume_local_tz_if_none_(dt: datetime.datetime): return dt if dt.tzinfo is not None else dt.replace(tzinfo=localzone) -side.assume_local_tz_if_none = assume_local_tz_if_none_ - - def assert_dt(dt_given: GoogleDateT, dt_expected: datetime.datetime): - parse_datetime = side.GCalSide.parse_datetime - dt_dt_given = parse_datetime(dt_given) + common.assume_local_tz_if_none = assume_local_tz_if_none_ + dt_dt_given = common.parse_google_datetime(dt_given) # make sure there's always a timezone associated with this date assert dt_dt_given.tzinfo is not None