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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FileDataSource - Internal testing utility.
|
|
3
|
+
|
|
4
|
+
INTERNAL USE ONLY: This is kept for test infrastructure.
|
|
5
|
+
Production code should use GCSDataSource or implement a custom DataSource.
|
|
6
|
+
|
|
7
|
+
This module is NOT part of the public API and may change without notice.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
from orgdatacore._exceptions import FileSourceError
|
|
18
|
+
from orgdatacore._log import get_logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileDataSource:
|
|
22
|
+
"""
|
|
23
|
+
FileDataSource loads organizational data from local files.
|
|
24
|
+
|
|
25
|
+
INTERNAL USE ONLY: This should only be used in test code.
|
|
26
|
+
Production code should use GCSDataSource or implement a custom DataSource.
|
|
27
|
+
|
|
28
|
+
For production deployments, file-based data sources are not recommended
|
|
29
|
+
for security reasons. Use GCS or implement your own DataSource (e.g., S3).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, file_paths: list[str] | str, poll_interval: float = 60.0
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Create a new file-based data source for testing.
|
|
37
|
+
|
|
38
|
+
INTERNAL USE ONLY.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
file_paths: Path(s) to the data file(s). If multiple provided,
|
|
42
|
+
the last one is used.
|
|
43
|
+
poll_interval: Interval in seconds for polling file changes.
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(file_paths, str):
|
|
46
|
+
file_paths = [file_paths]
|
|
47
|
+
self.file_paths = file_paths
|
|
48
|
+
self.poll_interval = poll_interval
|
|
49
|
+
self._stop_event = threading.Event()
|
|
50
|
+
|
|
51
|
+
def load(self) -> BinaryIO:
|
|
52
|
+
"""Load and return a reader for the organizational data file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
File-like object containing the data.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
FileSourceError: If loading fails.
|
|
59
|
+
"""
|
|
60
|
+
logger = get_logger()
|
|
61
|
+
|
|
62
|
+
if not self.file_paths:
|
|
63
|
+
raise FileSourceError("no file paths provided")
|
|
64
|
+
|
|
65
|
+
# Load the primary data file (use the last path if multiple provided)
|
|
66
|
+
file_path = self.file_paths[-1]
|
|
67
|
+
logger.debug("Loading from file", extra={"path": file_path})
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with open(file_path, "rb") as f:
|
|
71
|
+
content = f.read()
|
|
72
|
+
return BytesIO(content)
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
raise FileSourceError(f"file not found: {file_path}")
|
|
75
|
+
except PermissionError:
|
|
76
|
+
raise FileSourceError(f"permission denied: {file_path}")
|
|
77
|
+
except OSError as e:
|
|
78
|
+
raise FileSourceError(f"failed to read file {file_path}: {e}")
|
|
79
|
+
|
|
80
|
+
def watch(self, callback: Callable[[], Exception | None]) -> Exception | None:
|
|
81
|
+
"""
|
|
82
|
+
Monitor for file changes and call callback when data is updated.
|
|
83
|
+
|
|
84
|
+
This starts a background thread that polls for file changes.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
callback: Function to call when files change.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Exception if setup fails, None otherwise.
|
|
91
|
+
"""
|
|
92
|
+
logger = get_logger()
|
|
93
|
+
|
|
94
|
+
if not self.file_paths:
|
|
95
|
+
return FileSourceError("no file paths to watch")
|
|
96
|
+
|
|
97
|
+
# Get initial modification times
|
|
98
|
+
mod_times: dict[str, float] = {}
|
|
99
|
+
for path in self.file_paths:
|
|
100
|
+
try:
|
|
101
|
+
mod_times[path] = os.path.getmtime(path)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
logger.debug(
|
|
106
|
+
"Starting file watcher",
|
|
107
|
+
extra={"paths": self.file_paths, "poll_interval": self.poll_interval},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def watcher() -> None:
|
|
111
|
+
while not self._stop_event.is_set():
|
|
112
|
+
time.sleep(self.poll_interval)
|
|
113
|
+
if self._stop_event.is_set():
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
# Check if any files have changed
|
|
117
|
+
changed = False
|
|
118
|
+
for path in self.file_paths:
|
|
119
|
+
try:
|
|
120
|
+
current_mtime = os.path.getmtime(path)
|
|
121
|
+
last_mtime = mod_times.get(path)
|
|
122
|
+
if last_mtime is None or current_mtime > last_mtime:
|
|
123
|
+
mod_times[path] = current_mtime
|
|
124
|
+
changed = True
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
if changed:
|
|
129
|
+
logger.debug("File change detected, triggering reload")
|
|
130
|
+
try:
|
|
131
|
+
callback()
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.error("Reload callback failed", extra={"error": str(e)})
|
|
134
|
+
|
|
135
|
+
thread = threading.Thread(target=watcher, daemon=True, name="file-watcher")
|
|
136
|
+
thread.start()
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def stop_watching(self) -> None:
|
|
140
|
+
"""Stop the file watcher."""
|
|
141
|
+
self._stop_event.set()
|
|
142
|
+
|
|
143
|
+
def __str__(self) -> str:
|
|
144
|
+
"""Return a description of this data source."""
|
|
145
|
+
if len(self.file_paths) == 1:
|
|
146
|
+
return f"file:{self.file_paths[0]}"
|
|
147
|
+
return f"files:{','.join(self.file_paths)}"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Test helpers for orgdatacore."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from typing import BinaryIO
|
|
7
|
+
|
|
8
|
+
from orgdatacore._serialization import data_to_dict
|
|
9
|
+
from orgdatacore._types import (
|
|
10
|
+
Component,
|
|
11
|
+
Data,
|
|
12
|
+
Employee,
|
|
13
|
+
GitHubIDMappings,
|
|
14
|
+
Group,
|
|
15
|
+
GroupType,
|
|
16
|
+
Indexes,
|
|
17
|
+
JiraOwnerInfo,
|
|
18
|
+
Lookups,
|
|
19
|
+
MembershipIndex,
|
|
20
|
+
MembershipInfo,
|
|
21
|
+
Metadata,
|
|
22
|
+
Org,
|
|
23
|
+
ParentInfo,
|
|
24
|
+
Pillar,
|
|
25
|
+
SlackIDMappings,
|
|
26
|
+
Team,
|
|
27
|
+
TeamGroup,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeDataSource:
|
|
32
|
+
"""FakeDataSource implements DataSource for testing with controllable data."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
data: str = "",
|
|
37
|
+
load_error: Exception | None = None,
|
|
38
|
+
watch_error: Exception | None = None,
|
|
39
|
+
description: str = "fake-data-source",
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Create a fake data source for testing.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
data: JSON string data to return from load().
|
|
46
|
+
load_error: Error to raise from load().
|
|
47
|
+
watch_error: Error to return from watch().
|
|
48
|
+
description: Description string for __str__.
|
|
49
|
+
"""
|
|
50
|
+
self.data = data
|
|
51
|
+
self.load_error = load_error
|
|
52
|
+
self.watch_error = watch_error
|
|
53
|
+
self.description = description
|
|
54
|
+
self.watch_called = False
|
|
55
|
+
|
|
56
|
+
def load(self) -> BinaryIO:
|
|
57
|
+
"""Return the test data."""
|
|
58
|
+
if self.load_error:
|
|
59
|
+
raise self.load_error
|
|
60
|
+
return BytesIO(self.data.encode("utf-8"))
|
|
61
|
+
|
|
62
|
+
def watch(self, callback: Callable[[], Exception | None]) -> Exception | None:
|
|
63
|
+
"""Track that watch was called but don't actually watch."""
|
|
64
|
+
self.watch_called = True
|
|
65
|
+
if self.watch_error:
|
|
66
|
+
return self.watch_error
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
"""Return the description."""
|
|
71
|
+
return self.description
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_test_data() -> Data:
|
|
75
|
+
"""Create comprehensive test data for testing."""
|
|
76
|
+
return Data(
|
|
77
|
+
metadata=Metadata(
|
|
78
|
+
generated_at="2024-01-01T00:00:00Z",
|
|
79
|
+
data_version="test-v1.0",
|
|
80
|
+
total_employees=2,
|
|
81
|
+
total_orgs=1,
|
|
82
|
+
),
|
|
83
|
+
lookups=Lookups(
|
|
84
|
+
employees={
|
|
85
|
+
"testuser1": Employee(
|
|
86
|
+
uid="testuser1",
|
|
87
|
+
full_name="Test User One",
|
|
88
|
+
email="testuser1@example.com",
|
|
89
|
+
job_title="Test Engineer",
|
|
90
|
+
slack_uid="U111111",
|
|
91
|
+
github_id="ghuser1",
|
|
92
|
+
manager_uid="testuser2",
|
|
93
|
+
),
|
|
94
|
+
"testuser2": Employee(
|
|
95
|
+
uid="testuser2",
|
|
96
|
+
full_name="Test User Two",
|
|
97
|
+
email="testuser2@example.com",
|
|
98
|
+
job_title="Test Manager",
|
|
99
|
+
slack_uid="U222222",
|
|
100
|
+
github_id="ghuser2",
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
teams={
|
|
104
|
+
"test-squad": Team(
|
|
105
|
+
name="test-squad",
|
|
106
|
+
parent=ParentInfo(name="test-team-group", type="team_group"),
|
|
107
|
+
group=Group(
|
|
108
|
+
type=GroupType(name="team"),
|
|
109
|
+
resolved_people_uid_list=["testuser1", "testuser2"],
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
orgs={
|
|
114
|
+
"test-division": Org(
|
|
115
|
+
name="test-division",
|
|
116
|
+
group=Group(
|
|
117
|
+
type=GroupType(name="organization"),
|
|
118
|
+
resolved_people_uid_list=["testuser1", "testuser2"],
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
},
|
|
122
|
+
pillars={
|
|
123
|
+
"test-pillar": Pillar(
|
|
124
|
+
name="test-pillar",
|
|
125
|
+
parent=ParentInfo(name="test-division", type="org"),
|
|
126
|
+
group=Group(
|
|
127
|
+
type=GroupType(name="pillar"),
|
|
128
|
+
resolved_people_uid_list=["testuser1", "testuser2"],
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
team_groups={
|
|
133
|
+
"test-team-group": TeamGroup(
|
|
134
|
+
name="test-team-group",
|
|
135
|
+
parent=ParentInfo(name="test-pillar", type="pillar"),
|
|
136
|
+
group=Group(
|
|
137
|
+
type=GroupType(name="team_group"),
|
|
138
|
+
resolved_people_uid_list=["testuser1", "testuser2"],
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
},
|
|
142
|
+
components={
|
|
143
|
+
"test-component": Component(
|
|
144
|
+
name="test-component",
|
|
145
|
+
description="Test component",
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
),
|
|
149
|
+
indexes=Indexes(
|
|
150
|
+
membership=MembershipIndex(
|
|
151
|
+
membership_index={
|
|
152
|
+
"testuser1": (
|
|
153
|
+
MembershipInfo(name="test-squad", type="team"),
|
|
154
|
+
MembershipInfo(name="test-division", type="org"),
|
|
155
|
+
),
|
|
156
|
+
"testuser2": (
|
|
157
|
+
MembershipInfo(name="test-squad", type="team"),
|
|
158
|
+
MembershipInfo(name="test-division", type="org"),
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
slack_id_mappings=SlackIDMappings(
|
|
163
|
+
slack_uid_to_uid={
|
|
164
|
+
"U111111": "testuser1",
|
|
165
|
+
"U222222": "testuser2",
|
|
166
|
+
},
|
|
167
|
+
),
|
|
168
|
+
github_id_mappings=GitHubIDMappings(
|
|
169
|
+
github_id_to_uid={
|
|
170
|
+
"ghuser1": "testuser1",
|
|
171
|
+
"ghuser2": "testuser2",
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
jira={
|
|
175
|
+
"TEST": {
|
|
176
|
+
"Core": [JiraOwnerInfo(name="test-squad", type="team")],
|
|
177
|
+
"_project_level": [
|
|
178
|
+
JiraOwnerInfo(name="test-squad", type="team"),
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
"PLAT": {
|
|
182
|
+
"API": [JiraOwnerInfo(name="test-squad", type="team")],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def create_test_data_json() -> str:
|
|
190
|
+
"""Create test data as JSON string."""
|
|
191
|
+
data = create_test_data()
|
|
192
|
+
return json.dumps(data_to_dict(data))
|
orgdatacore/_log.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Logging configuration for orgdatacore.
|
|
2
|
+
|
|
3
|
+
This module provides structured logging support matching the Go library's
|
|
4
|
+
logging capabilities.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from orgdatacore import get_logger, set_logger
|
|
8
|
+
|
|
9
|
+
# Use the default logger
|
|
10
|
+
logger = get_logger()
|
|
11
|
+
logger.info("Data loaded", extra={"employee_count": 100})
|
|
12
|
+
|
|
13
|
+
# Set a custom logger
|
|
14
|
+
import logging
|
|
15
|
+
custom_logger = logging.getLogger("my_app.orgdata")
|
|
16
|
+
set_logger(custom_logger)
|
|
17
|
+
|
|
18
|
+
# Or configure the default logger
|
|
19
|
+
import logging
|
|
20
|
+
logging.getLogger("orgdatacore").setLevel(logging.DEBUG)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
# Default logger for the library
|
|
26
|
+
_logger: logging.Logger | None = None
|
|
27
|
+
|
|
28
|
+
# Default logger name
|
|
29
|
+
LOGGER_NAME = "orgdatacore"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_logger() -> logging.Logger:
|
|
33
|
+
"""Get the current logger for orgdatacore.
|
|
34
|
+
|
|
35
|
+
Returns the custom logger if set, otherwise returns the default
|
|
36
|
+
library logger.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The configured logger instance.
|
|
40
|
+
"""
|
|
41
|
+
global _logger
|
|
42
|
+
if _logger is not None:
|
|
43
|
+
return _logger
|
|
44
|
+
return logging.getLogger(LOGGER_NAME)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_logger(logger: logging.Logger | None) -> None:
|
|
48
|
+
"""Set a custom logger for orgdatacore.
|
|
49
|
+
|
|
50
|
+
This allows integrating orgdatacore logging with your application's
|
|
51
|
+
logging infrastructure.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
logger: Custom logger to use, or None to reset to default.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
import logging
|
|
58
|
+
from orgdatacore import set_logger
|
|
59
|
+
|
|
60
|
+
# Use your application's logger
|
|
61
|
+
app_logger = logging.getLogger("myapp.orgdata")
|
|
62
|
+
set_logger(app_logger)
|
|
63
|
+
"""
|
|
64
|
+
global _logger
|
|
65
|
+
_logger = logger
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configure_default_logging(
|
|
69
|
+
level: int = logging.INFO,
|
|
70
|
+
format_string: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Configure the default orgdatacore logger.
|
|
73
|
+
|
|
74
|
+
Convenience function to set up basic logging configuration.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
level: Logging level (default: INFO)
|
|
78
|
+
format_string: Custom format string (default: structured format)
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
from orgdatacore import configure_default_logging
|
|
82
|
+
import logging
|
|
83
|
+
|
|
84
|
+
configure_default_logging(level=logging.DEBUG)
|
|
85
|
+
"""
|
|
86
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
87
|
+
logger.setLevel(level)
|
|
88
|
+
|
|
89
|
+
if not logger.handlers:
|
|
90
|
+
handler = logging.StreamHandler()
|
|
91
|
+
handler.setLevel(level)
|
|
92
|
+
|
|
93
|
+
if format_string is None:
|
|
94
|
+
format_string = "[%(name)s] %(levelname)s: %(message)s"
|
|
95
|
+
|
|
96
|
+
formatter = logging.Formatter(format_string)
|
|
97
|
+
handler.setFormatter(formatter)
|
|
98
|
+
logger.addHandler(handler)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Data source wrappers for PII redaction."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from typing import TYPE_CHECKING, Any, BinaryIO
|
|
6
|
+
|
|
7
|
+
from ._serialization import data_to_json_bytes
|
|
8
|
+
from ._service import parse_data
|
|
9
|
+
from ._types import (
|
|
10
|
+
Data,
|
|
11
|
+
DataSource,
|
|
12
|
+
GitHubIDMappings,
|
|
13
|
+
Group,
|
|
14
|
+
PIIMode,
|
|
15
|
+
SlackIDMappings,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RedactingDataSource:
|
|
23
|
+
"""
|
|
24
|
+
DataSource decorator that redacts PII from loaded data.
|
|
25
|
+
|
|
26
|
+
When pii_mode is REDACTED, this wrapper intercepts the JSON stream
|
|
27
|
+
from the inner data source and strips PII fields before the data
|
|
28
|
+
is parsed by Service, so PII is not propagated to downstream consumers.
|
|
29
|
+
|
|
30
|
+
PII fields redacted (emitted as empty values in the JSON, not omitted):
|
|
31
|
+
- full_name -> "[REDACTED]"
|
|
32
|
+
- email -> "[REDACTED]"
|
|
33
|
+
- slack_uid -> ""
|
|
34
|
+
- github_id -> ""
|
|
35
|
+
- possible_github_ids -> []
|
|
36
|
+
|
|
37
|
+
PII indexes cleared:
|
|
38
|
+
- slack_id_mappings.slack_uid_to_uid
|
|
39
|
+
- github_id_mappings.github_id_to_uid
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
source: DataSource,
|
|
45
|
+
pii_mode: PIIMode = PIIMode.FULL,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Initialize the redacting data source.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
source: The underlying data source to wrap.
|
|
52
|
+
pii_mode: The PII handling mode. Defaults to FULL (no redaction).
|
|
53
|
+
"""
|
|
54
|
+
if pii_mode not in (PIIMode.FULL, PIIMode.REDACTED):
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"RedactingDataSource only supports FULL and REDACTED modes, got {pii_mode!r}"
|
|
57
|
+
)
|
|
58
|
+
self._source = source
|
|
59
|
+
self._pii_mode = pii_mode
|
|
60
|
+
|
|
61
|
+
def load(self) -> BinaryIO:
|
|
62
|
+
"""
|
|
63
|
+
Load data, optionally redacting PII fields.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Binary stream containing the (possibly redacted) JSON data.
|
|
67
|
+
"""
|
|
68
|
+
reader = self._source.load()
|
|
69
|
+
|
|
70
|
+
if self._pii_mode == PIIMode.FULL:
|
|
71
|
+
return reader # Pass through unchanged
|
|
72
|
+
|
|
73
|
+
# Parse, redact, re-serialize
|
|
74
|
+
try:
|
|
75
|
+
raw = json.load(reader)
|
|
76
|
+
finally:
|
|
77
|
+
reader.close()
|
|
78
|
+
data = parse_data(raw)
|
|
79
|
+
redacted = _redact(data)
|
|
80
|
+
return BytesIO(data_to_json_bytes(redacted))
|
|
81
|
+
|
|
82
|
+
def watch(self, callback: "Callable[[], Exception | None]") -> Exception | None:
|
|
83
|
+
"""
|
|
84
|
+
Monitor for changes and call the callback when data is updated.
|
|
85
|
+
|
|
86
|
+
Delegates to the underlying data source.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
callback: Function to call when data changes.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Exception if watch setup failed, None otherwise.
|
|
93
|
+
"""
|
|
94
|
+
return self._source.watch(callback)
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str:
|
|
97
|
+
"""Return a description of this data source."""
|
|
98
|
+
mode_suffix = " [PII redacted]" if self._pii_mode == PIIMode.REDACTED else ""
|
|
99
|
+
return f"{self._source}{mode_suffix}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AsyncRedactingDataSource:
|
|
103
|
+
"""Async DataSource decorator that redacts PII from loaded data.
|
|
104
|
+
|
|
105
|
+
Same redaction as RedactingDataSource but with async load().
|
|
106
|
+
Use with AsyncService and async inner sources.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
source: Any,
|
|
112
|
+
pii_mode: PIIMode = PIIMode.FULL,
|
|
113
|
+
) -> None:
|
|
114
|
+
if pii_mode not in (PIIMode.FULL, PIIMode.REDACTED):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"AsyncRedactingDataSource only supports FULL and REDACTED modes, got {pii_mode!r}"
|
|
117
|
+
)
|
|
118
|
+
self._source = source
|
|
119
|
+
self._pii_mode = pii_mode
|
|
120
|
+
|
|
121
|
+
async def load(self) -> BinaryIO:
|
|
122
|
+
"""Load data asynchronously, optionally redacting PII fields."""
|
|
123
|
+
reader: BinaryIO = await self._source.load()
|
|
124
|
+
|
|
125
|
+
if self._pii_mode == PIIMode.FULL:
|
|
126
|
+
return reader
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
raw = reader.read()
|
|
130
|
+
finally:
|
|
131
|
+
reader.close()
|
|
132
|
+
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
133
|
+
raw_data = json.loads(text)
|
|
134
|
+
data = parse_data(raw_data)
|
|
135
|
+
redacted = _redact(data)
|
|
136
|
+
return BytesIO(data_to_json_bytes(redacted))
|
|
137
|
+
|
|
138
|
+
async def watch(self, callback: Any) -> Any:
|
|
139
|
+
"""Delegate to the underlying data source."""
|
|
140
|
+
return await self._source.watch(callback)
|
|
141
|
+
|
|
142
|
+
def __str__(self) -> str:
|
|
143
|
+
mode_suffix = " [PII redacted]" if self._pii_mode == PIIMode.REDACTED else ""
|
|
144
|
+
return f"{self._source}{mode_suffix}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _redact(data: Data) -> Data:
|
|
148
|
+
"""Return a new Data with PII fields redacted."""
|
|
149
|
+
new_employees = {
|
|
150
|
+
uid: emp.model_copy(
|
|
151
|
+
update={
|
|
152
|
+
"full_name": "[REDACTED]",
|
|
153
|
+
"email": "[REDACTED]",
|
|
154
|
+
"slack_uid": "",
|
|
155
|
+
"github_id": "",
|
|
156
|
+
"possible_github_ids": [],
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
for uid, emp in data.lookups.employees.items()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
new_teams = {
|
|
163
|
+
name: team.model_copy(update={"group": _redact_group(team.group)})
|
|
164
|
+
for name, team in data.lookups.teams.items()
|
|
165
|
+
}
|
|
166
|
+
new_orgs = {
|
|
167
|
+
name: org.model_copy(update={"group": _redact_group(org.group)})
|
|
168
|
+
for name, org in data.lookups.orgs.items()
|
|
169
|
+
}
|
|
170
|
+
new_pillars = {
|
|
171
|
+
name: pillar.model_copy(update={"group": _redact_group(pillar.group)})
|
|
172
|
+
for name, pillar in data.lookups.pillars.items()
|
|
173
|
+
}
|
|
174
|
+
new_team_groups = {
|
|
175
|
+
name: tg.model_copy(update={"group": _redact_group(tg.group)})
|
|
176
|
+
for name, tg in data.lookups.team_groups.items()
|
|
177
|
+
}
|
|
178
|
+
new_cohorts = {
|
|
179
|
+
name: c.model_copy(update={"group": _redact_group(c.group)})
|
|
180
|
+
for name, c in data.lookups.cohorts.items()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return data.model_copy(
|
|
184
|
+
update={
|
|
185
|
+
"lookups": data.lookups.model_copy(
|
|
186
|
+
update={
|
|
187
|
+
"employees": new_employees,
|
|
188
|
+
"teams": new_teams,
|
|
189
|
+
"orgs": new_orgs,
|
|
190
|
+
"pillars": new_pillars,
|
|
191
|
+
"team_groups": new_team_groups,
|
|
192
|
+
"cohorts": new_cohorts,
|
|
193
|
+
}
|
|
194
|
+
),
|
|
195
|
+
"indexes": data.indexes.model_copy(
|
|
196
|
+
update={
|
|
197
|
+
"slack_id_mappings": SlackIDMappings(slack_uid_to_uid={}),
|
|
198
|
+
"github_id_mappings": GitHubIDMappings(github_id_to_uid={}),
|
|
199
|
+
}
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _redact_group(group: Group | None) -> Group | None:
|
|
206
|
+
"""Return a new Group with escalation contacts redacted (None if no group)."""
|
|
207
|
+
if group is None or not group.escalation:
|
|
208
|
+
return group
|
|
209
|
+
new_escalation = [
|
|
210
|
+
contact.model_copy(
|
|
211
|
+
update={"name": "[REDACTED]", "url": "", "description": "[REDACTED]"}
|
|
212
|
+
)
|
|
213
|
+
for contact in group.escalation
|
|
214
|
+
]
|
|
215
|
+
return group.model_copy(update={"escalation": new_escalation})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Internal serialization: Data model -> dict/JSON bytes.
|
|
2
|
+
|
|
3
|
+
Not part of the public API. Used by PII decorators and test helpers to
|
|
4
|
+
round-trip typed Data through JSON without raw dict manipulation.
|
|
5
|
+
|
|
6
|
+
The canonical ``orgindex-schema`` models mirror the on-disk index exactly, so
|
|
7
|
+
``model_dump`` reproduces the index shape directly — no hand-written per-model
|
|
8
|
+
serializers are needed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._types import Data
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def data_to_dict(data: Data) -> dict[str, Any]:
|
|
18
|
+
"""Convert Data to a JSON-serializable dict in the on-disk index shape."""
|
|
19
|
+
return data.model_dump(mode="json")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def data_to_json_bytes(data: Data) -> bytes:
|
|
23
|
+
"""Convert Data to JSON bytes."""
|
|
24
|
+
return json.dumps(data_to_dict(data)).encode("utf-8")
|