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

2

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

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

5

6 Phase 5: Oneiric Adapter Conversion - Storage Registry

7

8 Key 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

17 from __future__ import annotations

18

19 import typing as t

20 from dataclasses import replace

21 from datetime import datetime

22 from pathlib import Path

23

24 from session_buddy.adapters.settings import StorageAdapterSettings

25

26 if t.TYPE_CHECKING:

27 from collections.abc import AsyncIterator

28

29

30 class 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

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

46

47

48 class 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 ↛ 75 line 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 ↛ 94 line 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 ↛ 98 line 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 ↛ 127 line 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 ↛ 130 line 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 ↛ 134 line 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 ↛ 149 line 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 ↛ 152 line 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 ↛ 155 line 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 ↛ 167 line 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 ↛ 170 line 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 ↛ 174 line 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 ↛ 189 line 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 ↛ 192 line 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 ↛ 195 line 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 ↛ 284 line 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 ↛ exit line 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

318 class 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

328 class 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

339 class 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 ↛ 405 line 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 ↛ 428 line 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 ↛ exit line 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 ↛ exit line 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 ↛ 472 line 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 ↛ 494 line 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

527 def init_storage_registry() -> None:

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

529 # Synchronous initialization

530 _storage_registry._initialize_sync()

531

532

533 def get_storage_registry() -> StorageRegistryOneiric:

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

535 return _storage_registry

536

537

538 def 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

544 def 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

550 def 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)

572 DEFAULT_SESSION_BUCKET = "session-buddy-default"

573

574

575# Session storage adapter (required by adapters module)

576 class 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

627 def get_default_storage_adapter() -> SessionStorageAdapter:

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

629 return SessionStorageAdapter(backend="file")

630

631

632 def 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]