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,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")