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