Source code for siege_utilities.geo.data_reception
"""
Format detection and per-format attribute extraction for vector GIS files.
This module provides a small dispatcher that identifies a vector GIS file's
format (shapefile / GeoJSON / KML / KMZ / GeoPackage / DXF / SWMaps / unknown)
and exposes per-format attribute extractors that tolerate missing fields the
way OGR does (return None rather than raise).
Pattern lifted from a third-party tile-server audit
(``common/utils/DataUtils.py:488-563`` — ``_getDataFromSource`` and the
per-format ``_get*DataFromSource`` helpers). Patterns explicitly NOT carried
over from the salvage source:
* Hardcoded Windows binary paths (``osgeo4WBinPath``,
``ODA_FILE_CONVERTER_PATH``) — the salvage shell-out via subprocess for
DWG->DXF conversion is discarded entirely.
* ``osgeo`` Python bindings — the salvage source uses GDAL/OGR directly; SU
declares ``fiona`` (the high-level OGR wrapper) as the geo dep, so this
module is fiona-shaped at the attribute-extraction layer.
* Azure Blob fetch coupling around the dispatch.
* Application-schema-specific extractors (``map_job_id``, etc.) — the
generic extractors here accept candidate field-name lists; consumer code
supplies the schema.
Optional dependencies are guarded; calling an extractor whose backing
library is not installed raises ``ImportError`` with the install hint.
"""
from __future__ import annotations
import logging
import os
import sqlite3
import zipfile
from typing import Any, Iterable
log = logging.getLogger(__name__)
__all__ = [
"MISSING_NONE",
"MISSING_RAISE",
"MISSING_WARN",
"detect_format",
"extract_attributes_kml",
"extract_attributes_ogr",
"extract_attributes_swmaps",
]
# Extension -> format token. Extensions match the dispatcher cases in the
# salvage source plus the SWMaps `.swmz` archive form.
_EXTENSION_MAP = {
".shp": "shapefile",
".geojson": "geojson",
".kml": "kml",
".kmz": "kmz",
".gpkg": "geopackage",
".dxf": "dxf",
".swmz": "swmaps",
}
# Extensions that need magic-byte / archive-peek disambiguation.
_AMBIGUOUS_EXTENSIONS = {".json", ".sqlite", ".db", ".zip"}
# SWMaps SQLite schema fingerprint — these three tables appear together in
# every SWMaps export (verified against salvage-source query at DataUtils.py:687-692).
_SWMAPS_SQLITE_TABLES = frozenset({"features", "points", "feature_layers"})
# Sentinel values for the `missing=` keyword on the extractors.
MISSING_NONE = "none"
MISSING_RAISE = "raise"
MISSING_WARN = "warn"
_MISSING_VALID = {MISSING_NONE, MISSING_RAISE, MISSING_WARN}
[docs]
def detect_format(file_path: str) -> str:
"""Return a format token for a vector GIS file.
Args:
file_path: Path to a vector GIS file on disk.
Returns:
One of: ``"shapefile"``, ``"geojson"``, ``"kml"``, ``"kmz"``,
``"geopackage"``, ``"dxf"``, ``"swmaps"``, ``"unknown"``.
Raises:
FileNotFoundError: If the file does not exist.
Detection algorithm:
1. Extension lookup (case-insensitive) against a small canonical map.
2. For ambiguous extensions (``.json``, ``.sqlite``, ``.db``, ``.zip``),
a magic-byte / archive-peek fallback inspects the file body.
Directories return ``"unknown"`` — walking is the caller's responsibility.
"""
if not os.path.exists(file_path):
raise FileNotFoundError(file_path)
if os.path.isdir(file_path):
return "unknown"
ext = os.path.splitext(file_path)[1].lower()
if ext in _EXTENSION_MAP:
return _EXTENSION_MAP[ext]
if ext not in _AMBIGUOUS_EXTENSIONS:
return "unknown"
# Magic-byte / archive-peek disambiguation.
try:
if ext == ".json":
return _peek_json(file_path)
if ext in {".sqlite", ".db"}:
return _peek_sqlite(file_path)
if ext == ".zip":
return _peek_zip(file_path)
except (OSError, ValueError, UnicodeDecodeError, RuntimeError) as exc: # pragma: no cover - corrupt file fallback
log.warning("detect_format peek failed for %s: %s", file_path, exc)
return "unknown"
def _peek_json(file_path: str) -> str:
"""Return ``"geojson"`` if file body looks like GeoJSON, else ``"unknown"``."""
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
head = fh.read(4096)
except OSError:
return "unknown"
if '"FeatureCollection"' in head or '"Feature"' in head or '"geometry"' in head:
return "geojson"
return "unknown"
def _peek_sqlite(file_path: str) -> str:
"""Return ``"swmaps"`` if SQLite has the SWMaps table fingerprint, else ``"unknown"``."""
try:
with sqlite3.connect(f"file:{file_path}?mode=ro", uri=True, timeout=5.0) as conn:
cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cur.fetchall()}
except sqlite3.DatabaseError:
return "unknown"
if _SWMAPS_SQLITE_TABLES.issubset(tables):
return "swmaps"
return "unknown"
def _peek_zip(file_path: str) -> str:
"""Inspect a ``.zip`` archive's member names to pick a sub-format."""
try:
with zipfile.ZipFile(file_path) as zf:
names = [n.lower() for n in zf.namelist()]
except zipfile.BadZipFile:
return "unknown"
if any(n.endswith(".kml") for n in names):
return "kmz"
if any(n.endswith(".sqlite") or n.endswith(".db") for n in names):
return "swmaps"
if any(n.endswith(".shp") for n in names):
return "shapefile"
return "unknown"
def _resolve_missing(field_name: str, missing: str) -> Any:
"""Apply the ``missing=`` policy uniformly across extractors."""
if missing not in _MISSING_VALID:
raise ValueError(
f"missing= must be one of {sorted(_MISSING_VALID)}; got {missing!r}"
)
if missing == MISSING_RAISE:
raise KeyError(field_name)
if missing == MISSING_WARN:
log.warning("attribute field %r not present on feature", field_name)
return None
[docs]
def extract_attributes_ogr(
feature: Any,
field_names: Iterable[str],
missing: str = MISSING_NONE,
) -> Any:
"""Extract one attribute from a fiona-record-shaped feature.
Mirrors the candidate-list semantics of the salvage-source helper
``_getOGRDataFromSource`` (``DataUtils.py:489-513``) which accepts a
primary plus a fallback field name and returns ``None`` when neither is
present. The salvage source used the GDAL/OGR Python bindings directly;
this implementation is fiona-record-shaped (the high-level OGR wrapper
that SU declares as a geo dep).
Args:
feature: A fiona-style record (``{"properties": {...}, ...}``) OR a
plain ``dict`` (e.g. the property dict directly).
field_names: Candidate field names, in priority order. First name
present on the feature wins.
missing: One of ``"none"`` (default; return ``None``), ``"raise"``
(``KeyError``), or ``"warn"`` (log + return ``None``).
Returns:
The attribute value or ``None``.
"""
if feature is None:
return _resolve_missing(next(iter(field_names), ""), missing)
# fiona record vs bare dict.
if isinstance(feature, dict) and "properties" in feature and isinstance(
feature["properties"], dict
):
props = feature["properties"]
elif isinstance(feature, dict):
props = feature
else:
# Best-effort attribute-style fallback (e.g. an OGR Feature subclass
# in callers that DO have osgeo installed).
props = None
candidates = list(field_names)
if not candidates:
return None
if props is not None:
for name in candidates:
if name in props:
return props[name]
else: # pragma: no cover - osgeo fallback path
for name in candidates:
if hasattr(feature, name):
return getattr(feature, name)
return _resolve_missing(candidates[0], missing)
[docs]
def extract_attributes_kml(
kml_extended_data: dict | None,
field_names: Iterable[str],
missing: str = MISSING_NONE,
) -> Any:
"""Extract one attribute from a KML extended-data mapping.
Mirrors the salvage-source helper ``_getKMLDataFromSource``
(``DataUtils.py:516-520``). The salvage version was a one-liner dict
lookup with no candidate fallback; this version adds the same
candidate-list semantics as :func:`extract_attributes_ogr` for
consistency across the dispatcher.
Args:
kml_extended_data: A flat ``{name: value}`` mapping derived from a
KML ``<ExtendedData>`` block.
field_names: Candidate field names, in priority order.
missing: ``"none"`` / ``"raise"`` / ``"warn"``.
Returns:
The attribute value or ``None``.
"""
candidates = list(field_names)
if not candidates:
return None
if kml_extended_data:
for name in candidates:
if name in kml_extended_data:
return kml_extended_data[name]
return _resolve_missing(candidates[0], missing)
[docs]
def extract_attributes_swmaps(
db_path: str,
feature_id: str,
field_names: Iterable[str],
missing: str = MISSING_NONE,
) -> Any:
"""Extract one attribute for a SWMaps feature by attribute-field name.
Queries the SWMaps SQLite ``attribute_values`` table joined to
``attribute_fields`` for the named field, scoped to ``feature_id``.
This is the same join pattern as the salvage source's
``swmapsAttributeQuery`` (``DataUtils.py:693-698``); the
application-schema-specific mapper (``swMapsAttributeMapper`` dict at
``DataUtils.py:672-686``) is NOT lifted — that lives with the consumer.
For full SWMaps feature iteration (geometry + all attributes), use
:mod:`siege_utilities.geo.swmaps_reader` instead; this function is the
thin dispatcher-friendly accessor.
Args:
db_path: Path to a SWMaps SQLite file (already extracted from
``.swmz`` if needed; archive handling lives in the swmaps_reader
module).
feature_id: SWMaps feature UUID.
field_names: Candidate attribute-field names.
missing: ``"none"`` / ``"raise"`` / ``"warn"``.
Returns:
The attribute value (as stored in ``attribute_values.value``) or
``None``.
"""
candidates = list(field_names)
if not candidates:
return None
query = (
"SELECT af.field_name, av.value "
"FROM attribute_values av "
"INNER JOIN attribute_fields af ON af.uuid = av.field_id "
"WHERE av.item_id = ?"
)
try:
with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0) as conn:
rows = dict(conn.execute(query, (feature_id,)).fetchall())
except sqlite3.DatabaseError as exc:
log.warning("SWMaps query failed for %s: %s", db_path, exc)
return _resolve_missing(candidates[0], missing)
for name in candidates:
if name in rows:
return rows[name]
return _resolve_missing(candidates[0], missing)