orgdatacore 2.0.0__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.
orgdatacore/_async.py ADDED
@@ -0,0 +1,1516 @@
1
+ """Async service and data source implementations for orgdatacore.
2
+
3
+ This module provides async-compatible versions of Service and GCS data source
4
+ for use with asyncio-based frameworks like FastAPI, aiohttp, etc.
5
+
6
+ Example:
7
+ from orgdatacore import AsyncService, GCSConfig
8
+ from orgdatacore._async import AsyncGCSDataSource
9
+
10
+ async def main():
11
+ config = GCSConfig(bucket="my-bucket", object_path="data.json")
12
+ source = AsyncGCSDataSource(config)
13
+
14
+ service = AsyncService()
15
+ await service.load_from_data_source(source)
16
+ employee = await service.get_employee_by_uid("jdoe")
17
+ """
18
+
19
+ import asyncio
20
+ import inspect
21
+ import json
22
+ from collections.abc import Awaitable, Callable
23
+ from datetime import datetime, timedelta
24
+ from io import BytesIO
25
+ from typing import Any, BinaryIO
26
+
27
+ from ._exceptions import ConfigurationError, DataLoadError, GCSError
28
+ from ._log import get_logger
29
+ from ._service import (
30
+ _ancestor_org_info_type,
31
+ _EntityRef,
32
+ _group,
33
+ _NameTypeKey,
34
+ _normalize_slack_channel,
35
+ _to_entity_type,
36
+ parse_data,
37
+ )
38
+ from ._types import (
39
+ Component,
40
+ ComponentOwnerInfo,
41
+ ComponentOwnership,
42
+ ContextItemInfo,
43
+ Data,
44
+ DataVersion,
45
+ Employee,
46
+ EntityType,
47
+ EscalationContactInfo,
48
+ GCSConfig,
49
+ HierarchyNode,
50
+ HierarchyPathEntry,
51
+ JiraOwnerInfo,
52
+ MembershipInfo,
53
+ MembershipType,
54
+ Org,
55
+ OrgInfo,
56
+ OrgInfoType,
57
+ Pillar,
58
+ Team,
59
+ TeamGroup,
60
+ )
61
+
62
+ __all__ = ["AsyncService", "AsyncGCSDataSource"]
63
+
64
+ # Default retry configuration
65
+ DEFAULT_MAX_RETRIES = 3
66
+ DEFAULT_RETRY_DELAY = 1.0
67
+ DEFAULT_RETRY_BACKOFF = 2.0
68
+
69
+
70
+ class AsyncService:
71
+ """Async implementation of the organizational data service.
72
+
73
+ Thread-safe and asyncio-compatible. All lookup methods are async
74
+ to allow for non-blocking operation in async contexts.
75
+
76
+ Example:
77
+ service = AsyncService()
78
+ await service.load_from_data_source(source)
79
+ employee = await service.get_employee_by_uid("jdoe")
80
+ """
81
+
82
+ def __init__(self, *, data_source: Any | None = None) -> None:
83
+ """Initialize a new async organizational data service.
84
+
85
+ Args:
86
+ data_source: Optional async data source to load from immediately.
87
+ """
88
+ self._lock = asyncio.Lock()
89
+ self._data: Data | None = None
90
+ self._version = DataVersion()
91
+ self._init_source = data_source
92
+ self._watcher_running = False
93
+ self._watcher_task: asyncio.Task[None] | None = None
94
+ self._watcher_source: Any | None = None
95
+ self._slack_channel_index: dict[str, list[str]] = {}
96
+
97
+ # Derived identity indexes, rebuilt on every load. Populated only when
98
+ # the index carries stable IDs (_use_stable_ids); otherwise traversal
99
+ # falls back to the legacy name+type behavior.
100
+ self._entity_by_id: dict[str, _EntityRef] = {}
101
+ self._children_by_id: dict[str, list[str]] = {}
102
+ self._id_by_name_type: dict[_NameTypeKey, str] = {}
103
+ self._use_stable_ids: bool = False
104
+
105
+ async def initialize(self) -> None:
106
+ """Initialize the service if a data source was provided.
107
+
108
+ Call this after construction if you passed a data_source to __init__.
109
+ """
110
+ if self._init_source is not None:
111
+ await self.load_from_data_source(self._init_source)
112
+
113
+ async def load_from_data_source(self, source: Any) -> None:
114
+ """Load organizational data from an async data source.
115
+
116
+ Args:
117
+ source: Async data source with an async load() method.
118
+
119
+ Raises:
120
+ DataLoadError: If loading or parsing fails.
121
+ """
122
+ logger = get_logger()
123
+ logger.debug("Loading data from async source", extra={"source": str(source)})
124
+
125
+ try:
126
+ # Support both sync and async data sources
127
+ if inspect.iscoroutinefunction(source.load):
128
+ reader = await source.load()
129
+ else:
130
+ reader = await asyncio.to_thread(source.load)
131
+ except Exception as e:
132
+ logger.error(
133
+ "Failed to load from async data source",
134
+ extra={"source": str(source), "error": str(e)},
135
+ )
136
+ raise DataLoadError(f"failed to load from data source {source}: {e}") from e
137
+
138
+ try:
139
+ content = reader.read()
140
+ if isinstance(content, bytes):
141
+ content = content.decode("utf-8")
142
+ raw_data = json.loads(content)
143
+ except json.JSONDecodeError as e:
144
+ logger.error(
145
+ "Failed to parse JSON", extra={"source": str(source), "error": str(e)}
146
+ )
147
+ raise DataLoadError(
148
+ f"failed to parse JSON from source {source}: {e}"
149
+ ) from e
150
+ finally:
151
+ reader.close()
152
+
153
+ try:
154
+ org_data = parse_data(raw_data)
155
+ except Exception as e:
156
+ logger.error(
157
+ "Failed to parse data structure",
158
+ extra={"source": str(source), "error": str(e)},
159
+ )
160
+ raise DataLoadError(
161
+ f"failed to parse data structure from source {source}: {e}"
162
+ ) from e
163
+
164
+ async with self._lock:
165
+ self._data = org_data
166
+ self._version = DataVersion(
167
+ load_time=datetime.now(),
168
+ org_count=len(org_data.lookups.orgs),
169
+ employee_count=len(org_data.lookups.employees),
170
+ )
171
+
172
+ self._slack_channel_index = {}
173
+ for team in org_data.lookups.teams.values():
174
+ slack = _group(team).slack
175
+ if slack is None:
176
+ continue
177
+ for ch in slack.channels:
178
+ if ch.channel:
179
+ normalized = _normalize_slack_channel(ch.channel)
180
+ self._slack_channel_index.setdefault(normalized, []).append(
181
+ team.name
182
+ )
183
+
184
+ self._build_derived_indexes()
185
+
186
+ logger.info(
187
+ "Data loaded successfully (async)",
188
+ extra={
189
+ "source": str(source),
190
+ "employee_count": self._version.employee_count,
191
+ "org_count": self._version.org_count,
192
+ },
193
+ )
194
+
195
+ async def start_data_source_watcher(self, source: Any) -> None:
196
+ """Start watching an async data source for changes.
197
+
198
+ This method returns immediately after starting the watcher in the background.
199
+ Use stop_watcher() to stop the watcher.
200
+
201
+ Args:
202
+ source: Async data source to watch.
203
+
204
+ Raises:
205
+ DataLoadError: If initial load fails.
206
+ RuntimeError: If watcher is already running.
207
+ """
208
+ logger = get_logger()
209
+
210
+ if self._watcher_running:
211
+ raise RuntimeError("Watcher is already running")
212
+
213
+ # Perform initial load (before starting background watcher)
214
+ await self.load_from_data_source(source)
215
+
216
+ self._watcher_running = True
217
+ self._watcher_source = source
218
+
219
+ async def _run_watcher() -> None:
220
+ """Background watcher coroutine."""
221
+ try:
222
+
223
+ async def callback() -> Exception | None:
224
+ try:
225
+ logger.info(
226
+ "Reloading data from async source",
227
+ extra={"source": str(source)},
228
+ )
229
+ await self.load_from_data_source(source)
230
+ return None
231
+ except Exception as e:
232
+ logger.error(
233
+ "Failed to reload data",
234
+ extra={"source": str(source), "error": str(e)},
235
+ )
236
+ return e
237
+
238
+ if hasattr(source, "watch"):
239
+ logger.info(
240
+ "Starting async data source watcher",
241
+ extra={"source": str(source)},
242
+ )
243
+ if inspect.iscoroutinefunction(source.watch):
244
+ err = await source.watch(callback)
245
+ else:
246
+ # Sync watch - run in thread with async-safe callback
247
+ loop = asyncio.get_running_loop()
248
+
249
+ def sync_callback() -> Exception | None:
250
+ future = asyncio.run_coroutine_threadsafe(callback(), loop)
251
+ try:
252
+ return future.result(timeout=60)
253
+ except Exception as e:
254
+ return e
255
+
256
+ err = await asyncio.to_thread(source.watch, sync_callback)
257
+ if err:
258
+ logger.error(
259
+ "Watcher error",
260
+ extra={"source": str(source), "error": str(err)},
261
+ )
262
+ except asyncio.CancelledError:
263
+ logger.info("Watcher cancelled", extra={"source": str(source)})
264
+ raise
265
+ finally:
266
+ self._watcher_running = False
267
+ self._watcher_task = None
268
+ self._watcher_source = None
269
+
270
+ # Start as background task
271
+ self._watcher_task = asyncio.create_task(_run_watcher())
272
+
273
+ async def stop_watcher(self) -> None:
274
+ """Stop the data source watcher if running.
275
+
276
+ For sync data sources running via asyncio.to_thread(), calls source.stop()
277
+ if available to signal the watch loop to exit. This enables cooperative
278
+ cancellation since Python threads cannot be forcibly interrupted.
279
+ """
280
+ # Signal sync sources to stop (best effort)
281
+ if self._watcher_source is not None:
282
+ if hasattr(self._watcher_source, "stop"):
283
+ try:
284
+ self._watcher_source.stop()
285
+ except Exception:
286
+ pass # Best effort - don't fail stop_watcher if stop() fails
287
+
288
+ # Cancel the asyncio task
289
+ if self._watcher_task is not None:
290
+ self._watcher_task.cancel()
291
+ try:
292
+ await self._watcher_task
293
+ except asyncio.CancelledError:
294
+ pass
295
+ self._watcher_task = None
296
+
297
+ self._watcher_running = False
298
+ self._watcher_source = None
299
+
300
+ def is_healthy(self) -> bool:
301
+ """Check if the service has data loaded."""
302
+ return self._data is not None
303
+
304
+ def is_ready(self) -> bool:
305
+ """Check if the service is ready to serve requests."""
306
+ if self._data is None:
307
+ return False
308
+ return bool(self._data.lookups.employees) or self._data.metadata.pii_free
309
+
310
+ def get_data_age(self) -> timedelta:
311
+ """Get the duration since data was last loaded.
312
+
313
+ Returns:
314
+ timedelta since last load, or timedelta(0) if no data loaded.
315
+ """
316
+ if self._version.load_time == datetime.min:
317
+ return timedelta(0)
318
+ return datetime.now() - self._version.load_time
319
+
320
+ def is_data_stale(self, max_age: timedelta) -> bool:
321
+ """Check if data is older than max_age, or if no data is loaded.
322
+
323
+ Use this in health checks to detect stale data from failed reloads.
324
+
325
+ Args:
326
+ max_age: Maximum acceptable age for the data.
327
+
328
+ Returns:
329
+ True if data is stale or not loaded, False otherwise.
330
+ """
331
+ if self._data is None or self._version.load_time == datetime.min:
332
+ return True
333
+ return (datetime.now() - self._version.load_time) > max_age
334
+
335
+ # Async lookup methods
336
+
337
+ async def get_employee_by_uid(self, uid: str) -> Employee | None:
338
+ """Get an employee by their UID."""
339
+ async with self._lock:
340
+ if self._data is None:
341
+ return None
342
+ return self._data.lookups.employees.get(uid)
343
+
344
+ async def get_employee_by_email(self, email: str) -> Employee | None:
345
+ """Get an employee by their email address."""
346
+ async with self._lock:
347
+ if self._data is None:
348
+ return None
349
+ for emp in self._data.lookups.employees.values():
350
+ if (emp.email or "").lower() == email.lower():
351
+ return emp
352
+ return None
353
+
354
+ async def get_employee_by_slack_id(self, slack_id: str) -> Employee | None:
355
+ """Get an employee by their Slack ID."""
356
+ async with self._lock:
357
+ if self._data is None:
358
+ return None
359
+ uid = self._data.indexes.slack_id_mappings.slack_uid_to_uid.get(slack_id)
360
+ if uid:
361
+ return self._data.lookups.employees.get(uid)
362
+ return None
363
+
364
+ async def get_employee_by_github_id(self, github_id: str) -> Employee | None:
365
+ """Get an employee by their GitHub ID."""
366
+ async with self._lock:
367
+ if self._data is None:
368
+ return None
369
+ uid = self._data.indexes.github_id_mappings.github_id_to_uid.get(github_id)
370
+ if uid:
371
+ return self._data.lookups.employees.get(uid)
372
+ return None
373
+
374
+ async def get_team_by_name(self, team_name: str) -> Team | None:
375
+ """Get a team by name."""
376
+ async with self._lock:
377
+ if self._data is None:
378
+ return None
379
+ return self._data.lookups.teams.get(team_name)
380
+
381
+ async def get_teams_by_slack_channel(self, channel: str) -> list[Team]:
382
+ """Get teams associated with a Slack channel name.
383
+
384
+ Args:
385
+ channel: Slack channel name (e.g., "#test-team" or "test-team").
386
+ The "#" prefix and casing are ignored.
387
+
388
+ Returns:
389
+ List of matching teams, or empty list if none found.
390
+ """
391
+ async with self._lock:
392
+ if self._data is None or not channel:
393
+ return []
394
+
395
+ team_names = self._slack_channel_index.get(
396
+ _normalize_slack_channel(channel), []
397
+ )
398
+ return [
399
+ self._data.lookups.teams[name]
400
+ for name in team_names
401
+ if name in self._data.lookups.teams
402
+ ]
403
+
404
+ async def get_team_escalation(self, team_name: str) -> list[EscalationContactInfo]:
405
+ """Get the escalation contacts for a team.
406
+
407
+ Args:
408
+ team_name: The team name to look up.
409
+
410
+ Returns:
411
+ Ordered list of escalation contacts, or empty list if team
412
+ not found or has no escalation data.
413
+ """
414
+ async with self._lock:
415
+ if self._data is None or not self._data.lookups.teams:
416
+ return []
417
+ team = self._data.lookups.teams.get(team_name)
418
+ if team is None:
419
+ return []
420
+ return list(_group(team).escalation)
421
+
422
+ async def get_org_by_name(self, org_name: str) -> Org | None:
423
+ """Get an organization by name."""
424
+ async with self._lock:
425
+ if self._data is None:
426
+ return None
427
+ return self._data.lookups.orgs.get(org_name)
428
+
429
+ async def get_pillar_by_name(self, pillar_name: str) -> Pillar | None:
430
+ """Get a pillar by name."""
431
+ async with self._lock:
432
+ if self._data is None:
433
+ return None
434
+ return self._data.lookups.pillars.get(pillar_name)
435
+
436
+ async def get_team_group_by_name(self, team_group_name: str) -> TeamGroup | None:
437
+ """Get a team group by name."""
438
+ async with self._lock:
439
+ if self._data is None:
440
+ return None
441
+ return self._data.lookups.team_groups.get(team_group_name)
442
+
443
+ async def get_component_by_name(self, component_name: str) -> Component | None:
444
+ """Get a component by name."""
445
+ async with self._lock:
446
+ if self._data is None:
447
+ return None
448
+ return self._data.lookups.components.get(component_name)
449
+
450
+ async def get_user_memberships(self, uid: str) -> list[MembershipInfo]:
451
+ """Get all memberships for a user."""
452
+ async with self._lock:
453
+ if self._data is None:
454
+ return []
455
+ return list(self._data.indexes.membership.membership_index.get(uid, ()))
456
+
457
+ async def get_user_teams(self, uid: str) -> list[str]:
458
+ """Get team names for a user."""
459
+ memberships = await self.get_user_memberships(uid)
460
+ return [m.name for m in memberships if m.type == MembershipType.TEAM]
461
+
462
+ async def get_teams_for_uid(self, uid: str) -> list[str]:
463
+ """Get all teams a UID is a member of."""
464
+ return await self.get_user_teams(uid)
465
+
466
+ async def get_teams_for_slack_id(self, slack_id: str) -> list[str]:
467
+ """Get all teams a Slack user is a member of."""
468
+ uid = await self._get_uid_from_slack_id(slack_id)
469
+ if not uid:
470
+ return []
471
+ return await self.get_teams_for_uid(uid)
472
+
473
+ async def _get_uid_from_slack_id(self, slack_id: str) -> str:
474
+ """Get the UID for a given Slack ID."""
475
+ async with self._lock:
476
+ if self._data is None:
477
+ return ""
478
+ return self._data.indexes.slack_id_mappings.slack_uid_to_uid.get(
479
+ slack_id, ""
480
+ )
481
+
482
+ async def get_manager_for_employee(self, uid: str) -> Employee | None:
483
+ """Get the manager for a given employee UID."""
484
+ async with self._lock:
485
+ if self._data is None:
486
+ return None
487
+ emp = self._data.lookups.employees.get(uid)
488
+ if not emp or not emp.manager_uid:
489
+ return None
490
+ return self._data.lookups.employees.get(emp.manager_uid)
491
+
492
+ async def is_employee_in_team(self, uid: str, team_name: str) -> bool:
493
+ """Check if an employee is in a specific team."""
494
+ teams = await self.get_teams_for_uid(uid)
495
+ return team_name in teams
496
+
497
+ async def is_slack_user_in_team(self, slack_id: str, team_name: str) -> bool:
498
+ """Check if a Slack user is in a specific team."""
499
+ uid = await self._get_uid_from_slack_id(slack_id)
500
+ if not uid:
501
+ return False
502
+ return await self.is_employee_in_team(uid, team_name)
503
+
504
+ async def is_employee_in_org(self, uid: str, org_name: str) -> bool:
505
+ """Check if an employee is in a specific organization."""
506
+ async with self._lock:
507
+ if self._data is None:
508
+ return False
509
+
510
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
511
+
512
+ for membership in memberships:
513
+ if (
514
+ membership.type == MembershipType.ORG
515
+ and membership.name == org_name
516
+ ):
517
+ return True
518
+ elif membership.type == MembershipType.TEAM:
519
+ hierarchy_path = self._get_hierarchy_path(
520
+ membership.name, EntityType.TEAM
521
+ )
522
+ for entry in hierarchy_path:
523
+ if entry.type == EntityType.ORG and entry.name == org_name:
524
+ return True
525
+
526
+ return False
527
+
528
+ async def is_slack_user_in_org(self, slack_id: str, org_name: str) -> bool:
529
+ """Check if a Slack user is in a specific organization."""
530
+ uid = await self._get_uid_from_slack_id(slack_id)
531
+ if not uid:
532
+ return False
533
+ return await self.is_employee_in_org(uid, org_name)
534
+
535
+ def _build_derived_indexes(self) -> None:
536
+ """Build stable-ID relationship indexes from the loaded data.
537
+
538
+ Caller must hold the lock. Enables ``_use_stable_ids`` only when the
539
+ index is a sound identity graph: every entity carries a unique
540
+ ``stable_id``, every parent edge carries a ``parent_id``, and every
541
+ ``parent_id`` resolves to a known entity. Any defect (or an old-format
542
+ index) falls back to the legacy name+type traversal rather than silently
543
+ returning wrong ancestry.
544
+ """
545
+ self._entity_by_id = {}
546
+ self._children_by_id = {}
547
+ self._id_by_name_type = {}
548
+ self._use_stable_ids = False
549
+ if self._data is None:
550
+ return
551
+
552
+ any_stable_id = True
553
+ all_edges_identified = True
554
+ duplicate_stable_id = False
555
+
556
+ def register(
557
+ name: str,
558
+ typ: EntityType,
559
+ stable_id: str,
560
+ parent_id: str,
561
+ has_parent: bool,
562
+ ) -> None:
563
+ nonlocal any_stable_id, all_edges_identified, duplicate_stable_id
564
+ if not stable_id:
565
+ any_stable_id = False
566
+ return
567
+ if stable_id in self._entity_by_id:
568
+ duplicate_stable_id = True
569
+ self._entity_by_id[stable_id] = _EntityRef(
570
+ name=name, type=typ, parent_id=parent_id, stable_id=stable_id
571
+ )
572
+ self._id_by_name_type[_NameTypeKey(name, typ)] = stable_id
573
+ if has_parent and not parent_id:
574
+ all_edges_identified = False
575
+
576
+ for name, team in self._data.lookups.teams.items():
577
+ register(
578
+ name,
579
+ EntityType.TEAM,
580
+ team.stable_id,
581
+ team.parent_id,
582
+ team.parent is not None,
583
+ )
584
+ for name, org in self._data.lookups.orgs.items():
585
+ register(
586
+ name,
587
+ EntityType.ORG,
588
+ org.stable_id,
589
+ org.parent_id,
590
+ org.parent is not None,
591
+ )
592
+ for name, pillar in self._data.lookups.pillars.items():
593
+ register(
594
+ name,
595
+ EntityType.PILLAR,
596
+ pillar.stable_id,
597
+ pillar.parent_id,
598
+ pillar.parent is not None,
599
+ )
600
+ for name, tg in self._data.lookups.team_groups.items():
601
+ register(
602
+ name,
603
+ EntityType.TEAM_GROUP,
604
+ tg.stable_id,
605
+ tg.parent_id,
606
+ tg.parent is not None,
607
+ )
608
+
609
+ # Every parent_id must resolve to a known entity, otherwise traversal
610
+ # would silently truncate ancestry or drop a descendants branch.
611
+ all_parents_resolve = all(
612
+ ref.parent_id in self._entity_by_id
613
+ for ref in self._entity_by_id.values()
614
+ if ref.parent_id
615
+ )
616
+
617
+ if (
618
+ not self._entity_by_id
619
+ or not any_stable_id
620
+ or not all_edges_identified
621
+ or duplicate_stable_id
622
+ or not all_parents_resolve
623
+ ):
624
+ self._entity_by_id = {}
625
+ self._id_by_name_type = {}
626
+ return
627
+
628
+ for stable_id, ref in self._entity_by_id.items():
629
+ if ref.parent_id:
630
+ self._children_by_id.setdefault(ref.parent_id, []).append(stable_id)
631
+
632
+ self._use_stable_ids = True
633
+
634
+ def _get_entity_type(self, entity_name: str) -> str:
635
+ """Look up entity type by scanning lookups (first match)."""
636
+ if self._data is None:
637
+ return ""
638
+ if entity_name in self._data.lookups.teams:
639
+ return "team"
640
+ if entity_name in self._data.lookups.orgs:
641
+ return "org"
642
+ if entity_name in self._data.lookups.pillars:
643
+ return "pillar"
644
+ if entity_name in self._data.lookups.team_groups:
645
+ return "team_group"
646
+ return ""
647
+
648
+ def _resolve_start_id(self, entity_name: str, entity_type: str) -> str:
649
+ """Map a (name, type) pair to a stable ID. Caller must hold the lock."""
650
+ if not entity_type:
651
+ inferred = self._get_entity_type(entity_name)
652
+ if not inferred:
653
+ return ""
654
+ entity_type = inferred
655
+ etype = _to_entity_type(entity_type.lower())
656
+ if etype is None:
657
+ return ""
658
+ return self._id_by_name_type.get(_NameTypeKey(entity_name, etype), "")
659
+
660
+ def _get_entity_by_type(
661
+ self, entity_name: str, entity_type: str
662
+ ) -> Team | Org | Pillar | TeamGroup | None:
663
+ """Get entity from lookups by name and type."""
664
+ if self._data is None:
665
+ return None
666
+ entity_type_lower = entity_type.lower()
667
+ if entity_type_lower == "team":
668
+ return self._data.lookups.teams.get(entity_name)
669
+ elif entity_type_lower == "org":
670
+ return self._data.lookups.orgs.get(entity_name)
671
+ elif entity_type_lower == "pillar":
672
+ return self._data.lookups.pillars.get(entity_name)
673
+ elif entity_type_lower == "team_group":
674
+ return self._data.lookups.team_groups.get(entity_name)
675
+ return None
676
+
677
+ def _get_hierarchy_path(
678
+ self, entity_name: str, entity_type: str
679
+ ) -> list[HierarchyPathEntry]:
680
+ """Compute hierarchy path by walking parent references."""
681
+ if self._data is None:
682
+ return []
683
+
684
+ if self._use_stable_ids:
685
+ return self._compute_hierarchy_path_by_id(entity_name, entity_type)
686
+
687
+ entity = self._get_entity_by_type(entity_name, entity_type)
688
+ if entity is None:
689
+ return []
690
+
691
+ path = [HierarchyPathEntry(name=entity_name, type=entity_type)]
692
+ visited = {entity_name}
693
+ current: Team | Org | Pillar | TeamGroup | None = entity
694
+
695
+ while current and current.parent:
696
+ parent = current.parent
697
+ if parent.name in visited:
698
+ break
699
+ visited.add(parent.name)
700
+ path.append(HierarchyPathEntry(name=parent.name, type=parent.type))
701
+ current = self._get_entity_by_type(parent.name, parent.type)
702
+
703
+ return path
704
+
705
+ def _compute_hierarchy_path_by_id(
706
+ self, entity_name: str, entity_type: str
707
+ ) -> list[HierarchyPathEntry]:
708
+ """Walk the parent chain by stable ID. Caller must hold the lock."""
709
+ start_id = self._resolve_start_id(entity_name, entity_type)
710
+ if not start_id:
711
+ return []
712
+
713
+ path: list[HierarchyPathEntry] = []
714
+ visited: set[str] = set()
715
+ current_id = start_id
716
+ while current_id and current_id not in visited:
717
+ visited.add(current_id)
718
+ ref = self._entity_by_id.get(current_id)
719
+ if ref is None:
720
+ break
721
+ path.append(
722
+ HierarchyPathEntry(
723
+ name=ref.name, type=ref.type, stable_id=ref.stable_id
724
+ )
725
+ )
726
+ current_id = ref.parent_id
727
+ return path
728
+
729
+ async def get_hierarchy_path(
730
+ self, entity_name: str, entity_type: str = "team"
731
+ ) -> list[HierarchyPathEntry]:
732
+ """Get ordered hierarchy path from entity to root.
733
+
734
+ Computes path by walking parent references in entities.
735
+
736
+ Args:
737
+ entity_name: Name of the team/org/pillar/team_group
738
+ entity_type: Type of entity ("team", "org", "pillar", "team_group")
739
+
740
+ Returns:
741
+ Ordered list from entity to root. Empty list if not found.
742
+ """
743
+ async with self._lock:
744
+ return self._get_hierarchy_path(entity_name, entity_type)
745
+
746
+ async def get_descendants_tree(self, entity_name: str) -> HierarchyNode | None:
747
+ """Get all descendants of an entity as a nested tree.
748
+
749
+ Computes tree by scanning all entities for children.
750
+
751
+ Args:
752
+ entity_name: Name of the org/pillar/team_group/team
753
+
754
+ Returns:
755
+ Nested tree structure with all descendants, or None if not found.
756
+ """
757
+ async with self._lock:
758
+ if self._data is None:
759
+ return None
760
+
761
+ if self._use_stable_ids:
762
+ return self._get_descendants_tree_by_id(entity_name)
763
+
764
+ # Look up entity type
765
+ entity_type = ""
766
+ if entity_name in self._data.lookups.teams:
767
+ entity_type = "team"
768
+ elif entity_name in self._data.lookups.orgs:
769
+ entity_type = "org"
770
+ elif entity_name in self._data.lookups.pillars:
771
+ entity_type = "pillar"
772
+ elif entity_name in self._data.lookups.team_groups:
773
+ entity_type = "team_group"
774
+
775
+ if not entity_type:
776
+ return None
777
+
778
+ # Build children map by scanning all entities
779
+ children_map: dict[str, list[tuple[str, str]]] = {}
780
+ all_entities: list[tuple[str, Team | Org | Pillar | TeamGroup, str]] = [
781
+ *(
782
+ (name, info, "team")
783
+ for name, info in self._data.lookups.teams.items()
784
+ ),
785
+ *(
786
+ (name, info, "org")
787
+ for name, info in self._data.lookups.orgs.items()
788
+ ),
789
+ *(
790
+ (name, info, "pillar")
791
+ for name, info in self._data.lookups.pillars.items()
792
+ ),
793
+ *(
794
+ (name, info, "team_group")
795
+ for name, info in self._data.lookups.team_groups.items()
796
+ ),
797
+ ]
798
+
799
+ for name, info, etype in all_entities:
800
+ if info.parent:
801
+ if info.parent.name not in children_map:
802
+ children_map[info.parent.name] = []
803
+ children_map[info.parent.name].append((name, etype))
804
+
805
+ def build_node(name: str, type_: str, visited: set[str]) -> HierarchyNode:
806
+ if name in visited:
807
+ return HierarchyNode(name=name, type=type_, children=())
808
+ visited.add(name)
809
+ children = children_map.get(name, [])
810
+ child_nodes = tuple(build_node(n, t, visited) for n, t in children)
811
+ return HierarchyNode(name=name, type=type_, children=child_nodes)
812
+
813
+ return build_node(entity_name, entity_type, set())
814
+
815
+ def _get_descendants_tree_by_id(self, entity_name: str) -> HierarchyNode | None:
816
+ """Build the descendants tree using stable-ID edges. Caller holds lock."""
817
+ entity_type = self._get_entity_type(entity_name)
818
+ if not entity_type:
819
+ return None
820
+ start_id = self._id_by_name_type.get(
821
+ _NameTypeKey(entity_name, EntityType(entity_type)), ""
822
+ )
823
+ if not start_id:
824
+ return None
825
+
826
+ def build_node(stable_id: str, visited: set[str]) -> HierarchyNode:
827
+ ref = self._entity_by_id[stable_id]
828
+ if stable_id in visited:
829
+ return HierarchyNode(
830
+ name=ref.name, type=ref.type, stable_id=ref.stable_id, children=()
831
+ )
832
+ visited.add(stable_id)
833
+ child_ids = sorted(
834
+ self._children_by_id.get(stable_id, []),
835
+ key=lambda cid: (
836
+ self._entity_by_id[cid].name,
837
+ self._entity_by_id[cid].type,
838
+ ),
839
+ )
840
+ child_nodes = tuple(build_node(cid, visited) for cid in child_ids)
841
+ return HierarchyNode(
842
+ name=ref.name,
843
+ type=ref.type,
844
+ stable_id=ref.stable_id,
845
+ children=child_nodes,
846
+ )
847
+
848
+ return build_node(start_id, set())
849
+
850
+ async def get_user_organizations(self, slack_user_id: str) -> list[OrgInfo]:
851
+ """Get the complete organizational hierarchy a Slack user belongs to."""
852
+ async with self._lock:
853
+ if self._data is None or not self._data.indexes.membership.membership_index:
854
+ return []
855
+
856
+ uid = self._data.indexes.slack_id_mappings.slack_uid_to_uid.get(
857
+ slack_user_id, ""
858
+ )
859
+ if not uid:
860
+ return []
861
+
862
+ if self._use_stable_ids:
863
+ return self._user_organizations_by_id(uid)
864
+
865
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
866
+ result: list[OrgInfo] = []
867
+ # Legacy mode: dedupe by (name, type). Names are not unique across
868
+ # types, so keying on name alone would drop a legitimately distinct
869
+ # entity (e.g. a team_group sharing a team's name) from the result.
870
+ seen: set[_NameTypeKey] = set()
871
+
872
+ for m in memberships:
873
+ if m.type == MembershipType.ORG:
874
+ key = _NameTypeKey(m.name, EntityType.ORG)
875
+ if key not in seen:
876
+ result.append(
877
+ OrgInfo(name=m.name, type=OrgInfoType.ORGANIZATION)
878
+ )
879
+ seen.add(key)
880
+ elif m.type == MembershipType.TEAM:
881
+ key = _NameTypeKey(m.name, EntityType.TEAM)
882
+ if key not in seen:
883
+ result.append(OrgInfo(name=m.name, type=OrgInfoType.TEAM))
884
+ seen.add(key)
885
+
886
+ hierarchy_path = self._get_hierarchy_path(m.name, EntityType.TEAM)
887
+ for entry in hierarchy_path[1:]:
888
+ # Free-form index type: skip unknown/empty rather than
889
+ # raising, matching Go.
890
+ entry_type = _to_entity_type(entry.type.lower())
891
+ if entry_type is None:
892
+ continue
893
+ entry_key = _NameTypeKey(entry.name, entry_type)
894
+ if entry_key not in seen:
895
+ result.append(
896
+ OrgInfo(
897
+ name=entry.name,
898
+ type=_ancestor_org_info_type(entry_type),
899
+ stable_id=entry.stable_id,
900
+ )
901
+ )
902
+ seen.add(entry_key)
903
+
904
+ return result
905
+
906
+ def _user_organizations_by_id(self, uid: str) -> list[OrgInfo]:
907
+ """Resolve a user's organizations by stable ID. Caller must hold lock.
908
+
909
+ Each membership is resolved through its ``stable_id`` (the canonical
910
+ identity), team ancestry is walked by ``parent_id``, and results are
911
+ deduplicated by stable ID. Used only when ``_use_stable_ids`` is true.
912
+ """
913
+ assert self._data is not None
914
+ memberships = self._data.indexes.membership.membership_index.get(uid, ())
915
+ result: list[OrgInfo] = []
916
+ seen: set[str] = set()
917
+
918
+ for m in memberships:
919
+ if m.type not in (MembershipType.ORG, MembershipType.TEAM):
920
+ continue
921
+
922
+ stable_id = m.stable_id
923
+ if not stable_id:
924
+ etype = _to_entity_type(m.type)
925
+ if etype is not None:
926
+ stable_id = self._id_by_name_type.get(
927
+ _NameTypeKey(m.name, etype), ""
928
+ )
929
+ ref = self._entity_by_id.get(stable_id)
930
+ if ref is None:
931
+ continue
932
+
933
+ if stable_id not in seen:
934
+ seen.add(stable_id)
935
+ direct_type = (
936
+ OrgInfoType.ORGANIZATION
937
+ if m.type == MembershipType.ORG
938
+ else OrgInfoType.TEAM
939
+ )
940
+ result.append(
941
+ OrgInfo(name=ref.name, type=direct_type, stable_id=ref.stable_id)
942
+ )
943
+
944
+ if m.type != MembershipType.TEAM:
945
+ continue
946
+ # Walk ancestors with a per-walk visited set. Using the shared seen
947
+ # set as the stop condition would halt at an ancestor already
948
+ # recorded by an earlier (e.g. direct org) membership and drop its
949
+ # higher ancestors. visited also terminates cyclic parent links.
950
+ visited: set[str] = set()
951
+ parent_id = ref.parent_id
952
+ while parent_id and parent_id not in visited:
953
+ visited.add(parent_id)
954
+ parent = self._entity_by_id.get(parent_id)
955
+ if parent is None:
956
+ break
957
+ if parent_id not in seen:
958
+ seen.add(parent_id)
959
+ result.append(
960
+ OrgInfo(
961
+ name=parent.name,
962
+ type=_ancestor_org_info_type(parent.type),
963
+ stable_id=parent.stable_id,
964
+ )
965
+ )
966
+ parent_id = parent.parent_id
967
+
968
+ return result
969
+
970
+ async def get_all_employees(self) -> list[Employee]:
971
+ """Get all employees."""
972
+ async with self._lock:
973
+ if self._data is None:
974
+ return []
975
+ return list(self._data.lookups.employees.values())
976
+
977
+ async def get_all_teams(self) -> list[Team]:
978
+ """Get all teams."""
979
+ async with self._lock:
980
+ if self._data is None:
981
+ return []
982
+ return list(self._data.lookups.teams.values())
983
+
984
+ async def get_all_orgs(self) -> list[Org]:
985
+ """Get all organizations."""
986
+ async with self._lock:
987
+ if self._data is None:
988
+ return []
989
+ return list(self._data.lookups.orgs.values())
990
+
991
+ async def get_all_pillars(self) -> list[Pillar]:
992
+ """Get all pillars."""
993
+ async with self._lock:
994
+ if self._data is None:
995
+ return []
996
+ return list(self._data.lookups.pillars.values())
997
+
998
+ async def get_all_team_groups(self) -> list[TeamGroup]:
999
+ """Get all team groups."""
1000
+ async with self._lock:
1001
+ if self._data is None:
1002
+ return []
1003
+ return list(self._data.lookups.team_groups.values())
1004
+
1005
+ async def get_all_components(self) -> list[Component]:
1006
+ """Get all components."""
1007
+ async with self._lock:
1008
+ if self._data is None:
1009
+ return []
1010
+ return list(self._data.lookups.components.values())
1011
+
1012
+ async def get_all_component_names(self) -> list[str]:
1013
+ """Get all component names."""
1014
+ async with self._lock:
1015
+ if self._data is None:
1016
+ return []
1017
+ return list(self._data.lookups.components.keys())
1018
+
1019
+ async def get_teams_for_component(
1020
+ self, component_name: str
1021
+ ) -> list[ComponentOwnerInfo]:
1022
+ """Get all teams/entities that own a component.
1023
+
1024
+ Args:
1025
+ component_name: Component name to look up
1026
+
1027
+ Returns:
1028
+ List of owner entities with ownership types.
1029
+ """
1030
+ async with self._lock:
1031
+ if self._data is None:
1032
+ return []
1033
+ owners = self._data.indexes.component_ownership.get(component_name, [])
1034
+ return list(owners)
1035
+
1036
+ async def get_components_for_team(self, team_name: str) -> list[ComponentOwnership]:
1037
+ """Get all components owned by a team.
1038
+
1039
+ Uses the team's component_roles list for O(1) team lookup, then
1040
+ resolves ownership types from the component_ownership index.
1041
+
1042
+ Args:
1043
+ team_name: Team name to look up
1044
+
1045
+ Returns:
1046
+ List of ComponentOwnership with component name and ownership types.
1047
+ """
1048
+ async with self._lock:
1049
+ if self._data is None:
1050
+ return []
1051
+ team = self._data.lookups.teams.get(team_name)
1052
+ if not team:
1053
+ return []
1054
+ result: list[ComponentOwnership] = []
1055
+ for cr in _group(team).component_roles:
1056
+ ownership_types: list[str] = []
1057
+ owners = self._data.indexes.component_ownership.get(cr, [])
1058
+ for owner in owners:
1059
+ if owner.name == team_name:
1060
+ ownership_types = owner.ownership_types
1061
+ break
1062
+ result.append(
1063
+ ComponentOwnership(
1064
+ component=cr,
1065
+ ownership_types=tuple(ownership_types),
1066
+ )
1067
+ )
1068
+ return result
1069
+
1070
+ async def get_all_team_names(self) -> list[str]:
1071
+ """Get all team names."""
1072
+ async with self._lock:
1073
+ if self._data is None:
1074
+ return []
1075
+ return list(self._data.lookups.teams.keys())
1076
+
1077
+ async def get_all_org_names(self) -> list[str]:
1078
+ """Get all organization names."""
1079
+ async with self._lock:
1080
+ if self._data is None:
1081
+ return []
1082
+ return list(self._data.lookups.orgs.keys())
1083
+
1084
+ async def get_all_pillar_names(self) -> list[str]:
1085
+ """Get all pillar names."""
1086
+ async with self._lock:
1087
+ if self._data is None:
1088
+ return []
1089
+ return list(self._data.lookups.pillars.keys())
1090
+
1091
+ async def get_all_team_group_names(self) -> list[str]:
1092
+ """Get all team group names."""
1093
+ async with self._lock:
1094
+ if self._data is None:
1095
+ return []
1096
+ return list(self._data.lookups.team_groups.keys())
1097
+
1098
+ async def get_all_employee_uids(self) -> list[str]:
1099
+ """Get all employee UIDs in the system."""
1100
+ async with self._lock:
1101
+ if self._data is None:
1102
+ return []
1103
+ return list(self._data.lookups.employees.keys())
1104
+
1105
+ async def get_team_members(self, team_name: str) -> list[Employee]:
1106
+ """Get all members of a team."""
1107
+ async with self._lock:
1108
+ if self._data is None:
1109
+ return []
1110
+ team = self._data.lookups.teams.get(team_name)
1111
+ if not team:
1112
+ return []
1113
+ return [
1114
+ emp
1115
+ for uid in _group(team).resolved_people_uid_list
1116
+ if (emp := self._data.lookups.employees.get(uid))
1117
+ ]
1118
+
1119
+ async def get_org_members(self, org_name: str) -> list[Employee]:
1120
+ """Get all members of an organization."""
1121
+ async with self._lock:
1122
+ if self._data is None:
1123
+ return []
1124
+ org = self._data.lookups.orgs.get(org_name)
1125
+ if not org:
1126
+ return []
1127
+ return [
1128
+ emp
1129
+ for uid in _group(org).resolved_people_uid_list
1130
+ if (emp := self._data.lookups.employees.get(uid))
1131
+ ]
1132
+
1133
+ def get_version(self) -> DataVersion:
1134
+ """Get the current data version (sync - no lock needed for read)."""
1135
+ return self._version
1136
+
1137
+ async def get_jira_projects(self) -> list[str]:
1138
+ """Get all Jira project keys."""
1139
+ async with self._lock:
1140
+ if self._data is None:
1141
+ return []
1142
+ return list(self._data.indexes.jira.keys())
1143
+
1144
+ async def get_jira_components(self, project: str) -> list[str]:
1145
+ """Get all components for a Jira project.
1146
+
1147
+ Args:
1148
+ project: Jira project key (e.g., "OCPBUGS")
1149
+
1150
+ Returns:
1151
+ List of component names. "_project_level" indicates project-level ownership.
1152
+ """
1153
+ async with self._lock:
1154
+ if self._data is None:
1155
+ return []
1156
+ components = self._data.indexes.jira.get(project, {})
1157
+ return list(components.keys())
1158
+
1159
+ async def get_teams_by_jira_project(self, project: str) -> list[JiraOwnerInfo]:
1160
+ """Get all teams/entities that own any component in a Jira project.
1161
+
1162
+ Args:
1163
+ project: Jira project key (e.g., "OCPBUGS")
1164
+
1165
+ Returns:
1166
+ Deduplicated list of owner entities across all components.
1167
+ """
1168
+ async with self._lock:
1169
+ if self._data is None:
1170
+ return []
1171
+ components = self._data.indexes.jira.get(project, {})
1172
+ seen: set[str] = set()
1173
+ result: list[JiraOwnerInfo] = []
1174
+ for owners in components.values():
1175
+ for owner in owners:
1176
+ if owner.name not in seen:
1177
+ seen.add(owner.name)
1178
+ result.append(owner)
1179
+ return result
1180
+
1181
+ async def get_teams_by_jira_component(
1182
+ self, project: str, component: str
1183
+ ) -> list[JiraOwnerInfo]:
1184
+ """Get teams/entities that own a specific Jira component.
1185
+
1186
+ Args:
1187
+ project: Jira project key (e.g., "OCPBUGS")
1188
+ component: Component name (or "_project_level" for project ownership)
1189
+
1190
+ Returns:
1191
+ List of owner entities for the component.
1192
+ """
1193
+ async with self._lock:
1194
+ if self._data is None:
1195
+ return []
1196
+ components = self._data.indexes.jira.get(project, {})
1197
+ owners = components.get(component, [])
1198
+ return list(owners)
1199
+
1200
+ async def get_jira_ownership_for_team(self, team_name: str) -> list[dict[str, str]]:
1201
+ """Get all Jira projects and components owned by a team.
1202
+
1203
+ Args:
1204
+ team_name: Team name to look up
1205
+
1206
+ Returns:
1207
+ List of dicts with "project" and "component" keys.
1208
+ """
1209
+ async with self._lock:
1210
+ if self._data is None:
1211
+ return []
1212
+ result: list[dict[str, str]] = []
1213
+ for (
1214
+ project,
1215
+ components,
1216
+ ) in self._data.indexes.jira.items():
1217
+ for component, owners in components.items():
1218
+ for owner in owners:
1219
+ if owner.name == team_name:
1220
+ result.append({"project": project, "component": component})
1221
+ break
1222
+ return result
1223
+
1224
+ async def get_context_for_team(self, team_name: str) -> list[ContextItemInfo]:
1225
+ """Get resolved context items for a team (including inherited)."""
1226
+ async with self._lock:
1227
+ if self._data is None or not self._data.lookups.teams:
1228
+ return []
1229
+ team = self._data.lookups.teams.get(team_name)
1230
+ if team is None:
1231
+ return []
1232
+ return list(_group(team).resolved_context)
1233
+
1234
+ async def get_context_for_entity(
1235
+ self, entity_name: str, entity_type: str = "team"
1236
+ ) -> list[ContextItemInfo]:
1237
+ """Get resolved context items for any entity type."""
1238
+ async with self._lock:
1239
+ if self._data is None:
1240
+ return []
1241
+ entity = self._get_entity_by_type(entity_name, entity_type)
1242
+ if entity is None:
1243
+ return []
1244
+ return list(_group(entity).resolved_context)
1245
+
1246
+ async def get_context_by_type(
1247
+ self, entity_name: str, context_type: str, entity_type: str = "team"
1248
+ ) -> list[ContextItemInfo]:
1249
+ """Get resolved context items filtered by a specific context type."""
1250
+ async with self._lock:
1251
+ if self._data is None:
1252
+ return []
1253
+ entity = self._get_entity_by_type(entity_name, entity_type)
1254
+ if entity is None:
1255
+ return []
1256
+ return [
1257
+ item
1258
+ for item in _group(entity).resolved_context
1259
+ if context_type in item.types
1260
+ ]
1261
+
1262
+ async def get_all_context_types_for_entity(
1263
+ self, entity_name: str, entity_type: str = "team"
1264
+ ) -> list[str]:
1265
+ """Get distinct context types available for an entity."""
1266
+ async with self._lock:
1267
+ if self._data is None:
1268
+ return []
1269
+ entity = self._get_entity_by_type(entity_name, entity_type)
1270
+ if entity is None:
1271
+ return []
1272
+ seen: set[str] = set()
1273
+ result: list[str] = []
1274
+ for item in _group(entity).resolved_context:
1275
+ for t in item.types:
1276
+ if t not in seen:
1277
+ seen.add(t)
1278
+ result.append(t)
1279
+ return result
1280
+
1281
+ async def get_context_type_descriptions(self) -> dict[str, str]:
1282
+ """Get the description registry for all context types."""
1283
+ async with self._lock:
1284
+ if self._data is None:
1285
+ return {}
1286
+ return dict(self._data.metadata.context_type_descriptions)
1287
+
1288
+
1289
+ async def _async_retry_with_backoff(
1290
+ operation: Callable[[], Awaitable[BinaryIO]],
1291
+ max_retries: int = DEFAULT_MAX_RETRIES,
1292
+ initial_delay: float = DEFAULT_RETRY_DELAY,
1293
+ backoff: float = DEFAULT_RETRY_BACKOFF,
1294
+ operation_name: str = "operation",
1295
+ ) -> BinaryIO:
1296
+ """Execute an async operation with exponential backoff retry."""
1297
+ logger = get_logger()
1298
+ delay = initial_delay
1299
+ last_error: Exception | None = None
1300
+
1301
+ for attempt in range(max_retries + 1):
1302
+ try:
1303
+ return await operation()
1304
+ except Exception as e:
1305
+ last_error = e
1306
+ if attempt < max_retries:
1307
+ logger.warning(
1308
+ f"{operation_name} failed, retrying",
1309
+ extra={
1310
+ "attempt": attempt + 1,
1311
+ "max_retries": max_retries,
1312
+ "delay": delay,
1313
+ "error": str(e),
1314
+ },
1315
+ )
1316
+ await asyncio.sleep(delay)
1317
+ delay *= backoff
1318
+ else:
1319
+ logger.error(
1320
+ f"{operation_name} failed after all retries",
1321
+ extra={"attempts": max_retries + 1, "error": str(e)},
1322
+ )
1323
+
1324
+ raise GCSError(
1325
+ f"{operation_name} failed after {max_retries + 1} attempts: {last_error}"
1326
+ )
1327
+
1328
+
1329
+ try:
1330
+ from google.cloud import storage
1331
+
1332
+ _HAS_GCS = True
1333
+ except ImportError:
1334
+ _HAS_GCS = False
1335
+
1336
+
1337
+ class AsyncGCSDataSource:
1338
+ """Async GCS data source using google-cloud-storage.
1339
+
1340
+ Wraps sync GCS operations in asyncio.to_thread for non-blocking I/O.
1341
+
1342
+ Requires the google-cloud-storage package:
1343
+ pip install orgdatacore[gcs]
1344
+
1345
+ Example:
1346
+ config = GCSConfig(
1347
+ bucket="my-bucket",
1348
+ object_path="data.json",
1349
+ project_id="my-project",
1350
+ )
1351
+ source = AsyncGCSDataSource(config)
1352
+ service = AsyncService()
1353
+ await service.load_from_data_source(source)
1354
+ """
1355
+
1356
+ def __init__(
1357
+ self,
1358
+ config: GCSConfig,
1359
+ *,
1360
+ max_retries: int = DEFAULT_MAX_RETRIES,
1361
+ retry_delay: float = DEFAULT_RETRY_DELAY,
1362
+ retry_backoff: float = DEFAULT_RETRY_BACKOFF,
1363
+ ) -> None:
1364
+ """Create an async GCS data source.
1365
+
1366
+ Args:
1367
+ config: GCS configuration.
1368
+ max_retries: Maximum retry attempts for transient failures.
1369
+ retry_delay: Initial delay between retries in seconds.
1370
+ retry_backoff: Multiplier for delay after each retry.
1371
+
1372
+ Raises:
1373
+ ImportError: If google-cloud-storage is not installed.
1374
+ ConfigurationError: If configuration is invalid.
1375
+ """
1376
+ if not _HAS_GCS:
1377
+ raise ImportError(
1378
+ "google-cloud-storage is required for GCS support. "
1379
+ "Install it with: pip install orgdatacore[gcs]"
1380
+ )
1381
+ if not config.bucket:
1382
+ raise ConfigurationError("GCS bucket is required")
1383
+ if not config.object_path:
1384
+ raise ConfigurationError("GCS object_path is required")
1385
+
1386
+ self.config = config
1387
+ self.max_retries = max_retries
1388
+ self.retry_delay = retry_delay
1389
+ self.retry_backoff = retry_backoff
1390
+ self._client: Any = None
1391
+
1392
+ def _get_client(self) -> Any:
1393
+ """Get or create the GCS client (sync)."""
1394
+ if self._client is None:
1395
+ logger = get_logger()
1396
+ logger.debug(
1397
+ "Creating GCS client", extra={"project_id": self.config.project_id}
1398
+ )
1399
+
1400
+ if self.config.credentials_json:
1401
+ import json as _json
1402
+
1403
+ creds_info = _json.loads(self.config.credentials_json)
1404
+ self._client = storage.Client.from_service_account_info(creds_info)
1405
+ else:
1406
+ self._client = storage.Client(project=self.config.project_id or None)
1407
+ return self._client
1408
+
1409
+ async def load(self) -> BinaryIO:
1410
+ """Load data from GCS asynchronously.
1411
+
1412
+ Returns:
1413
+ File-like object containing the JSON data.
1414
+
1415
+ Raises:
1416
+ GCSError: If loading fails after all retries.
1417
+ """
1418
+ logger = get_logger()
1419
+ logger.debug(
1420
+ "Loading from GCS (async)",
1421
+ extra={"bucket": self.config.bucket, "object": self.config.object_path},
1422
+ )
1423
+
1424
+ async def _download() -> BinaryIO:
1425
+ def _sync_download() -> BinaryIO:
1426
+ client = self._get_client()
1427
+ bucket = client.bucket(self.config.bucket)
1428
+ blob = bucket.blob(self.config.object_path)
1429
+ return BytesIO(blob.download_as_bytes())
1430
+
1431
+ return await asyncio.to_thread(_sync_download)
1432
+
1433
+ return await _async_retry_with_backoff(
1434
+ _download,
1435
+ max_retries=self.max_retries,
1436
+ initial_delay=self.retry_delay,
1437
+ backoff=self.retry_backoff,
1438
+ operation_name=f"GCS download gs://{self.config.bucket}/{self.config.object_path}",
1439
+ )
1440
+
1441
+ async def watch(
1442
+ self, callback: Callable[[], Awaitable[Exception | None]]
1443
+ ) -> Exception | None:
1444
+ """Monitor for changes and call async callback when data is updated.
1445
+
1446
+ This coroutine blocks until cancelled. The caller should wrap it in
1447
+ asyncio.create_task() and cancel the task to stop watching.
1448
+
1449
+ Args:
1450
+ callback: Async function to call when data changes.
1451
+
1452
+ Returns:
1453
+ Exception if watcher setup fails, None otherwise.
1454
+ """
1455
+ logger = get_logger()
1456
+
1457
+ try:
1458
+ client = self._get_client()
1459
+ bucket = client.bucket(self.config.bucket)
1460
+ blob = bucket.blob(self.config.object_path)
1461
+
1462
+ # Get initial generation (sync, but quick)
1463
+ await asyncio.to_thread(blob.reload)
1464
+ last_generation = blob.generation
1465
+
1466
+ logger.info(
1467
+ "Starting async GCS watcher",
1468
+ extra={
1469
+ "bucket": self.config.bucket,
1470
+ "object": self.config.object_path,
1471
+ "check_interval": str(self.config.check_interval),
1472
+ },
1473
+ )
1474
+ except Exception as e:
1475
+ logger.error(
1476
+ "Failed to initialize async GCS watcher", extra={"error": str(e)}
1477
+ )
1478
+ return GCSError(f"Failed to initialize GCS watcher: {e}")
1479
+
1480
+ interval = self.config.check_interval.total_seconds()
1481
+
1482
+ try:
1483
+ while True:
1484
+ await asyncio.sleep(interval)
1485
+
1486
+ try:
1487
+ await asyncio.to_thread(blob.reload)
1488
+ if blob.generation != last_generation:
1489
+ logger.info(
1490
+ "GCS object changed, triggering async reload",
1491
+ extra={
1492
+ "old_generation": last_generation,
1493
+ "new_generation": blob.generation,
1494
+ },
1495
+ )
1496
+ last_generation = blob.generation
1497
+ err = await callback()
1498
+ if err:
1499
+ logger.error(
1500
+ "Async reload callback failed",
1501
+ extra={"error": str(err)},
1502
+ )
1503
+ except asyncio.CancelledError:
1504
+ raise
1505
+ except Exception as e:
1506
+ logger.error(
1507
+ "Async GCS watcher check failed", extra={"error": str(e)}
1508
+ )
1509
+ except asyncio.CancelledError:
1510
+ logger.info("Async GCS watcher cancelled")
1511
+
1512
+ return None
1513
+
1514
+ def __str__(self) -> str:
1515
+ """Return a description of this data source."""
1516
+ return f"gs://{self.config.bucket}/{self.config.object_path} (async)"