env-able 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
env_able/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ __version__ = "0.2.0"
2
+
3
+ from .vector import Intersect, Buffer, Clip
4
+ from .morph import morph
5
+
6
+
7
+ def main():
8
+ print(f"env-able | ready (v{__version__})")
9
+
10
+
11
+ main()
env_able/morph.py ADDED
@@ -0,0 +1,680 @@
1
+ """
2
+ env_able.morph — universal format translation layer
3
+
4
+ e.morph(input_path, output_path, **kwargs)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import warnings
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ import geopandas as gpd
17
+ import pandas as pd
18
+ import fiona
19
+
20
+
21
+ # ── Format registry ──────────────────────────────────────────────────────────
22
+
23
+ _EXT_TO_FORMAT: Dict[str, str] = {
24
+ ".shp": "shp",
25
+ ".gpkg": "gpkg",
26
+ ".gdb": "gdb",
27
+ ".csv": "csv",
28
+ ".xlsx": "xlsx",
29
+ ".xls": "xls",
30
+ ".dbf": "dbf",
31
+ ".geojson": "geojson",
32
+ ".json": "json",
33
+ }
34
+
35
+ _FORMAT_TO_DRIVER: Dict[str, Optional[str]] = {
36
+ "shp": "ESRI Shapefile",
37
+ "gpkg": "GPKG",
38
+ "gdb": "OpenFileGDB",
39
+ "csv": None,
40
+ "xlsx": None,
41
+ "xls": None,
42
+ "dbf": "ESRI Shapefile",
43
+ "geojson": "GeoJSON",
44
+ "json": None,
45
+ }
46
+
47
+ _SPATIAL_FORMATS = {"shp", "gpkg", "gdb", "geojson"}
48
+ _TABULAR_FORMATS = {"csv", "xlsx", "xls", "dbf"}
49
+ _PANDAS_FORMATS = {"csv", "xlsx", "xls"}
50
+ _MULTI_LAYER_FORMATS = {"gpkg", "gdb"}
51
+
52
+ _GEOJSON_CRS = "EPSG:4326"
53
+
54
+ _WKT_COL_NAMES = {"geometry", "geom", "wkt", "shape", "geo", "wkt_geom", "the_geom"}
55
+ _LAT_COL_NAMES = {"lat", "latitude", "y", "ylat", "y_coord", "ycoord", "y_lat"}
56
+ _LON_COL_NAMES = {"lon", "long", "longitude", "x", "xlon", "x_coord", "xcoord", "x_lon"}
57
+
58
+
59
+ # ── Utilities ─────────────────────────────────────────────────────────────────
60
+
61
+ def _sanitize_name(name: str, max_len: int = 255) -> str:
62
+ name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
63
+ name = name.strip(". ")
64
+ return name[:max_len] or "layer"
65
+
66
+
67
+ def _safe_shp_fields(columns: List[str]) -> Dict[str, str]:
68
+ """Produce original → truncated mapping for shapefile 10-char field limit."""
69
+ seen: Dict[str, int] = {}
70
+ result: Dict[str, str] = {}
71
+ for col in columns:
72
+ safe = re.sub(r"[^A-Za-z0-9_]", "_", col)[:10]
73
+ if not safe or safe[0].isdigit():
74
+ safe = ("F_" + safe)[:10]
75
+ base = safe
76
+ counter = 0
77
+ while safe in seen and seen[safe] != col:
78
+ counter += 1
79
+ suffix = str(counter)
80
+ safe = base[: 10 - len(suffix)] + suffix
81
+ seen[safe] = col
82
+ if safe != col:
83
+ result[col] = safe
84
+ return result
85
+
86
+
87
+ def _detect_format(path: Path) -> Optional[str]:
88
+ return _EXT_TO_FORMAT.get(path.suffix.lower())
89
+
90
+
91
+ def _list_fiona_layers(path: Path) -> List[str]:
92
+ try:
93
+ return fiona.listlayers(str(path))
94
+ except Exception:
95
+ return []
96
+
97
+
98
+ def _looks_like_wkt(value: Any) -> bool:
99
+ if not isinstance(value, str):
100
+ return False
101
+ s = value.strip().upper()
102
+ return any(s.startswith(k) for k in (
103
+ "POINT", "LINESTRING", "POLYGON", "MULTIPOINT",
104
+ "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION",
105
+ ))
106
+
107
+
108
+ def _looks_like_lat(series: pd.Series) -> bool:
109
+ try:
110
+ num = pd.to_numeric(series.dropna(), errors="coerce").dropna()
111
+ return len(num) > 0 and num.between(-90, 90).all()
112
+ except Exception:
113
+ return False
114
+
115
+
116
+ def _looks_like_lon(series: pd.Series) -> bool:
117
+ try:
118
+ num = pd.to_numeric(series.dropna(), errors="coerce").dropna()
119
+ return len(num) > 0 and num.between(-180, 180).all()
120
+ except Exception:
121
+ return False
122
+
123
+
124
+ # ── Smart geometry detection ──────────────────────────────────────────────────
125
+
126
+ @dataclass
127
+ class _GeomHint:
128
+ kind: str # "wkt" | "xy" | "none"
129
+ wkt_col: Optional[str] = None
130
+ x_col: Optional[str] = None
131
+ y_col: Optional[str] = None
132
+ confidence: str = "low" # "high" | "medium" | "low"
133
+
134
+ @property
135
+ def found(self) -> bool:
136
+ return self.kind != "none"
137
+
138
+
139
+ def _detect_geometry(df: pd.DataFrame) -> _GeomHint:
140
+ """
141
+ Scan a DataFrame for geometry columns without user hints.
142
+
143
+ Priority:
144
+ 1. Column name matches known WKT names → confirm by sampling values
145
+ 2. Column name matches known lat/lon names → confirm by value range
146
+ 3. Any column whose values parse as WKT
147
+ 4. Any numeric pair that looks like lat/lon by value range
148
+ """
149
+ cols_lower = {c: c.lower().strip() for c in df.columns}
150
+
151
+ # ── pass 1: name-based WKT match ─────────────────────────────────────────
152
+ for col, lower in cols_lower.items():
153
+ if lower in _WKT_COL_NAMES:
154
+ sample = df[col].dropna().iloc[0] if not df[col].dropna().empty else None
155
+ if sample and _looks_like_wkt(sample):
156
+ return _GeomHint(kind="wkt", wkt_col=col, confidence="high")
157
+
158
+ # ── pass 2: name-based lat/lon match ─────────────────────────────────────
159
+ lat_col = next((c for c, l in cols_lower.items() if l in _LAT_COL_NAMES), None)
160
+ lon_col = next((c for c, l in cols_lower.items() if l in _LON_COL_NAMES), None)
161
+ if lat_col and lon_col:
162
+ return _GeomHint(kind="xy", x_col=lon_col, y_col=lat_col, confidence="high")
163
+
164
+ # ── pass 3: value-based WKT scan ─────────────────────────────────────────
165
+ for col in df.columns:
166
+ sample = df[col].dropna().iloc[0] if not df[col].dropna().empty else None
167
+ if sample and _looks_like_wkt(sample):
168
+ return _GeomHint(kind="wkt", wkt_col=col, confidence="medium")
169
+
170
+ # ── pass 4: value-based lat/lon scan ─────────────────────────────────────
171
+ numeric_cols = df.select_dtypes(include="number").columns.tolist()
172
+ lat_candidates = [c for c in numeric_cols if _looks_like_lat(df[c])]
173
+ lon_candidates = [c for c in numeric_cols if _looks_like_lon(df[c])]
174
+
175
+ if lat_candidates and lon_candidates:
176
+ y = lat_candidates[0]
177
+ x = next((c for c in lon_candidates if c != y), None)
178
+ if x:
179
+ return _GeomHint(kind="xy", x_col=x, y_col=y, confidence="medium")
180
+
181
+ return _GeomHint(kind="none")
182
+
183
+
184
+ # ── InputSpec ─────────────────────────────────────────────────────────────────
185
+
186
+ @dataclass
187
+ class InputSpec:
188
+ path: Path
189
+ format: str
190
+ driver: Optional[str]
191
+ is_spatial: bool
192
+ is_multi_layer: bool
193
+ layers: List[str]
194
+ raw_path: str
195
+
196
+ @classmethod
197
+ def from_path(cls, raw: str) -> "InputSpec":
198
+ path = Path(raw)
199
+
200
+ if not path.exists():
201
+ raise FileNotFoundError(
202
+ f"env-able | morph: input not found — '{raw}'\n"
203
+ f" Check the path and make sure the file or folder exists."
204
+ )
205
+
206
+ fmt = _detect_format(path)
207
+ if fmt is None:
208
+ raise ValueError(
209
+ f"env-able | morph: unrecognised input format for '{raw}'.\n"
210
+ f" Supported: shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json"
211
+ )
212
+
213
+ driver = _FORMAT_TO_DRIVER.get(fmt)
214
+ is_spatial = fmt in _SPATIAL_FORMATS
215
+ is_multi = fmt in _MULTI_LAYER_FORMATS
216
+
217
+ layers: List[str] = []
218
+ if is_multi:
219
+ layers = _list_fiona_layers(path)
220
+ if not layers:
221
+ raise ValueError(
222
+ f"env-able | morph: no layers found in '{raw}'.\n"
223
+ f" The file may be empty or corrupted."
224
+ )
225
+ else:
226
+ layers = [path.stem]
227
+
228
+ if fmt == "json":
229
+ is_spatial = _json_is_geojson(path)
230
+
231
+ return cls(
232
+ path=path, format=fmt, driver=driver,
233
+ is_spatial=is_spatial, is_multi_layer=is_multi,
234
+ layers=layers, raw_path=raw,
235
+ )
236
+
237
+
238
+ def _json_is_geojson(path: Path) -> bool:
239
+ try:
240
+ with open(path, "r", encoding="utf-8") as f:
241
+ data = json.load(f)
242
+ return isinstance(data, dict) and data.get("type") in (
243
+ "FeatureCollection", "Feature",
244
+ "Point", "LineString", "Polygon",
245
+ "MultiPoint", "MultiLineString", "MultiPolygon",
246
+ )
247
+ except Exception:
248
+ return False
249
+
250
+
251
+ # ── OutputSpec ────────────────────────────────────────────────────────────────
252
+
253
+ @dataclass
254
+ class OutputSpec:
255
+ path: Path
256
+ format: str
257
+ driver: Optional[str]
258
+ is_spatial: bool
259
+ is_directory: bool
260
+ multiple_outputs: bool
261
+ layer_name: str
262
+ name: str
263
+ overwrite: bool = True
264
+ crs: Optional[str] = None
265
+ epsg: Optional[int] = None
266
+ geometry_type: Optional[str] = None
267
+ has_z: bool = False
268
+ has_m: bool = False
269
+ raw_path: str = ""
270
+
271
+ @classmethod
272
+ def from_path(cls, raw: str, inp: InputSpec, **kwargs) -> "OutputSpec":
273
+ path = Path(raw)
274
+ is_dir = raw.endswith("/") or raw.endswith("\\") or not path.suffix
275
+
276
+ if is_dir:
277
+ multiple = inp.is_multi_layer or len(inp.layers) > 1
278
+ return cls(
279
+ path=path, format="dir", driver=None,
280
+ is_spatial=inp.is_spatial, is_directory=True,
281
+ multiple_outputs=multiple, layer_name="", name=path.name,
282
+ raw_path=raw, crs=kwargs.get("crs"),
283
+ )
284
+
285
+ fmt = _detect_format(path)
286
+ if fmt is None:
287
+ raise ValueError(
288
+ f"env-able | morph: unrecognised output format for '{raw}'.\n"
289
+ f" Supported: shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json"
290
+ )
291
+
292
+ driver = _FORMAT_TO_DRIVER.get(fmt)
293
+ is_spatial = fmt in _SPATIAL_FORMATS
294
+
295
+ parts = path.stem.split(".")
296
+ if len(parts) >= 2:
297
+ name = _sanitize_name(parts[0])
298
+ layer_name = _sanitize_name(parts[-1])
299
+ else:
300
+ name = _sanitize_name(path.stem)
301
+ layer_name = name
302
+
303
+ multiple = (
304
+ (inp.is_multi_layer or len(inp.layers) > 1)
305
+ and fmt not in _MULTI_LAYER_FORMATS
306
+ )
307
+
308
+ crs = kwargs.get("crs")
309
+ if fmt == "geojson" and not crs:
310
+ crs = _GEOJSON_CRS
311
+
312
+ epsg: Optional[int] = None
313
+ if crs and str(crs).upper().startswith("EPSG:"):
314
+ try:
315
+ epsg = int(str(crs).split(":")[1])
316
+ except ValueError:
317
+ pass
318
+
319
+ return cls(
320
+ path=path, format=fmt, driver=driver,
321
+ is_spatial=is_spatial, is_directory=False,
322
+ multiple_outputs=multiple, layer_name=layer_name, name=name,
323
+ raw_path=raw, crs=crs, epsg=epsg,
324
+ )
325
+
326
+
327
+ # ── Reader ────────────────────────────────────────────────────────────────────
328
+
329
+ class _Reader:
330
+ def __init__(self, spec: InputSpec):
331
+ self.spec = spec
332
+
333
+ def spatial(self, layer: Optional[str] = None) -> gpd.GeoDataFrame:
334
+ s = self.spec
335
+ if s.format in {"shp", "dbf"}:
336
+ return gpd.read_file(str(s.path))
337
+ if s.format in {"gpkg", "gdb"}:
338
+ return gpd.read_file(str(s.path), layer=layer)
339
+ if s.format in {"geojson", "json"}:
340
+ return gpd.read_file(str(s.path))
341
+ raise ValueError(f"env-able | morph: cannot read '{s.format}' as spatial.")
342
+
343
+ def tabular(self) -> pd.DataFrame:
344
+ s = self.spec
345
+ if s.format == "csv":
346
+ return pd.read_csv(str(s.path))
347
+ if s.format == "xlsx":
348
+ return pd.read_excel(str(s.path), engine="openpyxl")
349
+ if s.format == "xls":
350
+ return pd.read_excel(str(s.path), engine="xlrd")
351
+ if s.format == "dbf":
352
+ try:
353
+ import dbfread
354
+ return pd.DataFrame(iter(dbfread.DBF(str(s.path))))
355
+ except ImportError:
356
+ gdf = gpd.read_file(str(s.path))
357
+ return pd.DataFrame(gdf.drop(columns="geometry", errors="ignore"))
358
+ if s.format == "json":
359
+ return pd.read_json(str(s.path))
360
+ raise ValueError(f"env-able | morph: cannot read '{s.format}' as tabular.")
361
+
362
+
363
+ # ── Writer ────────────────────────────────────────────────────────────────────
364
+
365
+ class _Writer:
366
+ def __init__(self, spec: OutputSpec):
367
+ self.spec = spec
368
+
369
+ def _ensure_dir(self, path: Path):
370
+ path.mkdir(parents=True, exist_ok=True)
371
+
372
+ def _apply_crs(self, gdf: gpd.GeoDataFrame, fmt: Optional[str] = None) -> gpd.GeoDataFrame:
373
+ target_crs = self.spec.crs
374
+ if fmt == "geojson":
375
+ target_crs = _GEOJSON_CRS
376
+ if not target_crs:
377
+ return gdf
378
+ if gdf.crs is None:
379
+ return gdf.set_crs(target_crs)
380
+ if str(gdf.crs) != str(target_crs):
381
+ return gdf.to_crs(target_crs)
382
+ return gdf
383
+
384
+ def _fix_shp_fields(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
385
+ non_geom = [c for c in gdf.columns if c != "geometry"]
386
+ renames = _safe_shp_fields(non_geom)
387
+ if renames:
388
+ warnings.warn(
389
+ "env-able | morph: shapefile field names truncated to 10 chars:\n"
390
+ + "\n".join(f" '{k}' → '{v}'" for k, v in renames.items()),
391
+ UserWarning, stacklevel=5,
392
+ )
393
+ gdf = gdf.rename(columns=renames)
394
+ return gdf
395
+
396
+ def spatial(self, gdf: gpd.GeoDataFrame, dest: Path, layer: str):
397
+ fmt = _detect_format(dest) if self.spec.is_directory else self.spec.format
398
+ gdf = self._apply_crs(gdf, fmt)
399
+ self._ensure_dir(dest.parent)
400
+
401
+ if fmt == "shp":
402
+ gdf = self._fix_shp_fields(gdf)
403
+ gdf.to_file(str(dest), driver="ESRI Shapefile")
404
+
405
+ elif fmt == "gpkg":
406
+ gdf.to_file(str(dest), layer=layer, driver="GPKG")
407
+
408
+ elif fmt == "gdb":
409
+ try:
410
+ gdf.to_file(str(dest), layer=layer, driver="OpenFileGDB")
411
+ except Exception as e:
412
+ raise IOError(
413
+ f"env-able | morph: GDB write failed for '{dest}'.\n"
414
+ f" Your GDAL build may not support GDB creation.\n"
415
+ f" Try outputting to GPKG instead.\n"
416
+ f" Original error: {e}"
417
+ )
418
+
419
+ elif fmt in {"geojson", "json"}:
420
+ gdf.to_file(str(dest), driver="GeoJSON")
421
+
422
+ else:
423
+ raise ValueError(
424
+ f"env-able | morph: cannot write spatial data to '{fmt}'.\n"
425
+ f" Spatial output formats: shp, gpkg, gdb, geojson, json"
426
+ )
427
+
428
+ def tabular(self, df: pd.DataFrame, dest: Path):
429
+ fmt = _detect_format(dest) if self.spec.is_directory else self.spec.format
430
+ self._ensure_dir(dest.parent)
431
+
432
+ if fmt == "csv":
433
+ df.to_csv(str(dest), index=False)
434
+ elif fmt == "xlsx":
435
+ df.to_excel(str(dest), index=False, engine="openpyxl")
436
+ elif fmt == "xls":
437
+ warnings.warn(
438
+ "env-able | morph: .xls is a legacy format — consider .xlsx.",
439
+ UserWarning, stacklevel=5,
440
+ )
441
+ df.to_excel(str(dest), index=False)
442
+ elif fmt in {"json", "geojson"}:
443
+ df.to_json(str(dest), orient="records", indent=2)
444
+ elif fmt == "dbf":
445
+ raise NotImplementedError(
446
+ "env-able | morph: writing standalone .dbf is not supported.\n"
447
+ " Try CSV or XLSX instead."
448
+ )
449
+ else:
450
+ raise ValueError(f"env-able | morph: cannot write tabular data to '{fmt}'.")
451
+
452
+
453
+ # ── MorphEngine ───────────────────────────────────────────────────────────────
454
+
455
+ class MorphEngine:
456
+ def __init__(self, inp: InputSpec, out: OutputSpec, kwargs: Dict[str, Any]):
457
+ self.inp = inp
458
+ self.out = out
459
+ self.kwargs = kwargs
460
+ self.reader = _Reader(inp)
461
+ self.writer = _Writer(out)
462
+
463
+ def _resolve_output(self, layer: str) -> Tuple[Path, str]:
464
+ out = self.out
465
+ safe = _sanitize_name(layer)
466
+
467
+ if out.is_directory:
468
+ ext = ".gpkg" if self.inp.is_spatial else ".csv"
469
+ return out.path / f"{safe}{ext}", safe
470
+
471
+ if out.format in _MULTI_LAYER_FORMATS:
472
+ return out.path, safe
473
+
474
+ if out.multiple_outputs:
475
+ return out.path.parent / f"{safe}{out.path.suffix}", safe
476
+
477
+ return out.path, out.layer_name or safe
478
+
479
+ def _build_geometry(self, df: pd.DataFrame) -> gpd.GeoDataFrame:
480
+ x_col = self.kwargs.get("x_col")
481
+ y_col = self.kwargs.get("y_col")
482
+ wkt_col = self.kwargs.get("wkt_col")
483
+ crs = self.kwargs.get("crs") or self.out.crs
484
+
485
+ if wkt_col or (x_col and y_col):
486
+ return self._build_from_explicit(df, x_col, y_col, wkt_col, crs)
487
+
488
+ hint = _detect_geometry(df)
489
+
490
+ if not hint.found:
491
+ raise ValueError(
492
+ "env-able | morph: could not find geometry columns in the source data.\n"
493
+ " Scanned for common WKT and lat/lon column names — none matched.\n"
494
+ " Provide explicit columns:\n"
495
+ " e.morph(..., x_col='LON', y_col='LAT', crs='EPSG:4326')\n"
496
+ " e.morph(..., wkt_col='GEOMETRY', crs='EPSG:4326')"
497
+ )
498
+
499
+ confidence_note = "" if hint.confidence == "high" else (
500
+ f"\n Confidence: {hint.confidence} — verify the output looks correct."
501
+ )
502
+
503
+ if hint.kind == "wkt":
504
+ print(f"env-able | morph: auto-detected WKT column '{hint.wkt_col}'.{confidence_note}")
505
+ return self._build_from_explicit(df, None, None, hint.wkt_col, crs)
506
+
507
+ if hint.kind == "xy":
508
+ print(
509
+ f"env-able | morph: auto-detected coordinate columns "
510
+ f"x='{hint.x_col}' y='{hint.y_col}'.{confidence_note}"
511
+ )
512
+ return self._build_from_explicit(df, hint.x_col, hint.y_col, None, crs)
513
+
514
+ raise ValueError("env-able | morph: geometry detection failed unexpectedly.")
515
+
516
+ def _build_from_explicit(
517
+ self,
518
+ df: pd.DataFrame,
519
+ x_col: Optional[str],
520
+ y_col: Optional[str],
521
+ wkt_col: Optional[str],
522
+ crs: Optional[str],
523
+ ) -> gpd.GeoDataFrame:
524
+ if wkt_col:
525
+ if wkt_col not in df.columns:
526
+ raise ValueError(
527
+ f"env-able | morph: wkt_col '{wkt_col}' not found.\n"
528
+ f" Available columns: {list(df.columns)}"
529
+ )
530
+ from shapely import wkt as swkt
531
+ gdf = gpd.GeoDataFrame(df, geometry=df[wkt_col].apply(swkt.loads))
532
+
533
+ elif x_col and y_col:
534
+ for col in (x_col, y_col):
535
+ if col not in df.columns:
536
+ raise ValueError(
537
+ f"env-able | morph: column '{col}' not found.\n"
538
+ f" Available columns: {list(df.columns)}"
539
+ )
540
+ gdf = gpd.GeoDataFrame(
541
+ df, geometry=gpd.points_from_xy(df[x_col], df[y_col])
542
+ )
543
+ else:
544
+ raise ValueError(
545
+ "env-able | morph: provide x_col + y_col or wkt_col to build geometry."
546
+ )
547
+
548
+ if not crs:
549
+ raise ValueError(
550
+ "env-able | morph: crs is required when building geometry from tabular data.\n"
551
+ " Example: e.morph('data.csv', 'data.shp', crs='EPSG:4326')\n"
552
+ " If you're not sure, EPSG:4326 (WGS84) is a safe starting point for lat/lon data."
553
+ )
554
+
555
+ return gdf.set_crs(crs)
556
+
557
+ def run(self) -> List[str]:
558
+ inp, out = self.inp, self.out
559
+ outputs: List[str] = []
560
+
561
+ # tabular → tabular
562
+ if inp.format in _TABULAR_FORMATS and out.format in _TABULAR_FORMATS | {"json"}:
563
+ df = self.reader.tabular()
564
+ dest, _ = self._resolve_output(inp.path.stem)
565
+ self.writer.tabular(df, dest)
566
+ print(f"env-able | morph: {inp.raw_path} → {dest} ({len(df)} rows)")
567
+ return [str(dest)]
568
+
569
+ # tabular → spatial
570
+ if inp.format in _TABULAR_FORMATS and (out.format in _SPATIAL_FORMATS or out.is_directory):
571
+ df = self.reader.tabular()
572
+ gdf = self._build_geometry(df)
573
+ dest, layer = self._resolve_output(out.layer_name or inp.path.stem)
574
+ self.writer.spatial(gdf, dest, layer)
575
+ print(f"env-able | morph: {inp.raw_path} → {dest} ({len(gdf)} features, {gdf.crs})")
576
+ return [str(dest)]
577
+
578
+ # JSON → depends on content
579
+ if inp.format == "json":
580
+ if inp.is_spatial:
581
+ gdf = self.reader.spatial()
582
+ dest, layer = self._resolve_output(inp.path.stem)
583
+ self.writer.spatial(gdf, dest, layer)
584
+ print(f"env-able | morph: {inp.raw_path} → {dest} ({len(gdf)} features)")
585
+ else:
586
+ df = self.reader.tabular()
587
+ if out.format in _SPATIAL_FORMATS or out.is_directory:
588
+ gdf = self._build_geometry(df)
589
+ dest, layer = self._resolve_output(inp.path.stem)
590
+ self.writer.spatial(gdf, dest, layer)
591
+ print(f"env-able | morph: {inp.raw_path} → {dest} ({len(gdf)} features)")
592
+ else:
593
+ dest, _ = self._resolve_output(inp.path.stem)
594
+ self.writer.tabular(df, dest)
595
+ print(f"env-able | morph: {inp.raw_path} → {dest} ({len(df)} rows)")
596
+ return [str(dest)]
597
+
598
+ # spatial → tabular (geometry dropped)
599
+ if inp.is_spatial and out.format in _TABULAR_FORMATS | {"json"}:
600
+ layer = inp.layers[0] if inp.layers else None
601
+ gdf = self.reader.spatial(layer)
602
+ df = pd.DataFrame(gdf.drop(columns="geometry", errors="ignore"))
603
+ if inp.is_multi_layer and len(inp.layers) > 1:
604
+ warnings.warn(
605
+ f"env-able | morph: source has {len(inp.layers)} layers but "
606
+ f"'{out.format}' is single-table. Only '{layer}' was exported.\n"
607
+ f" Use a directory output (trailing slash) to export all layers.",
608
+ UserWarning, stacklevel=3,
609
+ )
610
+ self.writer.tabular(df, out.path)
611
+ print(f"env-able | morph: {inp.raw_path} → {out.path} ({len(df)} rows, geometry dropped)")
612
+ return [str(out.path)]
613
+
614
+ # spatial → spatial
615
+ if inp.is_spatial and (out.format in _SPATIAL_FORMATS or out.is_directory):
616
+ layers = inp.layers if inp.layers else [None]
617
+
618
+ for layer in layers:
619
+ gdf = self.reader.spatial(layer)
620
+
621
+ if gdf.empty:
622
+ warnings.warn(
623
+ f"env-able | morph: layer '{layer}' has no features — skipping.",
624
+ UserWarning, stacklevel=3,
625
+ )
626
+ continue
627
+
628
+ dest, out_layer = self._resolve_output(layer or inp.path.stem)
629
+ self.writer.spatial(gdf, dest, out_layer)
630
+ outputs.append(str(dest))
631
+ print(f"env-able | morph: [{layer}] → {dest} ({len(gdf)} features, {gdf.crs})")
632
+
633
+ return outputs
634
+
635
+ raise ValueError(
636
+ f"env-able | morph: no conversion path from '{inp.format}' to '{out.format}'.\n"
637
+ f" This combination may not be supported yet."
638
+ )
639
+
640
+
641
+ # ── Public API ────────────────────────────────────────────────────────────────
642
+
643
+ def morph(input_path: str, output_path: str, **kwargs) -> List[str]:
644
+ """
645
+ Intelligently convert one spatial or tabular data source to another.
646
+
647
+ Supported formats: shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json
648
+
649
+ Parameters
650
+ ----------
651
+ input_path : str
652
+ Path to the source file or geodatabase.
653
+ output_path : str
654
+ Desired output path. Extension determines the format.
655
+ Trailing slash → directory output (one file per layer).
656
+ Dot notation → named layer: "roads.parcels.gpkg"
657
+ **kwargs
658
+ x_col : str — X/longitude column (auto-detected if not provided)
659
+ y_col : str — Y/latitude column (auto-detected if not provided)
660
+ wkt_col : str — WKT geometry column (auto-detected if not provided)
661
+ crs : str — coordinate system e.g. 'EPSG:4326' (required for tabular → spatial)
662
+
663
+ Returns
664
+ -------
665
+ list[str] — output file paths written
666
+
667
+ Examples
668
+ --------
669
+ e.morph("roads.shp", "roads.gpkg")
670
+ e.morph("county.gdb", "county.gpkg")
671
+ e.morph("county.gdb", "output_folder/")
672
+ e.morph("data.csv", "data.geojson", crs="EPSG:4326")
673
+ e.morph("data.csv", "data.shp", x_col="LON", y_col="LAT", crs="EPSG:4269")
674
+ e.morph("roads.gpkg", "roads.parcels.gpkg")
675
+ e.morph("data.json", "data.gpkg")
676
+ """
677
+ inp = InputSpec.from_path(input_path)
678
+ out = OutputSpec.from_path(output_path, inp, **kwargs)
679
+ engine = MorphEngine(inp, out, kwargs)
680
+ return engine.run()
env_able/vector.py ADDED
@@ -0,0 +1,308 @@
1
+ import os
2
+ import geopandas as gpd
3
+ from shapely.validation import make_valid
4
+
5
+ # Unit conversion to meters
6
+ _UNITS = {
7
+ "meters": 1.0,
8
+ "metres": 1.0,
9
+ "m": 1.0,
10
+ "kilometers": 1000.0,
11
+ "kilometres": 1000.0,
12
+ "km": 1000.0,
13
+ "miles": 1609.344,
14
+ "mi": 1609.344,
15
+ "feet": 0.3048,
16
+ "ft": 0.3048,
17
+ "usfeet": 0.30480060960121924,
18
+ "us_feet": 0.30480060960121924,
19
+ "us feet": 0.30480060960121924,
20
+ "nautical miles": 1852.0,
21
+ "nm": 1852.0,
22
+ }
23
+
24
+
25
+ def _load(item, label):
26
+ if isinstance(item, gpd.GeoDataFrame):
27
+ return item
28
+ if not os.path.exists(item):
29
+ raise FileNotFoundError(
30
+ f"env-able | {label}: file not found — '{item}'\n"
31
+ f" Check the path and make sure the file exists."
32
+ )
33
+ try:
34
+ gdf = gpd.read_file(item)
35
+ except Exception as e:
36
+ raise ValueError(
37
+ f"env-able | {label}: could not read '{item}'\n"
38
+ f" Is it a valid shapefile, GeoPackage, or GeoJSON?\n"
39
+ f" Original error: {e}"
40
+ )
41
+ if gdf.empty:
42
+ raise ValueError(
43
+ f"env-able | {label}: '{item}' loaded but contains no features."
44
+ )
45
+ return gdf
46
+
47
+
48
+ def _fix_geometries(gdf, label):
49
+ invalid = ~gdf.geometry.is_valid
50
+ if invalid.any():
51
+ count = invalid.sum()
52
+ print(f"env-able | warning | {label}: {count} invalid geometry(s) found — auto-fixing.")
53
+ gdf = gdf.copy()
54
+ gdf.geometry = gdf.geometry.apply(lambda g: make_valid(g) if not g.is_valid else g)
55
+ return gdf
56
+
57
+
58
+ def _check_overlap(gdf1, gdf2):
59
+ b1 = gdf1.total_bounds
60
+ b2 = gdf2.total_bounds
61
+ if b1[2] < b2[0] or b1[0] > b2[2] or b1[3] < b2[1] or b1[1] > b2[3]:
62
+ raise ValueError(
63
+ f"env-able | intersect: layers do not spatially overlap.\n"
64
+ f" Layer 1 extent: {b1}\n"
65
+ f" Layer 2 extent: {b2}\n"
66
+ f" Make sure both layers cover the same geographic area."
67
+ )
68
+
69
+
70
+ def Intersect(input_layer, intersect_layer, output=None):
71
+ """
72
+ Computes geometric intersection of input features against a polygon layer.
73
+
74
+ Parameters
75
+ ----------
76
+ input_layer : str or GeoDataFrame
77
+ Point, line, or polygon layer to intersect.
78
+ intersect_layer : str or GeoDataFrame
79
+ Polygon layer to intersect against. Must be polygon geometry.
80
+ output : str, optional
81
+ File path to write the result (e.g. "result.gpkg", "result.shp").
82
+ If None, returns a GeoDataFrame.
83
+
84
+ Returns
85
+ -------
86
+ GeoDataFrame clipped to the intersection.
87
+ """
88
+
89
+ input_layer = _load(input_layer, "input_layer")
90
+ intersect_layer = _load(intersect_layer, "intersect_layer")
91
+
92
+ # CRS checks
93
+ for label, lyr in [("input_layer", input_layer), ("intersect_layer", intersect_layer)]:
94
+ if lyr.crs is None:
95
+ raise ValueError(
96
+ f"env-able | Intersect: {label} has no CRS defined.\n"
97
+ f" Set a CRS before running Intersect.\n"
98
+ f" Example: gdf = gdf.set_crs('EPSG:4326')"
99
+ )
100
+
101
+ # intersect layer must be polygon
102
+ intersect_types = set(intersect_layer.geometry.geom_type.unique())
103
+ if not intersect_types.issubset({"Polygon", "MultiPolygon"}):
104
+ raise ValueError(
105
+ f"env-able | Intersect: intersect_layer must be polygon geometry.\n"
106
+ f" Got: {intersect_types}\n"
107
+ f" The intersect layer defines the boundary — it must be a polygon."
108
+ )
109
+
110
+ # reproject intersect layer to match input if needed
111
+ if intersect_layer.crs != input_layer.crs:
112
+ print(
113
+ f"env-able | warning | Intersect: CRS mismatch detected.\n"
114
+ f" input_layer CRS : {input_layer.crs}\n"
115
+ f" intersect_layer CRS : {intersect_layer.crs}\n"
116
+ f" Auto-reprojecting intersect_layer to match input."
117
+ )
118
+ intersect_layer = intersect_layer.to_crs(input_layer.crs)
119
+
120
+ # fix invalid geometries
121
+ input_layer = _fix_geometries(input_layer, "input_layer")
122
+ intersect_layer = _fix_geometries(intersect_layer, "intersect_layer")
123
+
124
+ # overlap check
125
+ _check_overlap(input_layer, intersect_layer)
126
+
127
+ # run intersect
128
+ result = gpd.overlay(input_layer, intersect_layer, how="intersection", keep_geom_type=True)
129
+
130
+ if result.empty:
131
+ print(
132
+ "env-able | warning | Intersect returned no features.\n"
133
+ " The layers overlap spatially but share no coincident features.\n"
134
+ " Check your data — this is often a CRS or snapping issue."
135
+ )
136
+ return result
137
+
138
+ # write output
139
+ if output:
140
+ try:
141
+ result.to_file(output)
142
+ print(f"env-able | Intersect: result written to '{output}' ({len(result)} features).")
143
+ except Exception as e:
144
+ raise IOError(
145
+ f"env-able | Intersect: could not write to '{output}'\n"
146
+ f" Original error: {e}"
147
+ )
148
+ else:
149
+ print(f"env-able | Intersect: complete — {len(result)} features returned.")
150
+
151
+ return result
152
+
153
+
154
+ def Buffer(input_layer, distance, unit="meters", output=None):
155
+ """
156
+ Buffers input features by a given distance and unit.
157
+
158
+ Parameters
159
+ ----------
160
+ input_layer : str or GeoDataFrame
161
+ File path or GeoDataFrame to buffer.
162
+ distance : float
163
+ Buffer distance.
164
+ unit : str
165
+ Unit of distance. Options:
166
+ meters / m, kilometers / km, miles / mi,
167
+ feet / ft, usfeet / us_feet, nautical miles / nm
168
+ output : str, optional
169
+ File path to write the result. If None, returns a GeoDataFrame.
170
+
171
+ Returns
172
+ -------
173
+ GeoDataFrame with buffered geometries.
174
+ """
175
+
176
+ gdf = _load(input_layer, "input")
177
+
178
+ if gdf.crs is None:
179
+ raise ValueError(
180
+ "env-able | Buffer: input layer has no CRS defined.\n"
181
+ " Set a CRS before buffering.\n"
182
+ " Example: gdf = gdf.set_crs('EPSG:4326')"
183
+ )
184
+
185
+ unit_key = unit.lower().strip()
186
+ if unit_key not in _UNITS:
187
+ raise ValueError(
188
+ f"env-able | Buffer: unrecognised unit '{unit}'.\n"
189
+ f" Valid options: {', '.join(sorted(set(_UNITS.keys())))}"
190
+ )
191
+
192
+ distance_m = distance * _UNITS[unit_key]
193
+
194
+ gdf = _fix_geometries(gdf, "input")
195
+
196
+ # if geographic CRS (degrees), reproject to a metre-based CRS for buffering
197
+ if gdf.crs.is_geographic:
198
+ print(
199
+ f"env-able | warning | Buffer: input CRS is geographic ({gdf.crs}).\n"
200
+ f" Reprojecting to EPSG:3857 (Web Mercator) for accurate distance buffering.\n"
201
+ f" Result will be returned in EPSG:3857."
202
+ )
203
+ gdf = gdf.to_crs("EPSG:3857")
204
+ buffer_distance = distance_m
205
+ else:
206
+ # convert distance_m to the CRS native unit
207
+ crs_unit = gdf.crs.axis_info[0].unit_name.lower()
208
+ if "foot" in crs_unit or "feet" in crs_unit:
209
+ buffer_distance = distance_m / 0.3048
210
+ else:
211
+ buffer_distance = distance_m # assume metres
212
+
213
+ result = gdf.copy()
214
+ result.geometry = gdf.geometry.buffer(buffer_distance)
215
+
216
+ print(f"env-able | Buffer: complete — {len(result)} features buffered by {distance} {unit}.")
217
+
218
+ if output:
219
+ try:
220
+ result.to_file(output)
221
+ print(f"env-able | Buffer: result written to '{output}'.")
222
+ except Exception as e:
223
+ raise IOError(
224
+ f"env-able | Buffer: could not write to '{output}'\n"
225
+ f" Original error: {e}"
226
+ )
227
+
228
+ return result
229
+
230
+
231
+ def Clip(input_layer, clip_layer, output=None):
232
+ """
233
+ Clips input features to the extent of a polygon clip layer.
234
+
235
+ Parameters
236
+ ----------
237
+ input_layer : str or GeoDataFrame
238
+ Features to clip (point, line, or polygon).
239
+ clip_layer : str or GeoDataFrame
240
+ Polygon layer defining the clip boundary.
241
+ output : str, optional
242
+ File path to write the result. If None, returns a GeoDataFrame.
243
+
244
+ Returns
245
+ -------
246
+ GeoDataFrame clipped to the clip boundary.
247
+ """
248
+
249
+ gdf = _load(input_layer, "input")
250
+ clip = _load(clip_layer, "clip")
251
+
252
+ # CRS checks
253
+ for i, lyr in enumerate([gdf, clip]):
254
+ if lyr.crs is None:
255
+ raise ValueError(
256
+ f"env-able | Clip: input[{i}] has no CRS defined.\n"
257
+ f" Set a CRS before clipping.\n"
258
+ f" Example: gdf = gdf.set_crs('EPSG:4326')"
259
+ )
260
+
261
+ # clip layer must be polygon
262
+ clip_types = set(clip.geometry.geom_type.unique())
263
+ if not clip_types.issubset({"Polygon", "MultiPolygon"}):
264
+ raise ValueError(
265
+ f"env-able | Clip: clip layer must be polygon geometry.\n"
266
+ f" Got: {clip_types}\n"
267
+ f" The clip boundary must be a polygon."
268
+ )
269
+
270
+ # reproject clip to match input if needed
271
+ if clip.crs != gdf.crs:
272
+ print(
273
+ f"env-able | warning | Clip: CRS mismatch detected.\n"
274
+ f" Input CRS : {gdf.crs}\n"
275
+ f" Clip CRS : {clip.crs}\n"
276
+ f" Auto-reprojecting clip layer to match input."
277
+ )
278
+ clip = clip.to_crs(gdf.crs)
279
+
280
+ # fix geometries
281
+ gdf = _fix_geometries(gdf, "input")
282
+ clip = _fix_geometries(clip, "clip")
283
+
284
+ # overlap check
285
+ _check_overlap(gdf, clip)
286
+
287
+ result = gdf.clip(clip)
288
+
289
+ if result.empty:
290
+ print(
291
+ "env-able | warning | Clip returned no features.\n"
292
+ " The layers overlap spatially but no features fell within the clip boundary."
293
+ )
294
+ return result
295
+
296
+ print(f"env-able | Clip: complete — {len(result)} features returned.")
297
+
298
+ if output:
299
+ try:
300
+ result.to_file(output)
301
+ print(f"env-able | Clip: result written to '{output}'.")
302
+ except Exception as e:
303
+ raise IOError(
304
+ f"env-able | Clip: could not write to '{output}'\n"
305
+ f" Original error: {e}"
306
+ )
307
+
308
+ return result
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: env-able
3
+ Version: 0.2.0
4
+ Summary: AI-ready GIS toolkit for energy and subsurface workflows.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.8
8
+ Requires-Dist: fiona
9
+ Requires-Dist: geopandas
10
+ Requires-Dist: openpyxl
11
+ Requires-Dist: xlrd
12
+ Description-Content-Type: text/markdown
13
+
14
+ # env-able
15
+
16
+ An open source spatial analysis library built for AI-driven GIS workflows. Designed to give AI systems like Claude reliable, hallucination-free tools for spatial operations in energy and subsurface contexts.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install env-able
22
+ ```
23
+
24
+ ## Functions
25
+
26
+ ### `e.Intersect(input_layer, intersect_layer, output=None)`
27
+ Computes geometric intersection of input features against a polygon boundary.
28
+ - `input_layer` — point, line, or polygon (file path or GeoDataFrame)
29
+ - `intersect_layer` — polygon layer defining the intersection boundary
30
+ - `output` — file path to save result (`.gpkg`, `.shp`, etc.) — omit to return a GeoDataFrame
31
+
32
+ ```python
33
+ import env_able as e
34
+ e.Intersect("wells.shp", "boundary.shp", "result.gpkg")
35
+ ```
36
+
37
+ ---
38
+
39
+ ### `e.Buffer(input_layer, distance, unit="meters", output=None)`
40
+ Buffers input features by a given distance and unit.
41
+ - `input_layer` — point, line, or polygon (file path or GeoDataFrame)
42
+ - `distance` — numeric buffer distance
43
+ - `unit` — `meters`, `km`, `miles`, `feet`, `usfeet`, `nautical miles`
44
+ - `output` — file path to save result — omit to return a GeoDataFrame
45
+
46
+ ```python
47
+ e.Buffer("wells.shp", 1, "miles", "wells_buffer.gpkg")
48
+ ```
49
+
50
+ ---
51
+
52
+ ### `e.Clip(input_layer, clip_layer, output=None)`
53
+ Clips input features to the extent of a polygon clip boundary.
54
+ - `input_layer` — point, line, or polygon (file path or GeoDataFrame)
55
+ - `clip_layer` — polygon layer defining the clip boundary
56
+ - `output` — file path to save result — omit to return a GeoDataFrame
57
+
58
+ ```python
59
+ e.Clip("roads.shp", "county.shp", "roads_clipped.gpkg")
60
+ ```
61
+
62
+ ---
63
+
64
+ ### `e.morph(input_path, output_path, **kwargs)`
65
+ Universal format translation. Converts between shp, gpkg, gdb, csv, xlsx, xls, dbf, geojson, json with automatic CRS handling, field name fixes, and multi-layer support.
66
+
67
+ - `input_path` — source file or geodatabase
68
+ - `output_path` — destination file. Extension sets the format. Use trailing `/` for directory output (one file per layer). Use dot notation for named layers: `roads.parcels.gpkg`
69
+ - `x_col`, `y_col` — column names for X/Y coordinates (auto-detected if not provided)
70
+ - `wkt_col` — column containing WKT geometry (auto-detected if not provided)
71
+ - `crs` — coordinate reference system e.g. `EPSG:4326` (required for tabular → spatial)
72
+
73
+ ```python
74
+ e.morph("roads.shp", "roads.gpkg")
75
+ e.morph("county.gdb", "county.gpkg")
76
+ e.morph("county.gdb", "output_folder/")
77
+ e.morph("owners.csv", "owners.geojson", crs="EPSG:4269")
78
+ e.morph("owners.csv", "owners.shp", x_col="LONGITUDE", y_col="LATITUDE", crs="EPSG:4269")
79
+ e.morph("roads.gpkg", "roads.parcels.gpkg")
80
+ e.morph("data.json", "data.gpkg")
81
+ ```
82
+
83
+ **Smart behavior:**
84
+ - GDB / GPKG with multiple layers → detects all layers automatically
85
+ - CRS mismatch → auto-reprojects
86
+ - Shapefile field name limit (10 chars) → auto-truncates with warnings
87
+ - Invalid output path → plain English error
88
+ - Empty layers → skipped with a warning, not a crash
89
+
90
+ ---
91
+
92
+ ## Changelog
93
+
94
+ ### v0.1.0 — 2026-07-14
95
+ - Initial release of env-able
96
+ - `Intersect()`, `Buffer()`, `Clip()` with full guardrails
97
+ - `morph()` — universal format translation
98
+ - Smart geometry detection for WKT and lat/lon columns
99
+ - GeoJSON, JSON support with auto WGS84 reprojection
100
+ - Multi-layer GDB/GPKG support
@@ -0,0 +1,7 @@
1
+ env_able/__init__.py,sha256=N1WtPOLeqkeCKlFujncOFoZCHqjnhBkqn--yi1nU7Hg,174
2
+ env_able/morph.py,sha256=jJbsgAHxdLYt_I5JWC26jybSVIuJ0LAyCnKqUmjtY1s,25964
3
+ env_able/vector.py,sha256=EMHfwqkQKJGvqGxtL5U8iPBdWa0SbbhJo4rGK9f7yBg,10053
4
+ env_able-0.2.0.dist-info/METADATA,sha256=cbhMfSMmzUG3IBeycs_-auDrlKgfNEOlyeIOpA6p0f0,3599
5
+ env_able-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
6
+ env_able-0.2.0.dist-info/licenses/LICENSE,sha256=fe2sDjqFxN-_mnF9irpbjuM_YUKEUS_iJCYEU9JpD5w,1065
7
+ env_able-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 env-able
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.