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,82 @@
1
+ """Custom exceptions for orgdatacore.
2
+
3
+ This module defines a hierarchy of exceptions for better error handling:
4
+
5
+ OrgDataError (base)
6
+ ├── DataLoadError - Failed to load/parse data
7
+ ├── DataSourceError - Data source operation failed
8
+ │ ├── GCSError - GCS-specific errors
9
+ │ └── FileSourceError - File source errors (internal)
10
+ └── ConfigurationError - Invalid configuration
11
+
12
+ Example:
13
+ try:
14
+ service.load_from_data_source(source)
15
+ except DataLoadError as e:
16
+ logger.error(f"Failed to load data: {e}")
17
+ except DataSourceError as e:
18
+ logger.error(f"Data source error: {e}")
19
+ """
20
+
21
+
22
+ class OrgDataError(Exception):
23
+ """Base exception for all orgdatacore errors."""
24
+
25
+ pass
26
+
27
+
28
+ class DataLoadError(OrgDataError):
29
+ """Failed to load or parse organizational data.
30
+
31
+ Raised when:
32
+ - JSON parsing fails
33
+ - Data structure is invalid
34
+ - Required fields are missing
35
+ """
36
+
37
+ pass
38
+
39
+
40
+ class DataSourceError(OrgDataError):
41
+ """Data source operation failed.
42
+
43
+ Base class for data source specific errors.
44
+ """
45
+
46
+ pass
47
+
48
+
49
+ class GCSError(DataSourceError):
50
+ """Google Cloud Storage operation failed.
51
+
52
+ Raised when:
53
+ - GCS connection fails
54
+ - Bucket/object not found
55
+ - Authentication fails
56
+ - Download fails
57
+ """
58
+
59
+ pass
60
+
61
+
62
+ class FileSourceError(DataSourceError):
63
+ """File source operation failed (internal testing only).
64
+
65
+ Raised when:
66
+ - File not found
67
+ - Permission denied
68
+ - Read error
69
+ """
70
+
71
+ pass
72
+
73
+
74
+ class ConfigurationError(OrgDataError):
75
+ """Invalid configuration provided.
76
+
77
+ Raised when:
78
+ - Required config fields are missing
79
+ - Config values are invalid
80
+ """
81
+
82
+ pass
orgdatacore/_gcs.py ADDED
@@ -0,0 +1,317 @@
1
+ # pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false
2
+ """
3
+ Data source implementations for orgdatacore.
4
+
5
+ This module provides the GCSDataSource for production use with Google Cloud Storage.
6
+
7
+ IMPORTANT: The caller is responsible for managing the data source lifecycle.
8
+ The reader returned by load() must be closed when done reading. When using
9
+ Service.load_from_data_source(), the service handles closing the reader.
10
+
11
+ For custom data sources (e.g., S3, Azure Blob, etc.), implement the DataSource
12
+ protocol (just implement load(), watch(), __str__() methods).
13
+
14
+ Example custom implementation:
15
+
16
+ from typing import BinaryIO, Callable, Optional
17
+ from io import BytesIO
18
+
19
+ class S3DataSource: # No inheritance needed - just implement the methods
20
+ def __init__(self, bucket: str, key: str):
21
+ self.bucket = bucket
22
+ self.key = key
23
+
24
+ def load(self) -> BinaryIO:
25
+ import boto3
26
+ s3 = boto3.client('s3')
27
+ response = s3.get_object(Bucket=self.bucket, Key=self.key)
28
+ return BytesIO(response['Body'].read())
29
+
30
+ def watch(self, callback: Callable[[], Optional[Exception]]) -> Optional[Exception]:
31
+ # Your S3 watching logic here
32
+ return None
33
+
34
+ def __str__(self) -> str:
35
+ return f"s3://{self.bucket}/{self.key}"
36
+ """
37
+
38
+ import threading
39
+ import time
40
+ from collections.abc import Callable
41
+ from io import BytesIO
42
+ from typing import Any, BinaryIO, TypeVar
43
+
44
+ from ._exceptions import ConfigurationError, GCSError
45
+ from ._log import get_logger
46
+ from ._types import GCSConfig
47
+
48
+ _T = TypeVar("_T")
49
+
50
+ # Default retry configuration
51
+ DEFAULT_MAX_RETRIES = 3
52
+ DEFAULT_RETRY_DELAY = 1.0 # seconds
53
+ DEFAULT_RETRY_BACKOFF = 2.0 # multiplier
54
+
55
+
56
+ try:
57
+ from google.cloud import storage
58
+
59
+ _HAS_GCS = True
60
+ except ImportError:
61
+ _HAS_GCS = False
62
+
63
+
64
+ def _retry_with_backoff(
65
+ operation: Callable[[], _T],
66
+ max_retries: int = DEFAULT_MAX_RETRIES,
67
+ initial_delay: float = DEFAULT_RETRY_DELAY,
68
+ backoff: float = DEFAULT_RETRY_BACKOFF,
69
+ operation_name: str = "operation",
70
+ ) -> _T:
71
+ """Execute an operation with exponential backoff retry.
72
+
73
+ Args:
74
+ operation: Callable to execute.
75
+ max_retries: Maximum number of retry attempts.
76
+ initial_delay: Initial delay between retries in seconds.
77
+ backoff: Multiplier for delay after each retry.
78
+ operation_name: Name of operation for logging.
79
+
80
+ Returns:
81
+ Result of the operation.
82
+
83
+ Raises:
84
+ GCSError: If all retries are exhausted.
85
+ """
86
+ logger = get_logger()
87
+ delay = initial_delay
88
+ last_error: Exception | None = None
89
+
90
+ for attempt in range(max_retries + 1):
91
+ try:
92
+ return operation()
93
+ except Exception as e:
94
+ last_error = e
95
+ if attempt < max_retries:
96
+ logger.warning(
97
+ f"{operation_name} failed, retrying",
98
+ extra={
99
+ "attempt": attempt + 1,
100
+ "max_retries": max_retries,
101
+ "delay": delay,
102
+ "error": str(e),
103
+ },
104
+ )
105
+ time.sleep(delay)
106
+ delay *= backoff
107
+ else:
108
+ logger.error(
109
+ f"{operation_name} failed after all retries",
110
+ extra={
111
+ "attempts": max_retries + 1,
112
+ "error": str(e),
113
+ },
114
+ )
115
+
116
+ raise GCSError(
117
+ f"{operation_name} failed after {max_retries + 1} attempts: {last_error}"
118
+ )
119
+
120
+
121
+ class GCSDataSource:
122
+ """GCS data source using google-cloud-storage.
123
+
124
+ Requires the google-cloud-storage package:
125
+ pip install orgdatacore[gcs]
126
+
127
+ Features:
128
+ - Automatic retry with exponential backoff for transient failures
129
+ - Structured logging for observability
130
+ - Proper error handling with custom exceptions
131
+
132
+ Example:
133
+ from orgdatacore import GCSConfig, GCSDataSource
134
+ from datetime import timedelta
135
+
136
+ config = GCSConfig(
137
+ bucket="your-bucket",
138
+ object_path="path/to/data.json",
139
+ project_id="your-project",
140
+ check_interval=timedelta(minutes=5),
141
+ )
142
+ source = GCSDataSource(config)
143
+ service.load_from_data_source(source)
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ config: GCSConfig,
149
+ *,
150
+ max_retries: int = DEFAULT_MAX_RETRIES,
151
+ retry_delay: float = DEFAULT_RETRY_DELAY,
152
+ retry_backoff: float = DEFAULT_RETRY_BACKOFF,
153
+ ) -> None:
154
+ """Create a GCS data source.
155
+
156
+ Args:
157
+ config: GCS configuration.
158
+ max_retries: Maximum retry attempts for transient failures.
159
+ retry_delay: Initial delay between retries in seconds.
160
+ retry_backoff: Multiplier for delay after each retry.
161
+
162
+ Raises:
163
+ ImportError: If google-cloud-storage is not installed.
164
+ ConfigurationError: If configuration is invalid.
165
+ """
166
+ if not _HAS_GCS:
167
+ raise ImportError(
168
+ "google-cloud-storage is required for GCS support. "
169
+ "Install it with: pip install orgdatacore[gcs]"
170
+ )
171
+ if not config.bucket:
172
+ raise ConfigurationError("GCS bucket is required")
173
+ if not config.object_path:
174
+ raise ConfigurationError("GCS object_path is required")
175
+
176
+ self.config = config
177
+ self.max_retries = max_retries
178
+ self.retry_delay = retry_delay
179
+ self.retry_backoff = retry_backoff
180
+ self._client: Any = None
181
+ self._stop_event = threading.Event()
182
+
183
+ def _get_client(self) -> Any:
184
+ """Get or create the GCS client."""
185
+ if self._client is None:
186
+ logger = get_logger()
187
+ logger.debug(
188
+ "Creating GCS client", extra={"project_id": self.config.project_id}
189
+ )
190
+
191
+ if self.config.credentials_json:
192
+ import json as _json
193
+
194
+ creds_info = _json.loads(self.config.credentials_json)
195
+ self._client = storage.Client.from_service_account_info(creds_info)
196
+ else:
197
+ self._client = storage.Client(project=self.config.project_id or None)
198
+ return self._client
199
+
200
+ def load(self) -> BinaryIO:
201
+ """Load and return a reader for the organizational data from GCS.
202
+
203
+ Returns:
204
+ File-like object containing the JSON data.
205
+
206
+ Raises:
207
+ GCSError: If loading fails after all retries.
208
+ """
209
+ logger = get_logger()
210
+ logger.debug(
211
+ "Loading from GCS",
212
+ extra={"bucket": self.config.bucket, "object": self.config.object_path},
213
+ )
214
+
215
+ def _download() -> BinaryIO:
216
+ client = self._get_client()
217
+ bucket = client.bucket(self.config.bucket)
218
+ blob = bucket.blob(self.config.object_path)
219
+ content = blob.download_as_bytes()
220
+ return BytesIO(content)
221
+
222
+ return _retry_with_backoff(
223
+ _download,
224
+ max_retries=self.max_retries,
225
+ initial_delay=self.retry_delay,
226
+ backoff=self.retry_backoff,
227
+ operation_name=f"GCS download gs://{self.config.bucket}/{self.config.object_path}",
228
+ )
229
+
230
+ def watch(self, callback: Callable[[], Exception | None]) -> Exception | None:
231
+ """Monitor for changes and call callback when data is updated.
232
+
233
+ Uses polling based on the configured check interval.
234
+ Detects changes by comparing object generation numbers.
235
+
236
+ Args:
237
+ callback: Function to call when data changes.
238
+
239
+ Returns:
240
+ Exception if watcher setup fails, None otherwise.
241
+ """
242
+ logger = get_logger()
243
+
244
+ # Reset stop event so watch() can be called again after stop_watching()
245
+ self._stop_event = threading.Event()
246
+
247
+ try:
248
+ client = self._get_client()
249
+ bucket = client.bucket(self.config.bucket)
250
+ blob = bucket.blob(self.config.object_path)
251
+
252
+ # Get initial generation
253
+ blob.reload()
254
+ last_generation = blob.generation
255
+
256
+ logger.info(
257
+ "Starting GCS watcher",
258
+ extra={
259
+ "bucket": self.config.bucket,
260
+ "object": self.config.object_path,
261
+ "check_interval": str(self.config.check_interval),
262
+ "initial_generation": last_generation,
263
+ },
264
+ )
265
+ except Exception as e:
266
+ logger.error("Failed to initialize GCS watcher", extra={"error": str(e)})
267
+ return GCSError(f"Failed to initialize GCS watcher: {e}")
268
+
269
+ def watcher() -> None:
270
+ nonlocal last_generation
271
+ interval = self.config.check_interval.total_seconds()
272
+
273
+ while not self._stop_event.is_set():
274
+ time.sleep(interval)
275
+ if self._stop_event.is_set():
276
+ break
277
+
278
+ try:
279
+ blob.reload()
280
+ if blob.generation != last_generation:
281
+ logger.info(
282
+ "GCS object changed, triggering reload",
283
+ extra={
284
+ "old_generation": last_generation,
285
+ "new_generation": blob.generation,
286
+ },
287
+ )
288
+ last_generation = blob.generation
289
+ err = callback()
290
+ if err:
291
+ logger.error(
292
+ "Reload callback failed", extra={"error": str(err)}
293
+ )
294
+ except Exception as e:
295
+ logger.error("GCS watcher check failed", extra={"error": str(e)})
296
+
297
+ thread = threading.Thread(target=watcher, daemon=True, name="gcs-watcher")
298
+ thread.start()
299
+ return None
300
+
301
+ def stop_watching(self) -> None:
302
+ """Stop the GCS watcher."""
303
+ logger = get_logger()
304
+ logger.info("Stopping GCS watcher")
305
+ self._stop_event.set()
306
+
307
+ def stop(self) -> None:
308
+ """Stop the GCS watcher.
309
+
310
+ Alias for stop_watching() to support the standard stop interface
311
+ expected by AsyncService.stop_watcher().
312
+ """
313
+ self.stop_watching()
314
+
315
+ def __str__(self) -> str:
316
+ """Return a description of this data source."""
317
+ return f"gs://{self.config.bucket}/{self.config.object_path}"
@@ -0,0 +1,5 @@
1
+ """Internal modules - not part of public API."""
2
+
3
+ from .testing import FakeDataSource, FileDataSource, create_test_data_json
4
+
5
+ __all__ = ["FileDataSource", "FakeDataSource", "create_test_data_json"]
@@ -0,0 +1,16 @@
1
+ """Internal testing utilities - not part of public API."""
2
+
3
+ from .fake_gcs import FakeBlob, FakeBucket, FakeGCSClient, FakeGCSDataSource
4
+ from .filesource import FileDataSource
5
+ from .helpers import FakeDataSource, create_test_data, create_test_data_json
6
+
7
+ __all__ = [
8
+ "FileDataSource",
9
+ "FakeDataSource",
10
+ "FakeGCSClient",
11
+ "FakeGCSDataSource",
12
+ "FakeBucket",
13
+ "FakeBlob",
14
+ "create_test_data_json",
15
+ "create_test_data",
16
+ ]
@@ -0,0 +1,173 @@
1
+ """Fake GCS client implementation for testing.
2
+
3
+ This module provides fake implementations of the google.cloud.storage
4
+ Client, Bucket, and Blob classes for testing GCS-related code without
5
+ requiring actual GCS connections.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from io import BytesIO
12
+ from typing import BinaryIO
13
+
14
+
15
+ class FakeBucket:
16
+ """Fake implementation of google.cloud.storage.Bucket."""
17
+
18
+ def __init__(self, name: str, client: FakeGCSClient | None = None) -> None:
19
+ self.name = name
20
+ self.client = client
21
+ self.blobs: dict[str, dict] = {}
22
+
23
+ def blob(self, name: str) -> FakeBlob:
24
+ """Get a blob reference."""
25
+ return FakeBlob(name, self)
26
+
27
+ def get_blob_data(self, name: str) -> dict | None:
28
+ """Get blob data from internal storage."""
29
+ return self.blobs.get(name)
30
+
31
+ def set_blob_data(self, name: str, data: dict) -> None:
32
+ """Set blob data in internal storage."""
33
+ self.blobs[name] = data
34
+
35
+ def add_blob(self, name: str, content: bytes, generation: int = 1) -> None:
36
+ """Add a blob with content for testing."""
37
+ self.blobs[name] = {
38
+ "content": content,
39
+ "generation": generation,
40
+ "updated": datetime.now(),
41
+ }
42
+
43
+ def update_blob(self, name: str, content: bytes) -> None:
44
+ """Update a blob's content and increment generation."""
45
+ if name not in self.blobs:
46
+ self.add_blob(name, content)
47
+ return
48
+ current = self.blobs[name]
49
+ self.blobs[name] = {
50
+ "content": content,
51
+ "generation": current["generation"] + 1,
52
+ "updated": datetime.now(),
53
+ }
54
+
55
+
56
+ class FakeBlob:
57
+ """Fake implementation of google.cloud.storage.Blob."""
58
+
59
+ def __init__(self, name: str, bucket: FakeBucket) -> None:
60
+ self.name = name
61
+ self.bucket = bucket
62
+ self._generation = 1
63
+ self._updated = datetime.now()
64
+
65
+ @property
66
+ def generation(self) -> int:
67
+ """Return the generation number of the blob."""
68
+ return self._generation
69
+
70
+ @property
71
+ def updated(self) -> datetime:
72
+ """Return when the blob was last updated."""
73
+ return self._updated
74
+
75
+ def reload(self) -> None:
76
+ """Reload blob metadata from the fake storage."""
77
+ data = self.bucket.get_blob_data(self.name)
78
+ if data is None:
79
+ raise Exception(f"Blob {self.name} not found")
80
+ self._generation = data["generation"]
81
+ self._updated = data["updated"]
82
+
83
+ def download_as_bytes(self) -> bytes:
84
+ """Download the blob content as bytes."""
85
+ data = self.bucket.get_blob_data(self.name)
86
+ if data is None:
87
+ raise Exception(f"Blob {self.name} not found")
88
+ return data["content"]
89
+
90
+ def upload_from_string(self, content: str | bytes) -> None:
91
+ """Upload content to the blob."""
92
+ if isinstance(content, str):
93
+ content = content.encode("utf-8")
94
+ self.bucket.set_blob_data(
95
+ self.name,
96
+ {
97
+ "content": content,
98
+ "generation": self._generation + 1,
99
+ "updated": datetime.now(),
100
+ },
101
+ )
102
+ self._generation += 1
103
+ self._updated = datetime.now()
104
+
105
+
106
+ class FakeGCSClient:
107
+ """Fake implementation of google.cloud.storage.Client."""
108
+
109
+ def __init__(self, project: str | None = None) -> None:
110
+ self.project = project
111
+ self.buckets: dict[str, FakeBucket] = {}
112
+
113
+ @classmethod
114
+ def from_service_account_json(cls, json_path: str) -> FakeGCSClient:
115
+ """Create a client from a service account JSON file (fake)."""
116
+ return cls(project="fake-project-from-json")
117
+
118
+ def bucket(self, name: str) -> FakeBucket:
119
+ """Get a bucket reference."""
120
+ if name not in self.buckets:
121
+ self.buckets[name] = FakeBucket(name, self)
122
+ return self.buckets[name]
123
+
124
+ def add_bucket(self, name: str) -> FakeBucket:
125
+ """Add a bucket for testing."""
126
+ bucket = FakeBucket(name, self)
127
+ self.buckets[name] = bucket
128
+ return bucket
129
+
130
+
131
+ class FakeGCSDataSource:
132
+ """A fake GCS data source that uses in-memory storage.
133
+
134
+ This can be used directly in tests as a DataSource implementation.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ bucket: str,
140
+ object_path: str,
141
+ content: bytes | str = b"{}",
142
+ ) -> None:
143
+ self.bucket_name = bucket
144
+ self.object_path = object_path
145
+ self._client = FakeGCSClient()
146
+ self._bucket = self._client.add_bucket(bucket)
147
+
148
+ if isinstance(content, str):
149
+ content = content.encode("utf-8")
150
+ self._bucket.add_blob(object_path, content)
151
+
152
+ self._generation = 1
153
+ self._stop_watching = False
154
+
155
+ def load(self) -> BinaryIO:
156
+ """Load data from fake GCS."""
157
+ blob = self._bucket.blob(self.object_path)
158
+ content = blob.download_as_bytes()
159
+ return BytesIO(content)
160
+
161
+ def watch(self, callback) -> Exception | None:
162
+ """Watch for changes (no-op in fake implementation)."""
163
+ return None
164
+
165
+ def update_content(self, content: bytes | str) -> None:
166
+ """Update the blob content (for testing hot reload)."""
167
+ if isinstance(content, str):
168
+ content = content.encode("utf-8")
169
+ self._bucket.update_blob(self.object_path, content)
170
+ self._generation += 1
171
+
172
+ def __str__(self) -> str:
173
+ return f"gs://{self.bucket_name}/{self.object_path} (fake)"