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.
@@ -0,0 +1,156 @@
1
+ """
2
+ orgdatacore - Python library for organizational data management.
3
+
4
+ This is a Python port of the Go orgdatacore library, providing thread-safe
5
+ access to organizational data including employees, teams, organizations,
6
+ pillars, and team groups.
7
+
8
+ Example with GCS:
9
+ from orgdatacore import Service, GCSConfig, GCSDataSource
10
+ from datetime import timedelta
11
+
12
+ config = GCSConfig(
13
+ bucket="your-bucket",
14
+ object_path="path/to/data.json",
15
+ project_id="your-project",
16
+ check_interval=timedelta(minutes=5),
17
+ )
18
+ source = GCSDataSource(config)
19
+
20
+ service = Service()
21
+ service.load_from_data_source(source)
22
+
23
+ Example with custom DataSource:
24
+ class S3DataSource: # No inheritance needed - duck typing!
25
+ def load(self) -> BinaryIO: ...
26
+ def watch(self, callback) -> Optional[Exception]: ...
27
+ def __str__(self) -> str: ...
28
+
29
+ service = Service()
30
+ service.load_from_data_source(S3DataSource("my-bucket", "key"))
31
+ """
32
+
33
+ from ._anonymization import AnonymizingDataSource, AsyncAnonymizingDataSource
34
+ from ._async import AsyncGCSDataSource, AsyncService
35
+ from ._exceptions import (
36
+ ConfigurationError,
37
+ DataLoadError,
38
+ DataSourceError,
39
+ GCSError,
40
+ OrgDataError,
41
+ )
42
+ from ._gcs import GCSDataSource
43
+ from ._log import configure_default_logging, get_logger, set_logger
44
+ from ._redaction import AsyncRedactingDataSource, RedactingDataSource
45
+ from ._service import Service
46
+ from ._types import (
47
+ AliasInfo,
48
+ ChannelInfo,
49
+ Component,
50
+ ComponentOwnerInfo,
51
+ ComponentOwnership,
52
+ ContextItemInfo,
53
+ Data,
54
+ DataSource,
55
+ DataVersion,
56
+ EmailInfo,
57
+ Employee,
58
+ EscalationContactInfo,
59
+ GCSConfig,
60
+ GitHubIDMappings,
61
+ Group,
62
+ GroupType,
63
+ HierarchyNode,
64
+ HierarchyPathEntry,
65
+ Indexes,
66
+ JiraInfo,
67
+ JiraOwnerInfo,
68
+ Lookups,
69
+ MembershipIndex,
70
+ MembershipInfo,
71
+ MembershipType,
72
+ Metadata,
73
+ Org,
74
+ OrgInfo,
75
+ OrgInfoType,
76
+ ParentInfo,
77
+ PIIMode,
78
+ Pillar,
79
+ RepoInfo,
80
+ ResourceInfo,
81
+ RoleInfo,
82
+ SlackConfig,
83
+ SlackIDMappings,
84
+ Team,
85
+ TeamGroup,
86
+ )
87
+ from ._version import (
88
+ API_VERSION,
89
+ __version__,
90
+ __version_info__,
91
+ check_api_compatibility,
92
+ get_version_dict,
93
+ )
94
+
95
+ __all__ = [
96
+ "AnonymizingDataSource",
97
+ "AsyncAnonymizingDataSource",
98
+ "AsyncRedactingDataSource",
99
+ "Employee",
100
+ "Team",
101
+ "Org",
102
+ "Pillar",
103
+ "TeamGroup",
104
+ "Group",
105
+ "GroupType",
106
+ "ParentInfo",
107
+ "Data",
108
+ "Metadata",
109
+ "Lookups",
110
+ "Indexes",
111
+ "MembershipIndex",
112
+ "MembershipInfo",
113
+ "HierarchyPathEntry",
114
+ "HierarchyNode",
115
+ "SlackIDMappings",
116
+ "GitHubIDMappings",
117
+ "JiraOwnerInfo",
118
+ "SlackConfig",
119
+ "ChannelInfo",
120
+ "AliasInfo",
121
+ "RoleInfo",
122
+ "JiraInfo",
123
+ "RepoInfo",
124
+ "EmailInfo",
125
+ "EscalationContactInfo",
126
+ "ResourceInfo",
127
+ "Component",
128
+ "ComponentOwnerInfo",
129
+ "ComponentOwnership",
130
+ "ContextItemInfo",
131
+ "OrgInfo",
132
+ "DataVersion",
133
+ "GCSConfig",
134
+ "MembershipType",
135
+ "OrgInfoType",
136
+ "PIIMode",
137
+ "DataSource",
138
+ "RedactingDataSource",
139
+ "Service",
140
+ "AsyncService",
141
+ "GCSDataSource",
142
+ "AsyncGCSDataSource",
143
+ "OrgDataError",
144
+ "DataLoadError",
145
+ "DataSourceError",
146
+ "GCSError",
147
+ "ConfigurationError",
148
+ "get_logger",
149
+ "set_logger",
150
+ "configure_default_logging",
151
+ "__version__",
152
+ "__version_info__",
153
+ "API_VERSION",
154
+ "check_api_compatibility",
155
+ "get_version_dict",
156
+ ]
@@ -0,0 +1,465 @@
1
+ """Data source wrappers for PII anonymization.
2
+
3
+ Replaces real UIDs with random nonces (HUMAN-<hex>) while preserving
4
+ structural relationships, allowing AI to reason about people without
5
+ seeing real identities. Nonce mappings are stable across reloads —
6
+ existing UIDs keep their nonces, new UIDs get fresh random nonces.
7
+ """
8
+
9
+ import json
10
+ import secrets
11
+ from io import BytesIO
12
+ from typing import TYPE_CHECKING, Any, BinaryIO
13
+
14
+ from ._serialization import data_to_json_bytes
15
+ from ._service import parse_data
16
+ from ._types import (
17
+ Data,
18
+ Employee,
19
+ GitHubIDMappings,
20
+ Group,
21
+ MembershipIndex,
22
+ PIIMode,
23
+ SlackIDMappings,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+
30
+ class _AnonymizationEngine:
31
+ """Shared anonymization logic for sync and async data sources.
32
+
33
+ Builds UID-to-nonce lookup tables and rewrites org data JSON
34
+ to replace real identifiers with anonymized nonces.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ self._nonce_to_uid: dict[str, str] = {}
39
+ self._uid_to_nonce: dict[str, str] = {}
40
+ self._name_to_nonce: dict[str, str] = {}
41
+ self._nonce_to_display: dict[str, str] = {}
42
+ self._slack_id_to_nonce: dict[str, str] = {}
43
+ self._github_id_to_nonce: dict[str, str] = {}
44
+
45
+ @property
46
+ def uid_to_nonce_map(self) -> dict[str, str]:
47
+ """Read-only map of real UID -> nonce."""
48
+ return dict(self._uid_to_nonce)
49
+
50
+ @property
51
+ def name_to_nonce_map(self) -> dict[str, str]:
52
+ """Read-only map of lowered full name -> nonce."""
53
+ return dict(self._name_to_nonce)
54
+
55
+ @property
56
+ def slack_id_to_nonce_map(self) -> dict[str, str]:
57
+ """Read-only map of Slack user ID -> nonce."""
58
+ return dict(self._slack_id_to_nonce)
59
+
60
+ @property
61
+ def github_id_to_nonce_map(self) -> dict[str, str]:
62
+ """Read-only map of GitHub ID -> nonce."""
63
+ return dict(self._github_id_to_nonce)
64
+
65
+ def resolve(self, nonce: str) -> str | None:
66
+ """Resolve a nonce back to a real UID."""
67
+ return self._nonce_to_uid.get(nonce)
68
+
69
+ def anonymize_uid(self, uid: str) -> str | None:
70
+ """Map a real UID to its nonce."""
71
+ return self._uid_to_nonce.get(uid)
72
+
73
+ def lookup_by_name(self, name: str) -> str | None:
74
+ """Case-insensitive name lookup returning the nonce."""
75
+ return self._name_to_nonce.get(name.lower())
76
+
77
+ def get_display_name(self, nonce: str) -> str | None:
78
+ """Get human-readable display for a nonce: 'Full Name (uid)'."""
79
+ return self._nonce_to_display.get(nonce)
80
+
81
+ def _generate_nonce(self, prefix: str, used: set[str]) -> str:
82
+ """Generate a unique <prefix><hex> nonce."""
83
+ while True:
84
+ nonce = f"{prefix}{secrets.token_hex(4)}"
85
+ if nonce not in used:
86
+ used.add(nonce)
87
+ return nonce
88
+
89
+ def anonymize(self, data: Data) -> Data:
90
+ """Anonymize org data and rebuild lookup tables. Returns new Data."""
91
+ employees = data.lookups.employees
92
+
93
+ # Preserve existing UID->nonce mappings for UIDs that still exist;
94
+ # generate new random nonces only for new employees.
95
+ prev_uid_to_nonce = self._uid_to_nonce
96
+
97
+ self._nonce_to_uid = {}
98
+ self._uid_to_nonce = {}
99
+ self._name_to_nonce = {}
100
+ self._nonce_to_display = {}
101
+ self._slack_id_to_nonce = {}
102
+ self._github_id_to_nonce = {}
103
+
104
+ used_nonces: set[str] = set(
105
+ prev_uid_to_nonce[uid] for uid in employees if uid in prev_uid_to_nonce
106
+ )
107
+
108
+ # Phase 1: Build UID -> nonce mapping for all employees
109
+ for uid, emp in employees.items():
110
+ # Reuse existing nonce if available, otherwise generate new one
111
+ nonce = prev_uid_to_nonce.get(uid) or self._generate_nonce(
112
+ "HUMAN-", used_nonces
113
+ )
114
+ self._uid_to_nonce[uid] = nonce
115
+ self._nonce_to_uid[nonce] = uid
116
+
117
+ if emp.full_name:
118
+ self._name_to_nonce[emp.full_name.lower()] = nonce
119
+ self._nonce_to_display[nonce] = emp.full_name
120
+ else:
121
+ self._nonce_to_display[nonce] = nonce
122
+
123
+ # Phase 2: Build Slack ID and GitHub ID nonce mappings.
124
+ # First try the indexes, then fall back to employee records
125
+ # (the indexes may be empty if the data dump didn't populate them).
126
+ slack_uid_to_uid = dict(data.indexes.slack_id_mappings.slack_uid_to_uid)
127
+ github_id_to_uid = dict(data.indexes.github_id_mappings.github_id_to_uid)
128
+
129
+ # Fill from employee records if indexes are incomplete
130
+ for uid, emp in employees.items():
131
+ if emp.slack_uid and emp.slack_uid not in slack_uid_to_uid:
132
+ slack_uid_to_uid[emp.slack_uid] = uid
133
+ if emp.github_id and emp.github_id not in github_id_to_uid:
134
+ github_id_to_uid[emp.github_id] = uid
135
+
136
+ for slack_id, uid in slack_uid_to_uid.items():
137
+ if uid in self._uid_to_nonce:
138
+ slack_nonce = self._generate_nonce("SLACK-", used_nonces)
139
+ self._slack_id_to_nonce[slack_id] = slack_nonce
140
+ self._nonce_to_display[slack_nonce] = slack_id
141
+ for github_id, uid in github_id_to_uid.items():
142
+ if uid in self._uid_to_nonce:
143
+ github_nonce = self._generate_nonce("GITHUB-", used_nonces)
144
+ self._github_id_to_nonce[github_id] = github_nonce
145
+ self._nonce_to_display[github_nonce] = github_id
146
+
147
+ # Build reverse maps: uid -> slack nonce, uid -> github nonce
148
+ uid_to_slack_nonce: dict[str, str] = {}
149
+ for slack_id, uid in slack_uid_to_uid.items():
150
+ if slack_id in self._slack_id_to_nonce:
151
+ uid_to_slack_nonce[uid] = self._slack_id_to_nonce[slack_id]
152
+ uid_to_github_nonce: dict[str, str] = {}
153
+ for github_id, uid in github_id_to_uid.items():
154
+ if github_id in self._github_id_to_nonce:
155
+ uid_to_github_nonce[uid] = self._github_id_to_nonce[github_id]
156
+
157
+ # Phase 3: Rewrite employee records
158
+ new_employees: dict[str, Employee] = {}
159
+ for uid, emp in employees.items():
160
+ nonce = self._uid_to_nonce[uid]
161
+
162
+ manager_nonce = ""
163
+ if emp.manager_uid:
164
+ manager_nonce = self._uid_to_nonce.get(emp.manager_uid, "")
165
+
166
+ new_employees[nonce] = emp.model_copy(
167
+ update={
168
+ "uid": nonce,
169
+ "full_name": "[ANONYMIZED]",
170
+ "email": "[ANONYMIZED]",
171
+ "slack_uid": uid_to_slack_nonce.get(uid, ""),
172
+ "github_id": uid_to_github_nonce.get(uid, ""),
173
+ # Alternate GitHub IDs are real identifiers; clear them so a
174
+ # person cannot be re-identified by a secondary GitHub handle.
175
+ "possible_github_ids": [],
176
+ "manager_uid": manager_nonce,
177
+ }
178
+ )
179
+
180
+ # Phase 4: Rewrite indexes
181
+ # Re-key membership index
182
+ new_membership_index: dict[str, Any] = {}
183
+ for uid, memberships in data.indexes.membership.membership_index.items():
184
+ mapped_nonce = self._uid_to_nonce.get(uid)
185
+ if mapped_nonce:
186
+ new_membership_index[mapped_nonce] = memberships
187
+
188
+ # Remap slack index: slack_nonce -> uid_nonce
189
+ new_slack_index: dict[str, str] = {}
190
+ for slack_id, slack_nonce in self._slack_id_to_nonce.items():
191
+ uid_nonce = self._uid_to_nonce.get(slack_uid_to_uid.get(slack_id, ""), "")
192
+ if uid_nonce:
193
+ new_slack_index[slack_nonce] = uid_nonce
194
+
195
+ # Remap github index: github_nonce -> uid_nonce
196
+ new_github_index: dict[str, str] = {}
197
+ for github_id, github_nonce in self._github_id_to_nonce.items():
198
+ uid_nonce = self._uid_to_nonce.get(github_id_to_uid.get(github_id, ""), "")
199
+ if uid_nonce:
200
+ new_github_index[github_nonce] = uid_nonce
201
+
202
+ # Phase 5: Rewrite group people lists in teams/orgs/pillars/team_groups
203
+ new_teams = {
204
+ name: t.model_copy(
205
+ update={"group": _remap_group(t.group, self._uid_to_nonce)}
206
+ )
207
+ for name, t in data.lookups.teams.items()
208
+ }
209
+ new_orgs = {
210
+ name: o.model_copy(
211
+ update={"group": _remap_group(o.group, self._uid_to_nonce)}
212
+ )
213
+ for name, o in data.lookups.orgs.items()
214
+ }
215
+ new_pillars = {
216
+ name: p.model_copy(
217
+ update={"group": _remap_group(p.group, self._uid_to_nonce)}
218
+ )
219
+ for name, p in data.lookups.pillars.items()
220
+ }
221
+ new_team_groups = {
222
+ name: tg.model_copy(
223
+ update={"group": _remap_group(tg.group, self._uid_to_nonce)}
224
+ )
225
+ for name, tg in data.lookups.team_groups.items()
226
+ }
227
+ new_cohorts = {
228
+ name: c.model_copy(
229
+ update={"group": _remap_group(c.group, self._uid_to_nonce)}
230
+ )
231
+ for name, c in data.lookups.cohorts.items()
232
+ }
233
+
234
+ return data.model_copy(
235
+ update={
236
+ "lookups": data.lookups.model_copy(
237
+ update={
238
+ "employees": new_employees,
239
+ "teams": new_teams,
240
+ "orgs": new_orgs,
241
+ "pillars": new_pillars,
242
+ "team_groups": new_team_groups,
243
+ "cohorts": new_cohorts,
244
+ }
245
+ ),
246
+ "indexes": data.indexes.model_copy(
247
+ update={
248
+ "membership": MembershipIndex(
249
+ membership_index=new_membership_index
250
+ ),
251
+ "slack_id_mappings": SlackIDMappings(
252
+ slack_uid_to_uid=new_slack_index
253
+ ),
254
+ "github_id_mappings": GitHubIDMappings(
255
+ github_id_to_uid=new_github_index
256
+ ),
257
+ }
258
+ ),
259
+ }
260
+ )
261
+
262
+
263
+ def _remap_group(group: Group | None, uid_to_nonce: dict[str, str]) -> Group | None:
264
+ """Return a new Group with people UIDs replaced by nonces (None if no group).
265
+
266
+ UIDs with no mapping (not an employee in this index) are dropped rather than
267
+ passed through: retaining them would emit a real UID in anonymized output.
268
+ """
269
+ if group is None:
270
+ return None
271
+ new_people = [
272
+ uid_to_nonce[uid]
273
+ for uid in group.resolved_people_uid_list
274
+ if uid in uid_to_nonce
275
+ ]
276
+ new_roles = [
277
+ role.model_copy(
278
+ update={
279
+ "people": [
280
+ uid_to_nonce[uid] for uid in role.people if uid in uid_to_nonce
281
+ ]
282
+ }
283
+ )
284
+ for role in group.resolved_roles
285
+ ]
286
+ new_escalation = [
287
+ contact.model_copy(
288
+ update={
289
+ "name": "[ANONYMIZED]",
290
+ "url": "",
291
+ "description": "[ANONYMIZED]",
292
+ }
293
+ )
294
+ for contact in group.escalation
295
+ ]
296
+ return group.model_copy(
297
+ update={
298
+ "resolved_people_uid_list": new_people,
299
+ "resolved_roles": new_roles,
300
+ "escalation": new_escalation,
301
+ }
302
+ )
303
+
304
+
305
+ class AnonymizingDataSource:
306
+ """DataSource decorator that anonymizes PII in loaded data.
307
+
308
+ When pii_mode is ANONYMIZED, replaces UIDs with random nonces
309
+ and strips PII fields, while preserving structural relationships.
310
+ Nonce tables are rebuilt on every load() call (ephemeral).
311
+
312
+ PII fields anonymized:
313
+ - uid -> HUMAN-<hex> nonce
314
+ - full_name -> "[ANONYMIZED]"
315
+ - email -> "[ANONYMIZED]"
316
+ - slack_uid -> SLACK-<hex> nonce
317
+ - github_id -> GITHUB-<hex> nonce
318
+ - possible_github_ids -> [] (cleared)
319
+ - manager_uid -> mapped HUMAN nonce (consistent)
320
+
321
+ Indexes re-mapped:
322
+ - membership_index keys -> HUMAN nonces
323
+ - resolved_people_uid_list -> HUMAN nonces
324
+ - roles.people -> HUMAN nonces
325
+ - slack_id_mappings: SLACK nonce -> HUMAN nonce
326
+ - github_id_mappings: GITHUB nonce -> HUMAN nonce
327
+ """
328
+
329
+ def __init__(
330
+ self,
331
+ source: Any,
332
+ pii_mode: PIIMode = PIIMode.FULL,
333
+ ) -> None:
334
+ self._source = source
335
+ self._pii_mode = pii_mode
336
+ self._engine = _AnonymizationEngine()
337
+
338
+ def load(self) -> BinaryIO:
339
+ """Load data, optionally anonymizing PII fields."""
340
+ reader: BinaryIO = self._source.load()
341
+
342
+ if self._pii_mode != PIIMode.ANONYMIZED:
343
+ return reader
344
+
345
+ try:
346
+ raw = json.load(reader)
347
+ finally:
348
+ reader.close()
349
+ data = parse_data(raw)
350
+ anonymized = self._engine.anonymize(data)
351
+ return BytesIO(data_to_json_bytes(anonymized))
352
+
353
+ def watch(self, callback: "Callable[[], Exception | None]") -> Exception | None:
354
+ """Delegate to the underlying data source."""
355
+ result: Exception | None = self._source.watch(callback)
356
+ return result
357
+
358
+ def __str__(self) -> str:
359
+ mode_suffix = (
360
+ " [PII anonymized]" if self._pii_mode == PIIMode.ANONYMIZED else ""
361
+ )
362
+ return f"{self._source}{mode_suffix}"
363
+
364
+ # Public API — delegates to engine
365
+ def resolve(self, nonce: str) -> str | None:
366
+ return self._engine.resolve(nonce)
367
+
368
+ def anonymize_uid(self, uid: str) -> str | None:
369
+ return self._engine.anonymize_uid(uid)
370
+
371
+ def lookup_by_name(self, name: str) -> str | None:
372
+ return self._engine.lookup_by_name(name)
373
+
374
+ def get_display_name(self, nonce: str) -> str | None:
375
+ return self._engine.get_display_name(nonce)
376
+
377
+ @property
378
+ def uid_to_nonce_map(self) -> dict[str, str]:
379
+ return self._engine.uid_to_nonce_map
380
+
381
+ @property
382
+ def name_to_nonce_map(self) -> dict[str, str]:
383
+ return self._engine.name_to_nonce_map
384
+
385
+ @property
386
+ def slack_id_to_nonce_map(self) -> dict[str, str]:
387
+ return self._engine.slack_id_to_nonce_map
388
+
389
+ @property
390
+ def github_id_to_nonce_map(self) -> dict[str, str]:
391
+ return self._engine.github_id_to_nonce_map
392
+
393
+
394
+ class AsyncAnonymizingDataSource:
395
+ """Async DataSource decorator that anonymizes PII in loaded data.
396
+
397
+ Same anonymization as AnonymizingDataSource but with async load().
398
+ Use with AsyncService and async inner sources (AsyncFileDataSource,
399
+ AsyncGCSDataSource).
400
+ """
401
+
402
+ def __init__(
403
+ self,
404
+ source: Any,
405
+ pii_mode: PIIMode = PIIMode.FULL,
406
+ ) -> None:
407
+ self._source = source
408
+ self._pii_mode = pii_mode
409
+ self._engine = _AnonymizationEngine()
410
+
411
+ async def load(self) -> BinaryIO:
412
+ """Load data asynchronously, optionally anonymizing PII fields."""
413
+ reader: BinaryIO = await self._source.load()
414
+
415
+ if self._pii_mode != PIIMode.ANONYMIZED:
416
+ return reader
417
+
418
+ try:
419
+ raw = reader.read()
420
+ finally:
421
+ reader.close()
422
+ text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
423
+ raw_data = json.loads(text)
424
+ data = parse_data(raw_data)
425
+ anonymized = self._engine.anonymize(data)
426
+ return BytesIO(data_to_json_bytes(anonymized))
427
+
428
+ async def watch(self, callback: Any) -> Any:
429
+ """Delegate to the underlying data source."""
430
+ return await self._source.watch(callback)
431
+
432
+ def __str__(self) -> str:
433
+ mode_suffix = (
434
+ " [PII anonymized]" if self._pii_mode == PIIMode.ANONYMIZED else ""
435
+ )
436
+ return f"{self._source}{mode_suffix}"
437
+
438
+ # Public API — delegates to engine
439
+ def resolve(self, nonce: str) -> str | None:
440
+ return self._engine.resolve(nonce)
441
+
442
+ def anonymize_uid(self, uid: str) -> str | None:
443
+ return self._engine.anonymize_uid(uid)
444
+
445
+ def lookup_by_name(self, name: str) -> str | None:
446
+ return self._engine.lookup_by_name(name)
447
+
448
+ def get_display_name(self, nonce: str) -> str | None:
449
+ return self._engine.get_display_name(nonce)
450
+
451
+ @property
452
+ def uid_to_nonce_map(self) -> dict[str, str]:
453
+ return self._engine.uid_to_nonce_map
454
+
455
+ @property
456
+ def name_to_nonce_map(self) -> dict[str, str]:
457
+ return self._engine.name_to_nonce_map
458
+
459
+ @property
460
+ def slack_id_to_nonce_map(self) -> dict[str, str]:
461
+ return self._engine.slack_id_to_nonce_map
462
+
463
+ @property
464
+ def github_id_to_nonce_map(self) -> dict[str, str]:
465
+ return self._engine.github_id_to_nonce_map