From f925ae094cfd49a8fccc9cbaeddd9c873b2d2f4e Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 11 May 2026 20:52:31 +0200 Subject: [PATCH] feat: add source adapter base class and local filesystem adapter - Abstract SourceAdapter with FileInfo dataclass - LocalAdapter for filesystem backups - Adapter factory for extensibility --- backend/backup/adapters/__init__.py | 13 ++++++++ backend/backup/adapters/base.py | 39 ++++++++++++++++++++++++ backend/backup/adapters/local.py | 47 +++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 backend/backup/adapters/__init__.py create mode 100644 backend/backup/adapters/base.py create mode 100644 backend/backup/adapters/local.py diff --git a/backend/backup/adapters/__init__.py b/backend/backup/adapters/__init__.py new file mode 100644 index 0000000..15e738f --- /dev/null +++ b/backend/backup/adapters/__init__.py @@ -0,0 +1,13 @@ +from typing import Dict, Any +from .base import SourceAdapter +from .local import LocalAdapter + +ADAPTER_MAP = { + "local": LocalAdapter, +} + +def get_adapter(source_type: str, config: Dict[str, Any]) -> SourceAdapter: + adapter_class = ADAPTER_MAP.get(source_type) + if not adapter_class: + raise ValueError(f"Unknown source type: {source_type}") + return adapter_class(config) diff --git a/backend/backup/adapters/base.py b/backend/backup/adapters/base.py new file mode 100644 index 0000000..b48ce08 --- /dev/null +++ b/backend/backup/adapters/base.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod +from typing import List, Dict, Any, AsyncIterator +from dataclasses import dataclass + +@dataclass +class FileInfo: + path: str + size: int + modified_time: float + is_directory: bool + +class SourceAdapter(ABC): + def __init__(self, config: Dict[str, Any]): + self.config = config + + @abstractmethod + async def connect(self) -> None: + """Establish connection to source.""" + pass + + @abstractmethod + async def disconnect(self) -> None: + """Close connection to source.""" + pass + + @abstractmethod + async def list_files(self, path: str = "") -> List[FileInfo]: + """List files at given path.""" + pass + + @abstractmethod + async def read_file(self, path: str) -> AsyncIterator[bytes]: + """Read file in chunks.""" + pass + + @abstractmethod + async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: + """Get database dump. Only implemented for database adapters.""" + pass diff --git a/backend/backup/adapters/local.py b/backend/backup/adapters/local.py new file mode 100644 index 0000000..5a2f500 --- /dev/null +++ b/backend/backup/adapters/local.py @@ -0,0 +1,47 @@ +import os +import aiofiles +from pathlib import Path +from typing import List, AsyncIterator, Dict, Any +from .base import SourceAdapter, FileInfo + +class LocalAdapter(SourceAdapter): + async def connect(self) -> None: + base_path = self.config.get("path", ".") + if not os.path.exists(base_path): + raise FileNotFoundError(f"Path not found: {base_path}") + + async def disconnect(self) -> None: + pass + + async def list_files(self, path: str = "") -> List[FileInfo]: + base_path = Path(self.config.get("path", ".")) + target_path = base_path / path if path else base_path + + files = [] + exclude_patterns = self.config.get("exclude", []) + + for item in target_path.iterdir(): + # Check exclude patterns + if any(item.match(pattern) for pattern in exclude_patterns): + continue + + stat = item.stat() + files.append(FileInfo( + path=str(item.relative_to(base_path)), + size=stat.st_size, + modified_time=stat.st_mtime, + is_directory=item.is_dir() + )) + + return files + + async def read_file(self, path: str) -> AsyncIterator[bytes]: + base_path = Path(self.config.get("path", ".")) + file_path = base_path / path + + async with aiofiles.open(file_path, "rb") as f: + while chunk := await f.read(8192): + yield chunk + + async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]: + raise NotImplementedError("Local adapter does not support database dumps")