# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Public packer registry and serialization helpers for survey objects."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Callable
import numpy as np
from ._registry import Registry, guess_kind
from .base import CoreObject, TFBundle, to_edi
__all__ = [
"Packer",
"register_packer",
"get_packer",
"list_packers",
"pack_to_file",
"unpack_from_file",
"RegistryAPI",
]
Packer = tuple[
Callable[[Any], dict[str, Any]],
Callable[[dict[str, Any]], Any],
]
_PACKERS: dict[str, Packer] = {}
[docs]
def register_packer(kind: str, packer: Packer) -> None:
r"""
Register a serializer/deserializer pair for a given ``kind``.
Parameters
----------
kind : str
Case-insensitive key that identifies the packer.
packer : tuple(callable, callable)
Pair ``(pack, unpack)``. The first callable takes an
object and returns a ``dict``; the second takes that
mapping and returns the reconstructed object.
Raises
------
ValueError
If ``kind`` is empty or not a ``str``.
Notes
-----
Registration is global to the current process via an
internal dictionary. Re-registering the same ``kind`` will
overwrite the previous pair.
Examples
--------
>>> def pack(x): return {"v": x}
>>> def unpack(d): return d["v"]
>>> register_packer("toy", (pack, unpack))
>>> get_packer("toy") is not None
True
See Also
--------
get_packer
list_packers
pack_to_file
unpack_from_file
References
----------
.. [1] NumPy. ``np.savez_compressed`` for NPZ payloads.
"""
if not kind or not isinstance(kind, str):
raise ValueError("kind must be non-empty str")
_PACKERS[kind.lower()] = packer
[docs]
def get_packer(kind: str) -> Packer | None:
r"""
Return the registered packer for ``kind`` or ``None``.
Parameters
----------
kind : str
Case-insensitive key used at registration time.
Returns
-------
tuple(callable, callable) or None
The ``(pack, unpack)`` pair if found, else ``None``.
Examples
--------
>>> _ = get_packer("missing") is None
>>> # After registering:
>>> # register_packer("toy", (pack, unpack))
>>> pk = get_packer("toy")
>>> callable(pk[0]) and callable(pk[1])
True
See Also
--------
register_packer
list_packers
"""
return _PACKERS.get(kind.lower())
[docs]
def list_packers() -> dict[str, str]:
r"""
List available packers as a mapping of kind to signatures.
Returns
-------
dict[str, str]
Mapping ``kind -> "pack_name | unpack_name"`` for quick
inspection.
Notes
-----
Only names available via ``__name__`` are shown. For
callables without a name, ``repr`` is used.
Examples
--------
>>> isinstance(list_packers(), dict)
True
See Also
--------
register_packer
get_packer
"""
out: dict[str, str] = {}
for k, (pk, up) in _PACKERS.items():
a = getattr(pk, "__name__", repr(pk))
b = getattr(up, "__name__", repr(up))
out[k] = f"{a} | {b}"
return out
def _bundle_pack(obj: Any) -> dict[str, Any]:
r"""
Pack a :class:`TFBundle` into an NPZ-friendly mapping.
Validates type and converts arrays/scalars.
"""
if not isinstance(obj, TFBundle):
raise TypeError("expected TFBundle")
def _arr(x):
return None if x is None else np.asarray(x)
payload = {
"kind": "bundle",
"freq": _arr(obj.freq),
"z": _arr(obj.z),
"z_err": _arr(obj.z_err),
"tipper": _arr(obj.tipper),
"tipper_err": _arr(obj.tipper_err),
"rho": _arr(obj.rho),
"phase": _arr(obj.phase),
"station": obj.station,
"station_id": obj.station_id,
"lat": obj.lat,
"lon": obj.lon,
"elev": obj.elev,
"azimuth": obj.azimuth,
"attrs": json.dumps(obj.attrs or {}),
}
# drop None to avoid object arrays in NPZ
return {k: v for k, v in payload.items() if v is not None}
def _bundle_unpack(d: dict[str, Any]) -> TFBundle:
r"""
Rebuild a :class:`TFBundle` from a mapping produced by
``_bundle_pack``.
Unboxes 0-D arrays and parses JSON attrs.
"""
def _sc(x):
# unbox 0-D numpy scalars to Python types
if isinstance(x, np.ndarray) and x.shape == ():
return x.item()
return x
attrs_txt = _sc(d.get("attrs"))
attrs = json.loads(attrs_txt) if attrs_txt else {}
return TFBundle(
freq=d.get("freq"),
z=d.get("z"),
z_err=d.get("z_err"),
tipper=d.get("tipper"),
tipper_err=d.get("tipper_err"),
rho=d.get("rho"),
phase=d.get("phase"),
station=_sc(d.get("station")),
station_id=_sc(d.get("station_id")),
lat=_sc(d.get("lat")),
lon=_sc(d.get("lon")),
elev=_sc(d.get("elev")),
azimuth=_sc(d.get("azimuth")),
attrs=attrs,
)
register_packer("bundle", (_bundle_pack, _bundle_unpack))
[docs]
def pack_to_file(
obj: Any,
path: Path | str,
*,
kind: str = "bundle",
) -> Path:
r"""
Serialize an object to ``.npz`` using a registered packer.
Parameters
----------
obj : Any
Object to serialize. Must be supported by the chosen
``kind`` (i.e., its packer).
path : path-like
Output file path. ``.npz`` is recommended.
kind : str, optional
Packer name. Defaults to ``"bundle"``.
Returns
-------
pathlib.Path
The path actually written.
Raises
------
ValueError
If no packer is registered for ``kind``.
OSError
For filesystem write errors.
Notes
-----
Data are written with :func:`numpy.savez_compressed`, which
stores arrays losslessly and compresses the archive.
Examples
--------
Pack a bundle to disk::
>>> # Assume a TFBundle instance: bndl
>>> out = pack_to_file(bndl, "site001.npz", kind="bundle")
>>> out.name.endswith(".npz")
True
See Also
--------
unpack_from_file
register_packer
get_packer
numpy.savez_compressed
References
----------
.. [1] NumPy Reference. *np.savez_compressed*.
"""
p = Path(path)
pk = get_packer(kind)
if pk is None:
raise ValueError(f"no packer for kind: {kind}")
payload = pk[0](obj)
np.savez_compressed(p, **payload)
return p
[docs]
def unpack_from_file(path: Path | str, *, kind: str = "bundle") -> Any:
r"""
Deserialize an object from ``.npz`` using a registered
packer.
Parameters
----------
path : path-like
Input NPZ path created by :func:`pack_to_file`.
kind : str, optional
Packer name. Defaults to ``"bundle"``. Must match the
packer used during serialization.
Returns
-------
Any
The reconstructed object.
Raises
------
ValueError
If no packer is registered for ``kind``.
OSError
For filesystem read errors.
KeyError
If expected payload keys are missing for the packer.
Notes
-----
Loading uses ``allow_pickle=False`` for safety; payloads must
be composed of basic NumPy/JSON types.
Examples
--------
Load a previously saved bundle::
>>> obj = unpack_from_file("site001.npz", kind="bundle")
>>> hasattr(obj, "z") and hasattr(obj, "freq")
True
See Also
--------
pack_to_file
register_packer
get_packer
"""
p = Path(path)
pk = get_packer(kind)
if pk is None:
raise ValueError(f"no packer for kind: {kind}")
with np.load(p, allow_pickle=False) as npz:
d = {k: npz[k] for k in npz.files}
return pk[1](d)
[docs]
class RegistryAPI(CoreObject):
r"""
Convenience façade over :class:`~pycsamt.core.registry.Registry`.
This high-level helper wires a registry root, ensures a
``packs/`` folder exists under that root, and exposes both
thin proxies to low-level registry calls and ergonomic
helpers for packing objects to NPZ bundles and restoring
them later.
Parameters
----------
root : path-like
Base directory that will contain the manifest and, if
needed, a ``packs/`` subdirectory for NPZ bundles.
manifest_name : str, optional
Manifest filename within ``root``. Defaults to
``"manifest.json"``.
Attributes
----------
low : Registry
The underlying registry instance that performs the core
operations (load/save, add, query).
root : pathlib.Path
Absolute path to the registry root directory.
pack_dir : pathlib.Path
Directory where NPZ bundles are stored (``root/packs``).
Notes
-----
``RegistryAPI`` aims to be pragmatic and file-system first.
It does not do locking or concurrency control. For multi-
process scenarios, add your own synchronization.
The packing flow defaults to the ``"bundle"`` packer, which
serializes :class:`TFBundle`-compatible objects into NPZ
using :func:`numpy.savez_compressed`.
Examples
--------
Create an API, add an EDI file, and list records::
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> _ = api.add_file("site001.edi", kind="edi", fmt="edi")
>>> [r.kind for r in api.list(kind="edi")]
['edi']
Pack an object to an NPZ bundle and store it in ``packs``::
>>> class Dummy:
... station = "S01"; station_id = 1
>>> rec = api.add_object(Dummy(), pack=True,
... pack_name="S01.npz")
>>> rec.kind
'bundle'
Materialize a record back into a Python object::
>>> obj = api.materialize(rec.rid) # may be Dummy or path
Convert a record to an EDI representation::
>>> edi_like = api.to_edi(rec.rid)
See Also
--------
pycsamt.core.registry.Registry
pycsamt.core.registry.pack_to_file
pycsamt.core.registry.unpack_from_file
pycsamt.core.registry.guess_kind
pycsamt.seg.edi.EDIFile
pycsamt.zonge.avg.AVG
pycsamt.jones.j.JFile
pycsamt.core.base.to_edi
References
----------
.. [1] D. Wight (1991). *SEG MT/EMAP EDI Standard*.
.. [2] NumPy Reference. *np.savez_compressed*, *np.load*.
"""
def __init__(
self,
root: Path | str,
*,
manifest_name: str = "manifest.json",
) -> None:
self.low = Registry(
root,
manifest_name=manifest_name,
)
self.root = Path(self.low.root)
self.pack_dir = self.root / "packs"
self.pack_dir.mkdir(parents=True, exist_ok=True)
# direct proxies
[docs]
def save(self) -> None:
r"""
Persist the manifest to disk.
Thin proxy to :meth:`Registry.save`. Use after mutating the
underlying manifest directly, though typical helpers call
save for you.
"""
self.low.save()
[docs]
def list(self, *, kind: str | None = None):
r"""
List all records, optionally filtered by kind.
Parameters
----------
kind : str or None, optional
If provided, only records whose ``kind`` matches are
returned.
Returns
-------
list of Record
Shallow views of stored :class:`Record` objects.
Examples
--------
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> isinstance(api.list(), list)
True
"""
return self.low.list(kind=kind)
[docs]
def get(self, rid: str):
r"""
Return the record by id or raise an error.
Parameters
----------
rid : str
Record identifier returned at insertion time.
Returns
-------
Record
The matching record.
Raises
------
RegistryError
If the id does not exist in the manifest.
"""
return self.low.get(rid)
[docs]
def find(
self,
*,
tag: str | None = None,
kind: str | None = None,
dataid: str | None = None,
):
r"""
Filter records by tag, kind, and/or data id.
Parameters
----------
tag : str or None, optional
Require that the tag is present in ``Record.tags``.
kind : str or None, optional
Require that the record kind matches the value.
dataid : str or None, optional
Require equality with ``Record.dataid``.
Returns
-------
list of Record
Records satisfying all given predicates.
Examples
--------
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> api.find(kind="edi") # doctest: +ELLIPSIS
[...] # list of records
"""
return self.low.find(tag=tag, kind=kind, dataid=dataid)
# add helpers
[docs]
def add_file(
self,
path: Path | str,
*,
kind: str | None = None,
fmt: str | None = None,
dataid: str | None = None,
station_id: str | None = None,
tags: list[str] | None = None,
meta: dict[str, Any] | None = None,
with_hash: bool = True,
):
r"""
Register a file path as a new record.
Parameters
----------
path : path-like
File path. Relative paths are resolved under ``root``.
kind : str or None, optional
Logical kind (e.g. ``"edi"``, ``"avg"``). If omitted,
defaults to ``"meta"``.
fmt : str or None, optional
Short format hint (e.g. ``"edi"``).
dataid : str or None, optional
External data or survey id.
station_id : str or int or None, optional
Station identifier. Cast to string if provided.
tags : list[str] or None, optional
User-defined labels to ease later queries.
meta : dict[str, Any] or None, optional
Extra JSON-serializable metadata.
with_hash : bool, optional
If ``True`` and the file exists, compute and store a
SHA-256 checksum. Defaults to ``True``.
Returns
-------
Record
The created and persisted record.
Notes
-----
This method saves the manifest on success.
Examples
--------
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> rec = api.add_file("site001.edi",
... kind="edi", fmt="edi")
>>> rec.kind, rec.fmt
('edi', 'edi')
"""
return self.low.add_path(
path,
kind=kind,
fmt=fmt,
dataid=dataid,
station_id=station_id,
tags=tags,
meta=meta,
with_hash=with_hash,
)
[docs]
def add_object(
self,
obj: Any,
*,
kind: str | None = None,
tags: list[str] | None = None,
meta: dict[str, Any] | None = None,
pack: bool = False,
pack_name: str | None = None,
):
r"""
Register an object and, optionally, pack it to an NPZ bundle.
Parameters
----------
obj : Any
Object to register. If ``kind`` is ``None``, a coarse
label is inferred via :func:`guess_kind`.
kind : str or None, optional
Explicit kind override (e.g. ``"edi"``, ``"bundle"``).
tags : list[str] or None, optional
User-defined labels.
meta : dict[str, Any] or None, optional
Extra JSON-serializable metadata for the record.
pack : bool, optional
If ``True``, serialize ``obj`` with the ``"bundle"``
packer into ``pack_dir`` and rewrite the record to refer
to that NPZ on disk. Defaults to ``False``.
pack_name : str or None, optional
Output NPZ filename when ``pack=True``. Defaults to
``"<rid>.npz"``.
Returns
-------
Record
The created record. When ``pack=True``, the record
``kind`` becomes ``"bundle"`` and ``path`` points to the
NPZ file inside ``pack_dir``.
Notes
-----
Packing uses :func:`pack_to_file` with ``kind="bundle"``.
The manifest is saved after packing.
Examples
--------
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> class Obj: station="S02"; station_id=2
>>> rec = api.add_object(Obj(), tags=["demo"])
>>> isinstance(rec.rid, str)
True
Pack immediately to NPZ::
>>> rec2 = api.add_object(Obj(), pack=True,
... pack_name="S02.npz")
>>> rec2.kind
'bundle'
See Also
--------
pycsamt.core.registry.pack_to_file
pycsamt.core.registry.guess_kind
"""
r = self.low.add_obj(
obj,
kind=kind or guess_kind(obj),
tags=tags,
meta=meta,
)
if pack:
name = pack_name or f"{r.rid}.npz"
out = self.pack_dir / name
pack_to_file(obj, out, kind="bundle")
r.path = str(out)
r.kind = "bundle"
self.save()
return r
# materializers
[docs]
def materialize(self, rid: str) -> Any:
r"""
Load a record into a concrete Python object when possible.
Parameters
----------
rid : str
Record id to resolve.
Returns
-------
Any
If the record is a ``"bundle"`` with a valid path,
returns the unpacked object via
:func:`unpack_from_file`. For known kinds with readable
files this returns:
* ``"edi"`` -> :class:`pycsamt.seg.edi.EDIFile`
* ``"avg"`` -> :class:`pycsamt.zonge.avg.AVG`
* ``"j"`` or ``"j_col"`` -> :class:`pycsamt.jones.j.JFile`
Otherwise, returns the string path (or ``None``) of the
record.
Notes
-----
Errors during import or read are swallowed and the path is
returned instead. This keeps the method side-effect free
with respect to the registry while remaining robust.
Examples
--------
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> # Assuming a valid record id:
>>> # obj = api.materialize(rid)
"""
r = self.low.get(rid)
if r.kind == "bundle" and r.path:
return unpack_from_file(r.path)
p = Path(r.path) if r.path else None
try:
if r.kind == "edi" and p and p.exists():
from pycsamt.seg.edi import EDIFile
return EDIFile.from_file(p)
if r.kind == "avg" and p and p.exists():
from pycsamt.zonge.avg import AVG
return AVG.from_file(p)
if r.kind in {"j", "j_col"} and p and p.exists():
from pycsamt.jones.j import JFile
return JFile.from_file(p)
except Exception:
return r.path
return r.path
[docs]
def to_edi(self, rid: str, **kw: Any) -> Any:
r"""
Convert a record's materialized object into an EDI-like form.
Parameters
----------
rid : str
Record id to resolve and convert.
**kw : Any
Forwarded to :func:`to_edi`, which performs the actual
conversion.
Returns
-------
Any
An object produced by :func:`to_edi`. The concrete type
depends on the available converters and inputs.
Notes
-----
This is a convenience wrapper that first calls
:meth:`materialize`, then delegates to
:func:`pycsamt.core.base.to_edi`.
Examples
--------
>>> from pycsamt.core.registry import RegistryAPI
>>> api = RegistryAPI("data")
>>> # Convert an existing record to an EDI representation
>>> edi_like = api.to_edi("some-record-id") # doctest: +SKIP
See Also
--------
pycsamt.core.base.to_edi
pycsamt.seg.edi.EDIFile
"""
obj = self.materialize(rid)
return to_edi(obj, **kw)