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

1"""Oneiric-compatible storage adapters using native implementations. 

2 

3Provides Oneiric-compatible storage adapters that maintain the existing StorageBase 

4API while using native implementations instead of ACB storage adapters. 

5 

6Phase 5: Oneiric Adapter Conversion - Storage Registry 

7 

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) 

14 

15""" 

16 

17from __future__ import annotations 

18 

19import typing as t 

20from dataclasses import replace 

21from datetime import datetime 

22from pathlib import Path 

23 

24from session_buddy.adapters.settings import StorageAdapterSettings 

25 

26if t.TYPE_CHECKING: 

27 from collections.abc import AsyncIterator 

28 

29 

30class StorageProtocol(t.Protocol): 

31 """Protocol for storage backend implementations.""" 

32 

33 async def init(self) -> None: ... 

34 

35 async def upload(self, bucket: str, path: str, data: bytes) -> None: ... 

36 

37 async def download(self, bucket: str, path: str) -> bytes: ... 

38 

39 async def delete(self, bucket: str, path: str) -> None: ... 

40 

41 async def exists(self, bucket: str, path: str) -> bool: ... 

42 

43 

44# Supported storage backend types 

45SUPPORTED_BACKENDS = ("file", "memory") 

46 

47 

48class StorageBaseOneiric: 

49 """Base class for Oneiric storage adapters. 

50 

51 This class provides the same interface as ACB's StorageBase but uses 

52 native implementations instead of ACB dependencies. 

53 

54 """ 

55 

56 def __init__(self, backend: str): 

57 """Initialize storage adapter. 

58 

59 Args: 

60 backend: Storage backend type (file, memory) 

61 

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] = {} 

68 

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 

73 

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) 

78 

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) 

88 

89 self._initialized = True 

90 

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 

95 

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) 

100 

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) 

110 

111 self._initialized = True 

112 

113 async def aclose(self) -> None: 

114 """Clean up storage adapter.""" 

115 # No cleanup needed for file storage 

116 

117 async def upload(self, bucket: str, path: str, data: bytes) -> None: 

118 """Upload data to storage. 

119 

120 Args: 

121 bucket: Bucket name 

122 path: Storage path within bucket 

123 data: Data to upload 

124 

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() 

128 

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) 

136 

137 async def download(self, bucket: str, path: str) -> bytes: 

138 """Download data from storage. 

139 

140 Args: 

141 bucket: Bucket name 

142 path: Storage path within bucket 

143 

144 Returns: 

145 Downloaded data as bytes 

146 

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() 

150 

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) 

157 

158 async def delete(self, bucket: str, path: str) -> None: 

159 """Delete data from storage. 

160 

161 Args: 

162 bucket: Bucket name 

163 path: Storage path within bucket 

164 

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() 

168 

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) 

176 

177 async def exists(self, bucket: str, path: str) -> bool: 

178 """Check if data exists in storage. 

179 

180 Args: 

181 bucket: Bucket name 

182 path: Storage path within bucket 

183 

184 Returns: 

185 True if data exists, False otherwise 

186 

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() 

190 

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) 

197 

198 async def stat(self, bucket: str, path: str) -> dict[str, t.Any]: 

199 """Get file statistics. 

200 

201 Args: 

202 bucket: Bucket name 

203 path: Storage path within bucket 

204 

205 Returns: 

206 Dictionary with file statistics 

207 

208 """ 

209 if not self._initialized: 

210 await self.init() 

211 

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) 

218 

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) 

225 

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() 

233 

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() 

239 

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() 

244 

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) 

251 

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 } 

258 

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) 

264 

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 

272 

273 return base_path / path 

274 

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 

279 

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] 

287 

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] 

293 

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 

298 

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) 

305 

306 data = self._memory_store[key] 

307 return { 

308 "size": len(data), 

309 "mtime": datetime.now().isoformat(), 

310 "created": datetime.now().isoformat(), 

311 } 

312 

313 def _get_memory_key(self, bucket: str, path: str) -> str: 

314 """Get memory storage key.""" 

315 return f"{bucket}/{path}" 

316 

317 

318class FileStorageOneiric(StorageBaseOneiric): 

319 """Oneiric-compatible file storage adapter.""" 

320 

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 

326 

327 

328class MemoryStorageOneiric(StorageBaseOneiric): 

329 """Oneiric-compatible memory storage adapter.""" 

330 

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] = {} 

337 

338 

339class StorageRegistryOneiric: 

340 """Oneiric-compatible storage registry. 

341 

342 This registry provides the same interface as the ACB storage registry 

343 but uses native Oneiric implementations instead of ACB adapters. 

344 

345 """ 

346 

347 def __init__(self) -> None: 

348 self._adapters: dict[str, StorageBaseOneiric] = {} 

349 self._settings: StorageAdapterSettings | None = None 

350 

351 async def init(self) -> None: 

352 """Initialize storage registry.""" 

353 self._settings = StorageAdapterSettings.from_settings() 

354 

355 def _initialize_sync(self) -> None: 

356 """Synchronous version of init for storage registry.""" 

357 self._settings = StorageAdapterSettings.from_settings() 

358 

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. 

366 

367 Args: 

368 backend: Storage backend type (file, memory) 

369 config_overrides: Configuration overrides 

370 force: Force re-registration even if adapter exists 

371 

372 Returns: 

373 Configured storage adapter 

374 

375 """ 

