Source code for pycsamt.geology.rock_providers

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Pluggable rock-property sources for :class:`~pycsamt.geology.lithology.RockDatabase`.

No public, machine-readable service currently maps a rock or lithology
name directly to a resistivity range (see :mod:`pycsamt.geology.rock_library`
for why the built-in table is a literature compilation rather than a live
fetch). This module instead gives
:meth:`~pycsamt.geology.lithology.RockDatabase.from_url` and
:meth:`~pycsamt.geology.lithology.RockDatabase.from_provider` a small,
source-agnostic contract, so a project- or organisation-controlled
endpoint -- an internal REST API, a JSON file on a shared drive or object
store, or a public service, should one appear -- can be plugged in without
touching :mod:`pycsamt.geology.lithology`.

Every provider implements :class:`RockPropertyProvider`: a zero-argument
``fetch()`` returning ``(entries, metadata)``, a list of
:class:`~pycsamt.geology.lithology.RockEntry` plus a small provenance
dictionary recording where they came from. :class:`RemoteRockPropertyProvider`
caches successful fetches under ``~/.pycsamt/rock_db`` (override with the
``PYCSAMT_ROCKDB_CACHE`` environment variable or the ``cache_dir``
argument, matching the convention already used by
:mod:`pycsamt.ai._zoo`'s model cache) and, on any failure -- network error,
timeout, or a response that does not match the expected schema -- falls
back first to a stale cache entry and then to
:class:`LocalRockPropertyProvider`, rather than raising, unless
``fallback=False``.
"""

from __future__ import annotations

import hashlib
import json
import os
import time
from pathlib import Path
from typing import Any, Protocol, runtime_checkable
from urllib.error import URLError
from urllib.request import urlopen

from ..log.logger import get_logger
from .lithology import RockDatabase, RockEntry

__all__ = [
    "RockPropertyProvider",
    "LocalRockPropertyProvider",
    "RemoteRockPropertyProvider",
    "RockProviderFetchError",
]

_logger = get_logger(__name__)

_REQUIRED_FIELDS = ("name", "rho_min", "rho_max")
_OPTIONAL_FIELDS = ("color", "description", "code", "source")


[docs] class RockProviderFetchError(RuntimeError): """Raised by a provider when a fetch fails and ``fallback=False``. Examples -------- >>> isinstance(RockProviderFetchError("network down"), RuntimeError) True """
[docs] @runtime_checkable class RockPropertyProvider(Protocol): """Minimal contract every rock-property source must satisfy."""
[docs] def fetch(self) -> tuple[list[RockEntry], dict[str, Any]]: """Return ``(entries, metadata)`` for a :class:`RockDatabase`.""" ...
[docs] class LocalRockPropertyProvider: """Provider wrapping the bundled table or a local CSV file. This is what :class:`RemoteRockPropertyProvider` falls back to, and is also usable directly wherever a :class:`RockPropertyProvider` is expected but no remote source is involved. Parameters ---------- csv_path : path-like, optional When given, entries come from :meth:`~pycsamt.geology.lithology.RockDatabase.from_csv`. When omitted, entries come from :meth:`~pycsamt.geology.lithology.RockDatabase.default`. """ def __init__(self, csv_path: str | Path | None = None) -> None: self.csv_path = Path(csv_path) if csv_path is not None else None
[docs] def fetch(self) -> tuple[list[RockEntry], dict[str, Any]]: db = ( RockDatabase.from_csv(self.csv_path) if self.csv_path is not None else RockDatabase.default() ) return list(db.entries), dict(db.metadata)
[docs] class RemoteRockPropertyProvider: """Fetch rock entries as JSON from *url*, with caching and fallback. Parameters ---------- url : str Any URL :func:`urllib.request.urlopen` can open (``http(s)://``, ``file://``, ...), serving a JSON array of objects with at least ``name``, ``rho_min``, ``rho_max``. cache_dir : path-like, optional Override the local cache location. Defaults to ``$PYCSAMT_ROCKDB_CACHE`` or ``~/.pycsamt/rock_db``. ttl_seconds : float Reuse a cached response younger than this many seconds instead of re-fetching. timeout : float Network timeout in seconds for the fetch itself. force : bool Re-fetch even if a fresh cache entry exists. fallback : bool On failure, fall back to a stale cache entry (if any) and then to :class:`LocalRockPropertyProvider`, instead of raising :class:`RockProviderFetchError`. """ def __init__( self, url: str, *, cache_dir: str | Path | None = None, ttl_seconds: float = 86400.0, timeout: float = 10.0, force: bool = False, fallback: bool = True, ) -> None: self.url = url self.cache_dir = cache_dir self.ttl_seconds = float(ttl_seconds) self.timeout = float(timeout) self.force = bool(force) self.fallback = bool(fallback)
[docs] def fetch(self) -> tuple[list[RockEntry], dict[str, Any]]: cache_path = _cache_path_for_url(self.url, self._resolve_cache_dir()) if not self.force: cached = _read_cache(cache_path, max_age_seconds=self.ttl_seconds) if cached is not None: entries = _entries_from_payload(cached["entries"]) return entries, { "origin": "url", "url": self.url, "cache_path": str(cache_path), "cache_hit": True, "fetched_at": cached["fetched_at"], } try: payload = _fetch_json(self.url, timeout=self.timeout) entries = _entries_from_payload(payload) except Exception as exc: # network, timeout, or bad schema _logger.warning( "RockDatabase.from_url: fetch from %r failed (%s); " "falling back.", self.url, exc, ) return self._on_failure(cache_path, exc) fetched_at = time.time() _write_cache(cache_path, payload, fetched_at) return entries, { "origin": "url", "url": self.url, "cache_path": str(cache_path), "cache_hit": False, "fetched_at": fetched_at, }
# ------------------------------------------------------------------ def _resolve_cache_dir(self) -> Path: if self.cache_dir is not None: return Path(self.cache_dir) env = os.environ.get("PYCSAMT_ROCKDB_CACHE") if env: return Path(env) return Path.home() / ".pycsamt" / "rock_db" def _on_failure( self, cache_path: Path, exc: Exception, ) -> tuple[list[RockEntry], dict[str, Any]]: if not self.fallback: raise RockProviderFetchError( f"Failed to fetch rock database from {self.url!r}: {exc}" ) from exc stale = _read_cache(cache_path, max_age_seconds=float("inf")) if stale is not None: entries = _entries_from_payload(stale["entries"]) return entries, { "origin": "url-stale-cache", "url": self.url, "cache_path": str(cache_path), "cache_hit": True, "fetched_at": stale["fetched_at"], "error": str(exc), } entries, metadata = LocalRockPropertyProvider().fetch() metadata = { **metadata, "origin": "default-fallback", "attempted_url": self.url, "error": str(exc), } return entries, metadata
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _cache_path_for_url(url: str, cache_dir: Path) -> Path: digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:32] return cache_dir / f"{digest}.json" def _read_cache(path: Path, *, max_age_seconds: float) -> dict[str, Any] | None: if not path.exists(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): return None fetched_at = payload.get("fetched_at") entries = payload.get("entries") if not isinstance(fetched_at, (int, float)) or not isinstance( entries, list ): return None if time.time() - float(fetched_at) > max_age_seconds: return None return {"fetched_at": float(fetched_at), "entries": entries} def _write_cache(path: Path, entries: list[dict], fetched_at: float) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") tmp.write_text( json.dumps({"fetched_at": fetched_at, "entries": entries}), encoding="utf-8", ) os.replace(tmp, path) def _fetch_json(url: str, *, timeout: float) -> list[dict]: try: with urlopen(url, timeout=timeout) as response: # noqa: S310 raw = response.read() except URLError as exc: raise RockProviderFetchError( f"Could not reach {url!r}: {exc}" ) from exc try: payload = json.loads(raw.decode("utf-8")) except ValueError as exc: raise RockProviderFetchError( f"Response from {url!r} is not valid JSON: {exc}" ) from exc if not isinstance(payload, list): raise RockProviderFetchError( f"Response from {url!r} must be a JSON array of rock entries." ) return payload def _entries_from_payload(payload: list[dict]) -> list[RockEntry]: entries: list[RockEntry] = [] for i, row in enumerate(payload): if not isinstance(row, dict): raise RockProviderFetchError( f"Entry {i} is not a JSON object: {row!r}" ) missing = [f for f in _REQUIRED_FIELDS if f not in row] if missing: raise RockProviderFetchError( f"Entry {i} is missing required field(s) {missing}: {row!r}" ) entries.append( RockEntry( name=str(row["name"]), rho_min=float(row["rho_min"]), rho_max=float(row["rho_max"]), color=str(row.get("color", "#AAAAAA")), description=str(row.get("description", "")), code=int(row.get("code", i + 1)), source=str(row.get("source", "")), ) ) return entries