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/__init__.py +156 -0
- orgdatacore/_anonymization.py +465 -0
- orgdatacore/_async.py +1516 -0
- orgdatacore/_exceptions.py +82 -0
- orgdatacore/_gcs.py +317 -0
- orgdatacore/_internal/__init__.py +5 -0
- orgdatacore/_internal/testing/__init__.py +16 -0
- orgdatacore/_internal/testing/fake_gcs.py +173 -0
- orgdatacore/_internal/testing/filesource.py +147 -0
- orgdatacore/_internal/testing/helpers.py +192 -0
- orgdatacore/_log.py +98 -0
- orgdatacore/_redaction.py +215 -0
- orgdatacore/_serialization.py +24 -0
- orgdatacore/_service.py +1458 -0
- orgdatacore/_types.py +269 -0
- orgdatacore/_version.py +119 -0
- orgdatacore/py.typed +0 -0
- orgdatacore-2.0.0.dist-info/METADATA +444 -0
- orgdatacore-2.0.0.dist-info/RECORD +21 -0
- orgdatacore-2.0.0.dist-info/WHEEL +4 -0
- orgdatacore-2.0.0.dist-info/licenses/LICENSE +201 -0
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)"
|