├── gcsa
├── __init__.py
├── util
│ ├── __init__.py
│ ├── utils.py
│ └── date_time_util.py
├── _services
│ ├── __init__.py
│ ├── settings_service.py
│ ├── colors_service.py
│ ├── base_service.py
│ ├── free_busy_service.py
│ ├── calendars_service.py
│ ├── calendar_lists_service.py
│ └── acl_service.py
├── serializers
│ ├── __init__.py
│ ├── person_serializer.py
│ ├── reminder_serializer.py
│ ├── acl_rule_serializer.py
│ ├── attachment_serializer.py
│ ├── attendee_serializer.py
│ ├── settings_serializer.py
│ ├── base_serializer.py
│ ├── free_busy_serializer.py
│ ├── calendar_serializer.py
│ └── conference_serializer.py
├── _resource.py
├── person.py
├── acl.py
├── attachment.py
├── attendee.py
├── settings.py
├── free_busy.py
├── google_calendar.py
└── reminders.py
├── tests
├── __init__.py
├── google_calendar_tests
│ ├── __init__.py
│ ├── mock_services
│ │ ├── __init__.py
│ │ ├── mock_settings_requests.py
│ │ ├── mock_colors_requests.py
│ │ ├── mock_service.py
│ │ ├── util.py
│ │ ├── mock_calendars_requests.py
│ │ ├── mock_calendar_list_requests.py
│ │ ├── mock_acl_requests.py
│ │ └── mock_free_busy_requests.py
│ ├── test_colors_service.py
│ ├── test_case_with_mocked_service.py
│ ├── test_settings_service.py
│ ├── test_calendars_service.py
│ ├── test_calendar_list_service.py
│ ├── test_acl_service.py
│ ├── test_free_busy_service.py
│ └── test_authentication.py
├── test_util.py
├── test_person.py
├── test_acl_rule.py
├── test_base_serializer.py
├── test_attendee.py
├── test_settings.py
├── test_attachment.py
└── test_reminder.py
├── docs
├── source
│ ├── _static
│ │ ├── push_ups.webp
│ │ └── css
│ │ │ └── custom.css
│ ├── code
│ │ ├── person.rst
│ │ ├── reminders.rst
│ │ ├── settings.rst
│ │ ├── attachment.rst
│ │ ├── recurrence.rst
│ │ ├── attendees.rst
│ │ ├── free_busy.rst
│ │ ├── event.rst
│ │ ├── acl.rst
│ │ ├── code.rst
│ │ ├── calendar.rst
│ │ ├── conference.rst
│ │ └── google_calendar.rst
│ ├── _templates
│ │ ├── layout.html
│ │ └── footer.html
│ ├── settings.rst
│ ├── attachments.rst
│ ├── acl.rst
│ ├── why_gcsa.rst
│ ├── index.rst
│ ├── attendees.rst
│ ├── free_busy.rst
│ ├── getting_started.rst
│ ├── conference.rst
│ ├── reminders.rst
│ ├── authentication.rst
│ ├── change_log.rst
│ └── events.rst
├── Makefile
└── make.bat
├── .readthedocs.yml
├── .gitignore
├── .github
├── workflows
│ ├── tests.yml
│ └── code-cov.yml
├── ISSUE_TEMPLATE
│ └── bug_report.md
└── CODE_OF_CONDUCT.md
├── LICENSE
├── tox.ini
├── CONTRIBUTING.md
├── README.rst
└── setup.py
/gcsa/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gcsa/util/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gcsa/_services/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gcsa/serializers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/source/_static/push_ups.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kuzmoyev/google-calendar-simple-api/HEAD/docs/source/_static/push_ups.webp
--------------------------------------------------------------------------------
/docs/source/code/person.rst:
--------------------------------------------------------------------------------
1 | Person
2 | ======
3 |
4 |
5 | .. autoclass:: gcsa.person.Person
6 | :members:
7 | :undoc-members:
8 |
--------------------------------------------------------------------------------
/docs/source/code/reminders.rst:
--------------------------------------------------------------------------------
1 | Reminders
2 | =========
3 |
4 |
5 | .. automodule:: gcsa.reminders
6 | :members:
7 | :undoc-members:
8 |
--------------------------------------------------------------------------------
/docs/source/code/settings.rst:
--------------------------------------------------------------------------------
1 | Settings
2 | ========
3 |
4 |
5 | .. autoclass:: gcsa.settings.Settings
6 | :members:
7 | :undoc-members:
8 |
--------------------------------------------------------------------------------
/docs/source/code/attachment.rst:
--------------------------------------------------------------------------------
1 | Attachments
2 | ===========
3 |
4 |
5 | .. autoclass:: gcsa.attachment.Attachment
6 | :members:
7 | :undoc-members:
8 |
--------------------------------------------------------------------------------
/gcsa/_resource.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 |
4 | class Resource(ABC):
5 | @property
6 | @abstractmethod
7 | def id(self):
8 | pass
9 |
--------------------------------------------------------------------------------
/docs/source/code/recurrence.rst:
--------------------------------------------------------------------------------
1 | Recurrence
2 | ==========
3 |
4 |
5 | .. automodule:: gcsa.recurrence
6 | :members:
7 | :undoc-members:
8 | :member-order: bysource
9 |
--------------------------------------------------------------------------------
/docs/source/code/attendees.rst:
--------------------------------------------------------------------------------
1 | Attendees
2 | =========
3 |
4 |
5 | .. autoclass:: gcsa.attendee.Attendee
6 | :members:
7 | :undoc-members:
8 |
9 | .. autoclass:: gcsa.attendee.ResponseStatus
10 | :members:
11 | :undoc-members:
12 |
--------------------------------------------------------------------------------
/docs/source/code/free_busy.rst:
--------------------------------------------------------------------------------
1 | Free busy
2 | =========
3 |
4 |
5 | .. autoclass:: gcsa.free_busy.FreeBusy
6 | :members:
7 | :undoc-members:
8 |
9 |
10 | .. autoclass:: gcsa.free_busy.FreeBusyQueryError
11 | :members:
12 | :undoc-members:
13 |
--------------------------------------------------------------------------------
/.readthedocs.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-22.04
5 | tools:
6 | python: "3.12"
7 |
8 | sphinx:
9 | configuration: docs/source/conf.py
10 |
11 | python:
12 | install:
13 | - method: pip
14 | path: .
15 | extra_requirements:
16 | - docs
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | .idea/
3 | credentials.json
4 | token.pickle
5 | venv/
6 | __pycache__
7 |
8 | build
9 | dist
10 | .eggs
11 | gcsa.egg-info
12 | docs/html
13 |
14 | example.py
15 | coverage.xml
16 | .coverage
17 | .DS_Store
18 | .tox
19 | .vscode
20 |
--------------------------------------------------------------------------------
/docs/source/code/event.rst:
--------------------------------------------------------------------------------
1 | Event
2 | =====
3 |
4 |
5 | .. autoclass:: gcsa.event.Event
6 | :members:
7 | :undoc-members:
8 |
9 | .. autoclass:: gcsa.event.Visibility
10 | :members:
11 | :undoc-members:
12 |
13 | .. autoclass:: gcsa.event.Transparency
14 | :members:
15 | :undoc-members:
16 |
--------------------------------------------------------------------------------
/docs/source/code/acl.rst:
--------------------------------------------------------------------------------
1 | Access control list
2 | ===================
3 |
4 |
5 | .. autoclass:: gcsa.acl.AccessControlRule
6 | :members:
7 | :undoc-members:
8 |
9 | .. autoclass:: gcsa.acl.ACLRole
10 | :members:
11 | :undoc-members:
12 |
13 | .. autoclass:: gcsa.acl.ACLScopeType
14 | :members:
15 | :undoc-members:
16 |
--------------------------------------------------------------------------------
/docs/source/code/code.rst:
--------------------------------------------------------------------------------
1 | Code documentation
2 | ==================
3 |
4 | .. toctree::
5 | :maxdepth: 3
6 | :caption: Contents:
7 |
8 | google_calendar
9 | calendar
10 | event
11 | person
12 | attendees
13 | attachment
14 | conference
15 | reminders
16 | recurrence
17 | acl
18 | free_busy
19 | settings
20 |
--------------------------------------------------------------------------------
/docs/source/code/calendar.rst:
--------------------------------------------------------------------------------
1 | Calendar
2 | ========
3 |
4 |
5 | .. autoclass:: gcsa.calendar.Calendar
6 | :members:
7 | :undoc-members:
8 |
9 |
10 | .. autoclass:: gcsa.calendar.CalendarListEntry
11 | :members:
12 | :undoc-members:
13 |
14 | .. autoclass:: gcsa.calendar.NotificationType
15 | :members:
16 | :undoc-members:
17 |
18 | .. autoclass:: gcsa.calendar.AccessRoles
19 | :members:
20 | :undoc-members:
21 |
--------------------------------------------------------------------------------
/docs/source/code/conference.rst:
--------------------------------------------------------------------------------
1 | Conference
2 | ==========
3 |
4 |
5 | .. autoclass:: gcsa.conference.ConferenceSolution
6 | :members:
7 | :undoc-members:
8 |
9 | .. autoclass:: gcsa.conference.EntryPoint
10 | :members:
11 | :undoc-members:
12 |
13 | .. autoclass:: gcsa.conference.ConferenceSolutionCreateRequest
14 | :members:
15 | :undoc-members:
16 |
17 | .. autoclass:: gcsa.conference.SolutionType
18 | :members:
19 | :undoc-members:
--------------------------------------------------------------------------------
/docs/source/_templates/layout.html:
--------------------------------------------------------------------------------
1 | {% extends "!layout.html" %}
2 | {% block sidebartitle %}{{ super() }}
3 |
7 | {% endblock %}
8 | {% block footer %}{{ super() }}
9 |
10 | {% endblock %}
--------------------------------------------------------------------------------
/docs/source/_templates/footer.html:
--------------------------------------------------------------------------------
1 | {% extends "!footer.html" %}
2 | {% block extrafooter %}{{ super() }}
3 |
10 | {% endblock %}
11 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/test_colors_service.py:
--------------------------------------------------------------------------------
1 | from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService
2 |
3 |
4 | class TestColorsService(TestCaseWithMockedService):
5 | def test_list_event_colors(self):
6 | event_colors = self.gc.list_event_colors()
7 | self.assertEqual(len(event_colors), 4)
8 |
9 | def test_list_calendar_colors(self):
10 | calendar_colors = self.gc.list_calendar_colors()
11 | self.assertEqual(len(calendar_colors), 5)
12 |
--------------------------------------------------------------------------------
/docs/source/_static/css/custom.css:
--------------------------------------------------------------------------------
1 | /* Newlines (\a) and spaces (\20) before each parameter */
2 | .sig-param::before {
3 | content: "\a\20\20\20\20\20\20\20\20\20\20\20\20\20\20\20\20";
4 | white-space: pre;
5 | }
6 |
7 | /* Newline after the last parameter (so the closing bracket is on a new line) */
8 | dt em.sig-param:last-of-type::after {
9 | content: "\a";
10 | white-space: pre;
11 | }
12 |
13 | /* To have blue background of width of the block (instead of width of content) */
14 | dl.class > dt:first-of-type {
15 | display: block !important;
16 | }
--------------------------------------------------------------------------------
/gcsa/_services/settings_service.py:
--------------------------------------------------------------------------------
1 | from gcsa._services.base_service import BaseService
2 | from gcsa.serializers.settings_serializer import SettingsSerializer
3 | from gcsa.settings import Settings
4 |
5 |
6 | class SettingsService(BaseService):
7 | """Settings management methods of the `GoogleCalendar`"""
8 |
9 | def get_settings(self) -> Settings:
10 | """Returns user settings for the authenticated user."""
11 | settings_list = list(self._list_paginated(self.service.settings().list))
12 | settings_json = {s['id']: s['value'] for s in settings_list}
13 | return SettingsSerializer.to_object(settings_json)
14 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SOURCEDIR = source
8 | BUILDDIR = build
9 |
10 | # Put it first so that "make" without argument is like "make help".
11 | help:
12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
13 |
14 | .PHONY: help Makefile
15 |
16 | # Catch-all target: route all unknown targets to Sphinx using the new
17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
18 | %: Makefile
19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
--------------------------------------------------------------------------------
/tests/google_calendar_tests/test_case_with_mocked_service.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from pyfakefs.fake_filesystem_unittest import TestCase
4 |
5 | from gcsa.google_calendar import GoogleCalendar
6 | from tests.google_calendar_tests.mock_services.mock_service import MockService
7 | from tests.google_calendar_tests.mock_services.util import MockToken
8 |
9 |
10 | class TestCaseWithMockedService(TestCase):
11 | def setUp(self):
12 | self.build_patcher = patch('googleapiclient.discovery.build', return_value=MockService())
13 | self.build_patcher.start()
14 |
15 | self.gc = GoogleCalendar(credentials=MockToken(valid=True))
16 |
17 | def tearDown(self):
18 | self.build_patcher.stop()
19 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | run:
11 |
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ]
16 | include:
17 | - python-version: '3.13'
18 | note: with-style-and-docs-checks
19 |
20 | steps:
21 | - uses: actions/checkout@v2
22 | - name: Set up Python ${{ matrix.python-version }}
23 | uses: actions/setup-python@v2
24 | with:
25 | python-version: ${{ matrix.python-version }}
26 |
27 | - name: Install tox
28 | run: pip install tox tox-gh-actions
29 |
30 | - name: Running tests
31 | run: tox
32 |
--------------------------------------------------------------------------------
/gcsa/_services/colors_service.py:
--------------------------------------------------------------------------------
1 | from gcsa._services.base_service import BaseService
2 |
3 |
4 | class ColorsService(BaseService):
5 | """Colors management methods of the `GoogleCalendar`"""
6 |
7 | def list_event_colors(self) -> dict:
8 | """A global palette of event colors, mapping from the color ID to its definition.
9 | An :py:class:`~gcsa.event.Event` may refer to one of these color IDs in its color_id field."""
10 | return self.service.colors().get().execute()['event']
11 |
12 | def list_calendar_colors(self) -> dict:
13 | """A global palette of calendar colors, mapping from the color ID to its definition.
14 | :py:class:`~gcsa.calendar.CalendarListEntry` resource refers to one of these color IDs in its color_id field."""
15 | return self.service.colors().get().execute()['calendar']
16 |
--------------------------------------------------------------------------------
/gcsa/serializers/person_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.person import Person
2 | from .base_serializer import BaseSerializer
3 |
4 |
5 | class PersonSerializer(BaseSerializer):
6 | type_ = Person
7 |
8 | def __init__(self, person):
9 | super().__init__(person)
10 |
11 | @staticmethod
12 | def _to_json(person: Person):
13 | data = {
14 | 'email': person.email,
15 | 'displayName': person.display_name
16 | }
17 | return {k: v for k, v in data.items() if v is not None}
18 |
19 | @staticmethod
20 | def _to_object(json_person):
21 | return Person(
22 | email=json_person['email'],
23 | display_name=json_person.get('displayName', None),
24 | _id=json_person.get('id', None),
25 | _is_self=json_person.get('self', None)
26 | )
27 |
--------------------------------------------------------------------------------
/.github/workflows/code-cov.yml:
--------------------------------------------------------------------------------
1 | name: Code coverage
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | run:
7 | # Don't run on PRs from forks
8 | if: github.event.pull_request.head.repo.full_name == github.repository
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v2
13 | - name: Set up Python
14 | uses: actions/setup-python@v2
15 | with:
16 | python-version: '3.12'
17 |
18 | - name: Install dependencies
19 | run: pip install tox
20 |
21 | - name: Generate code coverage
22 | run: tox -e coverage
23 |
24 |
25 | - name: Post to GitHub
26 | uses: 5monkeys/cobertura-action@master
27 | with:
28 | path: coverage.xml
29 | repo_token: ${{ secrets.GITHUB_TOKEN }}
30 | minimum_coverage: 75
31 | skip_covered: false
32 |
--------------------------------------------------------------------------------
/gcsa/serializers/reminder_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.reminders import Reminder, EmailReminder, PopupReminder
2 | from .base_serializer import BaseSerializer
3 |
4 |
5 | class ReminderSerializer(BaseSerializer):
6 | type_ = Reminder
7 |
8 | def __init__(self, reminder):
9 | super().__init__(reminder)
10 |
11 | @staticmethod
12 | def _to_json(reminder: Reminder):
13 | return {
14 | 'method': reminder.method,
15 | 'minutes': reminder.minutes_before_start
16 | }
17 |
18 | @staticmethod
19 | def _to_object(json_reminder):
20 | method = json_reminder['method']
21 | if method == 'email':
22 | return EmailReminder(int(json_reminder['minutes']))
23 | elif method == 'popup':
24 | return PopupReminder(int(json_reminder['minutes']))
25 | else:
26 | raise ValueError('Unexpected method "{}" for a reminder.'.format(method))
27 |
--------------------------------------------------------------------------------
/tests/test_util.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from beautiful_date import Sept
4 |
5 | from gcsa.util.date_time_util import ensure_localisation
6 |
7 |
8 | class TestReminder(TestCase):
9 | def test_ensure_localisation(self):
10 | initial_date = 23 / Sept / 2022
11 | d = ensure_localisation(initial_date)
12 | # Shouldn't do anything to date
13 | self.assertEqual(initial_date, d)
14 |
15 | initial_date_time = initial_date[:]
16 | self.assertIsNone(initial_date_time.tzinfo)
17 | dt_with_tz = ensure_localisation(initial_date_time)
18 | self.assertIsNotNone(dt_with_tz.tzinfo)
19 | self.assertNotEqual(dt_with_tz, initial_date_time)
20 |
21 | dt_with_tz_unchanged = ensure_localisation(dt_with_tz)
22 | self.assertEqual(dt_with_tz, dt_with_tz_unchanged)
23 |
24 | with self.assertRaises(TypeError):
25 | ensure_localisation('Hello')
26 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/test_settings_service.py:
--------------------------------------------------------------------------------
1 | from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService
2 |
3 |
4 | class TestSettingsService(TestCaseWithMockedService):
5 | def test_get_settings(self):
6 | settings = self.gc.get_settings()
7 | self.assertTrue(settings.auto_add_hangouts)
8 | self.assertEqual(settings.date_field_order, 'DMY')
9 | self.assertEqual(settings.default_event_length, 45)
10 | self.assertTrue(settings.format24_hour_time)
11 | self.assertTrue(settings.hide_invitations)
12 | self.assertTrue(settings.hide_weekends)
13 | self.assertEqual(settings.locale, 'cz')
14 | self.assertTrue(settings.remind_on_responded_events_only)
15 | self.assertFalse(settings.show_declined_events)
16 | self.assertEqual(settings.timezone, 'Europe/Prague')
17 | self.assertFalse(settings.use_keyboard_shortcuts)
18 | self.assertEqual(settings.week_start, 1)
19 |
--------------------------------------------------------------------------------
/gcsa/serializers/acl_rule_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.acl import AccessControlRule
2 | from gcsa.serializers.base_serializer import BaseSerializer
3 |
4 |
5 | class ACLRuleSerializer(BaseSerializer):
6 | type_ = AccessControlRule
7 |
8 | def __init__(self, access_control_rule):
9 | super().__init__(access_control_rule)
10 |
11 | @staticmethod
12 | def _to_json(acl_rule: AccessControlRule):
13 | data = {
14 | "id": acl_rule.id,
15 | "scope": {
16 | "type": acl_rule.scope_type,
17 | "value": acl_rule.scope_value
18 | },
19 | "role": acl_rule.role
20 | }
21 | data = ACLRuleSerializer._remove_empty_values(data)
22 | return data
23 |
24 | @staticmethod
25 | def _to_object(json_acl_rule):
26 | scope = json_acl_rule.get('scope', {})
27 | return AccessControlRule(
28 | acl_id=json_acl_rule.get('id'),
29 | scope_type=scope.get('type'),
30 | scope_value=scope.get('value'),
31 | role=json_acl_rule.get('role')
32 | )
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/gcsa/serializers/attachment_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.attachment import Attachment
2 | from .base_serializer import BaseSerializer
3 |
4 |
5 | class AttachmentSerializer(BaseSerializer):
6 | type_ = Attachment
7 |
8 | def __init__(self, attachment):
9 | super().__init__(attachment)
10 |
11 | @staticmethod
12 | def _to_json(attachment: Attachment):
13 | res = {
14 | "fileUrl": attachment.file_url,
15 | "title": attachment.title,
16 | "mimeType": attachment.mime_type,
17 | }
18 |
19 | if attachment.file_id:
20 | res['fileId'] = attachment.file_id
21 | if attachment.icon_link:
22 | res['iconLink'] = attachment.icon_link
23 |
24 | return res
25 |
26 | @staticmethod
27 | def _to_object(json_attachment):
28 | return Attachment(
29 | file_url=json_attachment['fileUrl'],
30 | title=json_attachment.get('title', None),
31 | mime_type=json_attachment.get('mimeType', None),
32 | _icon_link=json_attachment.get('iconLink', None),
33 | _file_id=json_attachment.get('fileId', None)
34 | )
35 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_settings_requests.py:
--------------------------------------------------------------------------------
1 | from .util import executable
2 |
3 |
4 | class MockSettingsRequests:
5 | """Emulates GoogleCalendar.service.settings()"""
6 |
7 | @executable
8 | def list(self, **_):
9 | """Emulates GoogleCalendar.service.settings().list().execute()"""
10 | return {
11 | "nextPageToken": None,
12 | "items": [
13 | {'id': 'autoAddHangouts', 'value': True},
14 | {'id': 'dateFieldOrder', 'value': 'DMY'},
15 | {'id': 'defaultEventLength', 'value': 45},
16 | {'id': 'format24HourTime', 'value': True},
17 | {'id': 'hideInvitations', 'value': True},
18 | {'id': 'hideWeekends', 'value': True},
19 | {'id': 'locale', 'value': 'cz'},
20 | {'id': 'remindOnRespondedEventsOnly', 'value': True},
21 | {'id': 'showDeclinedEvents', 'value': False},
22 | {'id': 'timezone', 'value': 'Europe/Prague'},
23 | {'id': 'useKeyboardShortcuts', 'value': False},
24 | {'id': 'weekStart', 'value': 1}
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_colors_requests.py:
--------------------------------------------------------------------------------
1 | from .util import executable
2 |
3 |
4 | class MockColorsRequests:
5 | """Emulates GoogleCalendar.service.colors()"""
6 |
7 | def __init__(self):
8 | self.test_colors = {
9 | 'event': {
10 | '1': {'background': '#a4bdfc', 'foreground': '#1d1d1d'},
11 | '2': {'background': '#7ae7bf', 'foreground': '#1d1d1d'},
12 | '3': {'background': '#dbadff', 'foreground': '#1d1d1d'},
13 | '4': {'background': '#ff887c', 'foreground': '#1d1d1d'},
14 | },
15 | 'calendar': {
16 | '1': {'background': '#ac725e', 'foreground': '#1d1d1d'},
17 | '2': {'background': '#d06b64', 'foreground': '#1d1d1d'},
18 | '3': {'background': '#f83a22', 'foreground': '#1d1d1d'},
19 | '4': {'background': '#fa573c', 'foreground': '#1d1d1d'},
20 | '5': {'background': '#fc573c', 'foreground': '#1d1d1d'},
21 | }
22 | }
23 |
24 | @executable
25 | def get(self, **_):
26 | """Emulates GoogleCalendar.service.colors().get().execute()"""
27 | return self.test_colors
28 |
--------------------------------------------------------------------------------
/docs/source/code/google_calendar.rst:
--------------------------------------------------------------------------------
1 | GoogleCalendar
2 | ==============
3 |
4 | .. autoclass:: gcsa.google_calendar.GoogleCalendar
5 | :members:
6 | get_events,
7 | get_instances,
8 | get_event,
9 | add_event,
10 | add_quick_event,
11 | update_event,
12 | import_event,
13 | move_event,
14 | delete_event,
15 | get_calendar,
16 | add_calendar,
17 | update_calendar,
18 | delete_calendar,
19 | clear_calendar,
20 | clear,
21 | get_calendar_list,
22 | get_calendar_list_entry,
23 | add_calendar_list_entry,
24 | update_calendar_list_entry,
25 | delete_calendar_list_entry,
26 | list_event_colors,
27 | list_calendar_colors,
28 | get_acl_rules,
29 | get_acl_rule,
30 | add_acl_rule,
31 | update_acl_rule,
32 | delete_acl_rule,
33 | get_free_busy,
34 | get_settings
35 | :undoc-members:
36 |
37 | .. autoclass:: gcsa.google_calendar.SendUpdatesMode
38 | :members:
39 | :undoc-members:
40 |
--------------------------------------------------------------------------------
/docs/source/settings.rst:
--------------------------------------------------------------------------------
1 | .. _settings:
2 |
3 | Settings
4 | ========
5 |
6 | You can retrieve user's settings for the given account with :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_settings`.
7 |
8 | To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your
9 | credentials):
10 |
11 | .. code-block:: python
12 |
13 | from gcsa.google_calendar import GoogleCalendar
14 |
15 | gc = GoogleCalendar()
16 |
17 |
18 | Following code will return a corresponding :py:class:`~gcsa.settings.Settings` object:
19 |
20 | .. code-block:: python
21 |
22 | from gcsa.google_calendar import GoogleCalendar
23 |
24 | gc = GoogleCalendar()
25 | settings = gc.get_settings()
26 | print(settings)
27 |
28 | .. code-block:: python
29 |
30 | User settings:
31 | auto_add_hangouts=true
32 | date_field_order=DMY
33 | default_event_length=60
34 | format24_hour_time=false
35 | hide_invitations=false
36 | hide_weekends=false
37 | locale=en
38 | remind_on_responded_events_only=false
39 | show_declined_events=true
40 | timezone=Europe/Prague
41 | use_keyboard_shortcuts=true
42 | week_start=1
43 |
44 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = pytest, code-cov, flake8, sphinx, mypy
3 |
4 | [gh-actions]
5 | python =
6 | 3.9: pytest
7 | 3.10: pytest
8 | 3.11: pytest
9 | 3.12: pytest
10 | 3.13: pytest, flake8, sphinx, mypy
11 |
12 | [flake8]
13 | max-line-length = 120
14 | per-file-ignores =
15 | # naming conventions broken by googleapiclient
16 | tests/google_calendar_tests/mock_services/*: N802,N803
17 |
18 | [coverage:report]
19 | exclude_lines =
20 | # Have to re-enable the standard pragma
21 | pragma: no cover
22 |
23 | # Don't complain if tests don't hit defensive assertion code:
24 | pass
25 | omit =
26 | */__init__.py
27 |
28 |
29 | [testenv:pytest]
30 | deps =
31 | pyfakefs
32 | pytest
33 | commands =
34 | pytest
35 |
36 | [testenv:coverage]
37 | deps =
38 | pyfakefs
39 | pytest
40 | pytest-cov
41 | commands =
42 | pytest --cov-report xml --cov=gcsa tests
43 |
44 | [testenv:flake8]
45 | deps =
46 | flake8
47 | pep8-naming
48 | commands =
49 | flake8 gcsa tests setup.py
50 |
51 | [testenv:mypy]
52 | deps =
53 | mypy
54 | commands =
55 | mypy --disable-error-code=import-untyped gcsa
56 |
57 | [testenv:sphinx]
58 | deps =
59 | sphinx
60 | sphinx_rtd_theme
61 | sphinxcontrib-googleanalytics
62 | commands =
63 | sphinx-build -W docs/source docs/build
64 |
--------------------------------------------------------------------------------
/docs/source/attachments.rst:
--------------------------------------------------------------------------------
1 | .. _attachments:
2 |
3 | Attachments
4 | -----------
5 |
6 | If you want to add attachment(s) to your event, just create :py:class:`~gcsa.attachment.Attachment` (s) and pass
7 | as a ``attachments`` parameter:
8 |
9 | .. code-block:: python
10 |
11 | from gcsa.attachment import Attachment
12 |
13 | attachment = Attachment(file_url='https://bit.ly/3lZo0Cc',
14 | title='My file',
15 | mime_type='application/vnd.google-apps.document')
16 |
17 | event = Event('Meeting',
18 | start=(22/Apr/2019)[12:00],
19 | attachments=attachment)
20 |
21 |
22 | You can pass multiple attachments at once in a list.
23 |
24 | .. code-block:: python
25 |
26 | event = Event('Meeting',
27 | start=(22/Apr/2019)[12:00],
28 | attachments=[attachment1, attachment2])
29 |
30 | To add attachment to an existing event use its :py:meth:`~gcsa.event.Event.add_attachment` method:
31 |
32 |
33 | .. code-block:: python
34 |
35 | event.add_attachment('My file',
36 | file_url='https://bit.ly/3lZo0Cc',
37 | mime_type='application/vnd.google-apps.document')
38 |
39 | Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes.
40 |
--------------------------------------------------------------------------------
/gcsa/serializers/attendee_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.attendee import Attendee
2 | from .base_serializer import BaseSerializer
3 |
4 |
5 | class AttendeeSerializer(BaseSerializer):
6 | type_ = Attendee
7 |
8 | def __init__(self, attendee):
9 | super().__init__(attendee)
10 |
11 | @staticmethod
12 | def _to_json(attendee: Attendee):
13 | data = {
14 | 'email': attendee.email,
15 | 'displayName': attendee.display_name,
16 | 'comment': attendee.comment,
17 | 'optional': attendee.optional,
18 | 'resource': attendee.is_resource,
19 | 'additionalGuests': attendee.additional_guests,
20 | 'responseStatus': attendee.response_status
21 | }
22 | return {k: v for k, v in data.items() if v is not None}
23 |
24 | @staticmethod
25 | def _to_object(json_attendee):
26 | return Attendee(
27 | email=json_attendee['email'],
28 | display_name=json_attendee.get('displayName', None),
29 | comment=json_attendee.get('comment', None),
30 | optional=json_attendee.get('optional', None),
31 | is_resource=json_attendee.get('resource', None),
32 | additional_guests=json_attendee.get('additionalGuests', None),
33 | _response_status=json_attendee.get('responseStatus', None)
34 | )
35 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_service.py:
--------------------------------------------------------------------------------
1 | from .mock_acl_requests import MockACLRequests
2 | from .mock_calendar_list_requests import MockCalendarListRequests
3 | from .mock_calendars_requests import MockCalendarsRequests
4 | from .mock_colors_requests import MockColorsRequests
5 | from .mock_events_requests import MockEventsRequests
6 | from .mock_free_busy_requests import MockFreeBusyRequests
7 | from .mock_settings_requests import MockSettingsRequests
8 |
9 |
10 | class MockService:
11 | """Emulates GoogleCalendar.service"""
12 |
13 | def __init__(self):
14 | self._events = MockEventsRequests()
15 | self._calendars = MockCalendarsRequests()
16 | self._calendar_list = MockCalendarListRequests()
17 | self._colors = MockColorsRequests()
18 | self._settings = MockSettingsRequests()
19 | self._acl = MockACLRequests()
20 | self._free_busy = MockFreeBusyRequests()
21 |
22 | def events(self):
23 | return self._events
24 |
25 | def calendars(self):
26 | return self._calendars
27 |
28 | def calendarList(self):
29 | return self._calendar_list
30 |
31 | def colors(self):
32 | return self._colors
33 |
34 | def settings(self):
35 | return self._settings
36 |
37 | def acl(self):
38 | return self._acl
39 |
40 | def freebusy(self):
41 | return self._free_busy
42 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | [READ and REMOVE: Please create an issue only if you think that it's something that needs a fix or you have a
11 | suggestion/request for improvement. If you have a question or issue not caused by gcsa itself, please use
12 | the [discussions page](https://github.com/kuzmoyev/google-calendar-simple-api/discussions).]
13 |
14 | ## Bug description
15 |
16 | A clear and concise description of what the bug is.
17 |
18 | ## To Reproduce
19 |
20 | Steps to reproduce the behavior:
21 |
22 | 1. Installed the latest version with `pip install gcsa`
23 | 2. ...
24 |
25 | Code used:
26 |
27 | ```python
28 | from gcsa.google_calendar import GoogleCalendar
29 |
30 | ...
31 | ```
32 |
33 | ## Error or unexpected output
34 |
35 | The whole traceback in case of an error:
36 | ```
37 | Traceback (most recent call last):
38 | ...
39 | ```
40 |
41 | ## Expected behavior
42 |
43 | A clear and concise description of what you expected to happen.
44 |
45 | ## Screenshots
46 |
47 | If applicable, add screenshots to help explain your problem.
48 |
49 | ## Tech:
50 |
51 | - OS: [e.g. Linux/Windows/MacOS]
52 | - GCSA version: [e.g. 2.0.1]
53 | - Python version: [e.g. 3.12]
54 |
55 | ## Additional context
56 |
57 | Add any other context about the problem here.
58 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/util.py:
--------------------------------------------------------------------------------
1 | import webbrowser
2 |
3 |
4 | class MockToken:
5 | def __init__(self, valid, refresh_token='refresh_token'):
6 | self.valid = valid
7 | self.expired = not valid
8 | self.refresh_token = refresh_token
9 |
10 | def refresh(self, _):
11 | self.valid = True
12 | self.expired = False
13 |
14 |
15 | class MockAuthFlow:
16 | def __init__(self, has_browser=True):
17 | self.has_browser = has_browser
18 |
19 | def run_local_server(self, *args, open_browser=True, **kwargs):
20 | if not self.has_browser and open_browser:
21 | raise webbrowser.Error
22 |
23 | return MockToken(valid=True)
24 |
25 |
26 | def executable(fn):
27 | """Decorator that stores data received from the function in object that returns that data when
28 | called its `execute` method. Emulates HttpRequest from googleapiclient."""
29 |
30 | class Executable:
31 | def __init__(self, data):
32 | self.data = data
33 |
34 | def execute(self):
35 | return self.data
36 |
37 | def wrapper(*args, **kwargs):
38 | data = fn(*args, **kwargs)
39 | return Executable(data)
40 |
41 | return wrapper
42 |
43 |
44 | def within(dt, time_min, time_max):
45 | return time_min <= dt <= time_max
46 |
47 |
48 | def time_range_within(tr, time_min, time_max):
49 | start, end = tr
50 | return within(start, time_min, time_max) and within(end, time_min, time_max)
51 |
--------------------------------------------------------------------------------
/tests/test_person.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from gcsa.person import Person
4 | from gcsa.serializers.person_serializer import PersonSerializer
5 |
6 |
7 | class TestPerson(TestCase):
8 | def test_repr_str(self):
9 | person = Person(
10 | email='mail@gmail.com',
11 | display_name='Guest',
12 | _id='123123',
13 | _is_self=False
14 | )
15 | self.assertEqual(person.__repr__(), "")
16 | self.assertEqual(person.__str__(), "'mail@gmail.com' - 'Guest'")
17 |
18 |
19 | class TestPersonSerializer(TestCase):
20 | def test_to_json(self):
21 | person = Person(
22 | email='mail@gmail.com',
23 | display_name='Organizer'
24 | )
25 |
26 | person_json = PersonSerializer(person).get_json()
27 |
28 | self.assertEqual(person.email, person_json['email'])
29 | self.assertEqual(person.display_name, person_json['displayName'])
30 |
31 | def test_to_object(self):
32 | person_json = {
33 | 'email': 'mail2@gmail.com',
34 | 'displayName': 'Creator',
35 | 'id': '123123',
36 | 'self': False
37 | }
38 |
39 | person = PersonSerializer.to_object(person_json)
40 |
41 | self.assertEqual(person_json['email'], person.email)
42 | self.assertEqual(person_json['displayName'], person.display_name)
43 | self.assertEqual(person_json['id'], person.id_)
44 | self.assertEqual(person_json['self'], person.is_self)
45 |
--------------------------------------------------------------------------------
/gcsa/util/utils.py:
--------------------------------------------------------------------------------
1 | from typing import List, Iterable, Type, Tuple, Optional
2 |
3 |
4 | def ensure_list(obj) -> List:
5 | if obj is None:
6 | return []
7 | elif isinstance(obj, list):
8 | return obj
9 | else:
10 | return [obj]
11 |
12 |
13 | def ensure_iterable(obj) -> Iterable:
14 | if obj is None:
15 | return []
16 | elif isinstance(obj, (list, tuple, set)):
17 | return obj
18 | else:
19 | return [obj]
20 |
21 |
22 | def check_all_type(it: Iterable, type_: Type, name: str):
23 | """Checks that all objects in `it` are of type `type_`."""
24 | if any(not isinstance(o, type_) for o in it):
25 | raise TypeError('"{}" parameter must be a {} or list of {}s.'
26 | .format(name, type_.__name__, type_.__name__))
27 |
28 |
29 | def check_all_type_and_range(it: Iterable, type_: Type, range_: Tuple[int, int], name: str, nonzero: bool = False):
30 | """Checks that all objects in `it` are of type `type_` and they are within `range_`"""
31 | check_all_type(it, type_, name)
32 | low, high = range_
33 | if any(not (low <= o <= high) for o in it):
34 | raise ValueError('"{}" parameter must be in range {}-{}.'
35 | .format(name, low, high))
36 | if nonzero and any(o == 0 for o in it):
37 | raise ValueError('"{}" parameter must be in range {}-{} and nonzero.'
38 | .format(name, low, high))
39 |
40 |
41 | def to_string(values: Optional[Iterable]):
42 | return ','.join(map(str, values)) if values else None
43 |
--------------------------------------------------------------------------------
/gcsa/person.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 |
4 | class Person:
5 | def __init__(
6 | self,
7 | email: Optional[str] = None,
8 | display_name: Optional[str] = None,
9 | _id: Optional[str] = None,
10 | _is_self: Optional[bool] = None
11 | ):
12 | """Represents organizer's, creator's, or primary attendee's fields.
13 | For attendees see more in :py:class:`~gcsa.attendee.Attendee`.
14 |
15 | :param email:
16 | The person's email address, if available
17 | :param display_name:
18 | The person's name, if available
19 | :param _id:
20 | The person's Profile ID, if available.
21 | It corresponds to the id field in the People collection of the Google+ API
22 | :param _is_self:
23 | Whether the person corresponds to the calendar on which the copy of the event appears.
24 | The default is False (set by Google's API).
25 | """
26 | self.email = email
27 | self.display_name = display_name
28 | self.id_ = _id
29 | self.is_self = _is_self
30 |
31 | def __eq__(self, other):
32 | return (
33 | isinstance(other, Person)
34 | and self.email == other.email
35 | and self.display_name == other.display_name
36 | and self.id_ == other.id_
37 | and self.is_self == other.is_self
38 | )
39 |
40 | def __str__(self):
41 | return "'{}' - '{}'".format(self.email, self.display_name)
42 |
43 | def __repr__(self):
44 | return ''.format(self.__str__())
45 |
--------------------------------------------------------------------------------
/docs/source/acl.rst:
--------------------------------------------------------------------------------
1 | .. _acl:
2 |
3 | Access Control List
4 | ===================
5 |
6 | Access control rule is represented by the class :py:class:`~gcsa.acl.AccessControlRule`.
7 |
8 | `gcsa` allows you to add a new access control rule, retrieve, update and delete existing rules.
9 |
10 |
11 | To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your
12 | credentials):
13 |
14 | .. code-block:: python
15 |
16 | from gcsa.google_calendar import GoogleCalendar
17 |
18 | gc = GoogleCalendar()
19 |
20 |
21 | List rules
22 | ~~~~~~~~~~
23 |
24 | .. code-block:: python
25 |
26 | for rule in gc.get_acl_rules():
27 | print(rule)
28 |
29 |
30 | Get rule by id
31 | ~~~~~~~~~~~~~~
32 |
33 | .. code-block:: python
34 |
35 | rule = gc.get_acl_rule(rule_id='')
36 | print(rule)
37 |
38 |
39 | Add access rule
40 | ~~~~~~~~~~~~~~~
41 |
42 | To add a new ACL rule, create an :py:class:`~gcsa.acl.AccessControlRule` object with specified role
43 | (see more in :py:class:`~gcsa.acl.ACLRole`), scope type (see more in :py:class:`~gcsa.acl.ACLScopeType`), and scope
44 | value.
45 |
46 | .. code-block:: python
47 |
48 | from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType
49 |
50 | rule = AccessControlRule(
51 | role=ACLRole.READER,
52 | scope_type=ACLScopeType.USER,
53 | scope_value='friend@gmail.com',
54 | )
55 |
56 | rule = gc.add_acl_rule(rule)
57 | print(rule.id)
58 |
59 |
60 | Update access rule
61 | ~~~~~~~~~~~~~~~~~~
62 |
63 | .. code-block:: python
64 |
65 | rule = gc.get_acl_rule('')
66 | rule.role = ACLRole.WRITER
67 | rule = gc.update_acl_rule(rule)
68 |
69 |
70 | Delete access rule
71 | ~~~~~~~~~~~~~~~~~~
72 |
73 | .. code-block:: python
74 |
75 | rule = gc.get_acl_rule('')
76 | gc.delete_acl_rule(rule)
77 |
--------------------------------------------------------------------------------
/docs/source/why_gcsa.rst:
--------------------------------------------------------------------------------
1 | Why GCSA?
2 | =========
3 |
4 | .. image:: _static/push_ups.webp
5 | :width: 200
6 | :alt: 50 push-ups in one month
7 | :align: right
8 |
9 |
10 | I found that picture "The 50 push-ups in a month challenge" back in 2017 and decided it was time to try it.
11 |
12 | I wanted a calendar reminder of how many push-ups I need to do every day. As a developer, I couldn't afford
13 | to spend *10 minutes* putting the events manually. So I spent *3 hours* getting the official API to work to do this
14 | for me. Then I thought that this simple task shouldn't take *3 hours* and have spent the next *couple of days*
15 | implementing the initial version of the gcsa. Several years later, I'm happy that people find this project useful.
16 |
17 |
18 | If you'd like to try this yourself, here's the code you need:
19 |
20 | .. code-block:: python
21 |
22 | from gcsa.google_calendar import GoogleCalendar
23 | from gcsa.event import Event
24 | from beautiful_date import D, drange, days, MO
25 |
26 | gc = GoogleCalendar()
27 |
28 | PUSH_UPS_COUNT = [
29 | 5, 5, 0, 5, 10, 0, 10,
30 | 0, 12, 12, 0, 15, 15, 0,
31 | 20, 24, 0, 25, 30, 0, 32,
32 | 35, 35, 0, 38, 40, 0, 42,
33 | 45, 50
34 | ]
35 |
36 | # starting next Monday (of course)
37 | # +1 days for the case that today is Monday
38 | start = D.today()[9:00] + 1 * days + MO
39 | end = start + len(PUSH_UPS_COUNT) * days
40 |
41 | for day, push_ups in zip(drange(start, end), PUSH_UPS_COUNT):
42 | e = Event(
43 | f'{push_ups} Push-Ups' if push_ups else 'Rest',
44 | start=day,
45 | minutes_before_popup_reminder=5
46 | )
47 | gc.add_event(e)
48 |
49 |
50 |
51 | Needless to say, I can't do 50 push-ups.
52 |
53 | Let me know in Discord_ if you've tried it.
54 |
55 | .. _Discord: https://discord.gg/mRAegbwYKS
56 |
--------------------------------------------------------------------------------
/tests/test_acl_rule.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType
4 | from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer
5 |
6 |
7 | class TestACLRule(TestCase):
8 | def test_repr_str(self):
9 | acl_rule = AccessControlRule(
10 | role=ACLRole.READER,
11 | scope_type=ACLScopeType.USER,
12 | scope_value='mail@gmail.com'
13 | )
14 | self.assertEqual(acl_rule.__repr__(), "")
15 | self.assertEqual(acl_rule.__str__(), "mail@gmail.com - reader")
16 |
17 |
18 | class TestACLRuleSerializer(TestCase):
19 | def test_to_json(self):
20 | acl_rule = AccessControlRule(
21 | role=ACLRole.READER,
22 | scope_type=ACLScopeType.USER,
23 | acl_id='user:mail@gmail.com',
24 | scope_value='mail@gmail.com'
25 | )
26 |
27 | acl_rule_json = ACLRuleSerializer.to_json(acl_rule)
28 | self.assertEqual(acl_rule.role, acl_rule_json['role'])
29 | self.assertEqual(acl_rule.scope_type, acl_rule_json['scope']['type'])
30 | self.assertEqual(acl_rule.acl_id, acl_rule_json['id'])
31 | self.assertEqual(acl_rule.scope_value, acl_rule_json['scope']['value'])
32 |
33 | def test_to_object(self):
34 | acl_rule_json = {
35 | 'id': 'user:mail@gmail.com',
36 | 'scope': {
37 | 'type': 'user',
38 | 'value': 'mail@gmail.com'
39 | },
40 | 'role': 'reader'
41 | }
42 |
43 | acl_rule = ACLRuleSerializer.to_object(acl_rule_json)
44 |
45 | self.assertEqual(acl_rule_json['role'], acl_rule.role)
46 | self.assertEqual(acl_rule_json['scope']['type'], acl_rule.scope_type)
47 | self.assertEqual(acl_rule_json['id'], acl_rule.acl_id)
48 | self.assertEqual(acl_rule_json['scope']['value'], acl_rule.scope_value)
49 |
--------------------------------------------------------------------------------
/gcsa/util/date_time_util.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, date, time
2 | from typing import overload, Union, Optional
3 |
4 | from dateutil.tz import gettz
5 | from tzlocal import get_localzone_name
6 |
7 | from beautiful_date import BeautifulDate
8 |
9 | DateOrDatetime = Union[date, datetime, BeautifulDate]
10 |
11 |
12 | @overload
13 | def ensure_localisation(dt: datetime, timezone: str = ...) -> datetime: ...
14 |
15 |
16 | @overload
17 | def ensure_localisation(dt: date, timezone: str = ...) -> date: ...
18 |
19 |
20 | @overload
21 | def ensure_localisation(dt: BeautifulDate, timezone: str = ...) -> BeautifulDate: ...
22 |
23 |
24 | def ensure_localisation(dt: DateOrDatetime, timezone: Optional[str] = get_localzone_name()) -> DateOrDatetime:
25 | """Ensures localization with provided timezone on a datetime object.
26 | Does nothing to an object of type date."""
27 | if isinstance(dt, datetime):
28 | if dt.tzinfo is None:
29 | tz = gettz(timezone)
30 | dt = dt.replace(tzinfo=tz)
31 | return dt
32 | elif isinstance(dt, date):
33 | return dt
34 | else:
35 | raise TypeError('"date" or "datetime" object expected, not {!r}.'.format(dt.__class__.__name__))
36 |
37 |
38 | def to_localized_iso(dt, timezone=get_localzone_name()):
39 | if not isinstance(dt, datetime):
40 | dt = datetime.combine(dt, time())
41 | return ensure_localisation(dt, timezone).isoformat()
42 |
43 |
44 | def ensure_date(d):
45 | """Converts d to date if it is of type BeautifulDate."""
46 | if isinstance(d, BeautifulDate):
47 | return date(year=d.year, month=d.month, day=d.day)
48 | else:
49 | return d
50 |
51 |
52 | def ensure_datetime(d, timezone):
53 | """Converts d to datetime if it is of type date.
54 | Used in events sorting."""
55 | if type(d) is date:
56 | return ensure_localisation(datetime(year=d.year, month=d.month, day=d.day), timezone)
57 | else:
58 | return ensure_localisation(d, timezone)
59 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Google Calendar Simple API documentation!
2 | =========================================
3 |
4 | `Google Calendar Simple API` or `gcsa` is a library that simplifies event and calendar management in Google Calendars.
5 | It is a Pythonic object oriented adapter for the `official API`_.
6 |
7 | Example usage
8 | -------------
9 |
10 | List events
11 | ~~~~~~~~~~~
12 |
13 | .. code-block:: python
14 |
15 | from gcsa.google_calendar import GoogleCalendar
16 |
17 | calendar = GoogleCalendar('your_email@gmail.com')
18 | for event in calendar:
19 | print(event)
20 |
21 |
22 | Create event
23 | ~~~~~~~~~~~~
24 |
25 | .. code-block:: python
26 |
27 | from gcsa.event import Event
28 |
29 | event = Event(
30 | 'The Glass Menagerie',
31 | start=datetime(2020, 7, 10, 19, 0),
32 | location='Záhřebská 468/21'
33 | minutes_before_popup_reminder=15
34 | )
35 | calendar.add_event(event)
36 |
37 |
38 | Create recurring event
39 | ~~~~~~~~~~~~~~~~~~~~~~
40 |
41 | .. code-block:: python
42 |
43 | from gcsa.recurrence import Recurrence, DAILY
44 |
45 | event = Event(
46 | 'Breakfast',
47 | start=date(2020, 7, 16),
48 | recurrence=Recurrence.rule(freq=DAILY)
49 | )
50 | calendar.add_event(event)
51 |
52 |
53 | Contents
54 | --------
55 |
56 | .. toctree::
57 | :maxdepth: 2
58 |
59 | getting_started
60 | authentication
61 | events
62 | calendars
63 | colors
64 | attendees
65 | attachments
66 | conference
67 | reminders
68 | recurrence
69 | acl
70 | free_busy
71 | settings
72 | serializers
73 | why_gcsa
74 | change_log
75 | code/code
76 |
77 | Indices and tables
78 | ==================
79 |
80 | * :ref:`genindex`
81 | * :ref:`modindex`
82 | * :ref:`search`
83 |
84 |
85 | References
86 | ==========
87 |
88 | Template for `setup.py` was taken from `kennethreitz/setup.py`_.
89 |
90 |
91 | .. _kennethreitz/setup.py: https://github.com/kennethreitz/setup.py
92 | .. _`official API`: https://developers.google.com/calendar
--------------------------------------------------------------------------------
/gcsa/serializers/settings_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.settings import Settings
2 | from .base_serializer import BaseSerializer
3 |
4 |
5 | class SettingsSerializer(BaseSerializer):
6 | type_ = Settings
7 |
8 | def __init__(self, settings):
9 | super().__init__(settings)
10 |
11 | @staticmethod
12 | def _to_json(settings: Settings):
13 | """Isn't used as Settings are read-only"""
14 | return {
15 | 'autoAddHangouts': settings.auto_add_hangouts,
16 | 'dateFieldOrder': settings.date_field_order,
17 | 'defaultEventLength': settings.default_event_length,
18 | 'format24HourTime': settings.format24_hour_time,
19 | 'hideInvitations': settings.hide_invitations,
20 | 'hideWeekends': settings.hide_weekends,
21 | 'locale': settings.locale,
22 | 'remindOnRespondedEventsOnly': settings.remind_on_responded_events_only,
23 | 'showDeclinedEvents': settings.show_declined_events,
24 | 'timezone': settings.timezone,
25 | 'useKeyboardShortcuts': settings.use_keyboard_shortcuts,
26 | 'weekStart': settings.week_start
27 | }
28 |
29 | @staticmethod
30 | def _to_object(json_settings):
31 | return Settings(
32 | auto_add_hangouts=json_settings.get('autoAddHangouts', False),
33 | date_field_order=json_settings.get('dateFieldOrder', 'MDY'),
34 | default_event_length=json_settings.get('defaultEventLength', 60),
35 | format24_hour_time=json_settings.get('format24HourTime', False),
36 | hide_invitations=json_settings.get('hideInvitations', False),
37 | hide_weekends=json_settings.get('hideWeekends', False),
38 | locale=json_settings.get('locale', 'en'),
39 | remind_on_responded_events_only=json_settings.get('remindOnRespondedEventsOnly', False),
40 | show_declined_events=json_settings.get('showDeclinedEvents', True),
41 | timezone=json_settings.get('timezone', 'Etc/GMT'),
42 | use_keyboard_shortcuts=json_settings.get('useKeyboardShortcuts', True),
43 | week_start=json_settings.get('weekStart', 0)
44 | )
45 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to GCSA
2 |
3 | Welcome and thank you for considering contributing to *Google Calendar Simple API* open source project!
4 |
5 | Before contributing to this repository, please first discuss the change you wish to make via
6 | [Issue](https://github.com/kuzmoyev/google-calendar-simple-api/issues),
7 | [GitHub Discussion](https://github.com/kuzmoyev/google-calendar-simple-api/discussions), or [Discord](https://discord.gg/mRAegbwYKS). Don’t hesitate to ask!
8 | Issue submissions, discussions, suggestions are as welcomed contributions as pull requests.
9 |
10 | ## Steps to contribute changes
11 |
12 | 1. [Fork](https://github.com/kuzmoyev/google-calendar-simple-api/fork) the repository
13 | 2. Clone it with `git clone git@github.com:{your_username}/google-calendar-simple-api.git`
14 | 3. Install dependencies if needed with `pip install -e .` (or `pip install -e ".[dev]"` if you want to run tests, compile documentation, etc.).
15 | Use [virtualenv](https://virtualenv.pypa.io/en/latest/) to avoid polluting your global python
16 | 4. Make and commit the changes. Add `closes #{issue_number}` to commit message if applies
17 | 5. Run the tests with `tox` (these will be run on pull request):
18 | * `tox` - all the tests
19 | * `tox -e pytest` - unit tests
20 | * `tox -e flake8` - style check
21 | * `tox -e sphinx` - docs compilation test
22 | * `tox -e mypy` - static type check
23 | 6. Push
24 | 7. Create pull request
25 | * towards `dev` branch if the changes require a new GCSA version (i.e. changes in [gcsa](https://github.com/kuzmoyev/google-calendar-simple-api/tree/master/gcsa) module)
26 | * towards `master` branch if they don't (e.x. changes in README, docs, tests)
27 |
28 | ## While contributing
29 |
30 | * Follow the [Code of conduct](https://github.com/kuzmoyev/google-calendar-simple-api/blob/master/.github/CODE_OF_CONDUCT.md)
31 | * Follow the [pep8](https://peps.python.org/pep-0008/) and the code style of the project (use your best judgement)
32 | * Add documentation of your changes to code and/or to [read-the-docs](https://github.com/kuzmoyev/google-calendar-simple-api/tree/master/docs/source) if needed (use your best judgement)
33 | * Add [tests](https://github.com/kuzmoyev/google-calendar-simple-api/tree/master/tests) if needed (use your best judgement)
34 |
--------------------------------------------------------------------------------
/docs/source/attendees.rst:
--------------------------------------------------------------------------------
1 | .. _attendees:
2 |
3 | Attendees
4 | =========
5 |
6 | If you want to add attendee(s) to your event, just create :py:class:`~gcsa.attendee.Attendee` (s) and pass
7 | as an ``attendees`` parameter (you can also pass just an email of the attendee and
8 | the :py:class:`~gcsa.attendee.Attendee` will be created for you):
9 |
10 | .. code-block:: python
11 |
12 | from gcsa.attendee import Attendee
13 |
14 | attendee = Attendee(
15 | 'attendee@gmail.com',
16 | display_name='Friend',
17 | additional_guests=3
18 | )
19 |
20 | event = Event('Meeting',
21 | start=(17/Jul/2020)[12:00],
22 | attendees=attendee)
23 |
24 | or
25 |
26 | .. code-block:: python
27 |
28 | event = Event('Meeting',
29 | start=(17/Jul/2020)[12:00],
30 | attendees='attendee@gmail.com')
31 |
32 | You can pass multiple attendees at once in a list.
33 |
34 |
35 | .. code-block:: python
36 |
37 | event = Event('Meeting',
38 | start=(17/Jul/2020)[12:00],
39 | attendees=[
40 | 'attendee@gmail.com',
41 | Attendee('attendee2@gmail.com', display_name='Friend')
42 | ])
43 |
44 | To **notify** attendees about created/updated/deleted event use `send_updates` parameter in `add_event`, `update_event`,
45 | and `delete_event` methods. See :py:class:`~gcsa.google_calendar.SendUpdatesMode` for possible values.
46 |
47 | To add attendees to an existing event use its :py:meth:`~gcsa.event.Event.add_attendee` method:
48 |
49 | .. code-block:: python
50 |
51 | event.add_attendee(
52 | Attendee('attendee@gmail.com',
53 | display_name='Friend',
54 | additional_guests=3
55 | )
56 | )
57 |
58 | or
59 |
60 | .. code-block:: python
61 |
62 | event.add_attendee('attendee@gmail.com')
63 |
64 | to add a single attendee.
65 |
66 | Use :py:meth:`~gcsa.event.Event.add_attendees` method to add multiple at once:
67 |
68 | .. code-block:: python
69 |
70 | event.add_attendees(
71 | [
72 | Attendee('attendee@gmail.com',
73 | display_name='Friend',
74 | additional_guests=3
75 | ),
76 | 'attendee_by_email1@gmail.com',
77 | 'attendee_by_email2@gmail.com'
78 | ]
79 | )
80 |
81 | Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes.
82 |
--------------------------------------------------------------------------------
/gcsa/acl.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from gcsa._resource import Resource
4 |
5 |
6 | class ACLRole:
7 | """
8 | * `NONE` - Provides no access.
9 | * `FREE_BUSY_READER` - Provides read access to free/busy information.
10 | * `READER` - Provides read access to the calendar. Private events will appear to users with reader access, but event
11 | details will be hidden.
12 | * `WRITER` - Provides read and write access to the calendar. Private events will appear to users with writer access,
13 | and event details will be visible.
14 | * `OWNER` - Provides ownership of the calendar. This role has all of the permissions of the writer role with
15 | the additional ability to see and manipulate ACLs.
16 | """
17 |
18 | NONE = "none"
19 | FREE_BUSY_READER = "freeBusyReader"
20 | READER = "reader"
21 | WRITER = "writer"
22 | OWNER = "owner"
23 |
24 |
25 | class ACLScopeType:
26 | """
27 | * `DEFAULT` - The public scope.
28 | * `USER` - Limits the scope to a single user.
29 | * `GROUP` - Limits the scope to a group.
30 | * `DOMAIN` - Limits the scope to a domain.
31 | """
32 |
33 | DEFAULT = "default"
34 | USER = "user"
35 | GROUP = "group"
36 | DOMAIN = "domain"
37 |
38 |
39 | class AccessControlRule(Resource):
40 | def __init__(
41 | self,
42 | *,
43 | role: str,
44 | scope_type: str,
45 | acl_id: Optional[str] = None,
46 | scope_value: Optional[str] = None
47 | ):
48 | """
49 | :param role:
50 | The role assigned to the scope. See :py:class:`~gcsa.acl.ACLRole`.
51 | :param scope_type:
52 | The type of the scope. See :py:class:`~gcsa.acl.ACLScopeType`.
53 | :param acl_id:
54 | Identifier of the Access Control List (ACL) rule.
55 | :param scope_value:
56 | The email address of a user or group, or the name of a domain, depending on the scope type.
57 | Omitted for type "default".
58 | """
59 | self.acl_id = acl_id
60 | self.role = role
61 | self.scope_type = scope_type
62 | self.scope_value = scope_value
63 |
64 | @property
65 | def id(self):
66 | return self.acl_id
67 |
68 | def __str__(self):
69 | return '{} - {}'.format(self.scope_value, self.role)
70 |
71 | def __repr__(self):
72 | return ''.format(self.__str__())
73 |
--------------------------------------------------------------------------------
/tests/test_base_serializer.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from gcsa.serializers.base_serializer import BaseSerializer
4 |
5 |
6 | class TestBaseSerializer(TestCase):
7 | def test_ensure_dict(self):
8 | json_str = """
9 | {
10 | "key": "value",
11 | "list": [1, 2, 4]
12 | }
13 | """
14 |
15 | json_dict = {
16 | "key": "value",
17 | "list": [1, 2, 4]
18 | }
19 |
20 | json_object = (1, 2, 3) # non-json object
21 |
22 | self.assertDictEqual(BaseSerializer.ensure_dict(json_str), json_dict)
23 | self.assertDictEqual(BaseSerializer.ensure_dict(json_dict), json_dict)
24 |
25 | with self.assertRaises(TypeError):
26 | BaseSerializer.ensure_dict(json_object)
27 |
28 | def test_subclass(self):
29 | class Apple:
30 | pass
31 |
32 | # should not raise any exceptions
33 | class AppleSerializer(BaseSerializer):
34 | type_ = Apple
35 |
36 | def __init__(self, apple):
37 | super().__init__(apple)
38 |
39 | @staticmethod
40 | def _to_json(obj):
41 | pass
42 |
43 | @staticmethod
44 | def _to_object(json_):
45 | pass
46 |
47 | with self.assertRaises(AssertionError):
48 | # type_ not defined
49 | class PeachSerializer(BaseSerializer):
50 | def __init__(self, peach):
51 | super().__init__(peach)
52 |
53 | @staticmethod
54 | def _to_json(obj):
55 | pass
56 |
57 | @staticmethod
58 | def _to_object(json_):
59 | pass
60 |
61 | class Watermelon:
62 | pass
63 |
64 | with self.assertRaises(AssertionError):
65 | # __init__ parameter should be "apple"
66 | class WatermelonSerializer(BaseSerializer):
67 | type_ = Watermelon
68 |
69 | def __init__(self, peach):
70 | super().__init__(peach)
71 |
72 | @staticmethod
73 | def _to_json(obj):
74 | pass
75 |
76 | @staticmethod
77 | def _to_object(json_):
78 | pass
79 |
80 | with self.assertRaises(TypeError):
81 | AppleSerializer(Watermelon)
82 |
83 | with self.assertRaises(TypeError):
84 | AppleSerializer.to_json(Watermelon)
85 |
--------------------------------------------------------------------------------
/gcsa/_services/base_service.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, Type, Union, Optional
2 |
3 | from gcsa._resource import Resource
4 | from gcsa._services.authentication import AuthenticatedService
5 |
6 |
7 | class BaseService(AuthenticatedService):
8 | def __init__(self, default_calendar, *args, **kwargs):
9 | """
10 | :param default_calendar:
11 | Users email address or name/id of the calendar. Default: primary calendar of the user
12 |
13 | If user's email or "primary" is specified, then primary calendar of the user is used.
14 | You don't need to specify this parameter in this case as it is a default behaviour.
15 |
16 | To use a different calendar you need to specify its id.
17 | Go to calendar's `settings and sharing` -> `Integrate calendar` -> `Calendar ID`.
18 | """
19 | super().__init__(*args, **kwargs)
20 | self.default_calendar = default_calendar
21 |
22 | @staticmethod
23 | def _list_paginated(
24 | request_method: Callable,
25 | serializer_cls: Optional[Type] = None,
26 | **kwargs
27 | ):
28 | page_token = None
29 | while True:
30 | response_json = request_method(
31 | **kwargs,
32 | pageToken=page_token
33 | ).execute()
34 | for item_json in response_json['items']:
35 | if serializer_cls:
36 | yield serializer_cls(item_json).get_object()
37 | else:
38 | yield item_json
39 | page_token = response_json.get('nextPageToken')
40 | if not page_token:
41 | break
42 |
43 | @staticmethod
44 | def _get_resource_id(resource: Union[Resource, str]):
45 | """If `resource` is `Resource` returns its id.
46 | If `resource` is string, returns `resource` itself.
47 |
48 | :raises:
49 | ValueError: if `resource` is `Resource` object that doesn't have id
50 | TypeError: if `resource` is neither `Resource` nor `str`
51 | """
52 | if isinstance(resource, Resource):
53 | if resource.id is None:
54 | raise ValueError("Resource has to have id to be updated, moved or deleted.")
55 | return resource.id
56 | elif isinstance(resource, str):
57 | return resource
58 | else:
59 | raise TypeError('"resource" object must be Resource or str, not {!r}'.format(
60 | resource.__class__.__name__
61 | ))
62 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/test_calendars_service.py:
--------------------------------------------------------------------------------
1 | from gcsa.calendar import Calendar, AccessRoles
2 | from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService
3 |
4 |
5 | class TestCalendarsService(TestCaseWithMockedService):
6 | def test_get_calendar(self):
7 | calendar = self.gc.get_calendar()
8 | self.assertEqual(calendar.id, 'primary')
9 | self.assertIsInstance(calendar, Calendar)
10 |
11 | calendar = self.gc.get_calendar('1')
12 | self.assertEqual(calendar.id, '1')
13 |
14 | def test_add_calendar(self):
15 | calendar = Calendar(
16 | summary='secondary',
17 | description='Description secondary',
18 | location='Location secondary',
19 | timezone='Timezone secondary',
20 | allowed_conference_solution_types=[
21 | AccessRoles.FREE_BUSY_READER,
22 | AccessRoles.READER
23 | ]
24 | )
25 | new_calendar = self.gc.add_calendar(calendar)
26 |
27 | self.assertIsNotNone(new_calendar.id)
28 | self.assertEqual(calendar.summary, new_calendar.summary)
29 |
30 | def test_update_calendar(self):
31 | calendar = Calendar(
32 | summary='secondary',
33 | description='Description secondary',
34 | location='Location secondary',
35 | timezone='Timezone secondary',
36 | allowed_conference_solution_types=[
37 | AccessRoles.FREE_BUSY_READER,
38 | AccessRoles.READER
39 | ]
40 | )
41 | new_calendar = self.gc.add_calendar(calendar)
42 |
43 | self.assertEqual(calendar.summary, new_calendar.summary)
44 |
45 | new_calendar.summary = 'Updated summary'
46 | updated_calendar = self.gc.update_calendar(new_calendar)
47 |
48 | self.assertEqual(new_calendar.summary, updated_calendar.summary)
49 |
50 | retrieved_updated_calendar = self.gc.get_calendar(new_calendar.id)
51 | self.assertEqual(retrieved_updated_calendar.summary, updated_calendar.summary)
52 |
53 | def test_delete_calendar(self):
54 | calendar = Calendar(
55 | summary='secondary'
56 | )
57 |
58 | with self.assertRaises(ValueError):
59 | # no calendar_id
60 | self.gc.delete_calendar(calendar)
61 |
62 | new_calendar = self.gc.add_calendar(calendar)
63 | self.gc.delete_calendar(new_calendar)
64 | self.gc.delete_calendar('2')
65 |
66 | with self.assertRaises(TypeError):
67 | # should be a Calendar or calendar id as a string
68 | self.gc.delete_calendar(None)
69 |
70 | def test_clear_calendar(self):
71 | self.gc.clear_calendar()
72 | self.gc.clear()
73 |
--------------------------------------------------------------------------------
/gcsa/serializers/base_serializer.py:
--------------------------------------------------------------------------------
1 | import re
2 | from abc import ABC, abstractmethod
3 | import json
4 | from typing import Type
5 |
6 | import dateutil.parser
7 |
8 |
9 | def _type_to_snake_case(type_):
10 | return re.sub(r'(?'.format(self.__str__())
76 |
--------------------------------------------------------------------------------
/gcsa/serializers/free_busy_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.free_busy import FreeBusy, TimeRange
2 | from gcsa.serializers.base_serializer import BaseSerializer
3 |
4 |
5 | class FreeBusySerializer(BaseSerializer):
6 | type_ = FreeBusy
7 |
8 | def __init__(self, free_busy):
9 | super().__init__(free_busy)
10 |
11 | @staticmethod
12 | def _to_json(free_busy: FreeBusy):
13 | """Isn't used as free busy data is read-only"""
14 | free_busy_json = {
15 | 'calendars': {
16 | c: {
17 | 'busy': [
18 | {
19 | 'start': start.isoformat(),
20 | 'end': end.isoformat(),
21 | }
22 | for start, end in free_busy.calendars.get(c, [])
23 | ],
24 | 'errors': free_busy.calendars_errors.get(c, [])
25 | }
26 | for c in {**free_busy.calendars, **free_busy.calendars_errors}
27 | },
28 | 'groups': {
29 | g: {
30 | 'calendars': free_busy.groups.get(g, []),
31 | 'errors': free_busy.groups_errors.get(g, [])
32 | }
33 | for g in {**free_busy.groups, **free_busy.groups_errors}
34 | },
35 | 'timeMin': free_busy.time_min.isoformat(),
36 | 'timeMax': free_busy.time_max.isoformat(),
37 |
38 | }
39 | return free_busy_json
40 |
41 | @staticmethod
42 | def _to_object(json_):
43 | time_min = FreeBusySerializer._get_datetime_from_string(json_['timeMin'])
44 | time_max = FreeBusySerializer._get_datetime_from_string(json_['timeMax'])
45 | groups_json = json_.get('groups')
46 | calendars_json = json_.get("calendars")
47 |
48 | if groups_json:
49 | groups = {gn: g['calendars'] for gn, g in groups_json.items() if g.get('calendars')}
50 | groups_errors = {gn: g['errors'] for gn, g in groups_json.items() if g.get('errors')}
51 | else:
52 | groups = {}
53 | groups_errors = {}
54 |
55 | if calendars_json:
56 | calendars = {
57 | cn: list(map(FreeBusySerializer._make_time_range, c['busy']))
58 | for cn, c in calendars_json.items() if c.get('busy') and not c.get('errors')
59 | }
60 | calendars_errors = {
61 | cn: c['errors']
62 | for cn, c in calendars_json.items() if c.get('errors')
63 | }
64 | else:
65 | calendars = {}
66 | calendars_errors = {}
67 |
68 | return FreeBusy(
69 | time_min=time_min,
70 | time_max=time_max,
71 | groups=groups,
72 | calendars=calendars,
73 | groups_errors=groups_errors,
74 | calendars_errors=calendars_errors
75 | )
76 |
77 | @staticmethod
78 | def _make_time_range(tp):
79 | return TimeRange(
80 | start=FreeBusySerializer._get_datetime_from_string(tp['start']),
81 | end=FreeBusySerializer._get_datetime_from_string(tp['end'])
82 | )
83 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_calendars_requests.py:
--------------------------------------------------------------------------------
1 | from gcsa.calendar import Calendar, AccessRoles
2 | from gcsa.serializers.calendar_serializer import CalendarSerializer
3 | from .util import executable
4 |
5 |
6 | class MockCalendarsRequests:
7 | """Emulates GoogleCalendar.service.calendars()"""
8 |
9 | def __init__(self):
10 | self.test_calendars = [
11 | Calendar(
12 | summary=f'Secondary {i}',
13 | calendar_id=str(i),
14 | description=f'Description {i}',
15 | location=f'Location {i}',
16 | timezone=f'Timezone {i}',
17 | allowed_conference_solution_types=[
18 | AccessRoles.FREE_BUSY_READER,
19 | AccessRoles.READER
20 | ]
21 | )
22 | for i in range(7)
23 | ]
24 | self.test_calendars.append(
25 | Calendar(
26 | summary='Primary',
27 | calendar_id='primary',
28 | description='Description',
29 | location='Location',
30 | timezone='Timezone',
31 | allowed_conference_solution_types=[
32 | AccessRoles.FREE_BUSY_READER,
33 | AccessRoles.READER,
34 | AccessRoles.WRITER,
35 | AccessRoles.OWNER,
36 | ]
37 | )
38 | )
39 |
40 | @property
41 | def test_calendars_by_id(self):
42 | return {c.id: c for c in self.test_calendars}
43 |
44 | @executable
45 | def get(self, calendarId):
46 | """Emulates GoogleCalendar.service.calendars().get().execute()"""
47 | try:
48 | return CalendarSerializer.to_json(self.test_calendars_by_id[calendarId])
49 | except KeyError:
50 | # shouldn't get here in tests
51 | raise ValueError(f'Calendar with id {calendarId} does not exist')
52 |
53 | @executable
54 | def insert(self, body):
55 | """Emulates GoogleCalendar.service.calendars().insert().execute()"""
56 | calendar = CalendarSerializer.to_object(body)
57 | calendar.calendar_id = str(len(self.test_calendars))
58 | self.test_calendars.append(calendar)
59 | return CalendarSerializer.to_json(calendar)
60 |
61 | @executable
62 | def update(self, calendarId, body):
63 | """Emulates GoogleCalendar.service.calendars().update().execute()"""
64 | calendar = CalendarSerializer.to_object(body)
65 | for i in range(len(self.test_calendars)):
66 | if calendarId == self.test_calendars[i].id:
67 | self.test_calendars[i] = calendar
68 | return CalendarSerializer.to_json(calendar)
69 |
70 | # shouldn't get here in tests
71 | raise ValueError(f'Calendar with id {calendarId} does not exist')
72 |
73 | @executable
74 | def delete(self, calendarId):
75 | """Emulates GoogleCalendar.service.calendars().delete().execute()"""
76 | self.test_calendars = [c for c in self.test_calendars if c.id != calendarId]
77 |
78 | @executable
79 | def clear(self, calendarId):
80 | """Emulates GoogleCalendar.service.calendars().clear().execute()"""
81 | pass
82 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_calendar_list_requests.py:
--------------------------------------------------------------------------------
1 | from gcsa.calendar import CalendarListEntry
2 | from gcsa.serializers.calendar_serializer import CalendarListEntrySerializer
3 | from .util import executable
4 |
5 |
6 | class MockCalendarListRequests:
7 | """Emulates GoogleCalendar.service.calendarList()"""
8 | CALENDAR_LIST_ENTRIES_PER_PAGE = 3
9 |
10 | def __init__(self):
11 | self.test_calendars = [
12 | CalendarListEntry(
13 | summary_override=f'Summery override {i}',
14 | _summary=f'Secondary {i}',
15 | calendar_id=str(i)
16 | )
17 | for i in range(7)
18 | ]
19 | self.test_calendars.append(
20 | CalendarListEntry(
21 | summary_override='Primary',
22 | _summary='Primary',
23 | calendar_id='primary'
24 | )
25 | )
26 |
27 | @property
28 | def test_calendars_by_id(self):
29 | return {c.id: c for c in self.test_calendars}
30 |
31 | @executable
32 | def list(self, pageToken, **_):
33 | page = pageToken or 0 # page number in this case
34 | page_calendars = self.test_calendars[
35 | page * self.CALENDAR_LIST_ENTRIES_PER_PAGE:(page + 1) * self.CALENDAR_LIST_ENTRIES_PER_PAGE
36 | ]
37 | next_page = page + 1 if (page + 1) * self.CALENDAR_LIST_ENTRIES_PER_PAGE < len(self.test_calendars) else None
38 |
39 | return {
40 | 'items': [
41 | CalendarListEntrySerializer.to_json(c)
42 | for c in page_calendars
43 | ],
44 | 'nextPageToken': next_page
45 | }
46 |
47 | @executable
48 | def get(self, calendarId):
49 | """Emulates GoogleCalendar.service.calendarList().get().execute()"""
50 | try:
51 | return CalendarListEntrySerializer.to_json(self.test_calendars_by_id[calendarId])
52 | except KeyError:
53 | # shouldn't get here in tests
54 | raise ValueError(f'Calendar with id {calendarId} does not exist')
55 |
56 | @executable
57 | def insert(self, body, colorRgbFormat):
58 | """Emulates GoogleCalendar.service.calendarList().insert().execute()"""
59 | calendar = CalendarListEntrySerializer.to_object(body)
60 | self.test_calendars.append(calendar)
61 | return CalendarListEntrySerializer.to_json(calendar)
62 |
63 | @executable
64 | def update(self, calendarId, body, colorRgbFormat):
65 | """Emulates GoogleCalendar.service.calendarList().insert().execute()"""
66 | calendar = CalendarListEntrySerializer.to_object(body)
67 | for i in range(len(self.test_calendars)):
68 | if calendarId == self.test_calendars[i].id:
69 | self.test_calendars[i] = calendar
70 | return CalendarListEntrySerializer.to_json(calendar)
71 |
72 | # shouldn't get here in tests
73 | raise ValueError(f'Calendar with id {calendarId} does not exist')
74 |
75 | @executable
76 | def delete(self, calendarId):
77 | """Emulates GoogleCalendar.service.calendarList().delete().execute()"""
78 | self.test_calendars = [c for c in self.test_calendars if c.id != calendarId]
79 |
--------------------------------------------------------------------------------
/gcsa/attendee.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from .person import Person
4 |
5 |
6 | class ResponseStatus:
7 | """Possible values for attendee's response status
8 |
9 | * NEEDS_ACTION - The attendee has not responded to the invitation.
10 | * DECLINED - The attendee has declined the invitation.
11 | * TENTATIVE - The attendee has tentatively accepted the invitation.
12 | * ACCEPTED - The attendee has accepted the invitation.
13 | """
14 | NEEDS_ACTION = "needsAction"
15 | DECLINED = "declined"
16 | TENTATIVE = "tentative"
17 | ACCEPTED = "accepted"
18 |
19 |
20 | class Attendee(Person):
21 | def __init__(
22 | self,
23 | email: str,
24 | display_name: Optional[str] = None,
25 | comment: Optional[str] = None,
26 | optional: Optional[bool] = None,
27 | is_resource: Optional[bool] = None,
28 | additional_guests: Optional[int] = None,
29 | _id: Optional[str] = None,
30 | _is_self: Optional[bool] = None,
31 | _response_status: Optional[str] = None
32 | ):
33 | """Represents attendee of the event.
34 |
35 | :param email:
36 | The attendee's email address, if available.
37 | :param display_name:
38 | The attendee's name, if available
39 | :param comment:
40 | The attendee's response comment
41 | :param optional:
42 | Whether this is an optional attendee. The default is False.
43 | :param is_resource:
44 | Whether the attendee is a resource.
45 | Can only be set when the attendee is added to the event
46 | for the first time. Subsequent modifications are ignored.
47 | The default is False.
48 | :param additional_guests:
49 | Number of additional guests. The default is 0.
50 | :param _id:
51 | The attendee's Profile ID, if available.
52 | It corresponds to the id field in the People collection of the Google+ API
53 | :param _is_self:
54 | Whether this entry represents the calendar on which this copy of the event appears.
55 | The default is False (set by Google's API).
56 | :param _response_status:
57 | The attendee's response status. See :py:class:`~gcsa.attendee.ResponseStatus`
58 | """
59 | super().__init__(email=email, display_name=display_name, _id=_id, _is_self=_is_self)
60 | self.comment = comment
61 | self.optional = optional
62 | self.is_resource = is_resource
63 | self.additional_guests = additional_guests
64 | self.response_status = _response_status
65 |
66 | def __eq__(self, other):
67 | return (
68 | isinstance(other, Attendee)
69 | and super().__eq__(other)
70 | and self.comment == other.comment
71 | and self.optional == other.optional
72 | and self.is_resource == other.is_resource
73 | and self.additional_guests == other.additional_guests
74 | and self.response_status == other.response_status
75 | )
76 |
77 | def __str__(self):
78 | return "'{}' - response: '{}'".format(self.email, self.response_status)
79 |
80 | def __repr__(self):
81 | return ''.format(self.__str__())
82 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_acl_requests.py:
--------------------------------------------------------------------------------
1 | from gcsa.acl import AccessControlRule, ACLRole, ACLScopeType
2 | from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer
3 | from .util import executable
4 |
5 |
6 | class MockACLRequests:
7 | """Emulates GoogleCalendar.service.acl()"""
8 | ACL_RULES_PER_PAGE = 3
9 |
10 | def __init__(self):
11 | self.test_acl_rules = []
12 | for i in range(4):
13 | self.test_acl_rules.append(
14 | AccessControlRule(
15 | role=ACLRole.READER,
16 | scope_type=ACLScopeType.USER,
17 | acl_id=f'user:mail{i}@gmail.com',
18 | scope_value=f'mail{i}@gmail.com'
19 | )
20 | )
21 | self.test_acl_rules.append(
22 | AccessControlRule(
23 | role=ACLRole.READER,
24 | scope_type=ACLScopeType.GROUP,
25 | acl_id=f'group:group-mail{i}@gmail.com',
26 | scope_value=f'group-mail{i}@gmail.com'
27 | )
28 | )
29 |
30 | @property
31 | def test_acl_rules_by_id(self):
32 | return {c.id: c for c in self.test_acl_rules}
33 |
34 | @executable
35 | def list(self, pageToken, **_):
36 | """Emulates GoogleCalendar.service.acl().list().execute()"""
37 | page = pageToken or 0 # page number in this case
38 | page_acl_rules = self.test_acl_rules[page * self.ACL_RULES_PER_PAGE:(page + 1) * self.ACL_RULES_PER_PAGE]
39 | next_page = page + 1 if (page + 1) * self.ACL_RULES_PER_PAGE < len(self.test_acl_rules) else None
40 |
41 | return {
42 | 'items': [
43 | ACLRuleSerializer.to_json(c)
44 | for c in page_acl_rules
45 | ],
46 | 'nextPageToken': next_page
47 | }
48 |
49 | @executable
50 | def get(self, calendarId, ruleId):
51 | """Emulates GoogleCalendar.service.acl().get().execute()"""
52 | try:
53 | return ACLRuleSerializer.to_json(self.test_acl_rules_by_id[ruleId])
54 | except KeyError:
55 | # shouldn't get here in tests
56 | raise ValueError(f'ACLRule with id {ruleId} does not exist')
57 |
58 | @executable
59 | def insert(self, calendarId, body, sendNotifications):
60 | """Emulates GoogleCalendar.service.acl().insert().execute()"""
61 | acl_rule: AccessControlRule = ACLRuleSerializer.to_object(body)
62 | acl_rule.acl_id = f'{acl_rule.scope_type}:{acl_rule.scope_value}'
63 | self.test_acl_rules.append(acl_rule)
64 | return ACLRuleSerializer.to_json(acl_rule)
65 |
66 | @executable
67 | def update(self, calendarId, ruleId, body, sendNotifications):
68 | """Emulates GoogleCalendar.service.acl().update().execute()"""
69 | acl_rule = ACLRuleSerializer.to_object(body)
70 | for i in range(len(self.test_acl_rules)):
71 | if ruleId == self.test_acl_rules[i].id:
72 | self.test_acl_rules[i] = acl_rule
73 | return ACLRuleSerializer.to_json(acl_rule)
74 |
75 | # shouldn't get here in tests
76 | raise ValueError(f'ACL rule with id {ruleId} does not exist')
77 |
78 | @executable
79 | def delete(self, calendarId, ruleId):
80 | """Emulates GoogleCalendar.service.acl().delete().execute()"""
81 | self.test_acl_rules = [c for c in self.test_acl_rules if c.id != ruleId]
82 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/test_free_busy_service.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from beautiful_date import D, weeks
4 |
5 | from gcsa.free_busy import FreeBusyQueryError
6 | from gcsa.util.date_time_util import ensure_localisation
7 | from tests.google_calendar_tests.mock_services.util import time_range_within
8 | from tests.google_calendar_tests.test_case_with_mocked_service import TestCaseWithMockedService
9 |
10 |
11 | class TestFreeBusyService(TestCaseWithMockedService):
12 | def test_query_default(self):
13 | free_busy = self.gc.get_free_busy()
14 |
15 | time_min = ensure_localisation(D.now())
16 | time_max = time_min + 2 * weeks
17 | self.assertAlmostEqual(free_busy.time_min, time_min, delta=timedelta(seconds=5))
18 | self.assertAlmostEqual(free_busy.time_max, time_max, delta=timedelta(seconds=5))
19 |
20 | self.assertEqual(len(free_busy.calendars), 1)
21 | self.assertEqual(len(free_busy.calendars['primary']), 2)
22 | self.assertTrue(
23 | all(
24 | time_range_within(tr, time_min, time_max)
25 | for tr in free_busy.calendars['primary']
26 | )
27 | )
28 |
29 | def test_query_with_resource_ids(self):
30 | time_min = ensure_localisation(D.now())
31 | time_max = time_min + 2 * weeks
32 |
33 | free_busy = self.gc.get_free_busy(resource_ids='calendar3')
34 |
35 | self.assertEqual(len(free_busy.calendars), 1)
36 | self.assertIn('calendar3', free_busy.calendars)
37 | self.assertTrue(
38 | all(
39 | time_range_within(tr, time_min, time_max)
40 | for tr in free_busy.calendars['calendar3']
41 | )
42 | )
43 |
44 | free_busy = self.gc.get_free_busy(resource_ids=['primary', 'group2'])
45 |
46 | self.assertEqual(len(free_busy.calendars), 3)
47 | # by calendar id
48 | self.assertIn('primary', free_busy.calendars)
49 | # by group
50 | self.assertIn('calendar3', free_busy.calendars)
51 | self.assertIn('calendar4', free_busy.calendars)
52 |
53 | self.assertTrue(
54 | all(
55 | time_range_within(tr, time_min, time_max)
56 | for tr in free_busy.calendars['primary']
57 | )
58 | )
59 | self.assertTrue(
60 | all(
61 | time_range_within(tr, time_min, time_max)
62 | for tr in free_busy.calendars['calendar3']
63 | )
64 | )
65 |
66 | self.assertTrue(len(free_busy.groups), 1)
67 | self.assertIn('group2', free_busy.groups)
68 |
69 | def test_query_with_errors(self):
70 | with self.assertRaises(FreeBusyQueryError) as cm:
71 | self.gc.get_free_busy(resource_ids=['calendar-unknown'])
72 | fb_exception = cm.exception
73 | self.assertIn('calendar-unknown', fb_exception.calendars_errors)
74 |
75 | with self.assertRaises(FreeBusyQueryError) as cm:
76 | self.gc.get_free_busy(resource_ids=['group-unknown'])
77 | fb_exception = cm.exception
78 | self.assertIn('group-unknown', fb_exception.groups_errors)
79 |
80 | def test_query_with_errors_ignored(self):
81 | free_busy = self.gc.get_free_busy(resource_ids=['calendar-unknown', 'group-unknown'], ignore_errors=True)
82 | self.assertIn('calendar-unknown', free_busy.calendars_errors)
83 | self.assertIn('group-unknown', free_busy.groups_errors)
84 | self.assertFalse(free_busy.calendars)
85 | self.assertFalse(free_busy.groups)
86 |
--------------------------------------------------------------------------------
/tests/test_attendee.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from gcsa.attendee import Attendee, ResponseStatus
4 | from gcsa.serializers.attendee_serializer import AttendeeSerializer
5 |
6 |
7 | class TestAttendee(TestCase):
8 | def test_repr_str(self):
9 | attendee = Attendee(
10 | email='mail@gmail.com',
11 | display_name='Guest',
12 | comment='I do not know him',
13 | optional=True,
14 | additional_guests=2,
15 | _response_status=ResponseStatus.NEEDS_ACTION
16 | )
17 | self.assertEqual(attendee.__repr__(), "")
18 | self.assertEqual(attendee.__str__(), "'mail@gmail.com' - response: 'needsAction'")
19 |
20 |
21 | class TestAttendeeSerializer(TestCase):
22 | def test_to_json(self):
23 | attendee = Attendee(
24 | email='mail@gmail.com',
25 | display_name='Guest',
26 | comment='I do not know him',
27 | optional=True,
28 | additional_guests=2,
29 | _response_status=ResponseStatus.NEEDS_ACTION
30 | )
31 |
32 | attendee_json = AttendeeSerializer.to_json(attendee)
33 |
34 | self.assertEqual(attendee.email, attendee_json['email'])
35 | self.assertEqual(attendee.display_name, attendee_json['displayName'])
36 | self.assertEqual(attendee.comment, attendee_json['comment'])
37 | self.assertEqual(attendee.optional, attendee_json['optional'])
38 | self.assertNotIn('resource', attendee_json)
39 | self.assertEqual(attendee.additional_guests, attendee_json['additionalGuests'])
40 | self.assertEqual(attendee.response_status, attendee_json['responseStatus'])
41 |
42 | def test_to_object(self):
43 | attendee_json = {
44 | 'email': 'mail2@gmail.com',
45 | 'displayName': 'Guest2',
46 | 'comment': 'I do not know him either',
47 | 'optional': True,
48 | 'resource': True,
49 | 'additionalGuests': 1,
50 | 'responseStatus': ResponseStatus.ACCEPTED
51 | }
52 |
53 | attendee = AttendeeSerializer.to_object(attendee_json)
54 |
55 | self.assertEqual(attendee_json['email'], attendee.email)
56 | self.assertEqual(attendee_json['displayName'], attendee.display_name)
57 | self.assertEqual(attendee_json['comment'], attendee.comment)
58 | self.assertEqual(attendee_json['optional'], attendee.optional)
59 | self.assertEqual(attendee_json['resource'], attendee.is_resource)
60 | self.assertEqual(attendee_json['additionalGuests'], attendee.additional_guests)
61 | self.assertEqual(attendee_json['responseStatus'], attendee.response_status)
62 |
63 | attendee_json_str = """{
64 | "email": "mail3@gmail.com",
65 | "displayName": "Guest3",
66 | "comment": "Who are these people?",
67 | "optional": true,
68 | "resource": false,
69 | "additionalGuests": 66,
70 | "responseStatus": "tentative"
71 | }"""
72 |
73 | serializer = AttendeeSerializer(attendee_json_str)
74 | attendee = serializer.get_object()
75 |
76 | self.assertEqual(attendee.email, "mail3@gmail.com")
77 | self.assertEqual(attendee.display_name, "Guest3")
78 | self.assertEqual(attendee.comment, "Who are these people?")
79 | self.assertEqual(attendee.optional, True)
80 | self.assertEqual(attendee.is_resource, False)
81 | self.assertEqual(attendee.additional_guests, 66)
82 | self.assertEqual(attendee.response_status, "tentative")
83 |
--------------------------------------------------------------------------------
/docs/source/getting_started.rst:
--------------------------------------------------------------------------------
1 | .. _getting_started:
2 |
3 | Getting started
4 | ===============
5 |
6 |
7 | Installation
8 | ------------
9 |
10 | To install ``gcsa`` run the following command:
11 |
12 | .. code-block:: bash
13 |
14 | pip install gcsa
15 |
16 |
17 | from sources:
18 |
19 | .. code-block:: bash
20 |
21 | git clone git@github.com:kuzmoyev/google-calendar-simple-api.git
22 | cd google-calendar-simple-api
23 | python setup.py install
24 |
25 | Credentials
26 | -----------
27 |
28 | Now you need to get your API credentials:
29 |
30 |
31 | 1. `Create a new Google Cloud Platform (GCP) project`_
32 |
33 | .. note:: You will need to enable the "Google Calendar API" for your project.
34 |
35 | 2. `Configure the OAuth consent screen`_
36 | 3. `Create a OAuth client ID credential`_ and download the ``credentials.json`` (``client_secret_*.json``) file
37 | 4. Put downloaded ``credentials.json`` (``client_secret_*.json``) file into ``~/.credentials/`` directory
38 |
39 |
40 | .. _`Create a new Google Cloud Platform (GCP) project`: https://developers.google.com/workspace/guides/create-project
41 | .. _`Configure the OAuth consent screen`: https://developers.google.com/workspace/guides/configure-oauth-consent
42 | .. _`Create a OAuth client ID credential`: https://developers.google.com/workspace/guides/create-credentials#oauth-client-id
43 |
44 |
45 | See more options in :ref:`authentication`.
46 |
47 | .. note:: You can put ``credentials.json`` (``client_secret_*.json``) file anywhere you want and specify
48 | the path to it in your code afterwords. But remember not to share it (e.g. add it
49 | to ``.gitignore``) as it is your private credentials.
50 |
51 | .. note::
52 | | On the first run, your application will prompt you to the default browser
53 | to get permissions from you to use your calendar. This will create
54 | ``token.pickle`` file in the same folder (unless specified otherwise) as your
55 | ``credentials.json`` (``client_secret_*.json``). So don't forget to also add it to ``.gitignore`` if
56 | it is in a GIT repository.
57 | | If you don't want to save it in ``.pickle`` file, you can use ``save_token=False``
58 | when initializing the ``GoogleCalendar``.
59 |
60 | Quick example
61 | -------------
62 |
63 | The following code will create a recurrent event in your calendar starting on January 1 and
64 | repeating everyday at 9:00am except weekends and two holidays (April 19, April 22).
65 |
66 | Then it will list all events for one year starting today.
67 |
68 | For ``date``/``datetime`` objects you can use Pythons datetime_ module or as in the
69 | example beautiful_date_ library (*because it's beautiful... just like you*).
70 |
71 | .. code-block:: python
72 |
73 | from gcsa.event import Event
74 | from gcsa.google_calendar import GoogleCalendar
75 | from gcsa.recurrence import Recurrence, DAILY, SU, SA
76 |
77 | from beautiful_date import Jan, Apr
78 |
79 |
80 | calendar = GoogleCalendar('your_email@gmail.com')
81 | event = Event(
82 | 'Breakfast',
83 | start=(1 / Jan / 2019)[9:00],
84 | recurrence=[
85 | Recurrence.rule(freq=DAILY),
86 | Recurrence.exclude_rule(by_week_day=[SU, SA]),
87 | Recurrence.exclude_times([
88 | (19 / Apr / 2019)[9:00],
89 | (22 / Apr / 2019)[9:00]
90 | ])
91 | ],
92 | minutes_before_email_reminder=50
93 | )
94 |
95 | calendar.add_event(event)
96 |
97 | for event in calendar:
98 | print(event)
99 |
100 | .. _datetime: https://docs.python.org/3/library/datetime.html
101 | .. _beautiful_date: https://github.com/kuzmoyev/beautiful-date
102 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/mock_services/mock_free_busy_requests.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | import dateutil.parser
4 | from beautiful_date import D, days, hours
5 |
6 | from gcsa.free_busy import FreeBusy, TimeRange
7 | from gcsa.serializers.free_busy_serializer import FreeBusySerializer
8 | from gcsa.util.date_time_util import ensure_localisation
9 | from .util import executable, time_range_within
10 |
11 | NOT_FOUND_ERROR = [
12 | {
13 | "domain": "global",
14 | "reason": "notFound"
15 | }
16 | ]
17 |
18 |
19 | class MockFreeBusyRequests:
20 | """Emulates GoogleCalendar.service.freebusy()"""
21 |
22 | def __init__(self):
23 | now = ensure_localisation(D.now())
24 | self.groups = {
25 | 'group1': ['primary', 'calendar2'],
26 | 'group2': ['calendar3', 'calendar4']
27 | }
28 | self.calendars = {
29 | 'primary': [
30 | TimeRange(now - 1 * days, now - 1 * days + 1 * hours),
31 | TimeRange(now + 1 * hours, now + 2 * hours),
32 | TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours),
33 | TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours),
34 | ],
35 | 'calendar2': [
36 | TimeRange(now - 1 * days, now - 1 * days + 1 * hours),
37 | TimeRange(now + 1 * hours, now + 2 * hours),
38 | TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours),
39 | TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours),
40 | ],
41 | 'calendar3': [
42 | TimeRange(now - 1 * days, now - 1 * days + 1 * hours),
43 | TimeRange(now + 1 * hours, now + 2 * hours),
44 | TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours),
45 | TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours),
46 | ],
47 | 'calendar4': [
48 | TimeRange(now - 1 * days, now - 1 * days + 1 * hours),
49 | TimeRange(now + 1 * hours, now + 2 * hours),
50 | TimeRange(now + 1 * days + 1 * hours, now + 1 * days + 2 * hours),
51 | TimeRange(now + 15 * days + 1 * hours, now + 15 * days + 2 * hours),
52 | ],
53 | }
54 |
55 | @executable
56 | def query(self, body):
57 | """Emulates GoogleCalendar.service.freebusy().query().execute()"""
58 | time_min = dateutil.parser.parse(body['timeMin'])
59 | time_max = dateutil.parser.parse(body['timeMax'])
60 | items = body['items']
61 |
62 | request_groups = [i['id'] for i in items if i['id'].startswith('group')]
63 | request_calendars = {i['id'] for i in items if not i['id'].startswith('group')}
64 |
65 | groups = {gn: g for gn, g in self.groups.items() if gn in request_groups}
66 | group_calendars = set(c for g in groups.values() for c in g)
67 | calendars = {
68 | cn: self._filter_ranges(c, time_min, time_max)
69 | for cn, c in self.calendars.items()
70 | if cn in request_calendars | group_calendars
71 | }
72 |
73 | calendars_errors = {c: NOT_FOUND_ERROR for c in request_calendars if c not in calendars}
74 | groups_errors = {g: NOT_FOUND_ERROR for g in request_groups if g not in groups}
75 |
76 | fb_json = FreeBusySerializer.to_json(FreeBusy(
77 | time_min=time_min,
78 | time_max=time_max,
79 | groups=groups,
80 | calendars=calendars,
81 | calendars_errors=calendars_errors,
82 | groups_errors=groups_errors
83 | ))
84 | return fb_json
85 |
86 | @staticmethod
87 | def _filter_ranges(time_ranges: List[TimeRange], time_min, time_max):
88 | return [tr for tr in time_ranges if time_range_within(tr, time_min, time_max)]
89 |
--------------------------------------------------------------------------------
/gcsa/settings.py:
--------------------------------------------------------------------------------
1 | class Settings:
2 | """Represents settings that users can change from the Calendar UI, such as the user's time zone.
3 | They can be retrieved via :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_settings`."""
4 |
5 | def __init__(
6 | self,
7 | *,
8 | auto_add_hangouts: bool = False,
9 | date_field_order: str = 'MDY',
10 | default_event_length: int = 60,
11 | format24_hour_time: bool = False,
12 | hide_invitations: bool = False,
13 | hide_weekends: bool = False,
14 | locale: str = 'en',
15 | remind_on_responded_events_only: bool = False,
16 | show_declined_events: bool = True,
17 | timezone: str = 'Etc/GMT',
18 | use_keyboard_shortcuts: bool = True,
19 | week_start: int = 0
20 | ):
21 | """
22 | :param auto_add_hangouts:
23 | Whether to automatically add Hangouts to all events.
24 | :param date_field_order:
25 | What should the order of day (D), month (M) and year (Y) be when displaying dates.
26 | :param default_event_length:
27 | The default length of events (in minutes) that were created without an explicit duration.
28 | :param format24_hour_time:
29 | Whether to show the time in 24-hour format.
30 | :param hide_invitations:
31 | Whether to hide events to which the user is invited but hasn't acted on (for example by responding).
32 | :param hide_weekends:
33 | Whether the weekends should be hidden when displaying a week.
34 | :param locale:
35 | User's locale.
36 | :param remind_on_responded_events_only:
37 | Whether event reminders should be sent only for events with the user's response status "Yes" and
38 | "Maybe".
39 | :param show_declined_events:
40 | Whether events to which the user responded "No" should be shown on the user's calendar.
41 | :param timezone:
42 | The ID of the user's timezone.
43 | :param use_keyboard_shortcuts:
44 | Whether the keyboard shortcuts are enabled.
45 | :param week_start:
46 | Whether the week should start on Sunday (0), Monday (1) or Saturday (6).
47 | """
48 | self.auto_add_hangouts = auto_add_hangouts
49 | self.date_field_order = date_field_order
50 | self.default_event_length = default_event_length
51 | self.format24_hour_time = format24_hour_time
52 | self.hide_invitations = hide_invitations
53 | self.hide_weekends = hide_weekends
54 | self.locale = locale
55 | self.remind_on_responded_events_only = remind_on_responded_events_only
56 | self.show_declined_events = show_declined_events
57 | self.timezone = timezone
58 | self.use_keyboard_shortcuts = use_keyboard_shortcuts
59 | self.week_start = week_start
60 |
61 | def __str__(self):
62 | return f'User settings:\n' \
63 | f'auto_add_hangouts={self.auto_add_hangouts}\n' \
64 | f'date_field_order={self.date_field_order}\n' \
65 | f'default_event_length={self.default_event_length}\n' \
66 | f'format24_hour_time={self.format24_hour_time}\n' \
67 | f'hide_invitations={self.hide_invitations}\n' \
68 | f'hide_weekends={self.hide_weekends}\n' \
69 | f'locale={self.locale}\n' \
70 | f'remind_on_responded_events_only={self.remind_on_responded_events_only}\n' \
71 | f'show_declined_events={self.show_declined_events}\n' \
72 | f'timezone={self.timezone}\n' \
73 | f'use_keyboard_shortcuts={self.use_keyboard_shortcuts}\n' \
74 | f'week_start={self.week_start}'
75 |
76 | def __repr__(self):
77 | return self.__str__()
78 |
--------------------------------------------------------------------------------
/docs/source/conference.rst:
--------------------------------------------------------------------------------
1 | .. _conference:
2 |
3 | Conference
4 | ----------
5 |
6 | To add conference (such as Hangouts or Google Meet) to an event you can use :py:class:`~gcsa.conference.ConferenceSolution`
7 | (for existing conferences) or :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` (to create new conference)
8 | and pass it as a ``conference_solution`` parameter:
9 |
10 |
11 | Existing conference
12 | ~~~~~~~~~~~~~~~~~~~
13 |
14 | To add existing conference you need to specify its ``solution_type`` (see :py:class:`~gcsa.conference.SolutionType` for
15 | available values) and at least one :py:class:`~gcsa.conference.EntryPoint` in ``entry_points`` parameter. You can pass
16 | single :py:class:`~gcsa.conference.EntryPoint`:
17 |
18 | .. code-block:: python
19 |
20 |
21 | from gcsa.conference import ConferenceSolution, EntryPoint, SolutionType
22 |
23 | event = Event(
24 | 'Meeting',
25 | start=(22 / Nov / 2020)[15:00],
26 | conference_solution=ConferenceSolution(
27 | entry_points=EntryPoint(
28 | EntryPoint.VIDEO,
29 | uri='https://meet.google.com/aaa-bbbb-ccc'
30 | ),
31 | solution_type=SolutionType.HANGOUTS_MEET,
32 | )
33 | )
34 |
35 | or multiple entry points in a list:
36 |
37 | .. code-block:: python
38 |
39 | event = Event(
40 | 'Event with conference',
41 | start=(22 / Nov / 2020)[15:00],
42 | conference_solution=ConferenceSolution(
43 | entry_points=[
44 | EntryPoint(
45 | EntryPoint.VIDEO,
46 | uri='https://meet.google.com/aaa-bbbb-ccc'
47 | ),
48 | EntryPoint(
49 | EntryPoint.PHONE,
50 | uri='tel:+12345678900'
51 | )
52 | ],
53 | solution_type=SolutionType.HANGOUTS_MEET,
54 | )
55 | )
56 |
57 | See more parameters for :py:class:`~gcsa.conference.ConferenceSolution` and :py:class:`~gcsa.conference.EntryPoint`.
58 |
59 |
60 | New conference
61 | ~~~~~~~~~~~~~~
62 | To generate new conference you need to specify its ``solution_type`` (see :py:class:`~gcsa.conference.SolutionType` for
63 | available values).
64 |
65 | .. code-block:: python
66 |
67 |
68 | from gcsa.conference import ConferenceSolutionCreateRequest, SolutionType
69 |
70 | event = Event(
71 | 'Meeting',
72 | start=(22 / Nov / 2020)[15:00],
73 | conference_solution=ConferenceSolutionCreateRequest(
74 | solution_type=SolutionType.HANGOUTS_MEET,
75 | )
76 | )
77 |
78 | See more parameters for :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest`.
79 |
80 | .. note:: Create requests are asynchronous. Check ``status`` field of event's ``conference_solution`` to find it's
81 | status. If the status is ``"success"``, ``conference_solution`` will contain a
82 | :py:class:`~gcsa.conference.ConferenceSolution` object and you'll be able to access its fields (like
83 | ``entry_points``). Otherwise (if ``status`` is ``"pending"`` or ``"failure"``), ``conference_solution`` will
84 | contain a :py:class:`~gcsa.conference.ConferenceSolutionCreateRequest` object.
85 |
86 |
87 | .. code-block:: python
88 |
89 | event = calendar.add_event(
90 | Event(
91 | 'Meeting',
92 | start=(22 / Nov / 2020)[15:00],
93 | conference_solution=ConferenceSolutionCreateRequest(
94 | solution_type=SolutionType.HANGOUTS_MEET,
95 | )
96 | )
97 | )
98 |
99 | if event.conference_solution.status == 'success':
100 | print(event.conference_solution.solution_id)
101 | print(event.conference_solution.entry_points)
102 | elif event.conference_solution.status == 'pending':
103 | print('Conference request has not been processed yet.')
104 | elif event.conference_solution.status == 'failure':
105 | print('Conference request has failed.')
106 |
--------------------------------------------------------------------------------
/gcsa/_services/free_busy_service.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Union, List, Optional
3 |
4 | from dateutil.relativedelta import relativedelta
5 | from tzlocal import get_localzone_name
6 |
7 | from gcsa._services.base_service import BaseService
8 | from gcsa.free_busy import FreeBusy, FreeBusyQueryError
9 | from gcsa.serializers.free_busy_serializer import FreeBusySerializer
10 | from gcsa.util.date_time_util import to_localized_iso, DateOrDatetime
11 |
12 |
13 | class FreeBusyService(BaseService):
14 | def get_free_busy(
15 | self,
16 | resource_ids: Optional[Union[str, List[str]]] = None,
17 | *,
18 | time_min: Optional[DateOrDatetime] = None,
19 | time_max: Optional[DateOrDatetime] = None,
20 | timezone: str = get_localzone_name(),
21 | group_expansion_max: Optional[int] = None,
22 | calendar_expansion_max: Optional[int] = None,
23 | ignore_errors: bool = False
24 | ) -> FreeBusy:
25 | """Returns free/busy information for a set of calendars and/or groups.
26 |
27 | :param resource_ids:
28 | Identifier or list of identifiers of calendar(s) and/or group(s).
29 | Default is `default_calendar` specified in `GoogleCalendar`.
30 | :param time_min:
31 | The start of the interval for the query.
32 | :param time_max:
33 | The end of the interval for the query.
34 | :param timezone:
35 | Timezone formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich". By default,
36 | the computers local timezone is used if it is configured. UTC is used otherwise.
37 | :param group_expansion_max:
38 | Maximal number of calendar identifiers to be provided for a single group.
39 | An error is returned for a group with more members than this value.
40 | Maximum value is 100.
41 | :param calendar_expansion_max:
42 | Maximal number of calendars for which FreeBusy information is to be provided.
43 | Maximum value is 50.
44 | :param ignore_errors:
45 | Whether errors related to calendars and/or groups should be ignored.
46 | If `False` :py:class:`~gcsa.free_busy.FreeBusyQueryError` is raised in case of query related errors.
47 | If `True`, related errors are stored in the resulting :py:class:`~gcsa.free_busy.FreeBusy` object.
48 | Default is `False`.
49 | Note, request related errors (e.x. authentication error) will not be ignored regardless of
50 | the `ignore_errors` value.
51 |
52 | :return:
53 | :py:class:`~gcsa.free_busy.FreeBusy` object.
54 | """
55 |
56 | time_min = time_min or datetime.now()
57 | time_max = time_max or time_min + relativedelta(weeks=2)
58 |
59 | time_min = to_localized_iso(time_min, timezone)
60 | time_max = to_localized_iso(time_max, timezone)
61 |
62 | if resource_ids is None:
63 | resource_ids = [self.default_calendar]
64 | elif not isinstance(resource_ids, (list, tuple, set)):
65 | resource_ids = [resource_ids]
66 |
67 | body = {
68 | "timeMin": time_min,
69 | "timeMax": time_max,
70 | "timeZone": timezone,
71 | "groupExpansionMax": group_expansion_max,
72 | "calendarExpansionMax": calendar_expansion_max,
73 | "items": [
74 | {
75 | "id": r_id
76 | } for r_id in resource_ids
77 | ]
78 | }
79 |
80 | free_busy_json = self.service.freebusy().query(body=body).execute()
81 | free_busy = FreeBusySerializer.to_object(free_busy_json)
82 | if not ignore_errors and (free_busy.groups_errors or free_busy.calendars_errors):
83 | raise FreeBusyQueryError(groups_errors=free_busy.groups_errors,
84 | calendars_errors=free_busy.calendars_errors)
85 |
86 | return free_busy
87 |
--------------------------------------------------------------------------------
/docs/source/reminders.rst:
--------------------------------------------------------------------------------
1 | .. _reminders:
2 |
3 | Reminders
4 | ---------
5 |
6 | To add reminder(s) to an event you can create :py:class:`~gcsa.reminders.EmailReminder` or
7 | :py:class:`~gcsa.reminders.PopupReminder` and pass them as a ``reminders`` parameter (single reminder
8 | or list of reminders):
9 |
10 |
11 | .. code-block:: python
12 |
13 |
14 | from gcsa.reminders import EmailReminder, PopupReminder
15 |
16 | event = Event('Meeting',
17 | start=(22/Apr/2019)[12:00],
18 | reminders=EmailReminder(minutes_before_start=30))
19 |
20 | or
21 |
22 | .. code-block:: python
23 |
24 | event = Event('Meeting',
25 | start=(22/Apr/2019)[12:00],
26 | reminders=[
27 | EmailReminder(minutes_before_start=30),
28 | EmailReminder(minutes_before_start=60),
29 | PopupReminder(minutes_before_start=15)
30 | ])
31 |
32 |
33 | You can also simply add reminders by specifying ``minutes_before_popup_reminder`` and/or
34 | ``minutes_before_email_reminder`` parameter of the :py:class:`~gcsa.event.Event` object:
35 |
36 | .. code-block:: python
37 |
38 | event = Event('Meeting',
39 | start=(22/Apr/2019)[12:00],
40 | minutes_before_popup_reminder=15,
41 | minutes_before_email_reminder=30)
42 |
43 |
44 | If you want to add a reminder to an existing event use :py:meth:`~gcsa.event.Event.add_email_reminder`
45 | and/or :py:meth:`~gcsa.event.Event.add_popup_reminder` methods:
46 |
47 |
48 | .. code-block:: python
49 |
50 | event.add_popup_reminder(minutes_before_start=30)
51 | event.add_email_reminder(minutes_before_start=50)
52 |
53 | Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes.
54 |
55 | To use default reminders of the calendar, set ``default_reminders`` parameter of the :py:class:`~gcsa.event.Event`
56 | to ``True``.
57 |
58 | .. note:: You can add up to 5 reminders to one event.
59 |
60 | Specific time reminders
61 | ~~~~~~~~~~~~~~~~~~~~~~~
62 |
63 | You can also set specific time for a reminder.
64 |
65 | .. code-block:: python
66 |
67 | from datetime import time
68 |
69 | event = Event(
70 | 'Meeting',
71 | start=(22/Apr/2019)[12:00],
72 | reminders=[
73 | # Day before the event at 13:30
74 | EmailReminder(days_before=1, at=time(13, 30)),
75 | # 2 days before the event at 19:15
76 | PopupReminder(days_before=2, at=time(19, 15))
77 | ]
78 | )
79 |
80 | event.add_popup_reminder(days_before=3, at=time(8, 30))
81 | event.add_email_reminder(days_before=4, at=time(9, 0))
82 |
83 |
84 | .. note:: Google calendar API only works with ``minutes_before_start``.
85 | The GCSA's interface that uses ``days_before`` and ``at`` arguments is only a convenient way of setting specific time.
86 | GCSA will convert ``days_before`` and ``at`` to ``minutes_before_start`` during API requests.
87 | So after you add or update the event, it will have reminders with only ``minutes_before_start`` set even if they
88 | were initially created with ``days_before`` and ``at``.
89 |
90 | .. code-block:: python
91 |
92 | from datetime import time
93 |
94 | event = Event(
95 | 'Meeting',
96 | start=(22/Apr/2019)[12:00],
97 | reminders=[
98 | # Day before the event at 12:00
99 | EmailReminder(days_before=1, at=time(12, 00))
100 | ]
101 | )
102 |
103 | event.reminders[0].minutes_before_start is None
104 | event.reminders[0].days_before == 1
105 | event.reminders[0].at == time(12, 00)
106 |
107 | event = gc.add_event(event)
108 |
109 | event.reminders[0].minutes_before_start == 24 * 60 # exactly one day before
110 | event.reminders[0].days_before is None
111 | event.reminders[0].at is None
112 |
113 | GCSA does not convert ``minutes_before_start`` to ``days_before`` and ``at`` (even for the whole-day events)
114 | for backwards compatibility reasons.
115 |
116 |
--------------------------------------------------------------------------------
/gcsa/free_busy.py:
--------------------------------------------------------------------------------
1 | import json
2 | from collections import namedtuple
3 | from datetime import datetime
4 | from typing import Dict, List, Optional
5 |
6 | TimeRange = namedtuple('TimeRange', ('start', 'end'))
7 |
8 |
9 | class FreeBusy:
10 | def __init__(
11 | self,
12 | *,
13 | time_min: datetime,
14 | time_max: datetime,
15 | groups: Dict[str, List[str]],
16 | calendars: Dict[str, List[TimeRange]],
17 | groups_errors: Optional[Dict] = None,
18 | calendars_errors: Optional[Dict] = None,
19 | ):
20 | """Represents free/busy information for a given calendar(s) and/or group(s)
21 |
22 | :param time_min:
23 | The start of the interval.
24 | :param time_max:
25 | The end of the interval.
26 | :param groups:
27 | Expansion of groups.
28 | Dictionary that maps the name of the group to the list of calendars that are members of this group.
29 | :param calendars:
30 | Free/busy information for calendars.
31 | Dictionary that maps calendar id to the list of time ranges during which this calendar should be
32 | regarded as busy.
33 | :param groups_errors:
34 | Optional error(s) (if computation for the group failed).
35 | Dictionary that maps the name of the group to the list of errors.
36 | :param calendars_errors:
37 | Optional error(s) (if computation for the calendar failed).
38 | Dictionary that maps calendar id to the list of errors.
39 |
40 |
41 | .. note:: Errors have the following format:
42 |
43 | .. code-block::
44 |
45 | {
46 | "domain": "",
47 | "reason": ""
48 | }
49 |
50 | Some possible values for "reason" are:
51 |
52 | * "groupTooBig" - The group of users requested is too large for a single query.
53 | * "tooManyCalendarsRequested" - The number of calendars requested is too large for a single query.
54 | * "notFound" - The requested resource was not found.
55 | * "internalError" - The API service has encountered an internal error.
56 |
57 | Additional error types may be added in the future.
58 | """
59 | self.time_min = time_min
60 | self.time_max = time_max
61 | self.groups = groups
62 | self.calendars = calendars
63 | self.groups_errors = groups_errors or {}
64 | self.calendars_errors = calendars_errors or {}
65 |
66 | def __iter__(self):
67 | """
68 | :returns:
69 | list of 'TimeRange's during which this calendar should be regarded as busy.
70 | :raises:
71 | ValueError if requested all requested calendars have errors
72 | or more than one calendar has been requested.
73 | """
74 | if len(self.calendars) == 0:
75 | raise ValueError("No free/busy information has been received. "
76 | "Check the 'calendars_errors' and 'groups_errors' fields.")
77 | if len(self.calendars) > 1 or len(self.calendars_errors) > 0:
78 | raise ValueError("Can't iterate over FreeBusy objects directly when more than one calendars were requested."
79 | "Use 'calendars' field instead to get free/busy information of the specific calendar.")
80 | return iter(next(iter(self.calendars.values())))
81 |
82 | def __str__(self):
83 | return ''.format(self.time_min, self.time_max)
84 |
85 | def __repr__(self):
86 | return self.__str__()
87 |
88 |
89 | class FreeBusyQueryError(Exception):
90 | def __init__(self, groups_errors, calendars_errors):
91 | message = '\n'
92 | if groups_errors:
93 | message += f'Groups errors: {json.dumps(groups_errors, indent=4)}'
94 | if calendars_errors:
95 | message += f'Calendars errors: {json.dumps(calendars_errors, indent=4)}'
96 | super().__init__(message)
97 | self.groups_errors = groups_errors
98 | self.calendars_errors = calendars_errors
99 |
--------------------------------------------------------------------------------
/gcsa/_services/calendars_service.py:
--------------------------------------------------------------------------------
1 | from typing import Union, Optional
2 |
3 | from gcsa._services.base_service import BaseService
4 | from gcsa.calendar import Calendar, CalendarListEntry
5 | from gcsa.serializers.calendar_serializer import CalendarSerializer
6 |
7 |
8 | class CalendarsService(BaseService):
9 | """Calendars management methods of the `GoogleCalendar`"""
10 |
11 | def get_calendar(
12 | self,
13 | calendar_id: Optional[str] = None
14 | ) -> Calendar:
15 | """Returns the calendar with the corresponding calendar_id.
16 |
17 | :param calendar_id:
18 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`.
19 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
20 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
21 |
22 | :return:
23 | The corresponding :py:class:`~gcsa.calendar.Calendar` object.
24 | """
25 | calendar_id = calendar_id or self.default_calendar
26 | calendar_resource = self.service.calendars().get(
27 | calendarId=calendar_id
28 | ).execute()
29 | return CalendarSerializer.to_object(calendar_resource)
30 |
31 | def add_calendar(
32 | self,
33 | calendar: Calendar
34 | ):
35 | """Creates a secondary calendar.
36 |
37 | :param calendar:
38 | Calendar object.
39 | :return:
40 | Created calendar object with ID.
41 | """
42 | body = CalendarSerializer.to_json(calendar)
43 | calendar_json = self.service.calendars().insert(
44 | body=body
45 | ).execute()
46 | return CalendarSerializer.to_object(calendar_json)
47 |
48 | def update_calendar(
49 | self,
50 | calendar: Calendar
51 | ):
52 | """Updates metadata for a calendar.
53 |
54 | :param calendar:
55 | Calendar object with set `calendar_id`
56 |
57 | :return:
58 | Updated calendar object
59 | """
60 | calendar_id = self._get_resource_id(calendar)
61 | body = CalendarSerializer.to_json(calendar)
62 | calendar_json = self.service.calendars().update(
63 | calendarId=calendar_id,
64 | body=body
65 | ).execute()
66 | return CalendarSerializer.to_object(calendar_json)
67 |
68 | def delete_calendar(
69 | self,
70 | calendar: Union[Calendar, CalendarListEntry, str]
71 | ):
72 | """Deletes a secondary calendar.
73 |
74 | Use :py:meth:`~gcsa.google_calendar.GoogleCalendar.clear_calendar` for clearing all events on primary calendars.
75 |
76 | :param calendar:
77 | Calendar's ID or :py:class:`~gcsa.calendar.Calendar` object with set `calendar_id`.
78 | """
79 | calendar_id = self._get_resource_id(calendar)
80 | self.service.calendars().delete(calendarId=calendar_id).execute()
81 |
82 | def clear_calendar(self):
83 | """Clears a **primary** calendar.
84 | This operation deletes all events associated with the **primary** calendar of an account.
85 |
86 | Currently, there is no way to clear a secondary calendar.
87 | You can use :py:meth:`~gcsa.google_calendar.GoogleCalendar.delete_event` method with the secondary calendar's ID
88 | to delete events from a secondary calendar.
89 | """
90 | self.service.calendars().clear(calendarId='primary').execute()
91 |
92 | def clear(self):
93 | """Kept for back-compatibility. Use :py:meth:`~gcsa.google_calendar.GoogleCalendar.clear_calendar` instead.
94 |
95 | Clears a **primary** calendar.
96 | This operation deletes all events associated with the **primary** calendar of an account.
97 |
98 | Currently, there is no way to clear a secondary calendar.
99 | You can use :py:meth:`~gcsa.google_calendar.GoogleCalendar.delete_event` method with the secondary calendar's ID
100 | to delete events from a secondary calendar.
101 | """
102 | self.clear_calendar()
103 |
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from gcsa.serializers.settings_serializer import SettingsSerializer
4 | from gcsa.settings import Settings
5 |
6 |
7 | class TestSettings(TestCase):
8 | def test_repr_str(self):
9 | settings = Settings(
10 | auto_add_hangouts=True,
11 | date_field_order='DMY',
12 | default_event_length=45,
13 | format24_hour_time=True,
14 | hide_invitations=True,
15 | hide_weekends=True,
16 | locale='cz',
17 | remind_on_responded_events_only=True,
18 | show_declined_events=False,
19 | timezone='Europe/Prague',
20 | use_keyboard_shortcuts=False,
21 | week_start=1,
22 | )
23 | expected_str = \
24 | "User settings:\n" \
25 | "auto_add_hangouts=True\n" \
26 | "date_field_order=DMY\n" \
27 | "default_event_length=45\n" \
28 | "format24_hour_time=True\n" \
29 | "hide_invitations=True\n" \
30 | "hide_weekends=True\n" \
31 | "locale=cz\n" \
32 | "remind_on_responded_events_only=True\n" \
33 | "show_declined_events=False\n" \
34 | "timezone=Europe/Prague\n" \
35 | "use_keyboard_shortcuts=False\n" \
36 | "week_start=1"
37 | self.assertEqual(settings.__str__(), expected_str)
38 | self.assertEqual(settings.__repr__(), expected_str)
39 |
40 |
41 | class TestSettingsSerializer(TestCase):
42 | def test_to_json(self):
43 | settings = Settings(
44 | auto_add_hangouts=True,
45 | date_field_order='DMY',
46 | default_event_length=45,
47 | format24_hour_time=True,
48 | hide_invitations=True,
49 | hide_weekends=True,
50 | locale='cz',
51 | remind_on_responded_events_only=True,
52 | show_declined_events=False,
53 | timezone='Europe/Prague',
54 | use_keyboard_shortcuts=False,
55 | week_start=1,
56 | )
57 | expected_json = {
58 | 'autoAddHangouts': settings.auto_add_hangouts,
59 | 'dateFieldOrder': settings.date_field_order,
60 | 'defaultEventLength': settings.default_event_length,
61 | 'format24HourTime': settings.format24_hour_time,
62 | 'hideInvitations': settings.hide_invitations,
63 | 'hideWeekends': settings.hide_weekends,
64 | 'locale': settings.locale,
65 | 'remindOnRespondedEventsOnly': settings.remind_on_responded_events_only,
66 | 'showDeclinedEvents': settings.show_declined_events,
67 | 'timezone': settings.timezone,
68 | 'useKeyboardShortcuts': settings.use_keyboard_shortcuts,
69 | 'weekStart': settings.week_start
70 | }
71 | self.assertDictEqual(SettingsSerializer(settings).get_json(), expected_json)
72 |
73 | def test_to_object(self):
74 | settings_json = {
75 | 'autoAddHangouts': True,
76 | 'dateFieldOrder': 'DMY',
77 | 'defaultEventLength': 45,
78 | 'format24HourTime': True,
79 | 'hideInvitations': True,
80 | 'hideWeekends': True,
81 | 'locale': 'cz',
82 | 'remindOnRespondedEventsOnly': True,
83 | 'showDeclinedEvents': False,
84 | 'timezone': 'Europe/Prague',
85 | 'useKeyboardShortcuts': False,
86 | 'weekStart': 1,
87 | }
88 | settings = SettingsSerializer(settings_json).get_object()
89 |
90 | self.assertTrue(settings.auto_add_hangouts)
91 | self.assertEqual(settings.date_field_order, 'DMY')
92 | self.assertEqual(settings.default_event_length, 45)
93 | self.assertTrue(settings.format24_hour_time)
94 | self.assertTrue(settings.hide_invitations)
95 | self.assertTrue(settings.hide_weekends)
96 | self.assertEqual(settings.locale, 'cz')
97 | self.assertTrue(settings.remind_on_responded_events_only)
98 | self.assertFalse(settings.show_declined_events)
99 | self.assertEqual(settings.timezone, 'Europe/Prague')
100 | self.assertFalse(settings.use_keyboard_shortcuts)
101 | self.assertEqual(settings.week_start, 1)
102 |
--------------------------------------------------------------------------------
/tests/test_attachment.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from gcsa.attachment import Attachment
4 | from gcsa.serializers.attachment_serializer import AttachmentSerializer
5 |
6 | DOC_URL = 'https://bit.ly/3lZo0Cc'
7 |
8 |
9 | class TestAttachment(TestCase):
10 |
11 | def test_create(self):
12 | attachment = Attachment(
13 | file_url=DOC_URL,
14 | title='My doc',
15 | mime_type="application/vnd.google-apps.document"
16 | )
17 | self.assertEqual(attachment.title, 'My doc')
18 |
19 | attachment = Attachment(
20 | file_url=DOC_URL,
21 | title='My doc',
22 | mime_type="application/vnd.google-apps.something"
23 | )
24 | self.assertTrue(attachment.unsupported_mime_type)
25 |
26 | def test_repr_str(self):
27 | attachment = Attachment(
28 | file_url=DOC_URL,
29 | title='My doc',
30 | mime_type="application/vnd.google-apps.document"
31 | )
32 | self.assertEqual(attachment.__repr__(), "")
33 | self.assertEqual(attachment.__str__(), "'My doc' - 'https://bit.ly/3lZo0Cc'")
34 |
35 |
36 | class TestAttachmentSerializer(TestCase):
37 |
38 | def test_to_json(self):
39 | attachment = Attachment(
40 | file_url=DOC_URL,
41 | title='My doc',
42 | mime_type="application/vnd.google-apps.document"
43 | )
44 | attachment_json = {
45 | 'title': 'My doc',
46 | 'fileUrl': DOC_URL,
47 | 'mimeType': "application/vnd.google-apps.document"
48 | }
49 | self.assertDictEqual(AttachmentSerializer.to_json(attachment), attachment_json)
50 |
51 | attachment = Attachment(
52 | file_url=DOC_URL,
53 | title='My doc2',
54 | mime_type="application/vnd.google-apps.drawing",
55 | _icon_link="https://some_link.com",
56 | _file_id='abc123'
57 | )
58 | attachment_json = {
59 | 'title': 'My doc2',
60 | 'fileUrl': DOC_URL,
61 | 'mimeType': "application/vnd.google-apps.drawing",
62 | 'iconLink': "https://some_link.com",
63 | 'fileId': 'abc123'
64 | }
65 | serializer = AttachmentSerializer(attachment)
66 | self.assertDictEqual(serializer.get_json(), attachment_json)
67 |
68 | def test_to_object(self):
69 | attachment_json = {
70 | 'title': 'My doc',
71 | 'fileUrl': DOC_URL,
72 | 'mimeType': "application/vnd.google-apps.document"
73 | }
74 | attachment = AttachmentSerializer.to_object(attachment_json)
75 |
76 | self.assertEqual(attachment.title, 'My doc')
77 | self.assertEqual(attachment.file_url, DOC_URL)
78 | self.assertEqual(attachment.mime_type, "application/vnd.google-apps.document")
79 | self.assertIsNone(attachment.icon_link)
80 | self.assertIsNone(attachment.file_id)
81 |
82 | attachment_json = {
83 | 'title': 'My doc2',
84 | 'fileUrl': DOC_URL,
85 | 'mimeType': "application/vnd.google-apps.drawing",
86 | 'iconLink': "https://some_link.com",
87 | 'fileId': 'abc123'
88 | }
89 | serializer = AttachmentSerializer(attachment_json)
90 | attachment = serializer.get_object()
91 |
92 | self.assertEqual(attachment.title, 'My doc2')
93 | self.assertEqual(attachment.file_url, DOC_URL)
94 | self.assertEqual(attachment.mime_type, "application/vnd.google-apps.drawing")
95 | self.assertEqual(attachment.icon_link, "https://some_link.com")
96 | self.assertEqual(attachment.file_id, 'abc123')
97 |
98 | attachment_json_str = """{
99 | "title": "My doc3",
100 | "fileUrl": "%s",
101 | "mimeType": "application/vnd.google-apps.drawing",
102 | "iconLink": "https://some_link.com",
103 | "fileId": "abc123"
104 | }
105 | """ % DOC_URL
106 | attachment = AttachmentSerializer.to_object(attachment_json_str)
107 |
108 | self.assertEqual(attachment.title, 'My doc3')
109 | self.assertEqual(attachment.file_url, DOC_URL)
110 | self.assertEqual(attachment.mime_type, "application/vnd.google-apps.drawing")
111 | self.assertEqual(attachment.icon_link, "https://some_link.com")
112 | self.assertEqual(attachment.file_id, 'abc123')
113 |
--------------------------------------------------------------------------------
/docs/source/authentication.rst:
--------------------------------------------------------------------------------
1 | .. _authentication:
2 |
3 | Authentication
4 | ==============
5 |
6 | There are several ways to authenticate in ``GoogleCalendar``.
7 |
8 | Credentials file
9 | ----------------
10 |
11 | If you have a ``credentials.json`` (``client_secret_*.json``) file (see :ref:`getting_started`), ``GoogleCalendar``
12 | will read all the needed data to generate the token and refresh-token from it.
13 |
14 | To read ``credentials.json`` (``client_secret_*.json``) from the default directory (``~/.credentials``) use:
15 |
16 | .. code-block:: python
17 |
18 | gc = GoogleCalendar()
19 |
20 | In this case, if ``~/.credentials/token.pickle`` file exists, it will read it and refresh only if needed. If
21 | ``token.pickle`` does not exist, it will be created during authentication flow and saved alongside with
22 | ``credentials.json`` (``client_secret_*.json``) in ``~/.credentials/token.pickle``.
23 |
24 | To **avoid saving** the token use:
25 |
26 | .. code-block:: python
27 |
28 | gc = GoogleCalendar(save_token=False)
29 |
30 | After token is generated during authentication flow, it can be accessed in ``gc.credentials`` field.
31 |
32 | To specify ``credentials.json`` (``client_secret_*.json``) file path use ``credentials_path`` parameter:
33 |
34 | .. code-block:: python
35 |
36 | gc = GoogleCalendar(credentials_path='path/to/credentials.json')
37 |
38 | or
39 |
40 | .. code-block:: python
41 |
42 | gc = GoogleCalendar(credentials_path='path/to/client_secret_273833015691-qwerty.apps.googleusercontent.com.json')
43 |
44 | Similarly, if ``token.pickle`` file exists in the same folder (``path/to/``), it will be used and refreshed only if
45 | needed. If it doesn't exist, it will be generated and stored alongside the ``credentials.json`` (``client_secret_*.json``)
46 | (in ``path/to/token.pickle``).
47 |
48 | To specify different path for the pickled token file use ``token_path`` parameter:
49 |
50 | .. code-block:: python
51 |
52 | gc = GoogleCalendar(credentials_path='path/to/credentials.json',
53 | token_path='another/path/user1_token.pickle')
54 |
55 | That could be useful if you want to save the file elsewhere, or if you have multiple google accounts.
56 |
57 | Token object
58 | ------------
59 |
60 | If you store/receive/generate the token in a different way, you can pass loaded token directly:
61 |
62 | .. code-block:: python
63 |
64 | from google.oauth2.credentials import Credentials
65 |
66 | token = Credentials(
67 | token='',
68 | refresh_token='',
69 | client_id='',
70 | client_secret='',
71 | scopes=['https://www.googleapis.com/auth/calendar'],
72 | token_uri='https://oauth2.googleapis.com/token'
73 | )
74 | gc = GoogleCalendar(credentials=token)
75 |
76 | It will be refreshed using ``refresh_token`` during initialization of ``GoogleCalendar`` if needed.
77 |
78 |
79 | Multiple calendars
80 | ------------------
81 | To authenticate multiple Google Calendars you should specify different `token_path` for each of them. Otherwise,
82 | `gcsa` would overwrite default token file location:
83 |
84 | .. code-block:: python
85 |
86 | gc_primary = GoogleCalendar(token_path='path/to/tokens/token_primary.pickle')
87 | gc_secondary = GoogleCalendar(calendar='f7c1gf7av3g6f2dave17gan4b8@group.calendar.google.com',
88 | token_path='path/to/tokens/token_secondary.pickle')
89 |
90 |
91 | Browser authentication timeout
92 | ------------------------------
93 |
94 | If you'd like to avoid your script hanging in case user closes the browser without finishing authentication flow,
95 | you can use the following solution with the help of Pebble_.
96 |
97 | First, install `Pebble` with ``pip install pebble``.
98 |
99 | .. code-block:: python
100 |
101 | from gcsa.google_calendar import GoogleCalendar
102 | from concurrent.futures import TimeoutError
103 | from pebble import concurrent
104 |
105 |
106 | @concurrent.process(timeout=60)
107 | def create_process():
108 | return GoogleCalendar()
109 |
110 |
111 | if __name__ == '__main__':
112 | try:
113 | process = create_process()
114 | gc = process.result()
115 | except TimeoutError:
116 | print("User hasn't authenticated in 60 seconds")
117 |
118 | Thanks to Teraskull_ for the idea and the example.
119 |
120 | .. _Pebble: https://pypi.org/project/Pebble/
121 | .. _Teraskull: https://github.com/Teraskull
122 |
123 |
--------------------------------------------------------------------------------
/gcsa/google_calendar.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from google.oauth2.credentials import Credentials
4 |
5 | from ._services.acl_service import ACLService
6 | from ._services.events_service import EventsService, SendUpdatesMode # noqa: F401
7 | from ._services.calendars_service import CalendarsService
8 | from ._services.calendar_lists_service import CalendarListService
9 | from ._services.colors_service import ColorsService
10 | from ._services.free_busy_service import FreeBusyService
11 | from ._services.settings_service import SettingsService
12 |
13 |
14 | class GoogleCalendar(
15 | EventsService,
16 | CalendarsService,
17 | CalendarListService,
18 | ColorsService,
19 | SettingsService,
20 | ACLService,
21 | FreeBusyService
22 | ):
23 | """Collection of all supported methods for events and calendars management."""
24 |
25 | def __init__(
26 | self,
27 | default_calendar: str = 'primary',
28 | *,
29 | credentials: Optional[Credentials] = None,
30 | credentials_path: Optional[str] = None,
31 | token_path: Optional[str] = None,
32 | save_token: bool = True,
33 | read_only: bool = False,
34 | authentication_flow_host: str = 'localhost',
35 | authentication_flow_port: int = 8080,
36 | authentication_flow_bind_addr: Optional[str] = None,
37 | open_browser: Optional[bool] = None
38 | ):
39 | """
40 | Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from.
41 |
42 | :param default_calendar:
43 | Users email address or name/id of the calendar. Default: primary calendar of the user
44 |
45 | If user's email or "primary" is specified, then primary calendar of the user is used.
46 | You don't need to specify this parameter in this case as it is a default behaviour.
47 |
48 | To use a different calendar you need to specify its id.
49 | Go to calendar's `settings and sharing` -> `Integrate calendar` -> `Calendar ID`.
50 | :param credentials:
51 | Credentials with token and refresh token.
52 | If specified, ``credentials_path``, ``token_path``, and ``save_token`` are ignored.
53 | If not specified, credentials are retrieved from "token.pickle" file (specified in ``token_path`` or
54 | default path) or with authentication flow using secret from "credentials.json" ("client_secret_*.json")
55 | (specified in ``credentials_path`` or default path)
56 | :param credentials_path:
57 | Path to "credentials.json" ("client_secret_*.json") file.
58 | Default: ~/.credentials/credentials.json or ~/.credentials/client_secret*.json
59 | :param token_path:
60 | Existing path to load the token from, or path to save the token after initial authentication flow.
61 | Default: "token.pickle" in the same directory as the credentials_path
62 | :param save_token:
63 | Whether to pickle token after authentication flow for future uses
64 | :param read_only:
65 | If require read only access. Default: False
66 | :param authentication_flow_host:
67 | Host to receive response during authentication flow
68 | :param authentication_flow_port:
69 | Port to receive response during authentication flow
70 | :param authentication_flow_bind_addr:
71 | Optional IP address for the redirect server to listen on when it is not the same as host
72 | (e.g. in a container)
73 | :param open_browser:
74 | Whether to open the authorization URL in the user's browser.
75 | - `None` (default): try opening the URL in the browser, if it fails proceed without the browser
76 | - `True`: try opening the URL in the browser,
77 | raise `webbrowser.Error` if runnable browser can not be located
78 | - `False`: do not open URL in the browser.
79 | """
80 | super().__init__(
81 | default_calendar=default_calendar,
82 | credentials=credentials,
83 | credentials_path=credentials_path,
84 | token_path=token_path,
85 | save_token=save_token,
86 | read_only=read_only,
87 | authentication_flow_host=authentication_flow_host,
88 | authentication_flow_port=authentication_flow_port,
89 | authentication_flow_bind_addr=authentication_flow_bind_addr,
90 | open_browser=open_browser
91 | )
92 |
--------------------------------------------------------------------------------
/gcsa/serializers/calendar_serializer.py:
--------------------------------------------------------------------------------
1 | from gcsa.calendar import Calendar, CalendarListEntry
2 | from .base_serializer import BaseSerializer
3 | from .reminder_serializer import ReminderSerializer
4 |
5 |
6 | class CalendarSerializer(BaseSerializer):
7 | type_ = Calendar
8 |
9 | def __init__(self, calendar):
10 | super().__init__(calendar)
11 |
12 | @staticmethod
13 | def _to_json(calendar: Calendar):
14 | data = {
15 | "id": calendar.calendar_id,
16 | "summary": calendar.summary,
17 | "description": calendar.description,
18 | "location": calendar.location,
19 | "timeZone": calendar.timezone,
20 | "conferenceProperties": (
21 | {"allowedConferenceSolutionTypes": calendar.allowed_conference_solution_types}
22 | if calendar.allowed_conference_solution_types else None
23 | )
24 | }
25 |
26 | data = CalendarSerializer._remove_empty_values(data)
27 |
28 | return data
29 |
30 | @staticmethod
31 | def _to_object(json_calendar):
32 | conference_properties = json_calendar.get('conferenceProperties', {})
33 | allowed_conference_solution_types = conference_properties.get('allowedConferenceSolutionTypes')
34 | return Calendar(
35 | summary=json_calendar.get('summary'),
36 | calendar_id=json_calendar.get('id'),
37 | description=json_calendar.get('description'),
38 | location=json_calendar.get('location'),
39 | timezone=json_calendar.get('timeZone'),
40 | allowed_conference_solution_types=allowed_conference_solution_types
41 | )
42 |
43 |
44 | class CalendarListEntrySerializer(BaseSerializer):
45 | type_ = CalendarListEntry
46 |
47 | def __init__(self, calendar_list_entry):
48 | super().__init__(calendar_list_entry)
49 |
50 | @staticmethod
51 | def _to_json(calendar: CalendarListEntry):
52 | data = {
53 | "id": calendar.calendar_id,
54 | "summaryOverride": calendar.summary_override,
55 | "colorId": calendar.color_id,
56 | "backgroundColor": calendar.background_color,
57 | "foregroundColor": calendar.foreground_color,
58 | "hidden": calendar.hidden,
59 | "selected": calendar.selected,
60 |
61 | }
62 | if calendar.default_reminders:
63 | data["defaultReminders"] = [ReminderSerializer.to_json(r) for r in calendar.default_reminders]
64 |
65 | if calendar.notification_types:
66 | data["notificationSettings"] = {
67 | "notifications": [
68 | {
69 | "type": notification_type,
70 | "method": "email"
71 | }
72 | for notification_type in calendar.notification_types
73 | ]
74 | }
75 |
76 | data = CalendarListEntrySerializer._remove_empty_values(data)
77 |
78 | return data
79 |
80 | @staticmethod
81 | def _to_object(json_calendar):
82 | conference_properties = json_calendar.pop('conferenceProperties', {})
83 | allowed_conference_solution_types = conference_properties.pop('allowedConferenceSolutionTypes', None)
84 |
85 | reminders_json = json_calendar.pop('defaultReminders', [])
86 | default_reminders = [ReminderSerializer.to_object(r) for r in reminders_json] if reminders_json else None
87 |
88 | notifications = json_calendar.pop('notificationSettings', {}).pop('notifications', None)
89 | notification_types = [n['type'] for n in notifications] if notifications else None
90 |
91 | return CalendarListEntry(
92 | calendar_id=json_calendar.pop('id'),
93 | summary_override=json_calendar.pop('summaryOverride', None),
94 | color_id=json_calendar.pop('colorId', None),
95 | background_color=json_calendar.pop('backgroundColor', None),
96 | foreground_color=json_calendar.pop('foregroundColor', None),
97 | hidden=json_calendar.pop('hidden', False),
98 | selected=json_calendar.pop('selected', False),
99 | default_reminders=default_reminders,
100 | notification_types=notification_types,
101 | _summary=json_calendar.pop('summary', None),
102 | _description=json_calendar.pop('description', None),
103 | _location=json_calendar.pop('location', None),
104 | _timezone=json_calendar.pop('timeZone', None),
105 | _allowed_conference_solution_types=allowed_conference_solution_types,
106 | _access_role=json_calendar.pop('accessRole', None),
107 | _primary=json_calendar.pop('primary', False),
108 | _deleted=json_calendar.pop('deleted', False)
109 | )
110 |
--------------------------------------------------------------------------------
/docs/source/change_log.rst:
--------------------------------------------------------------------------------
1 | .. _change_log:
2 |
3 | Change log
4 | ==========
5 |
6 | v2.6.0
7 | ~~~~~~
8 |
9 | API
10 | ---
11 | * Infer timezone from `start` field of the `Event` if not explicitly provided by `timezone` field
12 |
13 | Core
14 | ----
15 | * None
16 |
17 | Backward compatibility
18 | ----------------------
19 | * It will not use devices timezone if `start` field has `tzinfo`
20 |
21 |
22 | v2.5.1
23 | ~~~~~~
24 |
25 | API
26 | ---
27 | * Fixed comment typo
28 |
29 | Core
30 | ----
31 | * None
32 |
33 | Backward compatibility
34 | ----------------------
35 | * Full compatibility
36 |
37 | v2.5.0
38 | ~~~~~~
39 |
40 | API
41 | ---
42 | * Consistent type annotations (primarily regarding `Optional`)
43 | * Support python3.13
44 |
45 | Core
46 | ----
47 | * Include mypy as a PR check
48 |
49 | Backward compatibility
50 | ----------------------
51 | * Full compatibility
52 |
53 |
54 | v2.4.0
55 | ~~~~~~
56 |
57 | API
58 | ---
59 | * Warn user about microseconds in start/end datetime
60 | * Warn user about empty summary
61 | * Adds open_browser argument to GoogleCalendar
62 |
63 | Core
64 | ----
65 | * None
66 |
67 | Backward compatibility
68 | ----------------------
69 | * If there is no available browser, by default, authentication will not raise an error and retry without the browser
70 |
71 |
72 | v2.3.0
73 | ~~~~~~
74 |
75 | API
76 | ---
77 | * Adds `add_attendees` method to the `Event` for adding multiple attendees
78 | * Add specific time reminders (N days before at HH:MM)
79 | * Support Python3.12
80 | * Allow service account credentials in `GoogleCalendar`
81 |
82 | Core
83 | ----
84 | * Don't evaluate default arguments in code docs (primarily for `timezone=get_localzone_name()`)
85 |
86 | Backward compatibility
87 | ----------------------
88 | * If token is expired but doesn't have refresh token, raises `google.auth.exceptions.RefreshError`
89 | instead of sending the request
90 |
91 |
92 | v2.2.0
93 | ~~~~~~
94 |
95 | API
96 | ---
97 | * Adds support for new credentials file names (i.e. client_secret_*.json)
98 |
99 | Core
100 | ----
101 | * None
102 |
103 | Backward compatibility
104 | ----------------------
105 | * Full compatibility
106 |
107 |
108 | v2.1.0
109 | ~~~~~~
110 |
111 | API
112 | ---
113 | * Adds support for python3.11
114 | * Adds support for access control list (ACL) management
115 | * Fix converting date to datetime in get_events
116 | * Adds support for free/busy requests
117 |
118 | Core
119 | ----
120 | * None
121 |
122 | Backward compatibility
123 | ----------------------
124 | * Full compatibility
125 |
126 | v2.0.1
127 | ~~~~~~
128 |
129 | API
130 | ---
131 | * Fixes issue with unknown timezones
132 |
133 | Core
134 | ----
135 | * Removes pytz dependency
136 |
137 | Backward compatibility
138 | ----------------------
139 | * Full compatibility
140 |
141 |
142 | v2.0.0
143 | ~~~~~~
144 |
145 | API
146 | ---
147 | * Adds calendar and calendar list related methods
148 | * Adds settings related method
149 | * Adds colors related method
150 | * Adds support for python3.10
151 |
152 | Core
153 | ----
154 | * Separates ``GoogleCalendar`` into authentication, events, calendars, calendar list, colors, and settings services
155 | * Uses newest documentation generation libraries
156 |
157 | Backward compatibility
158 | ----------------------
159 | * Full compatibility
160 |
161 |
162 | v1.3.0
163 | ~~~~~~
164 |
165 | API
166 | ---
167 | * Adds deletion of event by its id in ``GoogleCalendar.delete_event()``
168 |
169 | Core
170 | ----
171 | * None
172 |
173 | Backward compatibility
174 | ----------------------
175 | * Full compatibility
176 |
177 |
178 | v1.2.1
179 | ~~~~~~
180 |
181 | API
182 | ---
183 | * Adds ``Event.id`` in serialized event
184 | * Fixes conference's entry point without ``entry_point_type``
185 |
186 | Core
187 | ----
188 | * Switches to tox for testing
189 |
190 | Backward compatibility
191 | ----------------------
192 | * Full compatibility
193 |
194 |
195 | v1.2.0
196 | ~~~~~~
197 |
198 | API
199 | ---
200 | * Adds ``GoogleCalendar.import_event()`` method
201 |
202 | Core
203 | ----
204 | * None
205 |
206 | Backward compatibility
207 | ----------------------
208 | * Full compatibility
209 |
210 |
211 | v1.1.0
212 | ~~~~~~
213 |
214 | API
215 | ---
216 | * Fixes event creation without ``start`` and ``end``
217 | * Adds ``creator``, ``organizer`` and ``transparency`` fields to event
218 |
219 | Core
220 | ----
221 | * None
222 |
223 | Backward compatibility
224 | ----------------------
225 | * Full compatibility
226 |
227 |
228 | v1.0.1
229 | ~~~~~~
230 |
231 | API
232 | ---
233 | * Fixes ``GoogleCalendar.clear()`` method
234 |
235 | Core
236 | ----
237 | * None
238 |
239 | Backward compatibility
240 | ----------------------
241 | * Full compatibility
242 |
243 |
244 | v1.0.0 and previous versions
245 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
246 |
247 | API
248 | ---
249 | * Adds authentication management
250 | * Adds event management
251 | * Adds documentation in readthedocs.com
252 |
253 | Core
254 | ----
255 | * Adds serializers for events and related objects
256 | * Adds automated testing in GitHub actions with code-coverage
257 |
258 | Backward compatibility
259 | ----------------------
260 | * Full compatibility
261 |
--------------------------------------------------------------------------------
/tests/google_calendar_tests/test_authentication.py:
--------------------------------------------------------------------------------
1 | import pickle
2 | import webbrowser
3 | from os import path
4 | from unittest.mock import patch
5 |
6 | from pyfakefs.fake_filesystem_unittest import TestCase
7 |
8 | from gcsa.google_calendar import GoogleCalendar
9 | from tests.google_calendar_tests.mock_services.util import MockToken, MockAuthFlow
10 |
11 |
12 | class TestGoogleCalendarCredentials(TestCase):
13 |
14 | def setUp(self):
15 | self.setUpPyfakefs()
16 |
17 | self.credentials_dir = path.join(path.expanduser('~'), '.credentials')
18 | self.credentials_path = path.join(self.credentials_dir, 'credentials.json')
19 | self.fs.create_dir(self.credentials_dir)
20 | self.fs.create_file(self.credentials_path)
21 |
22 | self.valid_token_path = path.join(self.credentials_dir, 'valid_token.pickle')
23 | self.expired_token_path = path.join(self.credentials_dir, 'expired_token.pickle')
24 |
25 | with open(self.valid_token_path, 'wb') as token_file:
26 | pickle.dump(MockToken(valid=True), token_file)
27 | with open(self.expired_token_path, 'wb') as token_file:
28 | pickle.dump(MockToken(valid=False), token_file)
29 |
30 | self._add_mocks()
31 |
32 | def _add_mocks(self):
33 | self.build_patcher = patch('googleapiclient.discovery.build', return_value=None).start()
34 |
35 | self.from_client_secrets_file_patcher = patch(
36 | 'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file',
37 | return_value=MockAuthFlow()
38 | ).start()
39 |
40 | def test_with_given_credentials(self):
41 | GoogleCalendar(credentials=MockToken(valid=True))
42 | self.assertFalse(self.from_client_secrets_file_patcher.called)
43 |
44 | def test_with_given_credentials_expired(self):
45 | gc = GoogleCalendar(credentials=MockToken(valid=False))
46 | self.assertTrue(gc.credentials.valid)
47 | self.assertFalse(gc.credentials.expired)
48 |
49 | def test_get_default_credentials_exist(self):
50 | self.assertEqual(
51 | self.credentials_path,
52 | GoogleCalendar._get_default_credentials_path()
53 | )
54 |
55 | def test_get_default_credentials_path_not_exist(self):
56 | self.fs.reset()
57 | with self.assertRaises(FileNotFoundError):
58 | GoogleCalendar._get_default_credentials_path()
59 |
60 | def test_get_default_credentials_not_exist(self):
61 | self.fs.remove(self.credentials_path)
62 | with self.assertRaises(FileNotFoundError):
63 | GoogleCalendar._get_default_credentials_path()
64 |
65 | def test_get_default_credentials_client_secrets(self):
66 | self.fs.remove(self.credentials_path)
67 | client_secret_path = path.join(self.credentials_dir, 'client_secret_1234.json')
68 | self.fs.create_file(client_secret_path)
69 | self.assertEqual(
70 | client_secret_path,
71 | GoogleCalendar._get_default_credentials_path()
72 | )
73 |
74 | def test_get_default_credentials_multiple_client_secrets(self):
75 | self.fs.remove(self.credentials_path)
76 | self.fs.create_file(path.join(self.credentials_dir, 'client_secret_1234.json'))
77 | self.fs.create_file(path.join(self.credentials_dir, 'client_secret_12345.json'))
78 | with self.assertRaises(ValueError):
79 | GoogleCalendar._get_default_credentials_path()
80 |
81 | def test_get_token_valid(self):
82 | gc = GoogleCalendar(token_path=self.valid_token_path)
83 | self.assertTrue(gc.credentials.valid)
84 | self.assertFalse(self.from_client_secrets_file_patcher.called)
85 |
86 | def test_get_token_expired(self):
87 | gc = GoogleCalendar(token_path=self.expired_token_path)
88 | self.assertTrue(gc.credentials.valid)
89 | self.assertFalse(gc.credentials.expired)
90 | self.assertFalse(self.from_client_secrets_file_patcher.called)
91 |
92 | def test_get_token_invalid_refresh(self):
93 | gc = GoogleCalendar(credentials_path=self.credentials_path)
94 | self.assertTrue(gc.credentials.valid)
95 | self.assertFalse(gc.credentials.expired)
96 | self.assertTrue(self.from_client_secrets_file_patcher.called)
97 |
98 | def test_no_browser_without_error(self):
99 | self.from_client_secrets_file_patcher = patch(
100 | 'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file',
101 | return_value=MockAuthFlow(has_browser=False)
102 | ).start()
103 |
104 | gc = GoogleCalendar(credentials_path=self.credentials_path, open_browser=None)
105 | self.assertTrue(gc.credentials.valid)
106 | self.assertTrue(self.from_client_secrets_file_patcher.called)
107 |
108 | def test_no_browser_with_error(self):
109 | self.from_client_secrets_file_patcher = patch(
110 | 'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file',
111 | return_value=MockAuthFlow(has_browser=False)
112 | ).start()
113 |
114 | with self.assertRaises(webbrowser.Error):
115 | GoogleCalendar(credentials_path=self.credentials_path, open_browser=True)
116 |
--------------------------------------------------------------------------------
/gcsa/serializers/conference_serializer.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Any
2 |
3 | from gcsa.conference import ConferenceSolutionCreateRequest, ConferenceSolution, EntryPoint
4 | from .base_serializer import BaseSerializer
5 |
6 |
7 | class EntryPointSerializer(BaseSerializer):
8 | type_ = EntryPoint
9 |
10 | def __init__(self, entry_point):
11 | super().__init__(entry_point)
12 |
13 | @staticmethod
14 | def _to_json(entry_point):
15 | data = {
16 | 'entryPointType': entry_point.entry_point_type,
17 | 'uri': entry_point.uri,
18 | 'label': entry_point.label,
19 | 'pin': entry_point.pin,
20 | 'accessCode': entry_point.access_code,
21 | 'meetingCode': entry_point.meeting_code,
22 | 'passcode': entry_point.passcode,
23 | 'password': entry_point.password
24 | }
25 | return EntryPointSerializer._remove_empty_values(data)
26 |
27 | @staticmethod
28 | def _to_object(json_):
29 | return EntryPoint(
30 | entry_point_type=json_.get('entryPointType'),
31 | uri=json_.get('uri'),
32 | label=json_.get('label'),
33 | pin=json_.get('pin'),
34 | access_code=json_.get('accessCode'),
35 | meeting_code=json_.get('meetingCode'),
36 | passcode=json_.get('passcode'),
37 | password=json_.get('password')
38 | )
39 |
40 |
41 | class ConferenceSolutionSerializer(BaseSerializer):
42 | type_ = ConferenceSolution
43 |
44 | def __init__(self, conference_solution):
45 | super().__init__(conference_solution)
46 |
47 | @staticmethod
48 | def _to_json(conference_solution: ConferenceSolution):
49 | data = {
50 | 'entryPoints': [
51 | EntryPointSerializer.to_json(ep)
52 | for ep in conference_solution.entry_points
53 | ],
54 | 'conferenceSolution':
55 | ConferenceSolutionSerializer._remove_empty_values(
56 | {
57 | 'key': {
58 | 'type': conference_solution.solution_type
59 | },
60 | 'name': conference_solution.name,
61 | 'iconUri': conference_solution.icon_uri
62 | }
63 | ),
64 | 'conferenceId': conference_solution.conference_id,
65 | 'signature': conference_solution.signature,
66 | 'notes': conference_solution.notes,
67 | }
68 |
69 | return ConferenceSolutionSerializer._remove_empty_values(data)
70 |
71 | @staticmethod
72 | def _to_object(json_):
73 | entry_points = [EntryPointSerializer.to_object(ep) for ep in json_.get('entryPoints', [])]
74 |
75 | conference_solution = json_.get('conferenceSolution', {})
76 | solution_type = conference_solution.get('key', {}).get('type')
77 | name = conference_solution.get('name')
78 | icon_uri = conference_solution.get('iconUri')
79 |
80 | conference_id = json_.get('conferenceId')
81 | signature = json_.get('signature')
82 | notes = json_.get('notes')
83 |
84 | return ConferenceSolution(
85 | entry_points=entry_points,
86 | solution_type=solution_type,
87 | name=name,
88 | icon_uri=icon_uri,
89 | conference_id=conference_id,
90 | signature=signature,
91 | notes=notes
92 | )
93 |
94 |
95 | class ConferenceSolutionCreateRequestSerializer(BaseSerializer):
96 | type_ = ConferenceSolutionCreateRequest
97 |
98 | def __init__(self, conference_solution_create_request):
99 | super().__init__(conference_solution_create_request)
100 |
101 | @staticmethod
102 | def _to_json(cscr: ConferenceSolutionCreateRequest):
103 | data: Dict[str, Any] = {
104 | 'createRequest': {
105 | 'requestId': cscr.request_id,
106 | 'conferenceSolutionKey': {
107 | 'type': cscr.solution_type
108 | }
109 | },
110 | 'conferenceId': cscr.conference_id,
111 | 'signature': cscr.signature,
112 | 'notes': cscr.notes
113 | }
114 |
115 | if cscr.status is not None:
116 | data['createRequest']['status'] = {'statusCode': cscr.status}
117 |
118 | return ConferenceSolutionCreateRequestSerializer._remove_empty_values(data)
119 |
120 | @staticmethod
121 | def _to_object(json_):
122 | create_request = json_['createRequest']
123 | solution_type = create_request.get('conferenceSolutionKey', {}).get('type')
124 | request_id = create_request.get('requestId')
125 | status = create_request.get('status', {}).get('statusCode')
126 |
127 | conference_id = json_.get('conferenceId')
128 | signature = json_.get('signature')
129 | notes = json_.get('notes')
130 |
131 | return ConferenceSolutionCreateRequest(
132 | solution_type=solution_type,
133 | request_id=request_id,
134 | _status=status,
135 | conference_id=conference_id,
136 | signature=signature,
137 | notes=notes
138 | )
139 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import subprocess
3 |
4 | from setuptools import setup, find_packages, Command
5 | from shutil import rmtree
6 | import os
7 | import sys
8 |
9 | here = os.path.abspath(os.path.dirname(__file__))
10 |
11 | VERSION = '2.6.0'
12 |
13 |
14 | class UploadCommand(Command):
15 | """Support setup.py upload."""
16 |
17 | description = 'Build and publish the package.'
18 | user_options = []
19 |
20 | @staticmethod
21 | def status(s):
22 | """Prints things in bold."""
23 | print('\033[1m{0}\033[0m'.format(s))
24 |
25 | def initialize_options(self):
26 | pass
27 |
28 | def finalize_options(self):
29 | pass
30 |
31 | def run(self):
32 | try:
33 | self.status('Removing previous builds...')
34 | rmtree(os.path.join(here, 'dist'))
35 | except OSError:
36 | pass
37 |
38 | self.status('Building Source and Wheel (universal) distribution...')
39 | error = os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable))
40 | if error:
41 | sys.exit()
42 |
43 | self.status('Uploading the package to PyPi via Twine...')
44 | error = os.system('twine upload dist/*')
45 | if error:
46 | sys.exit()
47 |
48 | self.status('Pushing git tags...')
49 | os.system('git tag v{0}'.format(VERSION))
50 | os.system('git push --tags')
51 |
52 | sys.exit()
53 |
54 |
55 | class BuildDoc(Command):
56 | user_options = []
57 |
58 | def initialize_options(self) -> None:
59 | pass
60 |
61 | def finalize_options(self) -> None:
62 | pass
63 |
64 | def run(self):
65 | output_path = 'docs/html'
66 | changed_files = []
67 | cmd = [
68 | 'sphinx-build',
69 | 'docs/source', output_path,
70 | '--builder', 'html',
71 | '--define', f'version={VERSION}',
72 | '--verbose'
73 | ]
74 | with subprocess.Popen(
75 | cmd,
76 | stdout=subprocess.PIPE,
77 | bufsize=1,
78 | universal_newlines=True
79 | ) as p:
80 | for line in p.stdout:
81 | print(line, end='')
82 | if line.startswith('reading sources... ['):
83 | file_name = line.rsplit(maxsplit=1)[1]
84 | if file_name:
85 | changed_files.append(file_name + '.html')
86 |
87 | index_path = os.path.join(os.getcwd(), output_path, 'index.html')
88 | print('\nIndex:')
89 | print(f'file://{index_path}')
90 |
91 | if changed_files:
92 | print('Update pages:')
93 | for cf in changed_files:
94 | f_path = os.path.join(os.getcwd(), output_path, cf)
95 | print(cf, f'file://{f_path}')
96 |
97 |
98 | with open('README.rst') as f:
99 | long_description = ''.join(f.readlines())
100 |
101 | DOCS_REQUIRES = [
102 | 'sphinx',
103 | 'sphinx-rtd-theme',
104 | 'sphinxcontrib-googleanalytics',
105 | ]
106 |
107 | TEST_REQUIRES = [
108 | 'setuptools',
109 | 'pytest',
110 | 'pytest-pep8',
111 | 'pytest-cov',
112 | 'pyfakefs',
113 | 'flake8',
114 | 'pep8-naming',
115 | 'twine',
116 | 'tox'
117 | ]
118 |
119 | setup(
120 | name='gcsa',
121 | version=VERSION,
122 | keywords='python conference calendar hangouts python-library event conferences google-calendar pip recurrence '
123 | 'google-calendar-api attendee gcsa',
124 | description='Simple API for Google Calendar management',
125 | long_description=long_description,
126 | author='Yevhen Kuzmovych',
127 | author_email='kuzmovych.yevhen@gmail.com',
128 | license='MIT',
129 | url='https://github.com/kuzmoyev/google-calendar-simple-api',
130 | zip_safe=False,
131 | packages=find_packages(exclude=("tests", "tests.*")),
132 | install_requires=[
133 | "tzlocal>=4,<6",
134 | "google-api-python-client>=1.8",
135 | "google-auth-httplib2>=0.0.4",
136 | "google-auth-oauthlib>=0.5,<2.0",
137 | "python-dateutil>=2.7",
138 | "beautiful_date>=2.0.0",
139 | ],
140 | extras_require={
141 | 'dev': [
142 | *TEST_REQUIRES,
143 | *DOCS_REQUIRES
144 | ],
145 | 'tests': TEST_REQUIRES,
146 | 'docs': DOCS_REQUIRES
147 | },
148 | classifiers=[
149 | 'License :: OSI Approved :: MIT License',
150 | 'Natural Language :: English',
151 | 'Programming Language :: Python',
152 | 'Programming Language :: Python :: 3',
153 | 'Programming Language :: Python :: 3.5',
154 | 'Programming Language :: Python :: 3.6',
155 | 'Programming Language :: Python :: 3.7',
156 | 'Programming Language :: Python :: 3.8',
157 | 'Programming Language :: Python :: 3.9',
158 | 'Programming Language :: Python :: 3.10',
159 | 'Programming Language :: Python :: 3.11',
160 | 'Programming Language :: Python :: 3.12',
161 | 'Programming Language :: Python :: 3.13',
162 | ],
163 | cmdclass={
164 | 'upload': UploadCommand,
165 | 'docs': BuildDoc,
166 | }
167 | )
168 |
--------------------------------------------------------------------------------
/docs/source/events.rst:
--------------------------------------------------------------------------------
1 | .. _events:
2 |
3 | Events
4 | ======
5 |
6 | Event in `gcsa` is represented by the class :py:class:`~gcsa.event.Event`. It stores all the needed information about
7 | the event including its summary, starting and ending dates/times, attachments, reminders, recurrence rules, etc.
8 |
9 | `gcsa` allows you to create a new events, retrieve, update, move and delete existing events.
10 |
11 | To do so, create a :py:class:`~gcsa.google_calendar.GoogleCalendar` instance (see :ref:`getting_started` to get your
12 | credentials):
13 |
14 | .. code-block:: python
15 |
16 | from gcsa.google_calendar import GoogleCalendar
17 |
18 | gc = GoogleCalendar()
19 |
20 |
21 | List events
22 | ~~~~~~~~~~~
23 |
24 | This code will print out events for one year starting today:
25 |
26 | .. code-block:: python
27 |
28 | for event in gc:
29 | print(event)
30 |
31 | .. note::
32 | In the following examples, :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_events` and
33 | :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_instances` return generators_. You can iterate over them directly:
34 |
35 | .. code-block::
36 |
37 | for event in gc.get_events():
38 | print(event)
39 |
40 | but to get the list of events use:
41 |
42 | .. code-block::
43 |
44 | events = list(gc.get_events())
45 |
46 | Specify range of listed events in two ways:
47 |
48 | .. code-block:: python
49 |
50 | events = gc.get_events(time_min, time_max, order_by='updated')
51 |
52 | or
53 |
54 | .. code-block:: python
55 |
56 | events = gc[time_min:time_max:'updated']
57 |
58 | ``time_min`` and ``time_max`` can be ``date`` or ``datetime`` objects. ``order_by`` can be `'startTime'`
59 | or `'updated'`. If not specified, unspecified stable order is used.
60 |
61 |
62 | Use ``query`` parameter for free text search through all event fields (except for extended properties):
63 |
64 | .. code-block:: python
65 |
66 | events = gc.get_events(query='Meeting')
67 |
68 | or
69 |
70 | .. code-block:: python
71 |
72 | events = gc.get_events(query='John') # Name of attendee
73 |
74 |
75 | Use ``single_events`` parameter to expand recurring events into instances and only return single one-off events and
76 | instances of recurring events, but not the underlying recurring events themselves.
77 |
78 | .. code-block:: python
79 |
80 | events = gc.get_events(single_events=True)
81 |
82 |
83 |
84 | List recurring event instances
85 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
86 |
87 | .. code-block:: python
88 |
89 | events = gc.get_instances('')
90 |
91 | or
92 |
93 | .. code-block:: python
94 |
95 | events = gc.get_instances(recurring_event)
96 |
97 | where ``recurring_event`` is :py:class:`~gcsa.event.Event` object with set ``event_id``. You'd probably get it from
98 | the ``get_events`` method.
99 |
100 | Get event by id
101 | ~~~~~~~~~~~~~~~
102 |
103 | .. code-block:: python
104 |
105 | event = gc.get_event('')
106 |
107 | Create event
108 | ~~~~~~~~~~~~
109 |
110 | .. code-block:: python
111 |
112 | from beautiful_date import Apr, hours
113 | from gcsa.event import Event
114 |
115 | start = (22/Apr/2019)[12:00]
116 | end = start + 2 * hours
117 | event = Event('Meeting',
118 | start=start,
119 | end=end)
120 |
121 | or to create an **all-day** event, use a `date` object:
122 |
123 | .. code-block:: python
124 |
125 | from beautiful_date import Aug, days
126 | from gcsa.event import Event
127 |
128 | start = 1/Aug/2021
129 | end = start + 7 * days
130 | event = Event('Vacation',
131 | start=start,
132 | end=end)
133 |
134 |
135 | For ``date``/``datetime`` objects you can use Pythons datetime_ module or as in the
136 | example beautiful_date_ library (*because it's beautiful... just like you*).
137 |
138 | Now **add** your event to the calendar:
139 |
140 | .. code-block:: python
141 |
142 | event = gc.add_event(event)
143 |
144 | See dedicated pages on how to add :ref:`attendees`, :ref:`attachments`, :ref:`conference`, :ref:`reminders`, and
145 | :ref:`recurrence` to an event.
146 |
147 |
148 | Update event
149 | ~~~~~~~~~~~~
150 |
151 | .. code-block:: python
152 |
153 | event.location = 'Prague'
154 | event = gc.update_event(event)
155 |
156 |
157 | Import event
158 | ~~~~~~~~~~~~
159 |
160 | .. code-block:: python
161 |
162 | event = gc.import_event(event)
163 |
164 | This operation is used to add a private copy of an existing event to a calendar.
165 |
166 |
167 | Move event to another calendar
168 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
169 |
170 | .. code-block:: python
171 |
172 | event = gc.move_event(event, destination_calendar_id='primary')
173 |
174 |
175 | Delete event
176 | ~~~~~~~~~~~~
177 |
178 | .. code-block:: python
179 |
180 | gc.delete_event(event)
181 |
182 |
183 | Event has to have ``event_id`` to be updated, moved, or deleted. Events that you get from
184 | :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_events` method already have their ids.
185 | You can also delete the event by providing its id.
186 |
187 | .. code-block:: python
188 |
189 | gc.delete_event('')
190 |
191 |
192 | .. _datetime: https://docs.python.org/3/library/datetime.html
193 | .. _beautiful_date: https://github.com/kuzmoyev/beautiful-date
194 | .. _generators: https://wiki.python.org/moin/Generators
195 |
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | kuzmovich.goog@gmail.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/gcsa/_services/calendar_lists_service.py:
--------------------------------------------------------------------------------
1 | from typing import Iterable, Union, Optional
2 |
3 | from gcsa._services.base_service import BaseService
4 | from gcsa.calendar import CalendarListEntry, Calendar
5 | from gcsa.serializers.calendar_serializer import CalendarListEntrySerializer
6 |
7 |
8 | class CalendarListService(BaseService):
9 | """Calendar list management methods of the `GoogleCalendar`"""
10 |
11 | def get_calendar_list(
12 | self,
13 | min_access_role: Optional[str] = None,
14 | show_deleted: bool = False,
15 | show_hidden: bool = False
16 | ) -> Iterable[CalendarListEntry]:
17 | """Returns the calendars on the user's calendar list.
18 |
19 | :param min_access_role:
20 | The minimum access role for the user in the returned entries. See :py:class:`~gcsa.calendar.AccessRoles`
21 | The default is no restriction.
22 | :param show_deleted:
23 | Whether to include deleted calendar list entries in the result. The default is False.
24 | :param show_hidden:
25 | Whether to show hidden entries. The default is False.
26 |
27 | :return:
28 | Iterable of :py:class:`~gcsa.calendar.CalendarListEntry` objects.
29 | """
30 | yield from self._list_paginated(
31 | self.service.calendarList().list,
32 | serializer_cls=CalendarListEntrySerializer,
33 | minAccessRole=min_access_role,
34 | showDeleted=show_deleted,
35 | showHidden=show_hidden,
36 | )
37 |
38 | def get_calendar_list_entry(
39 | self,
40 | calendar_id: Optional[str] = None
41 | ) -> CalendarListEntry:
42 | """Returns a calendar with the corresponding calendar_id from the user's calendar list.
43 |
44 | :param calendar_id:
45 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`
46 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
47 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
48 |
49 | :return:
50 | The corresponding :py:class:`~gcsa.calendar.CalendarListEntry` object.
51 | """
52 | calendar_id = calendar_id or self.default_calendar
53 | calendar_resource = self.service.calendarList().get(calendarId=calendar_id).execute()
54 | return CalendarListEntrySerializer.to_object(calendar_resource)
55 |
56 | def add_calendar_list_entry(
57 | self,
58 | calendar: CalendarListEntry,
59 | color_rgb_format: Optional[bool] = None
60 | ) -> CalendarListEntry:
61 | """Adds an existing calendar into the user's calendar list.
62 |
63 | :param calendar:
64 | :py:class:`~gcsa.calendar.CalendarListEntry` object.
65 | :param color_rgb_format:
66 | Whether to use the `foreground_color` and `background_color` fields to write the calendar colors (RGB).
67 | If this feature is used, the index-based `color_id` field will be set to the best matching option
68 | automatically. The default is True if `foreground_color` or `background_color` is set, False otherwise.
69 |
70 | :return:
71 | Created `CalendarListEntry` object with id.
72 | """
73 | if color_rgb_format is None:
74 | color_rgb_format = (calendar.foreground_color is not None) or (calendar.background_color is not None)
75 |
76 | body = CalendarListEntrySerializer.to_json(calendar)
77 | calendar_json = self.service.calendarList().insert(
78 | body=body,
79 | colorRgbFormat=color_rgb_format
80 | ).execute()
81 | return CalendarListEntrySerializer.to_object(calendar_json)
82 |
83 | def update_calendar_list_entry(
84 | self,
85 | calendar: CalendarListEntry,
86 | color_rgb_format: Optional[bool] = None
87 | ) -> CalendarListEntry:
88 | """Updates an existing calendar on the user's calendar list.
89 |
90 | :param calendar:
91 | :py:class:`~gcsa.calendar.Calendar` object with set `calendar_id`
92 | :param color_rgb_format:
93 | Whether to use the `foreground_color` and `background_color` fields to write the calendar colors (RGB).
94 | If this feature is used, the index-based color_id field will be set to the best matching option
95 | automatically. The default is True if `foreground_color` or `background_color` is set, False otherwise.
96 |
97 | :return:
98 | Updated calendar list entry object
99 | """
100 | calendar_id = self._get_resource_id(calendar)
101 | if color_rgb_format is None:
102 | color_rgb_format = calendar.foreground_color is not None or calendar.background_color is not None
103 |
104 | body = CalendarListEntrySerializer.to_json(calendar)
105 | calendar_json = self.service.calendarList().update(
106 | calendarId=calendar_id,
107 | body=body,
108 | colorRgbFormat=color_rgb_format
109 | ).execute()
110 | return CalendarListEntrySerializer.to_object(calendar_json)
111 |
112 | def delete_calendar_list_entry(
113 | self,
114 | calendar: Union[Calendar, CalendarListEntry, str]
115 | ):
116 | """Removes a calendar from the user's calendar list.
117 |
118 | :param calendar:
119 | Calendar's ID or :py:class:`~gcsa.calendar.Calendar`/:py:class:`~gcsa.calendar.CalendarListEntry` object
120 | with the set `calendar_id`.
121 | """
122 | calendar_id = self._get_resource_id(calendar)
123 | self.service.calendarList().delete(calendarId=calendar_id).execute()
124 |
--------------------------------------------------------------------------------
/gcsa/reminders.py:
--------------------------------------------------------------------------------
1 | from datetime import time, datetime
2 | from typing import Optional
3 |
4 | from beautiful_date import days
5 |
6 | from gcsa.util.date_time_util import DateOrDatetime
7 |
8 |
9 | class Reminder:
10 | def __init__(
11 | self,
12 | method: str,
13 | minutes_before_start: Optional[int] = None,
14 | days_before: Optional[int] = None,
15 | at: Optional[time] = None
16 | ):
17 | """Represents base reminder object
18 |
19 | Provide `minutes_before_start` to create "relative" reminder.
20 | Provide `days_before` and `at` to create "absolute" reminder.
21 |
22 | :param method:
23 | Method of the reminder. Possible values: email or popup
24 | :param minutes_before_start:
25 | Minutes before reminder
26 | :param days_before:
27 | Days before reminder
28 | :param at:
29 | Specific time for a reminder
30 | """
31 | # Nothing was provided
32 | if minutes_before_start is None and days_before is None and at is None:
33 | raise ValueError("Relative reminder needs 'minutes_before_start'. "
34 | "Absolute reminder 'days_before' and 'at' set. "
35 | "None of them were provided.")
36 |
37 | # Both minutes_before_start and days_before/at were provided
38 | if minutes_before_start is not None and (days_before is not None or at is not None):
39 | raise ValueError("Only minutes_before_start or days_before/at can be specified.")
40 |
41 | # Only one of days_before and at was provided
42 | if (days_before is None) != (at is None):
43 | raise ValueError(f'Both "days_before" and "at" values need to be set '
44 | f'when using absolute time for a reminder. '
45 | f'Provided days_before={days_before} and at={at}.')
46 |
47 | self.method = method
48 | self.minutes_before_start = minutes_before_start
49 | self.days_before = days_before
50 | self.at = at
51 |
52 | def __eq__(self, other):
53 | return (
54 | isinstance(other, Reminder)
55 | and self.method == other.method
56 | and self.minutes_before_start == other.minutes_before_start
57 | and self.days_before == other.days_before
58 | and self.at == other.at
59 | )
60 |
61 | def __str__(self):
62 | if self.minutes_before_start is not None:
63 | return '{} - minutes_before_start:{}'.format(self.__class__.__name__, self.minutes_before_start)
64 | else:
65 | return '{} - {} days before at {}'.format(self.__class__.__name__, self.days_before, self.at)
66 |
67 | def __repr__(self):
68 | return '<{}>'.format(self.__str__())
69 |
70 | def convert_to_relative(self, start: DateOrDatetime) -> 'Reminder':
71 | """Converts absolute reminder (with set `days_before` and `at`) to relative (with set `minutes_before_start`)
72 | relative to `start` date/datetime. Returns self if `minutes_before_start` is already set.
73 | """
74 | if self.minutes_before_start is not None:
75 | return self
76 |
77 | if self.days_before is None or self.at is None:
78 | raise ValueError(f'Both "days_before" and "at" values need to be set '
79 | f'when using absolute time for a reminder. '
80 | f'Provided days_before={self.days_before} and at={self.at}.')
81 |
82 | tzinfo = start.tzinfo if isinstance(start, datetime) else None
83 | start_of_the_day = datetime.combine(start, datetime.min.time(), tzinfo=tzinfo)
84 |
85 | reminder_tzinfo = self.at.tzinfo or tzinfo
86 | reminder_time = datetime.combine(start_of_the_day - self.days_before * days, self.at, tzinfo=reminder_tzinfo)
87 |
88 | if isinstance(start, datetime):
89 | minutes_before_start = int((start - reminder_time).total_seconds() / 60)
90 | else:
91 | minutes_before_start = int((start_of_the_day - reminder_time).total_seconds() / 60)
92 |
93 | return Reminder(
94 | method=self.method,
95 | minutes_before_start=minutes_before_start
96 | )
97 |
98 |
99 | class EmailReminder(Reminder):
100 | def __init__(
101 | self,
102 | minutes_before_start: Optional[int] = None,
103 | days_before: Optional[int] = None,
104 | at: Optional[time] = None
105 | ):
106 | """Represents email reminder object
107 |
108 | Provide `minutes_before_start` to create "relative" reminder.
109 | Provide `days_before` and `at` to create "absolute" reminder.
110 |
111 | :param minutes_before_start:
112 | Minutes before reminder
113 | :param days_before:
114 | Days before reminder
115 | :param at:
116 | Specific time for a reminder
117 | """
118 | if not days_before and not at and not minutes_before_start:
119 | minutes_before_start = 60
120 | super().__init__('email', minutes_before_start, days_before, at)
121 |
122 |
123 | class PopupReminder(Reminder):
124 | def __init__(
125 | self,
126 | minutes_before_start: Optional[int] = None,
127 | days_before: Optional[int] = None,
128 | at: Optional[time] = None
129 | ):
130 | """Represents popup reminder object
131 |
132 | Provide `minutes_before_start` to create "relative" reminder.
133 | Provide `days_before` and `at` to create "absolute" reminder.
134 |
135 | :param minutes_before_start:
136 | Minutes before reminder
137 | :param days_before:
138 | Days before reminder
139 | :param at:
140 | Specific time for a reminder
141 | """
142 | if not days_before and not at and not minutes_before_start:
143 | minutes_before_start = 30
144 | super().__init__('popup', minutes_before_start, days_before, at)
145 |
--------------------------------------------------------------------------------
/gcsa/_services/acl_service.py:
--------------------------------------------------------------------------------
1 | from typing import Iterable, Union, Optional
2 |
3 | from gcsa._services.base_service import BaseService
4 | from gcsa.acl import AccessControlRule
5 | from gcsa.serializers.acl_rule_serializer import ACLRuleSerializer
6 |
7 |
8 | class ACLService(BaseService):
9 | """Access Control List management methods of the `GoogleCalendar`"""
10 |
11 | def get_acl_rules(
12 | self,
13 | calendar_id: Optional[str] = None,
14 | show_deleted: bool = False
15 | ) -> Iterable[AccessControlRule]:
16 | """Returns the rules in the access control list for the calendar.
17 |
18 | :param calendar_id:
19 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`.
20 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
21 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
22 | :param show_deleted:
23 | Whether to include deleted ACLs in the result. Deleted ACLs are represented by role equal to "none".
24 | Deleted ACLs will always be included if syncToken is provided. Optional. The default is False.
25 |
26 | :return:
27 | Iterable of `AccessControlRule` objects
28 | """
29 | calendar_id = calendar_id or self.default_calendar
30 | yield from self._list_paginated(
31 | self.service.acl().list,
32 | serializer_cls=ACLRuleSerializer,
33 | calendarId=calendar_id,
34 | **{
35 | 'showDeleted': show_deleted,
36 | }
37 | )
38 |
39 | def get_acl_rule(
40 | self,
41 | rule_id: str,
42 | calendar_id: Optional[str] = None
43 | ) -> AccessControlRule:
44 | """Returns an access control rule
45 |
46 | :param rule_id:
47 | ACL rule identifier.
48 | :param calendar_id:
49 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`.
50 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
51 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
52 |
53 | :return:
54 | The corresponding `AccessControlRule` object
55 | """
56 | calendar_id = calendar_id or self.default_calendar
57 | acl_rule_resource = self.service.acl().get(
58 | calendarId=calendar_id,
59 | ruleId=rule_id
60 | ).execute()
61 | return ACLRuleSerializer.to_object(acl_rule_resource)
62 |
63 | def add_acl_rule(
64 | self,
65 | acl_rule: AccessControlRule,
66 | send_notifications: bool = True,
67 | calendar_id: Optional[str] = None
68 | ):
69 | """Adds access control rule
70 |
71 | :param acl_rule:
72 | AccessControlRule object.
73 | :param send_notifications:
74 | Whether to send notifications about the calendar sharing change. The default is True.
75 | :param calendar_id:
76 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`.
77 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
78 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
79 |
80 | :return:
81 | Created access control rule with id.
82 | """
83 | calendar_id = calendar_id or self.default_calendar
84 | body = ACLRuleSerializer.to_json(acl_rule)
85 | acl_rule_json = self.service.acl().insert(
86 | calendarId=calendar_id,
87 | body=body,
88 | sendNotifications=send_notifications
89 | ).execute()
90 | return ACLRuleSerializer.to_object(acl_rule_json)
91 |
92 | def update_acl_rule(
93 | self,
94 | acl_rule: AccessControlRule,
95 | send_notifications: bool = True,
96 | calendar_id: Optional[str] = None
97 | ):
98 | """Updates given access control rule
99 |
100 | :param acl_rule:
101 | AccessControlRule object.
102 | :param send_notifications:
103 | Whether to send notifications about the calendar sharing change. The default is True.
104 | :param calendar_id:
105 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`.
106 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
107 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
108 |
109 | :return:
110 | Updated access control rule.
111 | """
112 | calendar_id = calendar_id or self.default_calendar
113 | acl_id = self._get_resource_id(acl_rule)
114 | body = ACLRuleSerializer.to_json(acl_rule)
115 | acl_json = self.service.acl().update(
116 | calendarId=calendar_id,
117 | ruleId=acl_id,
118 | body=body,
119 | sendNotifications=send_notifications
120 | ).execute()
121 | return ACLRuleSerializer.to_object(acl_json)
122 |
123 | def delete_acl_rule(
124 | self,
125 | acl_rule: Union[AccessControlRule, str],
126 | calendar_id: Optional[str] = None
127 | ):
128 | """Deletes access control rule.
129 |
130 | :param acl_rule:
131 | Access control rule's ID or `AccessControlRule` object with set `acl_id`.
132 | :param calendar_id:
133 | Calendar identifier. Default is `default_calendar` specified in `GoogleCalendar`.
134 | To retrieve calendar IDs call the :py:meth:`~gcsa.google_calendar.GoogleCalendar.get_calendar_list`.
135 | If you want to access the primary calendar of the currently logged-in user, use the "primary" keyword.
136 | """
137 | calendar_id = calendar_id or self.default_calendar
138 | acl_id = self._get_resource_id(acl_rule)
139 |
140 | self.service.acl().delete(
141 | calendarId=calendar_id,
142 | ruleId=acl_id
143 | ).execute()
144 |
--------------------------------------------------------------------------------
/tests/test_reminder.py:
--------------------------------------------------------------------------------
1 | from datetime import time, datetime, date
2 | from unittest import TestCase
3 |
4 | from beautiful_date import Apr
5 |
6 | from gcsa.reminders import Reminder, EmailReminder, PopupReminder
7 | from gcsa.serializers.reminder_serializer import ReminderSerializer
8 |
9 |
10 | class TestReminder(TestCase):
11 | def test_email_reminder(self):
12 | reminder = EmailReminder()
13 | self.assertEqual(reminder.method, 'email')
14 | self.assertEqual(reminder.minutes_before_start, 60)
15 |
16 | reminder = EmailReminder(34)
17 | self.assertEqual(reminder.method, 'email')
18 | self.assertEqual(reminder.minutes_before_start, 34)
19 |
20 | reminder = EmailReminder(days_before=1, at=time(0, 0))
21 | self.assertEqual(reminder.method, 'email')
22 | self.assertEqual(reminder.minutes_before_start, None)
23 | self.assertEqual(reminder.days_before, 1)
24 | self.assertEqual(reminder.at, time(0, 0))
25 |
26 | def test_popup_reminder(self):
27 | reminder = PopupReminder()
28 | self.assertEqual(reminder.method, 'popup')
29 | self.assertEqual(reminder.minutes_before_start, 30)
30 |
31 | reminder = PopupReminder(51)
32 | self.assertEqual(reminder.method, 'popup')
33 | self.assertEqual(reminder.minutes_before_start, 51)
34 |
35 | reminder = PopupReminder(days_before=1, at=time(0, 0))
36 | self.assertEqual(reminder.method, 'popup')
37 | self.assertEqual(reminder.minutes_before_start, None)
38 | self.assertEqual(reminder.days_before, 1)
39 | self.assertEqual(reminder.at, time(0, 0))
40 |
41 | def test_repr_str(self):
42 | reminder = EmailReminder(34)
43 | self.assertEqual(reminder.__repr__(), "")
44 | self.assertEqual(reminder.__str__(), "EmailReminder - minutes_before_start:34")
45 |
46 | reminder = PopupReminder(days_before=1, at=time(0, 0))
47 | self.assertEqual(reminder.__repr__(), "")
48 | self.assertEqual(reminder.__str__(), "PopupReminder - 1 days before at 00:00:00")
49 |
50 | def test_absolute_reminders_conversion(self):
51 | absolute_reminder = EmailReminder(days_before=1, at=time(12, 0))
52 | reminder = absolute_reminder.convert_to_relative(datetime(2024, 4, 16, 10, 15))
53 | self.assertEqual(reminder.method, 'email')
54 | self.assertEqual(reminder.minutes_before_start, (12 + 10) * 60 + 15)
55 |
56 | absolute_reminder = PopupReminder(days_before=2, at=time(11, 30))
57 | reminder = absolute_reminder.convert_to_relative(date(2024, 4, 16))
58 | self.assertEqual(reminder.method, 'popup')
59 | self.assertEqual(reminder.minutes_before_start, 24 * 60 + 12 * 60 + 30)
60 |
61 | absolute_reminder = PopupReminder(days_before=5, at=time(10, 25))
62 | reminder = absolute_reminder.convert_to_relative(16 / Apr / 2024)
63 | self.assertEqual(reminder.method, 'popup')
64 | self.assertEqual(reminder.minutes_before_start, 4 * 24 * 60 + 13 * 60 + 35)
65 |
66 | def test_absolute_reminder_conversion_missing_fields(self):
67 | absolute_reminder = PopupReminder(days_before=5, at=time(10, 25))
68 | absolute_reminder.at = None
69 | with self.assertRaises(ValueError):
70 | absolute_reminder.convert_to_relative(16 / Apr / 2024)
71 |
72 | absolute_reminder = PopupReminder(days_before=5, at=time(10, 25))
73 | absolute_reminder.days_before = None
74 | with self.assertRaises(ValueError):
75 | absolute_reminder.convert_to_relative(16 / Apr / 2024)
76 |
77 | def test_reminder_checks(self):
78 | # No time provided
79 | with self.assertRaises(ValueError):
80 | Reminder(method='email')
81 |
82 | # Both relative and absolute times provided
83 | with self.assertRaises(ValueError):
84 | Reminder(method='email', minutes_before_start=22, days_before=1)
85 | with self.assertRaises(ValueError):
86 | Reminder(method='email', minutes_before_start=22, at=time(0, 0))
87 |
88 | # Only one of days_before and at provided
89 | with self.assertRaises(ValueError):
90 | Reminder(method='email', days_before=1)
91 | with self.assertRaises(ValueError):
92 | Reminder(method='email', at=time(0, 0))
93 | with self.assertRaises(ValueError):
94 | PopupReminder(days_before=1)
95 | with self.assertRaises(ValueError):
96 | EmailReminder(at=time(0, 0))
97 |
98 |
99 | class TestReminderSerializer(TestCase):
100 | def test_to_json(self):
101 | reminder_json = {
102 | 'method': 'email',
103 | 'minutes': 55
104 | }
105 | reminder = EmailReminder(55)
106 |
107 | self.assertDictEqual(ReminderSerializer.to_json(reminder), reminder_json)
108 |
109 | reminder_json = {
110 | 'method': 'popup',
111 | 'minutes': 13
112 | }
113 | reminder = PopupReminder(13)
114 |
115 | self.assertDictEqual(ReminderSerializer.to_json(reminder), reminder_json)
116 |
117 | serializer = ReminderSerializer(reminder)
118 | self.assertDictEqual(serializer.get_json(), reminder_json)
119 |
120 | def test_to_object(self):
121 | reminder_json = {
122 | 'method': 'email',
123 | 'minutes': 55
124 | }
125 |
126 | reminder = ReminderSerializer.to_object(reminder_json)
127 |
128 | self.assertIsInstance(reminder, EmailReminder)
129 | self.assertEqual(reminder.minutes_before_start, 55)
130 |
131 | reminder_json = {
132 | 'method': 'popup',
133 | 'minutes': 33
134 | }
135 |
136 | reminder = ReminderSerializer.to_object(reminder_json)
137 |
138 | self.assertIsInstance(reminder, PopupReminder)
139 | self.assertEqual(reminder.minutes_before_start, 33)
140 |
141 | reminder_json_str = """{
142 | "method": "popup",
143 | "minutes": 22
144 | }"""
145 |
146 | reminder = ReminderSerializer.to_object(reminder_json_str)
147 |
148 | self.assertIsInstance(reminder, PopupReminder)
149 | self.assertEqual(reminder.minutes_before_start, 22)
150 |
151 | with self.assertRaises(ValueError):
152 | reminder_json = {
153 | 'method': 'telegram',
154 | 'minutes': 33
155 | }
156 |
157 | ReminderSerializer.to_object(reminder_json)
158 |
--------------------------------------------------------------------------------