contextbase-plugin-google-calendar 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.3
2
+ Name: contextbase-plugin-google-calendar
3
+ Version: 0.2.3
4
+ Summary: Google Calendar plugin for ContextBase
5
+ Author: Alizain Feerasta
6
+ Author-email: Alizain Feerasta <alizain.feerasta@gmail.com>
7
+ Requires-Dist: contextbase-shared-plugins==0.2.3
8
+ Requires-Dist: dagster==1.12.14
9
+ Requires-Dist: dagster-dlt==0.28.14
10
+ Requires-Dist: dlt>=1.26.0
11
+ Requires-Dist: google-api-python-client>=2.185.0
12
+ Requires-Dist: pydantic>=2.12.0
13
+ Requires-Python: >=3.14, <3.15
@@ -0,0 +1,17 @@
1
+ plugin_google_calendar/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ plugin_google_calendar/binding_config.py,sha256=ywTFcaL5NnKKjc3c-Kx0XJTdAIVHfF9Oxuquew-c6GY,165
3
+ plugin_google_calendar/component.py,sha256=VHDK5ZuBz7E0aTHYA07SlWhGTmCk_e7TabCGOb_Tl40,4212
4
+ plugin_google_calendar/defs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ plugin_google_calendar/defs/defs.yaml,sha256=RWet1IW81pbom10Badvi6xu9RFcTUcDBvhbT8XCUybY,67
6
+ plugin_google_calendar/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ plugin_google_calendar/models/ctx.py,sha256=UP2pXtTZD9bflxIUegCaX-12CWfnCs7-bWvG3VcJ1p4,3263
8
+ plugin_google_calendar/models/ingress.py,sha256=vV5tUlX6JLXuMH52tdBuYGhzDGSlnfOY_Gjn2szUHNw,13797
9
+ plugin_google_calendar/models/translators.py,sha256=Ji8-3T19hjvVp01i-7VaYBZe2Hn1uyLGgX_kI7qg_es,7225
10
+ plugin_google_calendar/plugin.json,sha256=x1zAlAetAtZwfvIdRht5SXqWjWxHgSEpuNqN5VbxGIk,183
11
+ plugin_google_calendar/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ plugin_google_calendar/sources/sync.py,sha256=hFbtIN6GiiZaE7QBY4T6GUu2wcddSFhSCIxGiTzl_u8,8403
13
+ plugin_google_calendar/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ plugin_google_calendar/utils/client.py,sha256=xhGt_1T7uH5lzy3TZFGAdgFuwjTpp4k3JtFN1vmss6o,3673
15
+ contextbase_plugin_google_calendar-0.2.3.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
16
+ contextbase_plugin_google_calendar-0.2.3.dist-info/METADATA,sha256=2DfwQiqk-EZFrZuiMPu-3X63p3Zp1pgKXLn_mNFYnBE,467
17
+ contextbase_plugin_google_calendar-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.15
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
File without changes
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from shared_plugins.bindings import BaseBindingConfigModel
4
+
5
+
6
+ class GoogleCalendarBindingConfig(BaseBindingConfigModel):
7
+ pass
@@ -0,0 +1,126 @@
1
+ import dagster as dg
2
+ from dagster import AssetExecutionContext
3
+ from dagster_dlt import DagsterDltResource
4
+ from shared_plugins.automation import non_overlapping_automation_condition
5
+ from shared_plugins.bindings import (
6
+ parse_binding_config,
7
+ require_authenticated_account,
8
+ )
9
+ from shared_plugins.control_plane import ControlPlaneClient
10
+ from shared_plugins.dlt import resolve_partition_binding, run_dlt_pipeline
11
+ from shared_plugins.google_client.auth import (
12
+ build_google_service,
13
+ )
14
+ from shared_plugins.resources import DLT_RESOURCE
15
+ from shared_plugins.naming import (
16
+ dagster_asset_group_name,
17
+ dagster_asset_tags,
18
+ dagster_dlt_asset_key,
19
+ dagster_partition_def_name,
20
+ dagster_pool_name,
21
+ dlt_source_name,
22
+ plugin_id_from_module,
23
+ )
24
+
25
+ from .binding_config import GoogleCalendarBindingConfig
26
+ from .sources.sync import google_calendar_sync_source
27
+ from .utils.client import (
28
+ GoogleCalendarApiClient,
29
+ )
30
+
31
+ PLUGIN_ID = plugin_id_from_module(__file__)
32
+ SYNC_JOB = "sync"
33
+ SYNC_SOURCE_NAME = dlt_source_name(PLUGIN_ID, SYNC_JOB)
34
+
35
+
36
+ def _build_sync_specs(
37
+ partitions_def: dg.PartitionsDefinition,
38
+ automation_condition: dg.AutomationCondition,
39
+ ) -> list[dg.AssetSpec]:
40
+ shared = dict(
41
+ group_name=dagster_asset_group_name(PLUGIN_ID),
42
+ tags=dagster_asset_tags(PLUGIN_ID),
43
+ automation_condition=automation_condition,
44
+ partitions_def=partitions_def,
45
+ )
46
+
47
+ calendars_key = dagster_dlt_asset_key(SYNC_SOURCE_NAME, "calendars")
48
+ return [
49
+ dg.AssetSpec(key=calendars_key, **shared),
50
+ dg.AssetSpec(
51
+ key=dagster_dlt_asset_key(SYNC_SOURCE_NAME, "events"),
52
+ deps=[calendars_key],
53
+ **shared,
54
+ ),
55
+ ]
56
+
57
+
58
+ class GoogleCalendarSyncComponent(dg.Component):
59
+ def build_defs(self, context: dg.ComponentLoadContext) -> dg.Definitions:
60
+ partitions_def = dg.DynamicPartitionsDefinition(
61
+ name=dagster_partition_def_name(PLUGIN_ID)
62
+ )
63
+
64
+ sync_specs = _build_sync_specs(
65
+ partitions_def,
66
+ non_overlapping_automation_condition(
67
+ dg.AutomationCondition.on_missing()
68
+ | dg.AutomationCondition.on_cron("*/15 * * * *")
69
+ ),
70
+ )
71
+
72
+ @dg.multi_asset(
73
+ specs=sync_specs,
74
+ can_subset=True,
75
+ name="google_calendar_sync",
76
+ pool=dagster_pool_name(PLUGIN_ID),
77
+ )
78
+ def google_calendar_sync_assets(
79
+ context: AssetExecutionContext,
80
+ dlt_resource: DagsterDltResource,
81
+ control_plane: dg.ResourceParam[ControlPlaneClient],
82
+ ):
83
+ binding = resolve_partition_binding(
84
+ context=context,
85
+ control_plane=control_plane,
86
+ plugin_id=PLUGIN_ID,
87
+ )
88
+ binding_id = str(binding.binding_id)
89
+ parse_binding_config(binding, GoogleCalendarBindingConfig)
90
+ authenticated_account = require_authenticated_account(binding)
91
+ client = GoogleCalendarApiClient(
92
+ service=build_google_service(
93
+ api_name="calendar",
94
+ api_version="v3",
95
+ auth=authenticated_account,
96
+ control_plane=control_plane,
97
+ ),
98
+ )
99
+
100
+ source = google_calendar_sync_source(
101
+ binding_id,
102
+ client=client,
103
+ )
104
+ yield from run_dlt_pipeline(
105
+ context=context,
106
+ dlt_resource=dlt_resource,
107
+ source=source,
108
+ plugin_id=PLUGIN_ID,
109
+ binding_id=binding_id,
110
+ job_name=SYNC_JOB,
111
+ )
112
+
113
+ automation_sensor = dg.AutomationConditionSensorDefinition(
114
+ name="google_calendar_automation_sensor",
115
+ target=dg.AssetSelection.assets(google_calendar_sync_assets),
116
+ default_status=dg.DefaultSensorStatus.RUNNING,
117
+ minimum_interval_seconds=30,
118
+ )
119
+
120
+ return dg.Definitions(
121
+ assets=[google_calendar_sync_assets],
122
+ sensors=[automation_sensor],
123
+ resources={
124
+ "dlt_resource": DLT_RESOURCE,
125
+ },
126
+ )
File without changes
@@ -0,0 +1 @@
1
+ type: plugin_google_calendar.component.GoogleCalendarSyncComponent
File without changes
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import AwareDatetime
6
+ from shared_plugins.models import CtxModel, IdStr, NonNegativeInt
7
+
8
+
9
+ class CalendarRow(CtxModel):
10
+ id: IdStr
11
+ etag: str | None = None
12
+ summary: str | None = None
13
+ summary_override: str | None = None
14
+ description: str | None = None
15
+ location: str | None = None
16
+ time_zone: str | None = None
17
+ access_role: str | None = None
18
+ primary: bool | None = None
19
+ selected: bool | None = None
20
+ hidden: bool | None = None
21
+ is_deleted: bool = False
22
+ auto_accept_invitations: bool | None = None
23
+ data_owner: str | None = None
24
+ color_id: str | None = None
25
+ background_color: str | None = None
26
+ foreground_color: str | None = None
27
+ default_reminders: list[dict[str, Any]] | None = None
28
+ notification_settings: dict[str, Any] | None = None
29
+ conference_properties: dict[str, Any] | None = None
30
+
31
+
32
+ class EventRow(CtxModel):
33
+ calendar_id: IdStr
34
+ id: IdStr
35
+ etag: str | None = None
36
+ status: str | None = None
37
+ event_type: str | None = None
38
+ is_cancelled: bool = False
39
+ is_all_day: bool = False
40
+ is_recurring_instance: bool = False
41
+ i_cal_uid: str | None = None
42
+ recurring_event_id: str | None = None
43
+ sequence: NonNegativeInt | None = None
44
+ original_start_date: str | None = None
45
+ original_start_date_time: AwareDatetime | None = None
46
+ original_start_time_zone: str | None = None
47
+ summary: str | None = None
48
+ description: str | None = None
49
+ location: str | None = None
50
+ color_id: str | None = None
51
+ html_link: str | None = None
52
+ hangout_link: str | None = None
53
+ creator_email: str | None = None
54
+ creator_display_name: str | None = None
55
+ creator_self: bool | None = None
56
+ organizer_email: str | None = None
57
+ organizer_display_name: str | None = None
58
+ organizer_self: bool | None = None
59
+ created_at: AwareDatetime | None = None
60
+ updated_at: AwareDatetime | None = None
61
+ start_date: str | None = None
62
+ start_date_time: AwareDatetime | None = None
63
+ start_time_zone: str | None = None
64
+ end_date: str | None = None
65
+ end_date_time: AwareDatetime | None = None
66
+ end_time_zone: str | None = None
67
+ end_time_unspecified: bool | None = None
68
+ transparency: str | None = None
69
+ visibility: str | None = None
70
+ locked: bool | None = None
71
+ private_copy: bool | None = None
72
+ attendees_omitted: bool | None = None
73
+ anyone_can_add_self: bool | None = None
74
+ guests_can_invite_others: bool | None = None
75
+ guests_can_modify: bool | None = None
76
+ guests_can_see_other_guests: bool | None = None
77
+ recurrence: list[str] | None = None
78
+ attendees: list[dict[str, Any]] | None = None
79
+ attachments: list[dict[str, Any]] | None = None
80
+ conference_data: dict[str, Any] | None = None
81
+ reminders: dict[str, Any] | None = None
82
+ source: dict[str, Any] | None = None
83
+ extended_properties_private: dict[str, str] | None = None
84
+ extended_properties_shared: dict[str, str] | None = None
85
+ out_of_office_properties: dict[str, Any] | None = None
86
+ focus_time_properties: dict[str, Any] | None = None
87
+ working_location_properties: dict[str, Any] | None = None
88
+ birthday_properties: dict[str, Any] | None = None
@@ -0,0 +1,384 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import AwareDatetime, Field, field_validator
4
+ from typing import Annotated
5
+
6
+ from shared_plugins.models import IdStr, IngressModel
7
+ from shared_plugins.values import parse_utc_datetime_from_str
8
+
9
+ NonNegativeInt = Annotated[int, Field(ge=0, strict=True)]
10
+
11
+
12
+ class CalendarDefaultReminderIngress(IngressModel):
13
+ method: str | None = None
14
+ minutes: NonNegativeInt | None = None
15
+
16
+
17
+ class CalendarNotificationIngress(IngressModel):
18
+ type: str | None = None
19
+ method: str | None = None
20
+
21
+
22
+ class CalendarNotificationSettingsIngress(IngressModel):
23
+ notifications: list[CalendarNotificationIngress] = Field(default_factory=list)
24
+
25
+
26
+ class CalendarConferencePropertiesIngress(IngressModel):
27
+ allowed_conference_solution_types: list[str] = Field(
28
+ default_factory=list,
29
+ alias="allowedConferenceSolutionTypes",
30
+ )
31
+
32
+
33
+ class GoogleCalendarCalendarListItemIngress(IngressModel):
34
+ kind: str | None = None
35
+ id: IdStr
36
+ etag: str | None = None
37
+ summary: str | None = None
38
+ summary_override: str | None = Field(default=None, alias="summaryOverride")
39
+ description: str | None = None
40
+ location: str | None = None
41
+ time_zone: str | None = Field(default=None, alias="timeZone")
42
+ access_role: str | None = Field(default=None, alias="accessRole")
43
+ primary: bool | None = Field(default=None, strict=True)
44
+ selected: bool | None = Field(default=None, strict=True)
45
+ hidden: bool | None = Field(default=None, strict=True)
46
+ deleted: bool | None = Field(default=None, strict=True)
47
+ auto_accept_invitations: bool | None = Field(
48
+ default=None, alias="autoAcceptInvitations", strict=True
49
+ )
50
+ data_owner: str | None = Field(default=None, alias="dataOwner")
51
+ color_id: str | None = Field(default=None, alias="colorId")
52
+ background_color: str | None = Field(default=None, alias="backgroundColor")
53
+ foreground_color: str | None = Field(default=None, alias="foregroundColor")
54
+ default_reminders: list[CalendarDefaultReminderIngress] = Field(
55
+ default_factory=list,
56
+ alias="defaultReminders",
57
+ )
58
+ notification_settings: CalendarNotificationSettingsIngress | None = Field(
59
+ default=None,
60
+ alias="notificationSettings",
61
+ )
62
+ conference_properties: CalendarConferencePropertiesIngress | None = Field(
63
+ default=None,
64
+ alias="conferenceProperties",
65
+ )
66
+
67
+
68
+ class GoogleCalendarCalendarListResponseIngress(IngressModel):
69
+ kind: str | None = None
70
+ etag: str | None = None
71
+ items: list[GoogleCalendarCalendarListItemIngress] = Field(default_factory=list)
72
+ next_page_token: str | None = Field(default=None, alias="nextPageToken")
73
+ next_sync_token: str | None = Field(default=None, alias="nextSyncToken")
74
+
75
+
76
+ class EventDateTimeIngress(IngressModel):
77
+ date: str | None = None
78
+ date_time: AwareDatetime | None = Field(default=None, alias="dateTime")
79
+ time_zone: str | None = Field(default=None, alias="timeZone")
80
+
81
+ @field_validator("date_time", mode="before")
82
+ @classmethod
83
+ def _coerce_date_time(cls, value: object) -> object:
84
+ if not isinstance(value, str):
85
+ return value
86
+ return parse_utc_datetime_from_str(value)
87
+
88
+
89
+ class EventPersonIngress(IngressModel):
90
+ id: str | None = None
91
+ email: str | None = None
92
+ display_name: str | None = Field(default=None, alias="displayName")
93
+ is_self: bool | None = Field(default=None, alias="self", strict=True)
94
+
95
+
96
+ class EventAttendeeIngress(EventPersonIngress):
97
+ organizer: bool | None = Field(default=None, strict=True)
98
+ resource: bool | None = Field(default=None, strict=True)
99
+ optional: bool | None = Field(default=None, strict=True)
100
+ response_status: str | None = Field(default=None, alias="responseStatus")
101
+ comment: str | None = None
102
+ additional_guests: NonNegativeInt | None = Field(
103
+ default=None,
104
+ alias="additionalGuests",
105
+ )
106
+
107
+
108
+ class EventAttachmentIngress(IngressModel):
109
+ file_url: str | None = Field(default=None, alias="fileUrl")
110
+ title: str | None = None
111
+ mime_type: str | None = Field(default=None, alias="mimeType")
112
+ icon_link: str | None = Field(default=None, alias="iconLink")
113
+ file_id: str | None = Field(default=None, alias="fileId")
114
+
115
+
116
+ class EventConferenceSolutionKeyIngress(IngressModel):
117
+ type: str | None = None
118
+
119
+
120
+ class EventConferenceSolutionIngress(IngressModel):
121
+ key: EventConferenceSolutionKeyIngress | None = None
122
+ name: str | None = None
123
+ icon_uri: str | None = Field(default=None, alias="iconUri")
124
+
125
+
126
+ class EventConferenceEntryPointIngress(IngressModel):
127
+ entry_point_type: str | None = Field(default=None, alias="entryPointType")
128
+ uri: str | None = None
129
+ label: str | None = None
130
+ pin: str | None = None
131
+ access_code: str | None = Field(default=None, alias="accessCode")
132
+ meeting_code: str | None = Field(default=None, alias="meetingCode")
133
+ passcode: str | None = None
134
+ password: str | None = None
135
+ region_code: str | None = Field(default=None, alias="regionCode")
136
+ entry_point_features: list[str] = Field(
137
+ default_factory=list,
138
+ alias="entryPointFeatures",
139
+ )
140
+
141
+
142
+ class EventConferenceCreateRequestStatusIngress(IngressModel):
143
+ status_code: str | None = Field(default=None, alias="statusCode")
144
+
145
+
146
+ class EventConferenceCreateRequestIngress(IngressModel):
147
+ request_id: str | None = Field(default=None, alias="requestId")
148
+ conference_solution_key: EventConferenceSolutionKeyIngress | None = Field(
149
+ default=None,
150
+ alias="conferenceSolutionKey",
151
+ )
152
+ status: EventConferenceCreateRequestStatusIngress | None = None
153
+
154
+
155
+ class EventConferenceParametersAddOnIngress(IngressModel):
156
+ parameters: dict[str, str] = Field(default_factory=dict)
157
+
158
+
159
+ class EventConferenceParametersIngress(IngressModel):
160
+ add_on_parameters: EventConferenceParametersAddOnIngress | None = Field(
161
+ default=None,
162
+ alias="addOnParameters",
163
+ )
164
+
165
+
166
+ class EventConferenceDataIngress(IngressModel):
167
+ conference_id: str | None = Field(default=None, alias="conferenceId")
168
+ conference_solution: EventConferenceSolutionIngress | None = Field(
169
+ default=None,
170
+ alias="conferenceSolution",
171
+ )
172
+ entry_points: list[EventConferenceEntryPointIngress] = Field(
173
+ default_factory=list,
174
+ alias="entryPoints",
175
+ )
176
+ notes: str | None = None
177
+ signature: str | None = None
178
+ create_request: EventConferenceCreateRequestIngress | None = Field(
179
+ default=None,
180
+ alias="createRequest",
181
+ )
182
+ parameters: EventConferenceParametersIngress | None = None
183
+
184
+
185
+ class EventGadgetIngress(IngressModel):
186
+ type: str | None = None
187
+ title: str | None = None
188
+ link: str | None = None
189
+ icon_link: str | None = Field(default=None, alias="iconLink")
190
+ width: NonNegativeInt | None = None
191
+ height: NonNegativeInt | None = None
192
+ display: str | None = None
193
+ preferences: dict[str, str] = Field(default_factory=dict)
194
+
195
+
196
+ class EventReminderOverrideIngress(IngressModel):
197
+ method: str | None = None
198
+ minutes: NonNegativeInt | None = None
199
+
200
+
201
+ class EventRemindersIngress(IngressModel):
202
+ use_default: bool | None = Field(default=None, alias="useDefault", strict=True)
203
+ overrides: list[EventReminderOverrideIngress] = Field(default_factory=list)
204
+
205
+
206
+ class EventSourceIngress(IngressModel):
207
+ url: str | None = None
208
+ title: str | None = None
209
+
210
+
211
+ class EventExtendedPropertiesIngress(IngressModel):
212
+ private: dict[str, str] = Field(default_factory=dict)
213
+ shared: dict[str, str] = Field(default_factory=dict)
214
+
215
+
216
+ class EventOutOfOfficePropertiesIngress(IngressModel):
217
+ auto_decline_mode: str | None = Field(default=None, alias="autoDeclineMode")
218
+ decline_message: str | None = Field(default=None, alias="declineMessage")
219
+
220
+
221
+ class EventFocusTimePropertiesIngress(IngressModel):
222
+ auto_decline_mode: str | None = Field(default=None, alias="autoDeclineMode")
223
+ decline_message: str | None = Field(default=None, alias="declineMessage")
224
+ chat_status: str | None = Field(default=None, alias="chatStatus")
225
+
226
+
227
+ class EventWorkingLocationHomeOfficeIngress(IngressModel):
228
+ pass
229
+
230
+
231
+ class EventWorkingLocationCustomLocationIngress(IngressModel):
232
+ label: str | None = None
233
+
234
+
235
+ class EventWorkingLocationOfficeLocationIngress(IngressModel):
236
+ building_id: str | None = Field(default=None, alias="buildingId")
237
+ floor_id: str | None = Field(default=None, alias="floorId")
238
+ floor_section_id: str | None = Field(default=None, alias="floorSectionId")
239
+ desk_id: str | None = Field(default=None, alias="deskId")
240
+ label: str | None = None
241
+
242
+
243
+ class EventWorkingLocationPropertiesIngress(IngressModel):
244
+ type: str | None = None
245
+ home_office: EventWorkingLocationHomeOfficeIngress | None = Field(
246
+ default=None,
247
+ alias="homeOffice",
248
+ )
249
+ custom_location: EventWorkingLocationCustomLocationIngress | None = Field(
250
+ default=None,
251
+ alias="customLocation",
252
+ )
253
+ office_location: EventWorkingLocationOfficeLocationIngress | None = Field(
254
+ default=None,
255
+ alias="officeLocation",
256
+ )
257
+
258
+
259
+ class EventBirthdayPropertiesIngress(IngressModel):
260
+ contact: str | None = None
261
+ type: str | None = None
262
+ custom_type_name: str | None = Field(default=None, alias="customTypeName")
263
+
264
+
265
+ class GoogleCalendarEventIngress(IngressModel):
266
+ kind: str | None = None
267
+ id: IdStr
268
+ etag: str | None = None
269
+ status: str | None = None
270
+ html_link: str | None = Field(default=None, alias="htmlLink")
271
+ created: AwareDatetime | None = None
272
+ updated: AwareDatetime | None = None
273
+ summary: str | None = None
274
+ description: str | None = None
275
+ location: str | None = None
276
+ color_id: str | None = Field(default=None, alias="colorId")
277
+ creator: EventPersonIngress | None = None
278
+ organizer: EventPersonIngress | None = None
279
+ start: EventDateTimeIngress | None = None
280
+ end: EventDateTimeIngress | None = None
281
+ end_time_unspecified: bool | None = Field(
282
+ default=None,
283
+ alias="endTimeUnspecified",
284
+ strict=True,
285
+ )
286
+ recurring_event_id: str | None = Field(default=None, alias="recurringEventId")
287
+ original_start_time: EventDateTimeIngress | None = Field(
288
+ default=None,
289
+ alias="originalStartTime",
290
+ )
291
+ transparency: str | None = None
292
+ visibility: str | None = None
293
+ i_cal_uid: str | None = Field(default=None, alias="iCalUID")
294
+ sequence: NonNegativeInt | None = None
295
+ attendees: list[EventAttendeeIngress] = Field(default_factory=list)
296
+ attendees_omitted: bool | None = Field(
297
+ default=None,
298
+ alias="attendeesOmitted",
299
+ strict=True,
300
+ )
301
+ hangout_link: str | None = Field(default=None, alias="hangoutLink")
302
+ conference_data: EventConferenceDataIngress | None = Field(
303
+ default=None,
304
+ alias="conferenceData",
305
+ )
306
+ gadget: EventGadgetIngress | None = None
307
+ anyone_can_add_self: bool | None = Field(
308
+ default=None,
309
+ alias="anyoneCanAddSelf",
310
+ strict=True,
311
+ )
312
+ guests_can_invite_others: bool | None = Field(
313
+ default=None,
314
+ alias="guestsCanInviteOthers",
315
+ strict=True,
316
+ )
317
+ guests_can_modify: bool | None = Field(
318
+ default=None,
319
+ alias="guestsCanModify",
320
+ strict=True,
321
+ )
322
+ guests_can_see_other_guests: bool | None = Field(
323
+ default=None,
324
+ alias="guestsCanSeeOtherGuests",
325
+ strict=True,
326
+ )
327
+ private_copy: bool | None = Field(default=None, alias="privateCopy", strict=True)
328
+ locked: bool | None = Field(default=None, strict=True)
329
+ reminders: EventRemindersIngress | None = None
330
+ source: EventSourceIngress | None = None
331
+ attachments: list[EventAttachmentIngress] = Field(default_factory=list)
332
+ event_type: str | None = Field(default=None, alias="eventType")
333
+ out_of_office_properties: EventOutOfOfficePropertiesIngress | None = Field(
334
+ default=None,
335
+ alias="outOfOfficeProperties",
336
+ )
337
+ focus_time_properties: EventFocusTimePropertiesIngress | None = Field(
338
+ default=None,
339
+ alias="focusTimeProperties",
340
+ )
341
+ working_location_properties: EventWorkingLocationPropertiesIngress | None = Field(
342
+ default=None,
343
+ alias="workingLocationProperties",
344
+ )
345
+ birthday_properties: EventBirthdayPropertiesIngress | None = Field(
346
+ default=None,
347
+ alias="birthdayProperties",
348
+ )
349
+ recurrence: list[str] = Field(default_factory=list)
350
+ extended_properties: EventExtendedPropertiesIngress | None = Field(
351
+ default=None,
352
+ alias="extendedProperties",
353
+ )
354
+
355
+ @field_validator("created", "updated", mode="before")
356
+ @classmethod
357
+ def _coerce_event_datetimes(cls, value: object) -> object:
358
+ if not isinstance(value, str):
359
+ return value
360
+ return parse_utc_datetime_from_str(value)
361
+
362
+
363
+ class GoogleCalendarEventsListResponseIngress(IngressModel):
364
+ kind: str | None = None
365
+ etag: str | None = None
366
+ summary: str | None = None
367
+ description: str | None = None
368
+ updated: AwareDatetime | None = None
369
+ time_zone: str | None = Field(default=None, alias="timeZone")
370
+ access_role: str | None = Field(default=None, alias="accessRole")
371
+ default_reminders: list[EventReminderOverrideIngress] = Field(
372
+ default_factory=list,
373
+ alias="defaultReminders",
374
+ )
375
+ items: list[GoogleCalendarEventIngress] = Field(default_factory=list)
376
+ next_page_token: str | None = Field(default=None, alias="nextPageToken")
377
+ next_sync_token: str | None = Field(default=None, alias="nextSyncToken")
378
+
379
+ @field_validator("updated", mode="before")
380
+ @classmethod
381
+ def _coerce_response_updated(cls, value: object) -> object:
382
+ if not isinstance(value, str):
383
+ return value
384
+ return parse_utc_datetime_from_str(value)
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from shared_plugins.models import IngressModel
8
+ from shared_plugins.values import require_non_empty_text
9
+
10
+ from .ctx import CalendarRow, EventRow
11
+ from .ingress import (
12
+ EventDateTimeIngress,
13
+ GoogleCalendarCalendarListItemIngress,
14
+ GoogleCalendarEventIngress,
15
+ )
16
+
17
+
18
+ def utc_now_iso() -> str:
19
+ return datetime.now(timezone.utc).isoformat()
20
+
21
+
22
+ def calendars_to_ctx_models(
23
+ binding_id: str,
24
+ calendars: Iterable[GoogleCalendarCalendarListItemIngress],
25
+ ) -> Iterator[CalendarRow]:
26
+ for calendar in calendars:
27
+ yield CalendarRow(
28
+ ctx_binding_id=binding_id,
29
+ id=calendar.id,
30
+ etag=calendar.etag,
31
+ summary=calendar.summary,
32
+ summary_override=calendar.summary_override,
33
+ description=calendar.description,
34
+ location=calendar.location,
35
+ time_zone=calendar.time_zone,
36
+ access_role=calendar.access_role,
37
+ primary=calendar.primary,
38
+ selected=calendar.selected,
39
+ hidden=calendar.hidden,
40
+ is_deleted=calendar.deleted is True,
41
+ auto_accept_invitations=calendar.auto_accept_invitations,
42
+ data_owner=calendar.data_owner,
43
+ color_id=calendar.color_id,
44
+ background_color=calendar.background_color,
45
+ foreground_color=calendar.foreground_color,
46
+ default_reminders=_dump_model_list(calendar.default_reminders),
47
+ notification_settings=_dump_optional_model(calendar.notification_settings),
48
+ conference_properties=_dump_optional_model(calendar.conference_properties),
49
+ )
50
+
51
+
52
+ def events_to_ctx_models(
53
+ binding_id: str,
54
+ calendar_id: str,
55
+ events: Iterable[GoogleCalendarEventIngress],
56
+ ) -> Iterator[EventRow]:
57
+ normalized_calendar_id = require_non_empty_text(
58
+ calendar_id,
59
+ label="calendar_id",
60
+ context="Google Calendar payload",
61
+ )
62
+
63
+ for event in events:
64
+ event_id = require_non_empty_text(
65
+ event.id,
66
+ label="id",
67
+ context="Google Calendar payload",
68
+ )
69
+ is_recurring_instance = event.recurring_event_id is not None
70
+ if is_recurring_instance and not _has_original_start(event.original_start_time):
71
+ raise RuntimeError(
72
+ "Google Calendar recurring instance is missing originalStartTime."
73
+ )
74
+
75
+ yield EventRow(
76
+ ctx_binding_id=binding_id,
77
+ ctx_source_updated_at=event.updated,
78
+ calendar_id=normalized_calendar_id,
79
+ id=event_id,
80
+ etag=event.etag,
81
+ status=event.status,
82
+ event_type=event.event_type,
83
+ is_cancelled=event.status == "cancelled",
84
+ is_all_day=event.start is not None and event.start.date is not None,
85
+ is_recurring_instance=is_recurring_instance,
86
+ i_cal_uid=event.i_cal_uid,
87
+ recurring_event_id=event.recurring_event_id,
88
+ sequence=event.sequence,
89
+ original_start_date=(
90
+ event.original_start_time.date if event.original_start_time else None
91
+ ),
92
+ original_start_date_time=(
93
+ event.original_start_time.date_time
94
+ if event.original_start_time
95
+ else None
96
+ ),
97
+ original_start_time_zone=(
98
+ event.original_start_time.time_zone
99
+ if event.original_start_time
100
+ else None
101
+ ),
102
+ summary=event.summary,
103
+ description=event.description,
104
+ location=event.location,
105
+ color_id=event.color_id,
106
+ html_link=event.html_link,
107
+ hangout_link=event.hangout_link,
108
+ creator_email=event.creator.email if event.creator else None,
109
+ creator_display_name=event.creator.display_name if event.creator else None,
110
+ creator_self=event.creator.is_self if event.creator else None,
111
+ organizer_email=event.organizer.email if event.organizer else None,
112
+ organizer_display_name=(
113
+ event.organizer.display_name if event.organizer else None
114
+ ),
115
+ organizer_self=event.organizer.is_self if event.organizer else None,
116
+ created_at=event.created,
117
+ updated_at=event.updated,
118
+ start_date=event.start.date if event.start else None,
119
+ start_date_time=event.start.date_time if event.start else None,
120
+ start_time_zone=event.start.time_zone if event.start else None,
121
+ end_date=event.end.date if event.end else None,
122
+ end_date_time=event.end.date_time if event.end else None,
123
+ end_time_zone=event.end.time_zone if event.end else None,
124
+ end_time_unspecified=event.end_time_unspecified,
125
+ transparency=event.transparency,
126
+ visibility=event.visibility,
127
+ locked=event.locked,
128
+ private_copy=event.private_copy,
129
+ attendees_omitted=event.attendees_omitted,
130
+ anyone_can_add_self=event.anyone_can_add_self,
131
+ guests_can_invite_others=event.guests_can_invite_others,
132
+ guests_can_modify=event.guests_can_modify,
133
+ guests_can_see_other_guests=event.guests_can_see_other_guests,
134
+ recurrence=list(event.recurrence) or None,
135
+ attendees=_dump_model_list(event.attendees),
136
+ attachments=_dump_model_list(event.attachments),
137
+ conference_data=_dump_optional_model(event.conference_data),
138
+ reminders=_dump_optional_model(event.reminders),
139
+ source=_dump_optional_model(event.source),
140
+ extended_properties_private=(
141
+ dict(event.extended_properties.private)
142
+ if event.extended_properties and event.extended_properties.private
143
+ else None
144
+ ),
145
+ extended_properties_shared=(
146
+ dict(event.extended_properties.shared)
147
+ if event.extended_properties and event.extended_properties.shared
148
+ else None
149
+ ),
150
+ out_of_office_properties=_dump_optional_model(
151
+ event.out_of_office_properties
152
+ ),
153
+ focus_time_properties=_dump_optional_model(event.focus_time_properties),
154
+ working_location_properties=_dump_optional_model(
155
+ event.working_location_properties
156
+ ),
157
+ birthday_properties=_dump_optional_model(event.birthday_properties),
158
+ )
159
+
160
+
161
+ def _dump_optional_model(model: IngressModel | None) -> dict[str, Any] | None:
162
+ if model is None:
163
+ return None
164
+ dumped = model.model_dump(by_alias=True, exclude_none=True)
165
+ return dumped or None
166
+
167
+
168
+ def _dump_model_list(models: Iterable[IngressModel]) -> list[dict[str, Any]] | None:
169
+ dumped = [model.model_dump(by_alias=True, exclude_none=True) for model in models]
170
+ return dumped or None
171
+
172
+
173
+ def _has_original_start(value: EventDateTimeIngress | None) -> bool:
174
+ if value is None:
175
+ return False
176
+ return value.date is not None or value.date_time is not None
@@ -0,0 +1,9 @@
1
+ {
2
+ "auth": {
3
+ "provider_id": "google",
4
+ "scopes": ["https://www.googleapis.com/auth/calendar.readonly"],
5
+ "type": "oauth"
6
+ },
7
+ "mode": "dagster",
8
+ "plugin_id": "google_calendar"
9
+ }
File without changes
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any
7
+
8
+ import dlt
9
+ from shared_plugins.exceptions import PluginCursorExpiredError
10
+ from shared_plugins.google_client.http_errors import extract_http_status_code
11
+ from shared_plugins.naming import (
12
+ dlt_resource_name,
13
+ dlt_source_name,
14
+ plugin_id_from_module,
15
+ )
16
+ from shared_plugins.resources import ctx_dlt_resource
17
+ from shared_plugins.values import coerce_non_empty_string_mapping
18
+
19
+ from ..models.ctx import CalendarRow, EventRow
20
+ from ..models.translators import (
21
+ calendars_to_ctx_models,
22
+ events_to_ctx_models,
23
+ utc_now_iso,
24
+ )
25
+ from ..utils.client import GoogleCalendarApiClient
26
+
27
+ ACTIVE_CALENDAR_IDS_KEY = "active_calendar_ids"
28
+ EVENTS_SYNC_TOKENS_KEY = "events_sync_tokens"
29
+ EVENTS_LAST_SYNCED_AT_KEY = "events_last_synced_at"
30
+ PLUGIN_ID = plugin_id_from_module(__file__)
31
+ JOB = "sync"
32
+ FORCE_FULL_SYNC = False
33
+ HARD_DELETE_ON_CANCELLED_EVENT = True
34
+ LOGGER = logging.getLogger(__name__)
35
+
36
+
37
+ def prune_events_sync_tokens(
38
+ active_calendar_ids: Iterable[str],
39
+ events_sync_tokens: Mapping[str, str],
40
+ ) -> dict[str, str]:
41
+ active_set = {
42
+ calendar_id.strip()
43
+ for calendar_id in active_calendar_ids
44
+ if calendar_id.strip()
45
+ }
46
+ return {
47
+ calendar_id: token
48
+ for calendar_id, token in events_sync_tokens.items()
49
+ if calendar_id in active_set and token
50
+ }
51
+
52
+
53
+ def collect_calendar_snapshot(
54
+ *,
55
+ binding_id: str,
56
+ client: GoogleCalendarApiClient,
57
+ ) -> tuple[list[CalendarRow], list[str]]:
58
+ rows: list[CalendarRow] = []
59
+ active_calendar_ids: set[str] = set()
60
+ page_count = 0
61
+
62
+ for page in client.iter_calendar_list_pages():
63
+ page_count += 1
64
+ for row in calendars_to_ctx_models(binding_id, page.items):
65
+ rows.append(row)
66
+
67
+ if not row.is_deleted:
68
+ active_calendar_ids.add(row.id)
69
+
70
+ sorted_active = sorted(active_calendar_ids)
71
+ LOGGER.info(
72
+ "google_calendar.calendars.snapshot binding_id=%s total=%d active=%d deleted=%d pages=%d",
73
+ binding_id,
74
+ len(rows),
75
+ len(sorted_active),
76
+ len(rows) - len(sorted_active),
77
+ page_count,
78
+ )
79
+ return rows, sorted_active
80
+
81
+
82
+ def iter_events_for_active_calendars(
83
+ *,
84
+ binding_id: str,
85
+ client: GoogleCalendarApiClient,
86
+ active_calendar_ids: Iterable[str],
87
+ events_sync_tokens: Mapping[str, str],
88
+ next_sync_tokens: dict[str, str],
89
+ ) -> Iterable[EventRow]:
90
+ sorted_ids = sorted(
91
+ {
92
+ calendar_id.strip()
93
+ for calendar_id in active_calendar_ids
94
+ if calendar_id.strip()
95
+ }
96
+ )
97
+ total_events = 0
98
+ calendars_synced = 0
99
+ calendars_skipped = 0
100
+ t0 = time.monotonic()
101
+
102
+ for calendar_idx, calendar_id in enumerate(sorted_ids, 1):
103
+ initial_sync_token = events_sync_tokens.get(calendar_id)
104
+ # Attempt plan per calendar:
105
+ # - existing token: incremental first, then full-sync fallback on stale cursor
106
+ # - no token: full sync only
107
+ attempt_tokens: tuple[str | None, ...] = (
108
+ (initial_sync_token, None) if initial_sync_token else (None,)
109
+ )
110
+ latest_sync_token = initial_sync_token
111
+ fell_back_from_stale_cursor = False
112
+ calendar_event_count = 0
113
+
114
+ sync_mode = "incremental" if initial_sync_token else "full"
115
+ LOGGER.info(
116
+ "google_calendar.events.calendar_start binding_id=%s calendar=%d/%d calendar_id=%s mode=%s",
117
+ binding_id,
118
+ calendar_idx,
119
+ len(sorted_ids),
120
+ calendar_id,
121
+ sync_mode,
122
+ )
123
+
124
+ try:
125
+ for attempt_sync_token in attempt_tokens:
126
+ latest_sync_token = attempt_sync_token
127
+ try:
128
+ for page in client.iter_event_pages(
129
+ calendar_id, sync_token=attempt_sync_token
130
+ ):
131
+ if page.next_sync_token:
132
+ latest_sync_token = page.next_sync_token
133
+
134
+ for row in events_to_ctx_models(
135
+ binding_id, calendar_id, page.items
136
+ ):
137
+ calendar_event_count += 1
138
+ total_events += 1
139
+ yield row
140
+ break
141
+ except PluginCursorExpiredError:
142
+ if attempt_sync_token is None:
143
+ raise
144
+ LOGGER.info(
145
+ "google_calendar.events.cursor_expired binding_id=%s calendar_id=%s fallback=full",
146
+ binding_id,
147
+ calendar_id,
148
+ )
149
+ fell_back_from_stale_cursor = True
150
+ continue
151
+ except Exception as exc:
152
+ status_code = extract_http_status_code(exc)
153
+ LOGGER.warning(
154
+ "google_calendar.events.skip_calendar binding_id=%s calendar_id=%s status_code=%s reason=%s",
155
+ binding_id,
156
+ calendar_id,
157
+ status_code if status_code is not None else "-",
158
+ exc,
159
+ )
160
+ if initial_sync_token and not fell_back_from_stale_cursor:
161
+ next_sync_tokens[calendar_id] = initial_sync_token
162
+ calendars_skipped += 1
163
+ continue
164
+
165
+ calendars_synced += 1
166
+ LOGGER.info(
167
+ "google_calendar.events.calendar_done binding_id=%s calendar_id=%s events=%d",
168
+ binding_id,
169
+ calendar_id,
170
+ calendar_event_count,
171
+ )
172
+
173
+ if latest_sync_token:
174
+ next_sync_tokens[calendar_id] = latest_sync_token
175
+
176
+ elapsed = time.monotonic() - t0
177
+ LOGGER.info(
178
+ "google_calendar.events.complete binding_id=%s calendars_synced=%d calendars_skipped=%d total_events=%d elapsed=%.1fs",
179
+ binding_id,
180
+ calendars_synced,
181
+ calendars_skipped,
182
+ total_events,
183
+ elapsed,
184
+ )
185
+
186
+
187
+ @dlt.source(name=dlt_source_name(PLUGIN_ID, JOB))
188
+ def google_calendar_sync_source(
189
+ binding_id: str,
190
+ *,
191
+ client: GoogleCalendarApiClient,
192
+ ) -> tuple[Any, ...]:
193
+ calendars_cache: list[CalendarRow] | None = None
194
+ active_calendar_ids_cache: list[str] | None = None
195
+
196
+ def get_calendar_snapshot() -> tuple[list[CalendarRow], list[str]]:
197
+ nonlocal calendars_cache, active_calendar_ids_cache
198
+ if calendars_cache is None or active_calendar_ids_cache is None:
199
+ calendars_cache, active_calendar_ids_cache = collect_calendar_snapshot(
200
+ binding_id=binding_id,
201
+ client=client,
202
+ )
203
+ return calendars_cache, active_calendar_ids_cache
204
+
205
+ @ctx_dlt_resource(
206
+ name=dlt_resource_name("calendars"),
207
+ write_disposition="merge",
208
+ primary_key=("_ctx_binding_id", "id"),
209
+ )
210
+ def calendars_resource() -> Iterable[CalendarRow]:
211
+ rows, _ = get_calendar_snapshot()
212
+ for row in rows:
213
+ yield row
214
+
215
+ @ctx_dlt_resource(
216
+ name=dlt_resource_name("events"),
217
+ write_disposition="merge",
218
+ primary_key=("_ctx_binding_id", "calendar_id", "id"),
219
+ columns={"is_cancelled": {"hard_delete": HARD_DELETE_ON_CANCELLED_EVENT}},
220
+ )
221
+ def events_resource() -> Iterable[EventRow]:
222
+ source_state = dlt.current.source_state()
223
+ _, active_calendar_ids = get_calendar_snapshot()
224
+
225
+ if FORCE_FULL_SYNC:
226
+ sync_tokens_for_run: dict[str, str] = {}
227
+ else:
228
+ existing_sync_tokens = coerce_non_empty_string_mapping(
229
+ source_state.get(EVENTS_SYNC_TOKENS_KEY)
230
+ )
231
+ sync_tokens_for_run = prune_events_sync_tokens(
232
+ active_calendar_ids, existing_sync_tokens
233
+ )
234
+
235
+ next_sync_tokens: dict[str, str] = {}
236
+ yield from iter_events_for_active_calendars(
237
+ binding_id=binding_id,
238
+ client=client,
239
+ active_calendar_ids=active_calendar_ids,
240
+ events_sync_tokens=sync_tokens_for_run,
241
+ next_sync_tokens=next_sync_tokens,
242
+ )
243
+
244
+ source_state[ACTIVE_CALENDAR_IDS_KEY] = active_calendar_ids
245
+ source_state[EVENTS_SYNC_TOKENS_KEY] = next_sync_tokens
246
+ source_state[EVENTS_LAST_SYNCED_AT_KEY] = utc_now_iso()
247
+
248
+ return (calendars_resource, events_resource)
File without changes
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any, Iterator, TypeVar
5
+
6
+ from googleapiclient.errors import HttpError
7
+
8
+ from shared_plugins.exceptions import PluginConfigurationError, PluginCursorExpiredError
9
+ from shared_plugins.google_client.http_errors import extract_http_status_code
10
+
11
+ from shared_plugins.models import IngressModel
12
+
13
+ from ..models.ingress import (
14
+ GoogleCalendarCalendarListResponseIngress,
15
+ GoogleCalendarEventsListResponseIngress,
16
+ )
17
+
18
+ ModelT = TypeVar("ModelT", bound=IngressModel)
19
+ CALENDAR_LIST_MAX_RESULTS = 250
20
+ EVENTS_LIST_MAX_RESULTS = 2500
21
+ API_NUM_RETRIES = 5
22
+ INCLUDE_HIDDEN_CALENDARS = False
23
+ MIN_ACCESS_ROLE = "reader"
24
+ SINGLE_EVENTS = True
25
+
26
+
27
+ class GoogleCalendarApiClient:
28
+ def __init__(
29
+ self,
30
+ *,
31
+ service: Any,
32
+ ) -> None:
33
+ if service is None:
34
+ raise PluginConfigurationError(
35
+ "No Google Calendar service provided. Pass an authenticated service via service=..."
36
+ )
37
+
38
+ self._service = service
39
+
40
+ def _execute(self, request: Any) -> dict[str, Any]:
41
+ payload = request.execute(num_retries=API_NUM_RETRIES)
42
+ if not isinstance(payload, Mapping):
43
+ raise RuntimeError(
44
+ "Google Calendar API returned a non-object response payload."
45
+ )
46
+ return dict(payload)
47
+
48
+ def _execute_model(self, request: Any, model_type: type[ModelT]) -> ModelT:
49
+ payload = self._execute(request)
50
+ return model_type.model_validate(payload)
51
+
52
+ def iter_calendar_list_pages(
53
+ self,
54
+ ) -> Iterator[GoogleCalendarCalendarListResponseIngress]:
55
+ page_token: str | None = None
56
+
57
+ while True:
58
+ kwargs: dict[str, Any] = {
59
+ "maxResults": CALENDAR_LIST_MAX_RESULTS,
60
+ "showDeleted": True,
61
+ "showHidden": INCLUDE_HIDDEN_CALENDARS,
62
+ "minAccessRole": MIN_ACCESS_ROLE,
63
+ }
64
+ if page_token:
65
+ kwargs["pageToken"] = page_token
66
+
67
+ request = self._service.calendarList().list(**kwargs)
68
+ payload = self._execute_model(
69
+ request,
70
+ GoogleCalendarCalendarListResponseIngress,
71
+ )
72
+ yield payload
73
+
74
+ if not payload.next_page_token:
75
+ break
76
+ page_token = payload.next_page_token
77
+
78
+ def iter_event_pages(
79
+ self,
80
+ calendar_id: str,
81
+ *,
82
+ sync_token: str | None = None,
83
+ ) -> Iterator[GoogleCalendarEventsListResponseIngress]:
84
+ page_token: str | None = None
85
+
86
+ while True:
87
+ kwargs: dict[str, Any] = {
88
+ "calendarId": calendar_id,
89
+ "maxResults": EVENTS_LIST_MAX_RESULTS,
90
+ "showDeleted": True,
91
+ "singleEvents": SINGLE_EVENTS,
92
+ }
93
+ if sync_token:
94
+ kwargs["syncToken"] = sync_token
95
+ if page_token:
96
+ kwargs["pageToken"] = page_token
97
+
98
+ try:
99
+ request = self._service.events().list(**kwargs)
100
+ raw = self._execute(request)
101
+ payload = GoogleCalendarEventsListResponseIngress.model_validate(raw)
102
+ except HttpError as exc:
103
+ if extract_http_status_code(exc) == 410:
104
+ raise PluginCursorExpiredError(
105
+ "Google Calendar sync token is stale. Clear per-calendar token and rerun full sync."
106
+ ) from exc
107
+ raise
108
+
109
+ yield payload
110
+
111
+ if not payload.next_page_token:
112
+ break
113
+ page_token = payload.next_page_token