Coverage for session_buddy / adapters / storage_oneiric.py: 49.44%
262 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
1"""Oneiric-compatible storage adapters using native implementations.
3Provides Oneiric-compatible storage adapters that maintain the existing StorageBase
4API while using native implementations instead of ACB storage adapters.
6Phase 5: Oneiric Adapter Conversion - Storage Registry
8Key Features:
9 - Native file system storage implementation
10 - Oneiric settings and lifecycle management
11 - Backward-compatible API with existing StorageBase
12 - No ACB dependencies
13 - Support for multiple backends (file, memory)
15"""
17from __future__ import annotations
19import typing as t
20from dataclasses import replace
21from datetime import datetime
22from pathlib import Path
24from session_buddy.adapters.settings import StorageAdapterSettings
26if t.TYPE_CHECKING:
27 from collections.abc import AsyncIterator
30class StorageProtocol(t.Protocol):
31 """Protocol for storage backend implementations."""
33 async def init(self) -> None: ...
35 async def upload(self, bucket: str, path: str, data: bytes) -> None: ...
37 async def download(self, bucket: str, path: str) -> bytes: ...
39 async def delete(self, bucket: str, path: str) -> None: ...
41 async def exists(self, bucket: str, path: str) -> bool: ...
44# Supported storage backend types
45SUPPORTED_BACKENDS = ("file", "memory")
48class StorageBaseOneiric:
49 """Base class for Oneiric storage adapters.
51 This class provides the same interface as ACB's StorageBase but uses
52 native implementations instead of ACB dependencies.
54 """
56 def __init__(self, backend: str):
57 """Initialize storage adapter.
59 Args:
60 backend: Storage backend type (file, memory)
62 """
63 self.backend = backend
64 self.settings = StorageAdapterSettings.from_settings()
65 self.buckets: dict[str, str] = self.settings.buckets
66 self._initialized = False
67 self._memory_store: dict[str, bytes] = {}
69 async def init(self) -> None:
70 """Initialize storage adapter."""
71 if self._initialized: 71 ↛ 75line 71 didn't jump to line 75 because the condition on line 71 was always true
72 return
74 # Create base directory for file storage
75 if self.backend == "file":
76 base_path = self.settings.local_path
77 base_path.mkdir(parents=True, exist_ok=True)
79 # Create bucket directories
80 for bucket_path in self.buckets.values():
81 if bucket_path.startswith("/"):
82 # Absolute path
83 bucket_dir = Path(bucket_path)
84 else:
85 # Relative path
86 bucket_dir = base_path / bucket_path
87 bucket_dir.mkdir(parents=True, exist_ok=True)
89 self._initialized = True
91 def _initialize_sync(self) -> None:
92 """Synchronous version of init for use in synchronous contexts."""
93 if self._initialized: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 return
96 # Create base directory for file storage
97 if self.backend == "file": 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 base_path = self.settings.local_path
99 base_path.mkdir(parents=True, exist_ok=True)
101 # Create bucket directories
102 for bucket_path in self.buckets.values():
103 if bucket_path.startswith("/"):
104 # Absolute path
105 bucket_dir = Path(bucket_path)
106 else:
107 # Relative path
108 bucket_dir = base_path / bucket_path
109 bucket_dir.mkdir(parents=True, exist_ok=True)
111 self._initialized = True
113 async def aclose(self) -> None:
114 """Clean up storage adapter."""
115 # No cleanup needed for file storage
117 async def upload(self, bucket: str, path: str, data: bytes) -> None:
118 """Upload data to storage.
120 Args:
121 bucket: Bucket name
122 path: Storage path within bucket
123 data: Data to upload
125 """
126 if not self._initialized: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 await self.init()
129 if self.backend == "file": 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 await self._file_upload(bucket, path, data)
131 elif self.backend == "memory": 131 ↛ 134line 131 didn't jump to line 134 because the condition on line 131 was always true
132 await self._memory_upload(bucket, path, data)
133 else:
134 msg = f"Unsupported backend: {self.backend}"
135 raise ValueError(msg)
137 async def download(self, bucket: str, path: str) -> bytes:
138 """Download data from storage.
140 Args:
141 bucket: Bucket name
142 path: Storage path within bucket
144 Returns:
145 Downloaded data as bytes
147 """
148 if not self._initialized: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 await self.init()
151 if self.backend == "file": 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 return await self._file_download(bucket, path)
153 if self.backend == "memory": 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true
154 return await self._memory_download(bucket, path)
155 msg = f"Unsupported backend: {self.backend}"
156 raise ValueError(msg)
158 async def delete(self, bucket: str, path: str) -> None:
159 """Delete data from storage.
161 Args:
162 bucket: Bucket name
163 path: Storage path within bucket
165 """
166 if not self._initialized: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 await self.init()
169 if self.backend == "file": 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 await self._file_delete(bucket, path)
171 elif self.backend == "memory": 171 ↛ 174line 171 didn't jump to line 174 because the condition on line 171 was always true
172 await self._memory_delete(bucket, path)
173 else:
174 msg = f"Unsupported backend: {self.backend}"
175 raise ValueError(msg)
177 async def exists(self, bucket: str, path: str) -> bool:
178 """Check if data exists in storage.
180 Args:
181 bucket: Bucket name
182 path: Storage path within bucket
184 Returns:
185 True if data exists, False otherwise
187 """
188 if not self._initialized: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 await self.init()
191 if self.backend == "file": 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 return await self._file_exists(bucket, path)
193 if self.backend == "memory": 193 ↛ 195line 193 didn't jump to line 195 because the condition on line 193 was always true
194 return await self._memory_exists(bucket, path)
195 msg = f"Unsupported backend: {self.backend}"
196 raise ValueError(msg)
198 async def stat(self, bucket: str, path: str) -> dict[str, t.Any]:
199 """Get file statistics.
201 Args:
202 bucket: Bucket name
203 path: Storage path within bucket
205 Returns:
206 Dictionary with file statistics
208 """
209 if not self._initialized:
210 await self.init()
212 if self.backend == "file":
213 return await self._file_stat(bucket, path)
214 if self.backend == "memory":
215 return await self._memory_stat(bucket, path)
216 msg = f"Unsupported backend: {self.backend}"
217 raise ValueError(msg)
219 # File storage implementation
220 async def _file_upload(self, bucket: str, path: str, data: bytes) -> None:
221 """Upload data to file storage."""
222 file_path = self._get_file_path(bucket, path)
223 file_path.parent.mkdir(parents=True, exist_ok=True)
224 file_path.write_bytes(data)
226 async def _file_download(self, bucket: str, path: str) -> bytes:
227 """Download data from file storage."""
228 file_path = self._get_file_path(bucket, path)
229 if not file_path.exists():
230 msg = f"File not found: {path} in bucket {bucket}"
231 raise FileNotFoundError(msg)
232 return file_path.read_bytes()
234 async def _file_delete(self, bucket: str, path: str) -> None:
235 """Delete data from file storage."""
236 file_path = self._get_file_path(bucket, path)
237 if file_path.exists():
238 file_path.unlink()
240 async def _file_exists(self, bucket: str, path: str) -> bool:
241 """Check if data exists in file storage."""
242 file_path = self._get_file_path(bucket, path)
243 return file_path.exists()
245 async def _file_stat(self, bucket: str, path: str) -> dict[str, t.Any]:
246 """Get file statistics for file storage."""
247 file_path = self._get_file_path(bucket, path)
248 if not file_path.exists():
249 msg = f"File not found: {path} in bucket {bucket}"
250 raise FileNotFoundError(msg)
252 stat_info = file_path.stat()
253 return {
254 "size": stat_info.st_size,
255 "mtime": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
256 "created": datetime.fromtimestamp(stat_info.st_ctime).isoformat(),
257 }
259 def _get_file_path(self, bucket: str, path: str) -> Path:
260 """Get full file path for storage."""
261 if bucket not in self.buckets:
262 msg = f"Bucket not configured: {bucket}"
263 raise ValueError(msg)
265 bucket_path = self.buckets[bucket]
266 if bucket_path.startswith("/"):
267 # Absolute path
268 base_path = Path(bucket_path)
269 else:
270 # Relative path
271 base_path = self.settings.local_path / bucket_path
273 return base_path / path
275 async def _memory_upload(self, bucket: str, path: str, data: bytes) -> None:
276 """Upload data to memory storage."""
277 key = self._get_memory_key(bucket, path)
278 self._memory_store[key] = data
280 async def _memory_download(self, bucket: str, path: str) -> bytes:
281 """Download data from memory storage."""
282 key = self._get_memory_key(bucket, path)
283 if key not in self._memory_store: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true
284 msg = f"File not found: {path} in bucket {bucket}"
285 raise FileNotFoundError(msg)
286 return self._memory_store[key]
288 async def _memory_delete(self, bucket: str, path: str) -> None:
289 """Delete data from memory storage."""
290 key = self._get_memory_key(bucket, path)
291 if key in self._memory_store: 291 ↛ exitline 291 didn't return from function '_memory_delete' because the condition on line 291 was always true
292 del self._memory_store[key]
294 async def _memory_exists(self, bucket: str, path: str) -> bool:
295 """Check if data exists in memory storage."""
296 key = self._get_memory_key(bucket, path)
297 return key in self._memory_store
299 async def _memory_stat(self, bucket: str, path: str) -> dict[str, t.Any]:
300 """Get file statistics for memory storage."""
301 key = self._get_memory_key(bucket, path)
302 if key not in self._memory_store:
303 msg = f"File not found: {path} in bucket {bucket}"
304 raise FileNotFoundError(msg)
306 data = self._memory_store[key]
307 return {
308 "size": len(data),
309 "mtime": datetime.now().isoformat(),
310 "created": datetime.now().isoformat(),
311 }
313 def _get_memory_key(self, bucket: str, path: str) -> str:
314 """Get memory storage key."""
315 return f"{bucket}/{path}"
318class FileStorageOneiric(StorageBaseOneiric):
319 """Oneiric-compatible file storage adapter."""
321 def __init__(self, settings: StorageAdapterSettings | None = None):
322 self.backend = "file"
323 self.settings = settings or StorageAdapterSettings.from_settings()
324 self.buckets: dict[str, str] = self.settings.buckets
325 self._initialized = False
328class MemoryStorageOneiric(StorageBaseOneiric):
329 """Oneiric-compatible memory storage adapter."""
331 def __init__(self, settings: StorageAdapterSettings | None = None):
332 self.backend = "memory"
333 self.settings = settings or StorageAdapterSettings.from_settings()
334 self.buckets: dict[str, str] = self.settings.buckets
335 self._initialized = False
336 self._memory_store: dict[str, bytes] = {}
339class StorageRegistryOneiric:
340 """Oneiric-compatible storage registry.
342 This registry provides the same interface as the ACB storage registry
343 but uses native Oneiric implementations instead of ACB adapters.
345 """
347 def __init__(self) -> None:
348 self._adapters: dict[str, StorageBaseOneiric] = {}
349 self._settings: StorageAdapterSettings | None = None
351 async def init(self) -> None:
352 """Initialize storage registry."""
353 self._settings = StorageAdapterSettings.from_settings()
355 def _initialize_sync(self) -> None:
356 """Synchronous version of init for storage registry."""
357 self._settings = StorageAdapterSettings.from_settings()
359 def register_storage_adapter(
360 self,
361 backend: str,
362 config_overrides: dict[str, t.Any] | None = None,
363 force: bool = False,
364 ) -> StorageBaseOneiric:
365 """Register a storage adapter.
367 Args:
368 backend: Storage backend type (file, memory)
369 config_overrides: Configuration overrides
370 force: Force re-registration even if adapter exists
372 Returns:
373 Configured storage adapter
375 """
376 self._validate_backend(backend)
378 # Return cached adapter if exists and not forcing re-registration
379 if not force and backend in self._adapters:
380 return self._adapters[backend]
382 # Ensure settings are initialized
383 if self._settings is None:
384 self._settings = StorageAdapterSettings.from_settings()
386 # Create and configure adapter
387 adapter = self._create_adapter(backend)
388 self._apply_config_overrides(adapter, config_overrides)
390 # Cache and return
391 self._adapters[backend] = adapter
392 return adapter
394 def _validate_backend(self, backend: str) -> None:
395 """Validate backend type.
397 Args:
398 backend: Backend type to validate
400 Raises:
401 ValueError: If backend is not supported
403 """
404 if backend not in SUPPORTED_BACKENDS: 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true
405 msg = f"Unsupported backend: {backend}. Must be one of {SUPPORTED_BACKENDS}"
406 raise ValueError(msg)
408 def _create_adapter(self, backend: str) -> StorageBaseOneiric:
409 """Create a storage adapter instance.
411 Args:
412 backend: Backend type to create
414 Returns:
415 New adapter instance
417 Raises:
418 ValueError: If backend type is unknown
420 """
421 adapter_map: dict[str, type[StorageBaseOneiric]] = {
422 "file": FileStorageOneiric,
423 "memory": MemoryStorageOneiric,
424 }
426 adapter_class = adapter_map.get(backend)
427 if adapter_class is None: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 msg = f"Unsupported backend: {backend}"
429 raise ValueError(msg)
431 # Concrete adapters accept StorageAdapterSettings | None
432 # Base class signature differs from concrete classes
433 return adapter_class(self._settings) # type: ignore[arg-type]
435 def _apply_config_overrides(
436 self,
437 adapter: StorageBaseOneiric,
438 config_overrides: dict[str, t.Any] | None,
439 ) -> None:
440 """Apply configuration overrides to adapter.
442 Args:
443 adapter: Adapter to configure
444 config_overrides: Optional configuration overrides
446 """
447 if not config_overrides:
448 return
450 overrides = self._prepare_overrides(adapter, config_overrides)
451 if overrides: 451 ↛ exitline 451 didn't return from function '_apply_config_overrides' because the condition on line 451 was always true
452 adapter.settings = replace(adapter.settings, **overrides)
453 if "buckets" in overrides and overrides["buckets"] is not None: 453 ↛ exitline 453 didn't return from function '_apply_config_overrides' because the condition on line 453 was always true
454 adapter.buckets = dict(overrides["buckets"])
456 def _prepare_overrides(
457 self,
458 adapter: StorageBaseOneiric,
459 config_overrides: dict[str, t.Any],
460 ) -> dict[str, t.Any]:
461 """Prepare configuration overrides with type conversion.
463 Args:
464 adapter: Adapter being configured
465 config_overrides: Raw override values
467 Returns:
468 Processed overrides dictionary
470 """
471 overrides: dict[str, t.Any] = {}
472 for key, value in config_overrides.items():
473 # Convert string paths to Path objects
474 if key == "local_path" and isinstance(value, str):
475 value = Path(value)
477 # Only include if adapter has this attribute
478 if hasattr(adapter.settings, key): 478 ↛ 472line 478 didn't jump to line 472 because the condition on line 478 was always true
479 overrides[key] = value
481 return overrides
483 def get_storage_adapter(self, backend: str | None = None) -> StorageBaseOneiric:
484 """Get a storage adapter.
486 Args:
487 backend: Storage backend type. If None, uses default.
489 Returns:
490 Storage adapter instance
492 """
493 if backend is None: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true
494 backend = self._settings.default_backend if self._settings else "file"
496 if backend not in self._adapters:
497 # Auto-register if not found
498 adapter = self.register_storage_adapter(backend)
499 # Initialize the adapter synchronously
500 adapter._initialize_sync()
501 return adapter
503 return self._adapters[backend]
505 def configure_storage_buckets(self, buckets: dict[str, str]) -> None:
506 """Configure storage buckets.
508 Args:
509 buckets: Mapping of bucket names to paths/identifiers
511 """
512 if self._settings is None:
513 self._settings = StorageAdapterSettings.from_settings()
515 # Update settings with new buckets
516 self._settings.buckets.update(buckets)
518 # Update all registered adapters
519 for adapter in self._adapters.values():
520 adapter.buckets.update(buckets)
523# Global registry instance
524_storage_registry = StorageRegistryOneiric()
527def init_storage_registry() -> None:
528 """Initialize storage registry with Oneiric implementation."""
529 # Synchronous initialization
530 _storage_registry._initialize_sync()
533def get_storage_registry() -> StorageRegistryOneiric:
534 """Get storage registry instance."""
535 return _storage_registry
538def get_storage_adapter(backend: str | None = None) -> StorageBaseOneiric:
539 """Get storage adapter from registry."""
540 registry = get_storage_registry()
541 return registry.get_storage_adapter(backend)
544def configure_storage_buckets(buckets: dict[str, str]) -> None:
545 """Configure storage buckets."""
546 registry = get_storage_registry()
547 registry.configure_storage_buckets(buckets)
550def register_storage_adapter(
551 backend: str,
552 config_overrides: dict[str, t.Any] | None = None,
553 force: bool = False,
554) -> StorageBaseOneiric:
555 """Register a storage adapter for a specific backend.
557 Args:
558 backend: Backend name to associate with the adapter
559 config_overrides: Optional configuration overrides
560 force: If True, re-registers even if already registered
562 """
563 registry = get_storage_registry()
564 return registry.register_storage_adapter(
565 backend,
566 config_overrides=config_overrides,
567 force=force,
568 )
571# Default session bucket constant (required by adapters module)
572DEFAULT_SESSION_BUCKET = "session-buddy-default"
575# Session storage adapter (required by adapters module)
576class SessionStorageAdapter:
577 """Session storage adapter for Oneiric implementation.
579 This class provides the session storage interface expected by the adapters module.
580 """
582 def __init__(self, backend: str = "file") -> None:
583 """Initialize session storage adapter.
585 Args:
586 backend: Storage backend type (file, memory)
588 """
589 self.backend = backend
590 self._storage: StorageProtocol | None = None
592 async def initialize(self) -> None:
593 """Initialize the storage adapter."""
594 registry = get_storage_registry()
595 self._storage = registry.get_storage_adapter(self.backend)
596 await self._storage.init()
598 async def upload(self, bucket: str, path: str, data: bytes) -> None:
599 """Upload data to storage."""
600 if self._storage is None:
601 await self.initialize()
602 assert self._storage is not None # Type narrowing
603 await self._storage.upload(bucket, path, data)
605 async def download(self, bucket: str, path: str) -> bytes:
606 """Download data from storage."""
607 if self._storage is None:
608 await self.initialize()
609 assert self._storage is not None # Type narrowing
610 return await self._storage.download(bucket, path)
612 async def delete(self, bucket: str, path: str) -> None:
613 """Delete data from storage."""
614 if self._storage is None:
615 await self.initialize()
616 assert self._storage is not None # Type narrowing
617 await self._storage.delete(bucket, path)
619 async def exists(self, bucket: str, path: str) -> bool:
620 """Check if data exists in storage."""
621 if self._storage is None:
622 await self.initialize()
623 assert self._storage is not None # Type narrowing
624 return await self._storage.exists(bucket, path)
627def get_default_storage_adapter() -> SessionStorageAdapter:
628 """Get default storage adapter (required by adapters module)."""
629 return SessionStorageAdapter(backend="file")
632def get_default_session_buckets() -> dict[str, str]:
633 """Get default session buckets (required by adapters module)."""
634 return {
635 "default": DEFAULT_SESSION_BUCKET,
636 "sessions": "session-buddy-sessions",
637 "cache": "session-buddy-cache",
638 }
641__all__ = [
642 "DEFAULT_SESSION_BUCKET",
643 "SUPPORTED_BACKENDS",
644 "FileStorageOneiric",
645 "MemoryStorageOneiric",
646 "SessionStorageAdapter",
647 "StorageBaseOneiric",
648 "StorageRegistryOneiric",
649 "configure_storage_buckets",
650 "get_default_session_buckets",
651 "get_default_storage_adapter",
652 "get_storage_adapter",
653 "get_storage_registry",
654 "init_storage_registry",
655 "register_storage_adapter",
656]