| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| import os | |
| from io import BufferedReader | |
| from typing import Any, Dict, List, Optional | |
| from ..constants import CHUNK_SIZE | |
| from .base import BackendSession, ImageBackend | |
| class FileSession(BackendSession): | |
| """ | |
| Holds a single file handle open for the duration of a streaming read. | |
| Returns empty bytes at EOF (file semantics). | |
| """ | |
| def __init__(self, path: str): | |
| self._path = path | |
| self._fh: Optional[BufferedReader] = open(path, "rb") | |
| self._size = os.path.getsize(path) | |
| def size(self) -> int: | |
| return self._size | |
| def read(self, offset: int, length: int) -> bytes: | |
| if self._fh is None: | |
| raise RuntimeError("session is closed") | |
| self._fh.seek(offset) | |
| return self._fh.read(length) | |
| def close(self) -> None: | |
| if self._fh is not None: | |
| self._fh.close() | |
| self._fh = None | |
| class FileBackend(ImageBackend): | |
| """ | |
| ImageBackend implementation backed by a local file (qcow2 or raw). | |
| Supports full read/write and flush. Does not support extents or range writes. | |
| """ | |
| def __init__(self, file_path: str): | |
| self._path = file_path | |
| @property | |
| def supports_extents(self) -> bool: | |
| return False | |
| @property | |
| def supports_range_write(self) -> bool: | |
| return False | |
| def size(self) -> int: | |
| return os.path.getsize(self._path) | |
| def read(self, offset: int, length: int) -> bytes: | |
| with open(self._path, "rb") as f: | |
| f.seek(offset) | |
| return f.read(length) | |
| def write(self, data: bytes, offset: int) -> None: | |
| raise NotImplementedError("file backend does not support range writes") | |
| def write_full(self, stream: Any, content_length: int, flush: bool) -> int: | |
| bytes_written = 0 | |
| remaining = content_length | |
| with open(self._path, "wb") as f: | |
| while remaining > 0: | |
| chunk = stream.read(min(CHUNK_SIZE, remaining)) | |
| if not chunk: | |
| raise IOError( | |
| f"request body ended early at {bytes_written} bytes" | |
| ) | |
| f.write(chunk) | |
| bytes_written += len(chunk) | |
| remaining -= len(chunk) | |
| if flush: | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| return bytes_written | |
| def flush(self) -> None: | |
| with open(self._path, "rb") as f: | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| def zero(self, offset: int, length: int) -> None: | |
| raise NotImplementedError("file backend does not support zero") | |
| def get_capabilities(self) -> Dict[str, bool]: | |
| return { | |
| "read_only": False, | |
| "can_flush": True, | |
| "can_zero": False, | |
| } | |
| def get_allocation_extents(self) -> List[Dict[str, Any]]: | |
| raise NotImplementedError("file backend does not support extents") | |
| def get_dirty_extents(self, dirty_bitmap_context: str) -> List[Dict[str, Any]]: | |
| raise NotImplementedError("file backend does not support extents") | |
| def open_session(self) -> FileSession: | |
| return FileSession(self._path) | |
| def close(self) -> None: | |
| pass |