Skip to content

Commit

Permalink
Merge pull request #192 from kuzmoyev/dev
Browse files Browse the repository at this point in the history
  • Loading branch information
kuzmoyev authored Sep 19, 2024
2 parents 612ecc7 + cf52eb6 commit 49c5348
Show file tree
Hide file tree
Showing 10 changed files with 161 additions and 22 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ example.py
coverage.xml
.coverage
.DS_Store
.tox
.tox
.vscode
18 changes: 18 additions & 0 deletions docs/source/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
Change log
==========

v2.4.0
~~~~~~

API
---
* Warn user about microseconds in start/end datetime
* Warn user about empty summary
* Adds open_browser argument to GoogleCalendar

Core
----
* None

Backward compatibility
----------------------
* If there is no available browser, by default, authentication will not raise an error and retry without the browser


v2.3.0
~~~~~~

Expand Down
42 changes: 36 additions & 6 deletions gcsa/_services/authentication.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import logging
import pickle
import os.path
import glob
from typing import List
import webbrowser
from typing import List, Optional

from googleapiclient import discovery
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from google.auth.credentials import Credentials

log = logging.getLogger(__name__)


class AuthenticatedService:
"""Handles authentication of the `GoogleCalendar`"""

_READ_WRITE_SCOPES = 'https://www.googleapis.com/auth/calendar'
_LIST_ORDERS = ("startTime", "updated")

