Source code for pycsamt.site.export

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

from __future__ import annotations

import csv
import zipfile
from collections.abc import Iterable
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any

from .utils import (
    _ensure_head,
    station_name,
)

__all__ = ["write_site", "write_sites", "pack_zip"]


[docs] def write_site(site: Any, path: str | Path) -> Path: r""" Write a single site (EDI) to a target path. This is a thin, best-effort adapter around several common EDI writer spellings. The function will create parent directories as needed and then try, in order, the following methods on ``site`` until one succeeds: 1) ``write(new_edifn=path)`` 2) ``write(path)`` 3) ``to_file(path)`` 4) ``save(path)`` If none of these exist or succeed, a ``RuntimeError`` is raised. Parameters ---------- site : Any An EDI-like object. It can be a ``pycsamt.seg.edi.EDIFile`` or any object exposing one of the writer methods listed above. path : str or pathlib.Path Destination file path. Parent directories are created if they do not exist. Returns ------- pathlib.Path The resolved output path. Notes ----- This function does not enforce overwrite policy. Whether an existing file is replaced depends on the underlying writer implementation of the provided ``site`` object. Examples -------- >>> from pathlib import Path >>> from pycsamt.site.export import write_site >>> class Dummy: ... def to_file(self, p): # minimal writer ... Path(p).write_text("# dummy edi\\n", encoding="utf-8") ... >>> out = write_site(Dummy(), Path("out") / "S01.edi") >>> out.name 'S01.edi' >>> out.exists() True See Also -------- pycsamt.site.export.write_sites : Batch writing with templated filenames and optional manifest. pycsamt.site.export.pack_zip : Archive a set of sites into a zip. References ---------- .. [1] Python Software Foundation. "pathlib" and "io" modules. """ out = Path(path) _ensure_parent(out) _write_via_backend(site, out) return out
[docs] def write_sites( sites: Any, outdir: str | Path, *, template: str = "{station}.edi", exist_ok: bool = False, manifest_csv: str | Path | None = None, ) -> list[Path]: r""" Write a collection of sites to a directory using a filename template. The function accepts many input forms (``Sites``, an ``EDICollection``, any iterable of EDI-like objects, or a single object) and writes each item to ``outdir``. Filenames are rendered from a context and the ``template`` string. Supported template keys (filled via a safe formatter): - ``{station}`` : current station name - ``{index}`` : zero-based index in the iteration order - ``{lat}``, ``{lon}``, ``{elev}`` : header coordinates, or NaN - ``{chainage}`` : optional header chainage, or NaN If the rendered name does not end with ``.edi``, the extension is appended automatically. Parameters ---------- sites : Any A ``Sites`` instance, an ``EDICollection``, any iterable of EDI-like objects, or a single EDI-like object. EDI-like means it implements one of: ``write(new_edifn=...)``, ``write(...)``, ``to_file(...)``, or ``save(...)``. outdir : str or pathlib.Path Output directory. It is created if it does not exist. template : str, optional Filename template. Defaults to ``"{station}.edi"``. exist_ok : bool, optional If ``False`` (default), raise ``FileExistsError`` on the first name collision inside ``outdir``. If ``True``, allow overwriting subject to the writer behavior. manifest_csv : str or pathlib.Path or None, optional If provided, write a CSV manifest with one row per written site. Columns are: ``index, station, lat, lon, elev, chainage, filename, path``. Returns ------- list of pathlib.Path Paths to the files written, in the same order as the input iteration. Notes ----- The ``index`` used in templating and in the manifest is the zero-based position in the input order. Coordinate fields come from the EDI header when available; missing values are written as NaN. Examples -------- >>> from pathlib import Path >>> from pycsamt.site.export import write_sites >>> class EdiToFile: ... def __init__(self, name): self._n = name ... def to_file(self, p): ... Path(p).write_text(f"# {self._n}\\n", encoding="utf-8") ... # station name is taken from header helpers when present, ... # but the template can still use {index}. ... >>> outdir = Path("eds_out") >>> paths = write_sites( ... [EdiToFile("S01"), EdiToFile("S02")], ... outdir, ... template="{index:03d}_{station}" ... ) >>> [p.exists() for p in paths] [True, True] >>> # Write with a manifest >>> mpath = Path("eds_out") / "manifest.csv" >>> _ = write_sites( ... [EdiToFile("S01"), EdiToFile("S02")], ... outdir, ... template="{station}", ... exist_ok=True, ... manifest_csv=mpath, ... ) >>> mpath.exists() True See Also -------- pycsamt.site.base.Sites.write : Higher-level convenience bound to a ``Sites`` collection. pycsamt.site.export.pack_zip : Create a zip archive instead of a directory tree. References ---------- .. [1] Python Software Foundation. "csv" module. """ out_root = Path(outdir) out_root.mkdir(parents=True, exist_ok=True) written: list[tuple[int, Any, Path]] = [] used: set[str] = set() for i, ed in enumerate(_iter_any_sites(sites)): ctx = _context_for(ed, i) name = _render_name(template, ctx) or "" # Derive a non-empty stem. An empty station name would render to # ".edi" (a stem-less, often hidden file) — ``ensure_sites`` then # ignores it, so the export silently loads back as zero stations # ("No stations with valid impedance data found"). Fall back to a # positional name so every site lands in a real file. stem = name[:-4] if name.lower().endswith(".edi") else name stem = stem.strip() if not stem: stem = f"site_{i:03d}" name = f"{stem}.edi" # Disambiguate collisions. Two sites sharing a station name would # otherwise overwrite each other on disk (silent data loss while # still returning one path per input), corrupting the dataset. if name in used: name = f"{stem}_{i:03d}.edi" used.add(name) dest = out_root / name if dest.exists() and not exist_ok: raise FileExistsError(dest) _write_via_backend(ed, dest) written.append((i, ed, dest)) if manifest_csv: rows = _rows_for_manifest(written) _write_manifest_csv(rows, Path(manifest_csv)) return [p for _, _, p in written]
[docs] def pack_zip( sites: Any, out_zip: str | Path, *, template: str = "{station}.edi", manifest_csv: str | Path | None = None, ) -> Path: r""" Pack a set of sites into a zip archive using a filename template. Each input item is written to a temporary directory first, then added to the ``out_zip`` archive using ``ZIP_DEFLATED``. Filenames inside the archive are rendered from the same context as in :func:`write_sites`. If a name lacks the ``.edi`` suffix, it is appended automatically. Optionally, a CSV manifest can be written alongside the archive. Parameters ---------- sites : Any A ``Sites`` instance, an ``EDICollection``, any iterable of EDI-like objects, or a single EDI-like object. out_zip : str or pathlib.Path Destination zip file path. Parent directories are created as needed. template : str, optional Filename template for entries stored in the archive. Defaults to ``"{station}.edi"``. manifest_csv : str or pathlib.Path or None, optional If provided, write a CSV manifest next to the zip. Columns: ``index, station, lat, lon, elev, chainage, filename, path``. Returns ------- pathlib.Path The path to the created zip archive. Notes ----- Files are staged in a temporary directory and then compressed with ``zipfile.ZIP_DEFLATED``. The ``index`` used in templating and the manifest corresponds to the input iteration order. This function does not delete or modify any original EDI sources. Examples -------- >>> from pathlib import Path >>> from zipfile import ZipFile >>> from pycsamt.site.export import pack_zip >>> class EdiSave: ... def __init__(self, name): self._n = name ... def to_file(self, p): ... Path(p).write_text(f"# {self._n}\\n", encoding="utf-8") ... >>> zpath = Path("out_bundle") / "sites.zip" >>> out = pack_zip( ... [EdiSave("A01"), EdiSave("A02")], ... zpath, ... template="{station}.edi", ... manifest_csv=Path("out_bundle") / "manifest.csv", ... ) >>> out == zpath, zpath.exists() (True, True) >>> with ZipFile(zpath, "r") as zf: ... sorted(zf.namelist()) ['A01.edi', 'A02.edi'] See Also -------- pycsamt.site.export.write_sites : Write files to a directory instead of an archive. References ---------- .. [1] Python Software Foundation. "zipfile" module. """ out_zip = Path(out_zip) _ensure_parent(out_zip) rows: list[dict[str, Any]] = [] with TemporaryDirectory() as td: tmp = Path(td) file_map: list[tuple[Path, str]] = [] rows: list[dict] = [] for i, ed in enumerate(_iter_any_sites(sites)): ctx = _context_for(ed, i) name = _render_name(template, ctx) or "site.edi" if not name.lower().endswith(".edi"): name = f"{name}.edi" dst = tmp / name _ensure_parent(dst) _write_via_backend(ed, dst) file_map.append((dst, name)) rows.append( { "index": ctx["index"], "station": ctx["station"], "lat": ctx["lat"], "lon": ctx["lon"], "elev": ctx["elev"], "chainage": ctx["chainage"], "filename": name, "path": str(out_zip), } ) with zipfile.ZipFile( out_zip, "w", compression=zipfile.ZIP_DEFLATED ) as zf: for fp, arcname in file_map: zf.write(fp, arcname) if manifest_csv: _write_manifest_csv(rows, Path(manifest_csv)) return out_zip
# ----- helpers ------------ class _SafeDict(dict): def __missing__(self, key): return "" def _ensure_parent(path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) def _get_number(v: Any) -> float: try: if v is None: return float("nan") return float(v) except Exception: return float("nan") def _ctx_coords(h: Any) -> tuple[float, float, float]: # be robust to different backends / spellings la = getattr(h, "lat", None) if la is None: la = getattr(h, "latitude", None) lo = getattr(h, "lon", None) if lo is None: lo = getattr(h, "long", None) if lo is None: lo = getattr(h, "longitude", None) ev = getattr(h, "elev", None) if ev is None: ev = getattr(h, "elevation", None) return _get_number(la), _get_number(lo), _get_number(ev) def _context_for(ed: Any, index: int) -> dict[str, Any]: h = _ensure_head(ed) nm = station_name(ed) lat, lon, elev = _ctx_coords(h) ch = getattr(ed, "chainage", None) return { "station": str(nm or ""), "index": int(index), "lat": float(lat), "lon": float(lon), "elev": float(elev), "chainage": _get_number(ch), } def _render_name(template: str, ctx: dict[str, Any]) -> str: return str(template).format_map(_SafeDict(ctx)) def _write_via_backend(ed: Any, path: Path) -> None: """ Best-effort write using common backend spellings. """ # pycsamt EDI backends usually accept `write(new_edifn=...)` try: ed.write(new_edifn=path.name, savepath=path.parent) if path.exists(): return except Exception: pass # Some backends accept a complete destination as `new_edifn`. try: ed.write(new_edifn=str(path)) if path.exists(): return except Exception: pass # or `write(path)` try: ed.write(str(path)) if path.exists(): return except Exception: pass # or `to_file(path)` try: ed.to_file(str(path)) if path.exists(): return except Exception: pass # or `save(path)` try: ed.save(str(path)) if path.exists(): return except Exception: pass raise RuntimeError("Cannot write EDI file for site") def _rows_for_manifest( items: Iterable[tuple[int, Any, Path]], ) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for idx, ed, fp in items: c = _context_for(ed, idx) rows.append( { "index": c["index"], "station": c["station"], "lat": c["lat"], "lon": c["lon"], "elev": c["elev"], "chainage": c["chainage"], "filename": str(fp.name), "path": str(fp), } ) return rows def _write_manifest_csv(rows: list[dict[str, Any]], csv_path: Path) -> None: if not rows: return _ensure_parent(csv_path) fields = [ "index", "station", "lat", "lon", "elev", "chainage", "filename", "path", ] with csv_path.open("w", encoding="utf-8", newline="") as f: w = csv.DictWriter(f, fieldnames=fields) w.writeheader() for r in rows: w.writerow(r) def _iter_any_sites(sites: Any): # Accept Sites wrapper try: from .base import Sites if isinstance(sites, Sites): # Sites exposes .as_list() of EDI-like items for ed in sites.as_list(): yield ed return except Exception: pass # Accept EDICollection (if present) from .utils import as_edicollection coll = as_edicollection(sites) if coll is not None: for ed in coll: yield ed return # Accept generic iterables if hasattr(sites, "__iter__") and not isinstance(sites, (str, bytes)): for ed in sites: yield ed return # Fallback: single item yield sites