Source code for pycsamt.format.borehole.jsonio

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Canonical UTF-8 JSON reader and writer for PCBH 0.1."""

from __future__ import annotations

import json
import logging
import os
import tempfile
from collections.abc import Mapping, Sequence
from os import PathLike
from pathlib import Path
from typing import Any

from ._version import check_pcbh_version
from .schema import (
    PCBH_VERSION,
    Collar,
    CoordinateReferenceSystem,
    LogInterval,
    PCBHBorehole,
    PCBHDocument,
    StructureObservation,
    SurveyStation,
    Trajectory,
    UnitSystem,
    VocabularyEntry,
)

__all__ = [
    "PCBH_SCHEMA_URI",
    "DEFAULT_MAX_BYTES",
    "DEFAULT_MAX_BOREHOLES",
    "DEFAULT_MAX_INTERVALS",
    "DEFAULT_MAX_NESTING",
    "pcbh_to_dict",
    "pcbh_from_dict",
    "read_pcbh",
    "write_pcbh",
]

PCBH_SCHEMA_URI = "https://pycsamt.org/schemas/pcbh/0.1/schema.json"
DEFAULT_MAX_BYTES = 16 * 1024 * 1024
DEFAULT_MAX_BOREHOLES = 10_000
DEFAULT_MAX_INTERVALS = 1_000_000
DEFAULT_MAX_NESTING = 32

logger = logging.getLogger(__name__)


def _positive_limit(name: str, value: int) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
        raise ValueError(f"{name} must be a positive integer")
    return value


def _mapping(value: Any, path: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise ValueError(f"{path} must be a JSON object")
    return value


def _sequence(value: Any, path: str) -> Sequence[Any]:
    if not isinstance(value, list):
        raise ValueError(f"{path} must be a JSON array")
    return value


def _reject_unknown(
    value: Mapping[str, Any], allowed: set[str], path: str
) -> None:
    unknown = sorted(set(value) - allowed)
    if unknown:
        raise ValueError(
            f"{path} contains unsupported field(s): {', '.join(unknown)}"
        )


def _required(value: Mapping[str, Any], name: str, path: str) -> Any:
    if name not in value:
        raise ValueError(f"{path}.{name} is required")
    return value[name]


def _without_none(value: dict[str, Any]) -> dict[str, Any]:
    return {key: item for key, item in value.items() if item is not None}


def _vocabulary_to_dict(entry: VocabularyEntry) -> dict[str, Any]:
    return _without_none(
        {
            "code": entry.code,
            "name": entry.name,
            "color": entry.color,
            "description": entry.description,
            "external_ids": dict(entry.external_ids),
            "properties": dict(entry.properties),
        }
    )


def _station_to_dict(station: SurveyStation) -> dict[str, Any]:
    return {
        "md": station.md,
        "azimuth_deg": station.azimuth_deg,
        "inclination_deg": station.inclination_deg,
    }


def _interval_to_dict(interval: LogInterval) -> dict[str, Any]:
    return _without_none(
        {
            "from_md": interval.from_md,
            "to_md": interval.to_md,
            "code": interval.code,
            "label": interval.label,
            "description": interval.description,
            "resistivity_ohm_m": interval.resistivity_ohm_m,
            "data_nature": interval.data_nature,
            "confidence": interval.confidence,
            "properties": dict(interval.properties),
        }
    )


def _structure_to_dict(
    structure: StructureObservation,
) -> dict[str, Any]:
    return _without_none(
        {
            "kind": structure.kind,
            "at_md": structure.at_md,
            "from_md": structure.from_md,
            "to_md": structure.to_md,
            "orientation_representation": (
                structure.orientation_representation
            ),
            "strike_deg": structure.strike_deg,
            "dip_deg": structure.dip_deg,
            "dip_direction_deg": structure.dip_direction_deg,
            "trend_deg": structure.trend_deg,
            "plunge_deg": structure.plunge_deg,
            "alpha_deg": structure.alpha_deg,
            "beta_deg": structure.beta_deg,
            "aperture_m": structure.aperture_m,
            "fill": structure.fill,
            "data_nature": structure.data_nature,
            "confidence": structure.confidence,
        }
    )


def _borehole_to_dict(borehole: PCBHBorehole) -> dict[str, Any]:
    collar = _without_none(
        {
            "x": borehole.collar.x,
            "y": borehole.collar.y,
            "z": borehole.collar.z,
            "longitude": borehole.collar.longitude,
            "latitude": borehole.collar.latitude,
            "position_uncertainty_m": (borehole.collar.position_uncertainty_m),
        }
    )
    trajectory = {
        "method": borehole.trajectory.method,
        "north_reference": borehole.trajectory.north_reference,
        "desurvey_method": borehole.trajectory.desurvey_method,
        "stations": [
            _station_to_dict(station)
            for station in borehole.trajectory.stations
        ],
    }
    result = _without_none(
        {
            "id": borehole.id,
            "name": borehole.name,
            "kind": borehole.kind,
            "status": borehole.status,
            "aliases": list(borehole.aliases),
            "collar": collar,
            "total_depth_md": borehole.total_depth_md,
            "diameter": borehole.diameter,
            "trajectory": trajectory,
            "interval_logs": {
                family: [_interval_to_dict(item) for item in intervals]
                for family, intervals in borehole.interval_logs.items()
            },
            "structures": [
                _structure_to_dict(item) for item in borehole.structures
            ],
            "metadata": dict(borehole.metadata),
            "extensions": dict(borehole.extensions),
        }
    )
    return result


[docs] def pcbh_to_dict( document: PCBHDocument, *, validate: bool = True ) -> dict[str, Any]: """Convert a PCBH document to its canonical JSON-compatible mapping. Parameters ---------- document : PCBHDocument In-memory document to convert. validate : bool, default True Run semantic validation before conversion. Returns ------- dict Canonically ordered JSON-compatible mapping. Raises ------ TypeError If *document* is not a :class:`PCBHDocument`. PCBHValidationError If semantic validation fails. ValueError If metadata or extension values are not finite JSON values. """ if not isinstance(document, PCBHDocument): raise TypeError("document must be a PCBHDocument") if validate: document.validate() result = { "$schema": PCBH_SCHEMA_URI, "pcbh_version": document.pcbh_version, "document_id": document.document_id, "title": document.title, "description": document.description, "created_at": document.created_at, "created_by": document.created_by, "crs": { "horizontal": document.crs.horizontal, "vertical": document.crs.vertical, "axis_order": document.crs.axis_order, "coordinate_unit": document.crs.coordinate_unit, }, "units": { "depth": document.units.depth, "diameter": document.units.diameter, "angle": document.units.angle, "resistivity": document.units.resistivity, }, "conventions": { "depth_reference": document.depth_reference, "z_positive": document.z_positive, "azimuth_direction": document.azimuth_direction, "inclination_reference": document.inclination_reference, }, "dictionaries": { "lithologies": [ _vocabulary_to_dict(entry) for entry in document.lithologies ], "formations": [ _vocabulary_to_dict(entry) for entry in document.formations ], }, "boreholes": [ _borehole_to_dict(borehole) for borehole in document.boreholes ], "metadata": dict(document.metadata), "extensions": dict(document.extensions), } _check_json_value(result, "$", max_nesting=DEFAULT_MAX_NESTING) return result
def _vocabulary_from_dict(value: Any, path: str) -> VocabularyEntry: item = _mapping(value, path) _reject_unknown( item, { "code", "name", "color", "description", "external_ids", "properties", }, path, ) return VocabularyEntry( code=_required(item, "code", path), name=_required(item, "name", path), color=item.get("color"), description=item.get("description", ""), external_ids=dict(_mapping(item.get("external_ids", {}), path)), properties=dict(_mapping(item.get("properties", {}), path)), ) def _collar_from_dict(value: Any, path: str) -> Collar: item = _mapping(value, path) _reject_unknown( item, { "x", "y", "z", "longitude", "latitude", "position_uncertainty_m", }, path, ) return Collar( x=_required(item, "x", path), y=_required(item, "y", path), z=_required(item, "z", path), longitude=item.get("longitude"), latitude=item.get("latitude"), position_uncertainty_m=item.get("position_uncertainty_m"), ) def _trajectory_from_dict(value: Any, path: str) -> Trajectory: item = _mapping(value, path) _reject_unknown( item, {"method", "north_reference", "desurvey_method", "stations"}, path, ) stations = [] station_values = _sequence(item.get("stations", []), f"{path}.stations") for index, station in enumerate(station_values): station_path = f"{path}.stations[{index}]" station_item = _mapping(station, station_path) _reject_unknown( station_item, {"md", "azimuth_deg", "inclination_deg"}, station_path, ) stations.append( SurveyStation( md=_required(station_item, "md", station_path), azimuth_deg=_required( station_item, "azimuth_deg", station_path ), inclination_deg=_required( station_item, "inclination_deg", station_path ), ) ) return Trajectory( method=_required(item, "method", path), north_reference=_required(item, "north_reference", path), desurvey_method=_required(item, "desurvey_method", path), stations=stations, ) def _interval_from_dict(value: Any, path: str) -> LogInterval: item = _mapping(value, path) _reject_unknown( item, { "from_md", "to_md", "code", "label", "description", "resistivity_ohm_m", "data_nature", "confidence", "properties", }, path, ) return LogInterval( from_md=_required(item, "from_md", path), to_md=_required(item, "to_md", path), code=item.get("code"), label=item.get("label"), description=item.get("description", ""), resistivity_ohm_m=item.get("resistivity_ohm_m"), data_nature=item.get("data_nature", "unknown"), confidence=item.get("confidence"), properties=dict(_mapping(item.get("properties", {}), path)), ) def _structure_from_dict(value: Any, path: str) -> StructureObservation: item = _mapping(value, path) fields = { "kind", "at_md", "from_md", "to_md", "orientation_representation", "strike_deg", "dip_deg", "dip_direction_deg", "trend_deg", "plunge_deg", "alpha_deg", "beta_deg", "aperture_m", "fill", "data_nature", "confidence", } _reject_unknown(item, fields, path) return StructureObservation( kind=_required(item, "kind", path), at_md=item.get("at_md"), from_md=item.get("from_md"), to_md=item.get("to_md"), orientation_representation=item.get( "orientation_representation", "none" ), strike_deg=item.get("strike_deg"), dip_deg=item.get("dip_deg"), dip_direction_deg=item.get("dip_direction_deg"), trend_deg=item.get("trend_deg"), plunge_deg=item.get("plunge_deg"), alpha_deg=item.get("alpha_deg"), beta_deg=item.get("beta_deg"), aperture_m=item.get("aperture_m"), fill=item.get("fill"), data_nature=item.get("data_nature", "unknown"), confidence=item.get("confidence"), ) def _borehole_from_dict(value: Any, path: str) -> PCBHBorehole: item = _mapping(value, path) _reject_unknown( item, { "id", "name", "kind", "status", "aliases", "collar", "total_depth_md", "diameter", "trajectory", "interval_logs", "structures", "metadata", "extensions", }, path, ) logs_raw = _mapping(item.get("interval_logs", {}), f"{path}.interval_logs") interval_logs = { str(family): [ _interval_from_dict( interval, f"{path}.interval_logs.{family}[{index}]" ) for index, interval in enumerate( _sequence(intervals, f"{path}.interval_logs.{family}") ) ] for family, intervals in logs_raw.items() } return PCBHBorehole( id=_required(item, "id", path), name=_required(item, "name", path), kind=_required(item, "kind", path), status=_required(item, "status", path), aliases=list(_sequence(item.get("aliases", []), f"{path}.aliases")), collar=_collar_from_dict( _required(item, "collar", path), f"{path}.collar" ), total_depth_md=_required(item, "total_depth_md", path), diameter=item.get("diameter"), trajectory=_trajectory_from_dict( _required(item, "trajectory", path), f"{path}.trajectory" ), interval_logs=interval_logs, structures=[ _structure_from_dict(structure, f"{path}.structures[{index}]") for index, structure in enumerate( _sequence(item.get("structures", []), f"{path}.structures") ) ], metadata=dict(_mapping(item.get("metadata", {}), f"{path}.metadata")), extensions=dict( _mapping(item.get("extensions", {}), f"{path}.extensions") ), )
[docs] def pcbh_from_dict( value: Mapping[str, Any], *, validate: bool = True, max_boreholes: int = DEFAULT_MAX_BOREHOLES, max_intervals: int = DEFAULT_MAX_INTERVALS, max_nesting: int = DEFAULT_MAX_NESTING, ) -> PCBHDocument: """Build a PCBH document from a decoded JSON mapping. Parameters ---------- value : mapping Decoded PCBH root object. validate : bool, default True Run semantic validation before returning. max_boreholes, max_intervals, max_nesting : int Positive resource limits applied before object construction. Returns ------- PCBHDocument Parsed in-memory document. Raises ------ ValueError If structure, version, or resource limits are invalid. PCBHValidationError If semantic validation fails. """ max_boreholes = _positive_limit("max_boreholes", max_boreholes) max_intervals = _positive_limit("max_intervals", max_intervals) max_nesting = _positive_limit("max_nesting", max_nesting) root = _mapping(value, "$") _check_json_value(root, "$", max_nesting=max_nesting) _reject_unknown( root, { "$schema", "pcbh_version", "document_id", "title", "description", "created_at", "created_by", "crs", "units", "conventions", "dictionaries", "boreholes", "metadata", "extensions", }, "$", ) version = _required(root, "pcbh_version", "$") check_pcbh_version(version, PCBH_VERSION) boreholes_raw = _sequence(_required(root, "boreholes", "$"), "$.boreholes") if len(boreholes_raw) > max_boreholes: raise ValueError( f"PCBH contains {len(boreholes_raw)} boreholes; limit is " f"{max_boreholes}" ) interval_count = _count_intervals(boreholes_raw) if interval_count > max_intervals: raise ValueError( f"PCBH contains {interval_count} intervals; limit is " f"{max_intervals}" ) crs_raw = _mapping(_required(root, "crs", "$"), "$.crs") _reject_unknown( crs_raw, {"horizontal", "vertical", "axis_order", "coordinate_unit"}, "$.crs", ) units_raw = _mapping(_required(root, "units", "$"), "$.units") _reject_unknown( units_raw, {"depth", "diameter", "angle", "resistivity"}, "$.units", ) conventions = _mapping( _required(root, "conventions", "$"), "$.conventions" ) _reject_unknown( conventions, { "depth_reference", "z_positive", "azimuth_direction", "inclination_reference", }, "$.conventions", ) dictionaries = _mapping(root.get("dictionaries", {}), "$.dictionaries") _reject_unknown( dictionaries, {"lithologies", "formations"}, "$.dictionaries" ) document = PCBHDocument( pcbh_version=version, document_id=_required(root, "document_id", "$"), title=root.get("title", ""), description=root.get("description", ""), created_at=_required(root, "created_at", "$"), created_by=_required(root, "created_by", "$"), crs=CoordinateReferenceSystem( horizontal=_required(crs_raw, "horizontal", "$.crs"), vertical=_required(crs_raw, "vertical", "$.crs"), axis_order=_required(crs_raw, "axis_order", "$.crs"), coordinate_unit=_required(crs_raw, "coordinate_unit", "$.crs"), ), units=UnitSystem( depth=_required(units_raw, "depth", "$.units"), diameter=_required(units_raw, "diameter", "$.units"), angle=_required(units_raw, "angle", "$.units"), resistivity=_required(units_raw, "resistivity", "$.units"), ), depth_reference=_required( conventions, "depth_reference", "$.conventions" ), z_positive=_required(conventions, "z_positive", "$.conventions"), azimuth_direction=_required( conventions, "azimuth_direction", "$.conventions" ), inclination_reference=_required( conventions, "inclination_reference", "$.conventions" ), lithologies=[ _vocabulary_from_dict(item, f"$.dictionaries.lithologies[{index}]") for index, item in enumerate( _sequence( dictionaries.get("lithologies", []), "$.dictionaries.lithologies", ) ) ], formations=[ _vocabulary_from_dict(item, f"$.dictionaries.formations[{index}]") for index, item in enumerate( _sequence( dictionaries.get("formations", []), "$.dictionaries.formations", ) ) ], boreholes=[ _borehole_from_dict(item, f"$.boreholes[{index}]") for index, item in enumerate(boreholes_raw) ], metadata=dict(_mapping(root.get("metadata", {}), "$.metadata")), extensions=dict(_mapping(root.get("extensions", {}), "$.extensions")), ) if validate: document.validate() return document
def _count_intervals(boreholes: Sequence[Any]) -> int: count = 0 for index, value in enumerate(boreholes): hole = _mapping(value, f"$.boreholes[{index}]") logs = _mapping( hole.get("interval_logs", {}), f"$.boreholes[{index}].interval_logs", ) for family, intervals in logs.items(): count += len( _sequence( intervals, f"$.boreholes[{index}].interval_logs.{family}", ) ) return count def _check_json_value(value: Any, path: str, *, max_nesting: int) -> None: if max_nesting < 0: raise ValueError(f"JSON nesting exceeds configured limit at {path}") if value is None or isinstance(value, (str, bool, int)): return if isinstance(value, float): if value != value or value in (float("inf"), float("-inf")): raise ValueError(f"non-finite JSON number at {path}") return if isinstance(value, Mapping): for key, item in value.items(): if not isinstance(key, str): raise ValueError(f"JSON object key at {path} must be a string") _check_json_value( item, f"{path}.{key}", max_nesting=max_nesting - 1, ) return if isinstance(value, (list, tuple)): for index, item in enumerate(value): _check_json_value( item, f"{path}[{index}]", max_nesting=max_nesting - 1, ) return raise ValueError( f"value at {path} is not JSON serializable: {type(value).__name__}" ) def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for key, value in pairs: if key in result: raise ValueError(f"duplicate JSON object key {key!r}") result[key] = value return result
[docs] def read_pcbh( path: str | PathLike[str], *, validate: bool = True, max_bytes: int = DEFAULT_MAX_BYTES, max_boreholes: int = DEFAULT_MAX_BOREHOLES, max_intervals: int = DEFAULT_MAX_INTERVALS, max_nesting: int = DEFAULT_MAX_NESTING, ) -> PCBHDocument: """Read a canonical PCBH JSON file. Parameters ---------- path : path-like Input `.pcbh.json` file. validate : bool, default True Run semantic validation before returning. max_bytes, max_boreholes, max_intervals, max_nesting : int Positive limits for untrusted input. Returns ------- PCBHDocument Parsed and optionally validated document. Raises ------ OSError If the file cannot be read. UnicodeError If the file is not UTF-8. ValueError If JSON, structure, version, or limits are invalid. PCBHValidationError If semantic validation fails. """ max_bytes = _positive_limit("max_bytes", max_bytes) source = Path(path) size = source.stat().st_size if size > max_bytes: raise ValueError( f"PCBH file is {size} bytes; max_bytes is {max_bytes}" ) raw = source.read_bytes() if len(raw) > max_bytes: raise ValueError( f"PCBH file is {len(raw)} bytes; max_bytes is {max_bytes}" ) try: value = json.loads( raw.decode("utf-8"), object_pairs_hook=_unique_object ) except json.JSONDecodeError as exc: raise ValueError( f"{source} is not valid PCBH JSON: {exc.msg} at line " f"{exc.lineno}, column {exc.colno}" ) from exc document = pcbh_from_dict( value, validate=validate, max_boreholes=max_boreholes, max_intervals=max_intervals, max_nesting=max_nesting, ) logger.debug("Read PCBH document %s from %s", document.document_id, source) return document
[docs] def write_pcbh( document: PCBHDocument, path: str | PathLike[str], *, validate: bool = True, indent: int = 2, ) -> Path: """Atomically write a canonical PCBH JSON file. Parameters ---------- document : PCBHDocument Document to serialize. path : path-like Destination path. Parent directories are created when needed. validate : bool, default True Run semantic validation before writing. indent : int, default 2 Positive JSON indentation width. Returns ------- pathlib.Path Destination path. Raises ------ TypeError If *document* is not a PCBH document. ValueError If validation, JSON values, or *indent* are invalid. OSError If the atomic write fails. Notes ----- The temporary file is created beside the destination so :func:`os.replace` remains an atomic same-filesystem operation. """ if isinstance(indent, bool) or not isinstance(indent, int) or indent <= 0: raise ValueError("indent must be a positive integer") value = pcbh_to_dict(document, validate=validate) text = ( json.dumps( value, ensure_ascii=False, allow_nan=False, indent=indent, sort_keys=False, ) + "\n" ) destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) temporary: Path | None = None try: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", newline="\n", prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent, delete=False, ) as handle: temporary = Path(handle.name) handle.write(text) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, destination) except Exception: if temporary is not None: temporary.unlink(missing_ok=True) raise logger.debug( "Wrote PCBH document %s to %s", document.document_id, destination ) return destination