├── 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 |
4 | Star 6 |
7 | {% endblock %} 8 | {% block footer %}{{ super() }} 9 | 10 | {% endblock %} -------------------------------------------------------------------------------- /docs/source/_templates/footer.html: -------------------------------------------------------------------------------- 1 | {% extends "!footer.html" %} 2 | {% block extrafooter %}{{ super() }} 3 |
4 |
5 | Portions of this page are reproduced from and/or are modifications based on work created and 6 | shared by Google 7 | and used according to terms described in the 8 | Creative Commons 4.0 Attribution License. 9 |
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 | --------------------------------------------------------------------------------