def __init__(
self,
Expand All @@ -25,7 +28,8 @@ def __init__(
read_only: bool = False,
authentication_flow_host: str = 'localhost',
authentication_flow_port: int = 8080,
authentication_flow_bind_addr: str = None
authentication_flow_bind_addr: str = None,
open_browser: Optional[bool] = None
):
"""
Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from.
Expand Down Expand Up @@ -53,6 +57,12 @@ def __init__(
:param authentication_flow_bind_addr:
Optional IP address for the redirect server to listen on when it is not the same as host
(e.g. in a container)
:param open_browser:
Whether to open the authorization URL in the user's browser.
- `None` (default): try opening the URL in the browser, if it fails proceed without the browser
- `True`: try opening the URL in the browser,
raise `webbrowser.Error` if runnable browser can not be located
- `False`: do not open URL in the browser.
"""

if credentials:
Expand All @@ -71,7 +81,8 @@ def __init__(
save_token,
authentication_flow_host,
authentication_flow_port,
authentication_flow_bind_addr
authentication_flow_bind_addr,
open_browser
)

self.service = discovery.build('calendar', 'v3', credentials=self.credentials)
Expand All @@ -93,7 +104,8 @@ def _get_credentials(
save_token: bool,
host: str,
port: int,
bind_addr: str
bind_addr: str,
open_browser: Optional[bool]
) -> Credentials:
credentials = None

Expand All @@ -107,7 +119,25 @@ def _get_credentials(
else:
credentials_path = os.path.join(credentials_dir, credentials_file)
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, scopes)
credentials = flow.run_local_server(host=host, port=port, bind_addr=bind_addr)
try:
credentials = flow.run_local_server(
host=host,
port=port,
bind_addr=bind_addr,
open_browser=open_browser or open_browser is None
)
except webbrowser.Error:
if open_browser:
raise
else:
# Try without browser
log.warning("Could not locate runnable browser")
credentials = flow.run_local_server(
host=host,
port=port,
bind_addr=bind_addr,
open_browser=False
)

if save_token:
with open(token_path, 'wb') as token_file:
Expand Down
4 changes: 3 additions & 1 deletion gcsa/_services/events_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class SendUpdatesMode:
class EventsService(BaseService):
"""Event management methods of the `GoogleCalendar`"""

_EVENTS_LIST_ORDERS = ("startTime", "updated")

def _list_events(
self,
request_method: Callable,
Expand Down Expand Up @@ -174,7 +176,7 @@ def __getitem__(self, r):
if (
(time_min and not isinstance(time_min, (date, datetime)))
or (time_max and not isinstance(time_max, (date, datetime)))
or (order_by and (not isinstance(order_by, str) or order_by not in self._LIST_ORDERS))
or (order_by and (not isinstance(order_by, str) or order_by not in self._EVENTS_LIST_ORDERS))
):
raise ValueError('Calendar indexing is in the following format: time_min[:time_max[:order_by]],'
' where time_min and time_max are date/datetime objects'
Expand Down
20 changes: 18 additions & 2 deletions gcsa/event.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from functools import total_ordering
from typing import List, Union
import logging
from typing import List, Optional, Union

from beautiful_date import BeautifulDate
from tzlocal import get_localzone_name
Expand All @@ -13,6 +14,8 @@
from .reminders import PopupReminder, EmailReminder, Reminder
from .util.date_time_util import ensure_localisation

log = logging.getLogger(__name__)


class Visibility:
"""Possible values of the event visibility.
Expand Down Expand Up @@ -44,7 +47,7 @@ class Transparency:
class Event(Resource):
def __init__(
self,
summary: str,
summary: Optional[str],
start: Union[date, datetime, BeautifulDate],
end: Union[date, datetime, BeautifulDate] = None,
*,
Expand Down Expand Up @@ -159,6 +162,13 @@ def ensure_list(obj):
if isinstance(self.start, datetime) and isinstance(self.end, datetime):
self.start = ensure_localisation(self.start, timezone)
self.end = ensure_localisation(self.end, timezone)

if self.start.microsecond != 0 or self.end.microsecond != 0:
log.warning(
"Microseconds are used in start/end, " +
"but are not supported in the Google Calendar API " +
"and will be dropped on submission."
)
elif isinstance(self.start, datetime) or isinstance(self.end, datetime):
raise TypeError('Start and end must either both be date or both be datetime.')

Expand Down Expand Up @@ -186,6 +196,12 @@ def ensure_date(d):

self.event_id = event_id
self.summary = summary
if self.summary == "":
log.warning(
f"Summary is empty in {self}. Note that if the event is loaded "
+ "from Google Calendar, its summary will be `None`"
)

self.description = description
self.location = location
self.recurrence = ensure_list(recurrence)
Expand Down
14 changes: 12 additions & 2 deletions gcsa/google_calendar.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from google.oauth2.credentials import Credentials

from ._services.acl_service import ACLService
Expand Down Expand Up @@ -31,7 +33,8 @@ def __init__(
read_only: bool = False,
authentication_flow_host: str = 'localhost',
authentication_flow_port: int = 8080,
authentication_flow_bind_addr: str = None
authentication_flow_bind_addr: str = None,
open_browser: Optional[bool] = None
):
"""
Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from.
Expand Down Expand Up @@ -67,6 +70,12 @@ def __init__(
:param authentication_flow_bind_addr:
Optional IP address for the redirect server to listen on when it is not the same as host
(e.g. in a container)
:param open_browser:
Whether to open the authorization URL in the user's browser.
- `None` (default): try opening the URL in the browser, if it fails proceed without the browser
- `True`: try opening the URL in the browser,
raise `webbrowser.Error` if runnable browser can not be located
- `False`: do not open URL in the browser.
"""
super().__init__(
default_calendar=default_calendar,
Expand All @@ -77,5 +86,6 @@ def __init__(
read_only=read_only,
authentication_flow_host=authentication_flow_host,
authentication_flow_port=authentication_flow_port,
authentication_flow_bind_addr=authentication_flow_bind_addr
authentication_flow_bind_addr=authentication_flow_bind_addr,
open_browser=open_browser
)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

here = os.path.abspath(os.path.dirname(__file__))

VERSION = '2.3.0'
VERSION = '2.4.0'


class UploadCommand(Command):
Expand Down
14 changes: 14 additions & 0 deletions tests/google_calendar_tests/mock_services/util.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import webbrowser


class MockToken:
def __init__(self, valid, refresh_token='refresh_token'):
self.valid = valid
Expand All @@ -9,6 +12,17 @@ def refresh(self, _):
self.expired = False


class MockAuthFlow:
def __init__(self, has_browser=True):
self.has_browser = has_browser

def run_local_server(self, *args, open_browser=True, **kwargs):
if not self.has_browser and open_browser:
raise webbrowser.Error

return MockToken(valid=True)


def executable(fn):
"""Decorator that stores data received from the function in object that returns that data when
called its `execute` method. Emulates HttpRequest from googleapiclient."""
Expand Down
30 changes: 21 additions & 9 deletions tests/google_calendar_tests/test_authentication.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import pickle
import webbrowser
from os import path
from unittest.mock import patch

from pyfakefs.fake_filesystem_unittest import TestCase

from gcsa.google_calendar import GoogleCalendar
from tests.google_calendar_tests.mock_services.util import MockToken
from tests.google_calendar_tests.mock_services.util import MockToken, MockAuthFlow


class TestGoogleCalendarCredentials(TestCase):
Expand All @@ -31,19 +32,11 @@ def setUp(self):
def _add_mocks(self):
self.build_patcher = patch('googleapiclient.discovery.build', return_value=None).start()

class MockAuthFlow:
def run_local_server(self, *args, **kwargs):
return MockToken(valid=True)

self.from_client_secrets_file_patcher = patch(
'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file',
return_value=MockAuthFlow()
).start()

def tearDown(self):
self.build_patcher.stop()
self.from_client_secrets_file_patcher.stop()

def test_with_given_credentials(self):
GoogleCalendar(credentials=MockToken(valid=True))
self.assertFalse(self.from_client_secrets_file_patcher.called)
Expand Down Expand Up @@ -101,3 +94,22 @@ def test_get_token_invalid_refresh(self):
self.assertTrue(gc.credentials.valid)
self.assertFalse(gc.credentials.expired)
self.assertTrue(self.from_client_secrets_file_patcher.called)

def test_no_browser_without_error(self):
self.from_client_secrets_file_patcher = patch(
'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file',
return_value=MockAuthFlow(has_browser=False)
).start()

gc = GoogleCalendar(credentials_path=self.credentials_path, open_browser=None)
self.assertTrue(gc.credentials.valid)
self.assertTrue(self.from_client_secrets_file_patcher.called)

def test_no_browser_with_error(self):
self.from_client_secrets_file_patcher = patch(
'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file',
return_value=MockAuthFlow(has_browser=False)
).start()

with self.assertRaises(webbrowser.Error):
GoogleCalendar(credentials_path=self.credentials_path, open_browser=True)
36 changes: 36 additions & 0 deletions tests/test_event.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import time
import datetime
from unittest import TestCase
from beautiful_date import Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sept, Oct, Dec, hours, days, Nov

Expand Down Expand Up @@ -67,6 +68,41 @@ def test_init_no_end(self):
event = Event('Lunch', start, timezone=TEST_TIMEZONE)
self.assertEqual(event.end, start + 1 * hours)

def test_init_with_microseconds(self):
with self.assertLogs("gcsa.event", level="WARNING") as cm:
Event(
"Test",
start=datetime.datetime(
year=1979, month=1, day=1, hour=1, minute=1, second=1, microsecond=1
),
)
self.assertEqual(
cm.output,
[
"WARNING:gcsa.event:Microseconds are used in start/end, " +
"but are not supported in the Google Calendar API " +
"and will be dropped on submission."
],
)

def test_init_without_title(self):
with self.assertLogs("gcsa.event", level="WARNING") as cm:
Event(
"",
start=datetime.datetime(
year=1979, month=1, day=1, hour=1, minute=1, second=1, microsecond=0
),
timezone=TEST_TIMEZONE,
)
self.assertEqual(
cm.output,
[
"WARNING:gcsa.event:Summary is empty in 1979-01-01 01:01:01+12:00 - . " +
"Note that if the event is loaded from Google Calendar, " +
"its summary will be `None`"
],
)

def test_init_no_start_or_end(self):
event = Event('Good day', start=None, timezone=TEST_TIMEZONE)
self.assertIsNone(event.start)
Expand Down

0 comments on commit 49c5348

Please sign in to comment.