Source code for pyiwfm.io.base

"""
Base classes for IWFM file I/O.

This module provides abstract base classes for reading and writing
IWFM model files in various formats, including support for comment
preservation during round-trip operations.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, BinaryIO

from pyiwfm.io.binary import read_fortran_record as _read_fortran_record
from pyiwfm.io.iwfm_writer import ensure_parent_dir

if TYPE_CHECKING:
    from pyiwfm.core.mesh import AppGrid
    from pyiwfm.core.model import IWFMModel
    from pyiwfm.core.stratigraphy import Stratigraphy
    from pyiwfm.io.comment_metadata import CommentMetadata


[docs] @dataclass class FileInfo: """Information about an IWFM file.""" path: Path format: str # 'ascii', 'binary', 'hdf5', 'dss' version: str | None = None description: str = "" metadata: dict[str, Any] = field(default_factory=dict)
[docs] class BaseReader(ABC): """Abstract base class for IWFM file readers."""
[docs] def __init__(self, filepath: Path | str) -> None: """ Initialize the reader. Args: filepath: Path to the file to read """ self.filepath = Path(filepath) self._validate_file()
def _validate_file(self) -> None: """Validate that the file exists and is readable.""" if not self.filepath.exists(): raise FileNotFoundError(f"File not found: {self.filepath}") if not self.filepath.is_file(): raise ValueError(f"Path is not a file: {self.filepath}")
[docs] @abstractmethod def read(self) -> Any: """ Read the file and return the parsed data. Returns: Parsed data (type depends on subclass) """ pass
@property @abstractmethod def format(self) -> str: """Return the file format identifier.""" pass
[docs] class BaseWriter(ABC): """Abstract base class for IWFM file writers."""
[docs] def __init__(self, filepath: Path | str) -> None: """ Initialize the writer. Args: filepath: Path to the output file """ self.filepath = Path(filepath)
def _ensure_parent_exists(self) -> None: """Ensure the parent directory exists.""" ensure_parent_dir(self.filepath)
[docs] @abstractmethod def write(self, data: Any) -> None: """ Write data to the file. Args: data: Data to write (type depends on subclass) """ pass
@property @abstractmethod def format(self) -> str: """Return the file format identifier.""" pass
[docs] class ModelReader(BaseReader): """Abstract base class for reading complete IWFM models."""
[docs] @abstractmethod def read(self) -> IWFMModel: """ Read the model from file(s). Returns: Complete IWFMModel instance """ pass
[docs] @abstractmethod def read_mesh(self) -> AppGrid: """ Read only the mesh from the model files. Returns: AppGrid instance """ pass
[docs] @abstractmethod def read_stratigraphy(self) -> Stratigraphy: """ Read only the stratigraphy from the model files. Returns: Stratigraphy instance """ pass
[docs] class ModelWriter(BaseWriter): """Abstract base class for writing complete IWFM models."""
[docs] @abstractmethod def write(self, model: IWFMModel) -> None: """ Write the model to file(s). Args: model: IWFMModel instance to write """ pass
[docs] @abstractmethod def write_mesh(self, mesh: AppGrid) -> None: """ Write only the mesh. Args: mesh: AppGrid instance to write """ pass
[docs] @abstractmethod def write_stratigraphy(self, stratigraphy: Stratigraphy) -> None: """ Write only the stratigraphy. Args: stratigraphy: Stratigraphy instance to write """ pass
[docs] class BinaryReader(BaseReader): """Base class for reading IWFM binary files.""" # Fortran record markers are 4 bytes RECORD_MARKER_SIZE = 4
[docs] def __init__(self, filepath: Path | str, endian: str = "<") -> None: """ Initialize the binary reader. Args: filepath: Path to the binary file endian: Byte order ('<' = little-endian, '>' = big-endian) """ super().__init__(filepath) self.endian = endian
@property def format(self) -> str: return "binary" def _read_fortran_record(self, f: BinaryIO) -> bytes: """Read a Fortran unformatted record. Delegates to :func:`pyiwfm.io.binary.read_fortran_record`. """ return _read_fortran_record(f, self.endian)
[docs] class BinaryWriter(BaseWriter): """Base class for writing IWFM binary files.""" RECORD_MARKER_SIZE = 4
[docs] def __init__(self, filepath: Path | str, endian: str = "<") -> None: """ Initialize the binary writer. Args: filepath: Path to the output file endian: Byte order ('<' = little-endian, '>' = big-endian) """ super().__init__(filepath) self.endian = endian
@property def format(self) -> str: return "binary" def _write_fortran_record(self, f: BinaryIO, data: bytes) -> None: """ Write a Fortran unformatted record. Args: f: Binary file object data: Record data as bytes """ import struct record_length = len(data) marker = struct.pack(f"{self.endian}i", record_length) f.write(marker) f.write(data) f.write(marker)
# ============================================================================= # Comment-Aware Reader/Writer Base Classes # =============================================================================
[docs] class CommentAwareReader(BaseReader): """Base class for readers that preserve comments. This class extends BaseReader to extract and preserve comments from IWFM input files during reading. The extracted comment metadata can be used later for round-trip preservation. Example: >>> reader = MyCommentAwareReader("Preprocessor.in", preserve_comments=True) >>> data = reader.read() >>> metadata = reader.comment_metadata >>> metadata.save_for_file("Preprocessor.in") Attributes: preserve_comments: Whether to extract and store comments. _comment_metadata: Extracted comment metadata (lazy-loaded). """
[docs] def __init__( self, filepath: Path | str, preserve_comments: bool = True, ) -> None: """ Initialize the comment-aware reader. Args: filepath: Path to the file to read. preserve_comments: If True, extract and store comments. """ super().__init__(filepath) self.preserve_comments = preserve_comments self._comment_metadata: CommentMetadata | None = None
@property def comment_metadata(self) -> CommentMetadata | None: """Get extracted comment metadata. Returns None if preserve_comments is False or if comments have not been extracted yet. """ return self._comment_metadata
[docs] def extract_comments(self) -> CommentMetadata: """Extract comments from the file. This method can be called explicitly to extract comments without reading the full file content. Returns: CommentMetadata containing all extracted comments. """ from pyiwfm.io.comment_extractor import CommentExtractor extractor = CommentExtractor() self._comment_metadata = extractor.extract(self.filepath) return self._comment_metadata
def _ensure_comments_extracted(self) -> None: """Ensure comments have been extracted if preservation is enabled.""" if self.preserve_comments and self._comment_metadata is None: self.extract_comments()
[docs] class CommentAwareWriter(BaseWriter): """Base class for writers that can restore preserved comments. This class extends BaseWriter to support injecting preserved comments into output files, enabling round-trip preservation of user comments. Example: >>> # Load metadata from sidecar file >>> metadata = CommentMetadata.load_for_file("Preprocessor.in") >>> writer = MyCommentAwareWriter("output/Preprocessor.in", metadata) >>> writer.write(data) Attributes: comment_metadata: CommentMetadata to use for restoration. use_templates_for_missing: If True, use template defaults when no preserved comments exist. """
[docs] def __init__( self, filepath: Path | str, comment_metadata: CommentMetadata | None = None, use_templates_for_missing: bool = True, ) -> None: """ Initialize the comment-aware writer. Args: filepath: Path to the output file. comment_metadata: CommentMetadata with preserved comments. use_templates_for_missing: If True, use default templates when no preserved comments exist. """ super().__init__(filepath) self.comment_metadata = comment_metadata self.use_templates_for_missing = use_templates_for_missing
[docs] def has_preserved_comments(self) -> bool: """Check if preserved comments are available.""" return self.comment_metadata is not None and self.comment_metadata.has_comments()
[docs] def get_comment_writer(self) -> CommentWriter: """Get a CommentWriter configured with our metadata. Returns: CommentWriter instance for restoring comments. """ from pyiwfm.io.comment_writer import CommentWriter return CommentWriter( self.comment_metadata, use_fallback=self.use_templates_for_missing, )
[docs] def save_comment_metadata(self) -> Path | None: """Save comment metadata as a sidecar file. Returns: Path to saved sidecar file, or None if no metadata. """ if self.comment_metadata is not None: return self.comment_metadata.save_for_file(self.filepath) return None
# Type alias for import convenience from pyiwfm.io.comment_writer import CommentWriter # noqa: E402