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