Source code for pycsamt.airborne.io

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0

"""Common native-I/O dispatcher for airborne EM deliveries.

No vendor reader is registered by default. Technology adapters already accept
decoded scientific arrays; native readers should enter this registry only when
their actual delivery schema has been verified from a representative file or
an authoritative format specification.

Architecturally this module mirrors :mod:`pycsamt.io.formats` /
:mod:`pycsamt.io.transfer` (:func:`~pycsamt.io.formats.detect_tf_format`,
:func:`~pycsamt.io.transfer.read_transfer_function`): a
:class:`~pycsamt.airborne.registry.AirborneFormatDefinition` registry
resolved by detector-then-extension, dispatched through one stable
public entry point. The two are deliberately *not* the same registry,
because they operate on different units of work: ``pycsamt.io``
readers/writers exchange one site's transfer function (EDI/EMTF XML),
while this module exchanges a whole-survey
:class:`~pycsamt.airborne.base.AirborneEMDataset` (many flight lines,
each with many samples). :class:`AirborneIOError` is a ``RuntimeError``
rather than :class:`~pycsamt.io.formats.TransferFunctionFormatError`'s
``ValueError`` for the same reason it exists at all right now: every
failure currently reachable here is "no native reader/writer is
registered for this technology yet" -- a capability gap, not bad user
input -- because, per the project roadmap, no vendor has supplied a
delivery sample to validate a native decoder against (see
:mod:`pycsamt.airborne.mobilemt` for why that is permanent for
MobileMT specifically). Should a genuine format-detection-from-bad-
input failure mode be added later, reconsider this rather than
assuming that day is today.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from .base import AirborneEMDataset
from .registry import (
    AirborneFormatDefinition,
    AirborneFormatDetectionError,
    AirborneRegistryError,
    detect_airborne_format,
    get_airborne_format,
    get_airborne_technology,
    list_airborne_formats,
)

__all__ = [
    "AirborneIOError",
    "read_airborne",
    "write_airborne",
    "available_airborne_readers",
    "available_airborne_writers",
]


[docs] class AirborneIOError(RuntimeError): """Raised when no defensible native airborne I/O path is available. See the module docstring for why this is a ``RuntimeError`` rather than a ``ValueError``. """
def _resolved_format( source: Any, *, format: str | None, technology: str | None, ) -> AirborneFormatDefinition: """Resolve one :class:`AirborneFormatDefinition` for *source*. Parameters ---------- source : Any Candidate to resolve a format for: an explicit *format* name skips inspecting this value entirely, otherwise it is passed to :func:`~pycsamt.airborne.registry.detect_airborne_format`. format : str, optional Explicit registered format name or alias. When ``None``, content/extension-based detection is used instead. technology : str, optional When given, cross-checked against the resolved format's own ``technology`` so a caller cannot silently read/write a MobileMT file through a ZTEM-scoped call, for example. Returns ------- AirborneFormatDefinition The resolved format definition. Raises ------ AirborneIOError If *format* is given but unregistered; if detection is ambiguous, finds nothing, or *format* is omitted; or if *technology* is given but does not own the resolved format. """ if format is not None: definition = get_airborne_format(format) if definition is None: raise AirborneIOError(f"unknown airborne format: {format!r}") else: try: detected = detect_airborne_format(source) except AirborneFormatDetectionError as exc: raise AirborneIOError(str(exc)) from exc if detected is None: tech_text = "" if technology is not None: tech_text = f" for technology {technology!r}" raise AirborneIOError( "no registered native airborne format recognized the source" f"{tech_text}; register a verified reader after obtaining a " "representative delivery file" ) definition = get_airborne_format(detected) if definition is None: # pragma: no cover - registry invariant raise AirborneIOError(f"detected unknown format: {detected!r}") if technology is not None: tech = get_airborne_technology(technology) if tech is None: raise AirborneIOError(f"unknown technology: {technology!r}") if definition.technology != tech.name: raise AirborneIOError( f"format {definition.name!r} belongs to " f"{definition.technology!r}, not {tech.name!r}" ) return definition
[docs] def read_airborne( source: Any, *, format: str | None = None, technology: str | None = None, **kwargs: Any, ) -> AirborneEMDataset: """Read one verified native airborne delivery into the common dataset. Parameters ---------- source : Any Delivery to read: typically a path, though the concrete type accepted depends on the registered reader. Passing an existing :class:`AirborneEMDataset` is an intentional no-op when *format* is not explicitly requested, so pipeline code can call this uniformly whether it already has a dataset or a raw delivery. format : str, optional Explicit registered format name or alias. When omitted, content/extension-based detection selects the format. technology : str, optional Restrict resolution to one technology's formats; see :func:`_resolved_format`. **kwargs Forwarded to the selected format's registered reader. Returns ------- AirborneEMDataset The dataset produced by the resolved reader, or *source* itself when it already was one and *format* was omitted. Raises ------ AirborneIOError If no reader can be resolved for *source* (see :func:`_resolved_format`), or if the resolved format has no registered reader, or if that reader does not return an :class:`AirborneEMDataset`. """ if isinstance(source, AirborneEMDataset) and format is None: return source definition = _resolved_format( source, format=format, technology=technology, ) if definition.reader is None: raise AirborneIOError( f"airborne format {definition.name!r} has no registered reader" ) result = definition.reader(source, **kwargs) if not isinstance(result, AirborneEMDataset): raise AirborneIOError( f"reader {definition.name!r} did not return AirborneEMDataset" ) return result
[docs] def write_airborne( dataset: AirborneEMDataset, target: Any, *, format: str | None = None, technology: str | None = None, **kwargs: Any, ) -> Any: """Write a dataset through a verified native airborne writer. Parameters ---------- dataset : AirborneEMDataset Dataset to serialize. target : Any Output destination. When *format* is omitted, *target* must be a path/string with an extension that resolves unambiguously via :func:`~pycsamt.airborne.registry.detect_airborne_format`. format : str, optional Explicit registered format name or alias. technology : str, optional Restrict resolution to one technology's formats; see :func:`_resolved_format`. **kwargs Forwarded to the selected format's registered writer. Returns ------- Any Whatever the resolved writer returns; not constrained by this dispatcher. Raises ------ TypeError If *dataset* is not an :class:`AirborneEMDataset`. AirborneIOError If *format* is omitted and cannot be inferred from *target*, if no writer can otherwise be resolved (see :func:`_resolved_format`), or if the resolved format has no registered writer. """ if not isinstance(dataset, AirborneEMDataset): raise TypeError("dataset must be an AirborneEMDataset") if format is None: if isinstance(target, (str, Path)): detected = detect_airborne_format(target) else: detected = None if detected is None: raise AirborneIOError( "output format is required because no registered native " "airborne format can be inferred" ) format = detected definition = _resolved_format( target, format=format, technology=technology, ) if definition.writer is None: raise AirborneIOError( f"airborne format {definition.name!r} has no registered writer" ) return definition.writer(dataset, target, **kwargs)
[docs] def available_airborne_readers( *, technology: str | None = None, ) -> tuple[str, ...]: """Return native formats with a registered reader. Parameters ---------- technology : str, optional Restrict to one technology's formats; ``None`` lists across all registered technologies. Returns ------- tuple of str Canonical format names currently readable. Empty until a native reader has been registered for at least one format; see the module docstring. Raises ------ AirborneIOError If *technology* is given but not registered. """ try: formats = list_airborne_formats(technology=technology) except AirborneRegistryError as exc: raise AirborneIOError(str(exc)) from exc return tuple(fmt.name for fmt in formats if fmt.readable)
[docs] def available_airborne_writers( *, technology: str | None = None, ) -> tuple[str, ...]: """Return native formats with a registered writer. Parameters ---------- technology : str, optional Restrict to one technology's formats; ``None`` lists across all registered technologies. Returns ------- tuple of str Canonical format names currently writable. Empty until a native writer has been registered for at least one format; see the module docstring. Raises ------ AirborneIOError If *technology* is given but not registered. """ try: formats = list_airborne_formats(technology=technology) except AirborneRegistryError as exc: raise AirborneIOError(str(exc)) from exc return tuple(fmt.name for fmt in formats if fmt.writable)