Skip to content

Commit e700bd2

Browse files
authored
refactor(datasets): swap hand-rolled helpers for shapely + python-slugify (#46)
* refactor(datasets): use shapely + python-slugify instead of hand-rolled helpers - extractors.py: replace custom GeoJSON parsing (_features, _compute_bbox, _iter_coords, _walk) with shapely.geometry.shape().bounds. Drops ~50 LOC of coordinate-walking and edge-case handling. - service.py: delegate slugify() to python-slugify so accented characters transliterate correctly (e.g. "Café" -> "cafe"). The custom regex only stripped non-ASCII. All 54 datasets tests pass. * chore(datasets): ruff format extractors.py
1 parent da3b1a9 commit e700bd2

3 files changed

Lines changed: 30 additions & 62 deletions

File tree

‎modules/datasets/datasets/extractors.py‎

Lines changed: 26 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Best-effort metadata extraction for uploaded datasets.
22
3-
The stdlib JSON path covers GeoJSON without any extra dependency. Optional
4-
``fiona`` and ``rasterio`` extras enrich extraction for shapefiles, KML, and
5-
rasters when available. Every extractor degrades to
6-
``ExtractionStatus.MANUAL`` so the catalog never blocks an upload because
7-
a parser is missing.
3+
GeoJSON is handled with ``shapely`` (a standard geospatial dependency).
4+
Optional ``fiona`` and ``rasterio`` extras enrich extraction for
5+
shapefiles, KML, and rasters when available. Every extractor degrades
6+
to ``ExtractionStatus.MANUAL`` so the catalog never blocks an upload
7+
because a parser is missing.
88
"""
99

1010
from __future__ import annotations
@@ -13,6 +13,8 @@
1313
from dataclasses import dataclass
1414
from pathlib import Path
1515

16+
from shapely.geometry import shape
17+
1618
from datasets import constants
1719
from datasets.constants import DatasetKind, ExtractionStatus
1820

@@ -76,12 +78,28 @@ def _extract_geojson(path: Path) -> ExtractedMeta:
7678
with path.open("rb") as fp:
7779
doc = json.load(fp)
7880

79-
features = _features(doc)
81+
features: list[dict]
82+
if isinstance(doc, dict) and doc.get("type") == "FeatureCollection":
83+
features = [f for f in doc.get("features", []) if isinstance(f, dict)]
84+
elif isinstance(doc, dict) and doc.get("type") == "Feature":
85+
features = [doc]
86+
else:
87+
features = []
88+
8089
bbox = doc.get("bbox") if isinstance(doc, dict) else None
8190
if not bbox:
82-
bbox = _compute_bbox(features)
91+
bounds = [
92+
shape(f["geometry"]).bounds for f in features if isinstance(f.get("geometry"), dict)
93+
]
94+
if bounds:
95+
bbox = (
96+
min(b[0] for b in bounds),
97+
min(b[1] for b in bounds),
98+
max(b[2] for b in bounds),
99+
max(b[3] for b in bounds),
100+
)
83101

84-
if bbox is None:
102+
if not bbox:
85103
return ExtractedMeta(
86104
crs=constants.DEFAULT_GEOJSON_CRS,
87105
feature_count=len(features),
@@ -98,57 +116,6 @@ def _extract_geojson(path: Path) -> ExtractedMeta:
98116
)
99117

100118

101-
def _features(doc: object) -> list[dict]:
102-
if isinstance(doc, dict):
103-
if doc.get("type") == "FeatureCollection":
104-
features = doc.get("features", [])
105-
return [f for f in features if isinstance(f, dict)]
106-
if doc.get("type") == "Feature":
107-
return [doc]
108-
return []
109-
110-
111-
def _compute_bbox(features: list[dict]) -> tuple[float, float, float, float] | None:
112-
min_x = min_y = float("inf")
113-
max_x = max_y = float("-inf")
114-
for feature in features:
115-
geom = feature.get("geometry") if isinstance(feature, dict) else None
116-
if not isinstance(geom, dict):
117-
continue
118-
for x, y in _iter_coords(geom):
119-
min_x = min(min_x, x)
120-
min_y = min(min_y, y)
121-
max_x = max(max_x, x)
122-
max_y = max(max_y, y)
123-
if min_x == float("inf"):
124-
return None
125-
return min_x, min_y, max_x, max_y
126-
127-
128-
def _iter_coords(geom: dict):
129-
coords = geom.get("coordinates")
130-
geom_type = geom.get("type")
131-
if geom_type == "GeometryCollection":
132-
for sub in geom.get("geometries", []):
133-
if isinstance(sub, dict):
134-
yield from _iter_coords(sub)
135-
return
136-
yield from _walk(coords)
137-
138-
139-
def _walk(node: object):
140-
if (
141-
isinstance(node, list)
142-
and len(node) >= 2
143-
and all(isinstance(v, (int, float)) for v in node[:2])
144-
):
145-
yield float(node[0]), float(node[1])
146-
return
147-
if isinstance(node, list):
148-
for child in node:
149-
yield from _walk(child)
150-
151-
152119
def _extract_via_fiona(path: Path) -> ExtractedMeta:
153120
try:
154121
import fiona # type: ignore[import-not-found] # ty: ignore[unresolved-import]

‎modules/datasets/datasets/service.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from typing import Final
1717

1818
from file_storage.contracts.service import StorageBackend, StorageNotFoundError
19+
from slugify import slugify as _slugify
1920
from sqlalchemy import select
2021
from sqlalchemy.ext.asyncio import AsyncSession
2122

@@ -44,16 +45,14 @@ class UploadInput:
4445
kind: str | None = None
4546

4647

47-
_SLUG_RE = re.compile(r"[^a-z0-9]+")
4848
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
4949

5050

5151
_DEFAULT_SLUG: Final = "dataset"
5252

5353

5454
def slugify(value: str) -> str:
55-
slug = _SLUG_RE.sub("-", value.lower()).strip("-")
56-
return slug or _DEFAULT_SLUG
55+
return _slugify(value) or _DEFAULT_SLUG
5756

5857

5958
def safe_filename(name: str) -> str:

‎modules/datasets/pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ dependencies = [
1111
"file-storage",
1212
"background-tasks",
1313
"celery>=5.4",
14+
"shapely>=2.0",
15+
"python-slugify>=8.0",
1416
]
1517

1618
[project.entry-points.simple_module]

0 commit comments

Comments
 (0)