376 self._validate_backend(backend) 

377 

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] 

381 

382 # Ensure settings are initialized 

383 if self._settings is None: 

384 self._settings = StorageAdapterSettings.from_settings() 

385 

386 # Create and configure adapter 

387 adapter = self._create_adapter(backend) 

388 self._apply_config_overrides(adapter, config_overrides) 

389 

390 # Cache and return 

391 self._adapters[backend] = adapter 

392 return adapter 

393 

394 def _validate_backend(self, backend: str) -> None: 

395 """Validate backend type. 

396 

397 Args: 

398 backend: Backend type to validate 

399 

400 Raises: 

401 ValueError: If backend is not supported 

402 

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) 

407 

408 def _create_adapter(self, backend: str) -> StorageBaseOneiric: 

409 """Create a storage adapter instance. 

410 

411 Args: 

412 backend: Backend type to create 

413 

414 Returns: 

415 New adapter instance 

416 

417 Raises: 

418 ValueError: If backend type is unknown 

419 

420 """ 

421 adapter_map: dict[str, type[StorageBaseOneiric]] = { 

422 "file": FileStorageOneiric, 

423 "memory": MemoryStorageOneiric, 

424 } 

425 

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) 

430 

431 # Concrete adapters accept StorageAdapterSettings | None 

432 # Base class signature differs from concrete classes 

433 return adapter_class(self._settings) # type: ignore[arg-type] 

434 

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. 

441 

442 Args: 

443 adapter: Adapter to configure 

444 config_overrides: Optional configuration overrides 

445 

446 """ 

447 if not config_overrides: 

448 return 

449 

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

455 

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. 

462 

463 Args: 

464 adapter: Adapter being configured 

465 config_overrides: Raw override values 

466 

467 Returns: 

468 Processed overrides dictionary 

469 

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) 

476 

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 

480 

481 return overrides 

482 

483 def get_storage_adapter(self, backend: str | None = None) -> StorageBaseOneiric: 

484 """Get a storage adapter. 

485 

486 Args: 

487 backend: Storage backend type. If None, uses default. 

488 

489 Returns: 

490 Storage adapter instance 

491 

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" 

495 

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 

502 

503 return self._adapters[backend] 

504 

505 def configure_storage_buckets(self, buckets: dict[str, str]) -> None: 

506 """Configure storage buckets. 

507 

508 Args: 

509 buckets: Mapping of bucket names to paths/identifiers 

510 

511 """ 

512 if self._settings is None: 

513 self._settings = StorageAdapterSettings.from_settings() 

514 

515 # Update settings with new buckets 

516 self._settings.buckets.update(buckets) 

517 

518 # Update all registered adapters 

519 for adapter in self._adapters.values(): 

520 adapter.buckets.update(buckets) 

521 

522 

523# Global registry instance 

524_storage_registry = StorageRegistryOneiric() 

525 

526 

527def init_storage_registry() -> None: 

528 """Initialize storage registry with Oneiric implementation.""" 

529 # Synchronous initialization 

530 _storage_registry._initialize_sync() 

531 

532 

533def get_storage_registry() -> StorageRegistryOneiric: 

534 """Get storage registry instance.""" 

535 return _storage_registry 

536 

537 

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) 

542 

543 

544def configure_storage_buckets(buckets: dict[str, str]) -> None: 

545 """Configure storage buckets.""" 

546 registry = get_storage_registry() 

547 registry.configure_storage_buckets(buckets) 

548 

549 

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. 

556 

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 

561 

562 """ 

563 registry = get_storage_registry() 

564 return registry.register_storage_adapter( 

565 backend, 

566 config_overrides=config_overrides, 

567 force=force, 

568 ) 

569 

570 

571# Default session bucket constant (required by adapters module) 

572DEFAULT_SESSION_BUCKET = "session-buddy-default" 

573 

574 

575# Session storage adapter (required by adapters module) 

576class SessionStorageAdapter: 

577 """Session storage adapter for Oneiric implementation. 

578 

579 This class provides the session storage interface expected by the adapters module. 

580 """ 

581 

582 def __init__(self, backend: str = "file") -> None: 

583 """Initialize session storage adapter. 

584 

585 Args: 

586 backend: Storage backend type (file, memory) 

587 

588 """ 

589 self.backend = backend 

590 self._storage: StorageProtocol | None = None 

591 

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() 

597 

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) 

604 

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) 

611 

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) 

618 

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) 

625 

626 

627def get_default_storage_adapter() -> SessionStorageAdapter: 

628 """Get default storage adapter (required by adapters module).""" 

629 return SessionStorageAdapter(backend="file") 

630 

631 

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 } 

639 

640 

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]