Source code for pycsamt.jones.validation

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations

import os
from abc import ABC, abstractmethod
from pathlib import Path

from ..exceptions import (
    FileHandlingError,
    JError,
)
from .config import (
    RE_BANNER,
    RE_BLANK,
    RE_COMMENT,
    RE_DATATYPE_UNITS,
    RE_INFO,
    RE_NPOINTS,
    RE_ROW_R,
    RE_ROW_TF,
    RE_STATION,
)

__all__ = ["IsJ", "is_j_file"]


def _read_lines(path: Path) -> list[str]:
    text = path.read_text(encoding="utf-8-sig", errors="replace")
    return text.splitlines()


def _is_comment(s: str) -> bool:
    return bool(RE_COMMENT.match(s))


def _is_blank(s: str) -> bool:
    return bool(RE_BLANK.match(s))


def _is_info(s: str) -> bool:
    return bool(RE_INFO.match(s))


def _first_nonblank(lines: list[str], i: int) -> int:
    n = len(lines)
    j = i
    while j < n and _is_blank(lines[j]):
        j += 1
    return j


def _find_dtype_after(lines: list[str], i: int) -> int | None:
    n = len(lines)
    j = _first_nonblank(lines, i + 1)
    if j >= n:
        return None
    # dtype first
    if RE_DATATYPE_UNITS.match(lines[j]):
        return j
    # or count first then dtype
    if RE_NPOINTS.match(lines[j]):
        k = j + 1
        while k < n:
            if RE_STATION.match(lines[k]):
                break
            if _is_blank(lines[k]):
                k += 1
                continue
            if RE_DATATYPE_UNITS.match(lines[k]):
                return k
            k += 1
    return None


def _find_count_after(lines: list[str], i: int) -> int | None:
    n = len(lines)
    j = _first_nonblank(lines, i + 1)
    if j >= n:
        return None
    # count first
    if RE_NPOINTS.match(lines[j]):
        return j
    # or dtype first then count
    if RE_DATATYPE_UNITS.match(lines[j]):
        k = j + 1
        while k < n and _is_blank(lines[k]):
            k += 1
        if k < n and RE_NPOINTS.match(lines[k]):
            return k
    return None


def _guess_kind(line: str) -> str | None:
    m = RE_DATATYPE_UNITS.match(line)
    if not m:
        return None
    return m.group("kind").upper()


def _has_at_least_one_row(
    lines: list[str], count_idx: int, kind: str
) -> bool:
    n = len(lines)
    # next non-blank after count is the first row
    i = _first_nonblank(lines, count_idx + 1)
    if i >= n:
        return False
    # decide row regex by kind
    if kind in {"R", "S"}:
        return bool(RE_ROW_R.match(lines[i]))
    if kind in {"Z", "Q", "C", "T"}:
        return bool(RE_ROW_TF.match(lines[i]))
    # unknown kind: be conservative
    return False


def _scan_j_blocks(lines: list[str]) -> int:
    i, n = 0, len(lines)

    # Skip banner/comments/info/blank before first station
    while i < n and (
        _is_comment(lines[i])
        or _is_info(lines[i])
        or _is_blank(lines[i])
        or RE_BANNER.match(lines[i])
    ):
        i += 1

    n_ok = 0
    while i < n:
        m = RE_STATION.match(lines[i])
        if not m:
            i += 1
            continue

        # Find dtype and count in any accepted order
        j = _find_dtype_after(lines, i)
        k = _find_count_after(lines, i)
        if j is None or k is None:
            # advance to avoid infinite loop
            i += 1
            continue

        kind = _guess_kind(lines[j]) or ""
        if not kind:
            i += 1
            continue

        if not _has_at_least_one_row(lines, k, kind):
            i += 1
            continue

        n_ok += 1

        # advance roughly past this block using count
        try:
            cn = int(RE_NPOINTS.match(lines[k]).group("n"))  # type: ignore
        except Exception:
            cn = 0
        # station + (dtype|count) + (count|dtype) + rows
        i = max(k + 1 + max(cn, 0), i + 1)

    return n_ok


[docs] class IsJ(ABC): r""" Abstract base for A.G. Jones J-format validation helpers. Subclasses implement :pyattr:`is_valid`. The static method :py:meth:`_assert_j` provides a robust, file-level validator that accepts either a path or an existing :class:`IsJ` instance. The check is heuristic, fast and tolerant of minor formatting issues (e.g., non-canonical head order). """
[docs] @property @abstractmethod def is_valid(self) -> bool: r"""Return ``True`` when the instance is structurally valid.""" raise NotImplementedError
@staticmethod def _assert_j( file: str | os.PathLike | IsJ, *, deep: bool = True, ) -> bool: r""" Validate that ``file`` is a Jones J-format file (or obj). When ``deep`` is ``False``, only a light extension check is performed (``.j``, ``.jones``, ``.txt``,``.dat`` accepted). When ``deep`` is ``True`` (default), the function scans the text and requires at least one complete data block: a station line, a data-type line, a count line and a row that matches the expected numeric layout. """ if file is None: raise FileHandlingError("NoneType not allowed") if isinstance(file, IsJ): if not file.is_valid: raise JError("J object reports invalid state") return True path = Path(file) # type: ignore[arg-type] if not path.exists() or not path.is_file(): raise FileNotFoundError(f"{str(path)!r} not a file") if not deep: ext = path.suffix.lower().lstrip(".") if ext in {"j", "jones", "txt", "dat"}: return True raise JError("Unexpected extension; expected .j/.jones/.txt/.dat") try: lines = _read_lines(path) except PermissionError: raise PermissionError("Permission denied while reading file") # Heuristic header checks (optional but helpful) # - zero or more info lines at the top are OK # - optional banner # We only require at least one valid block. n_blocks = _scan_j_blocks(lines) if n_blocks <= 0: raise JError( "Unrecognized Jones J file structure: found no " "valid data blocks (station + dtype + count + row)" ) return True
[docs] def is_j_file(file: str | os.PathLike | IsJ, *, deep: bool = True) -> bool: """ Convenience wrapper around :meth:`IsJ._assert_j`. """ return IsJ._assert_j(file, deep=deep)