cfasig 0.1.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.
cfasig/__init__.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ cfasig — Wrapper simple sobre geopandas/shapely para tareas de SIG.
3
+
4
+ La idea es que operaciones comunes (cargar, recortar, disolver, buffer,
5
+ combinar, guardar...) se llamen con una sola función en español, sin
6
+ tener que recordar la sintaxis de geopandas cada vez.
7
+
8
+ Uso rápido:
9
+
10
+ import cfasig as sig
11
+
12
+ ejido = sig.cargar("ejido.shp")
13
+ hidro = sig.cargar("hidrologia.shp")
14
+
15
+ hidro = sig.reproyectar(hidro, 32613)
16
+ hidro = sig.recortar(hidro, ejido)
17
+ hidro = sig.disolver(hidro)
18
+ hidro = sig.buffer(hidro, 20)
19
+ sig.guardar(hidro, "salida.shp")
20
+ """
21
+
22
+ from .archivo import (
23
+ cargar,
24
+ cargar_puntos,
25
+ guardar,
26
+ convertir,
27
+ cargar_waypoints,
28
+ cargar_tracks,
29
+ )
30
+ from .proyeccion import reproyectar, asegurar_crs
31
+ from .campos import campo_seguro
32
+ from .geometria import (
33
+ recortar,
34
+ disolver,
35
+ buffer,
36
+ puntos_a_linea,
37
+ puntos_a_poligono,
38
+ linea_a_poligono,
39
+ quitar_solape,
40
+ combinar,
41
+ crear_cuadro,
42
+ limpiar_vacias,
43
+ reparar_geometrias,
44
+ calcular_superficie,
45
+ intersectar,
46
+ unir_atributos,
47
+ calcular_longitud,
48
+ simplificar,
49
+ cerrar_microhuecos,
50
+ subdividir_por_area,
51
+ fusionar_menores,
52
+ )
53
+ from .raster import (
54
+ cargar_raster,
55
+ guardar_raster,
56
+ recortar_raster,
57
+ rellenar_nodata,
58
+ remuestrear,
59
+ interpolar_mde,
60
+ rasterizar,
61
+ quemar_cauces,
62
+ combinar_categorias,
63
+ poligonizar,
64
+ acondicionar_mde,
65
+ direccion_flujo,
66
+ acumulacion_flujo,
67
+ etiquetar_cuencas,
68
+ calcular_aspecto,
69
+ )
70
+
71
+ __all__ = [
72
+ # archivo
73
+ "cargar",
74
+ "cargar_puntos",
75
+ "guardar",
76
+ "convertir",
77
+ "cargar_waypoints",
78
+ "cargar_tracks",
79
+ # proyeccion
80
+ "reproyectar",
81
+ "asegurar_crs",
82
+ # campos
83
+ "campo_seguro",
84
+ # geometria
85
+ "recortar",
86
+ "disolver",
87
+ "buffer",
88
+ "puntos_a_linea",
89
+ "puntos_a_poligono",
90
+ "linea_a_poligono",
91
+ "quitar_solape",
92
+ "combinar",
93
+ "crear_cuadro",
94
+ "limpiar_vacias",
95
+ "reparar_geometrias",
96
+ "calcular_superficie",
97
+ "intersectar",
98
+ "unir_atributos",
99
+ "calcular_longitud",
100
+ "simplificar",
101
+ "cerrar_microhuecos",
102
+ "subdividir_por_area",
103
+ "fusionar_menores",
104
+ # raster
105
+ "cargar_raster",
106
+ "guardar_raster",
107
+ "recortar_raster",
108
+ "rellenar_nodata",
109
+ "remuestrear",
110
+ "interpolar_mde",
111
+ "rasterizar",
112
+ "quemar_cauces",
113
+ "combinar_categorias",
114
+ "poligonizar",
115
+ "acondicionar_mde",
116
+ "direccion_flujo",
117
+ "acumulacion_flujo",
118
+ "etiquetar_cuencas",
119
+ "calcular_aspecto",
120
+ ]
121
+
122
+ __version__ = "0.1.0"
cfasig/archivo.py ADDED
@@ -0,0 +1,375 @@
1
+ """Entrada y salida de capas (leer/escribir archivos vectoriales)."""
2
+
3
+ import os
4
+ import re
5
+ import warnings
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager, nullcontext
8
+ from os import PathLike
9
+
10
+ import geopandas as gpd
11
+ import pandas as pd
12
+ from shapely.geometry import LineString, MultiLineString
13
+ from shapely.geometry.base import BaseGeometry
14
+
15
+ from .proyeccion import _exigir_crs
16
+
17
+ # Formatos que exigen WGS84 lon/lat (EPSG:4326) y driver propio de GDAL.
18
+ # GPX y KML/KMZ solo hablan geográficas; hay que reproyectar al exportar.
19
+ _DRIVERS_GEO = {".gpx": "GPX", ".kml": "LIBKML", ".kmz": "LIBKML"}
20
+
21
+ # Avisos de GDAL/pyogrio (en inglés) traducidos: fragmento a buscar -> texto ES.
22
+ _AVISOS_ES = {
23
+ "Column names longer than 10 characters": "el formato SHP recorta los nombres de columna a 10 caracteres",
24
+ "created as String field": "GPX guardó un campo de fecha/hora como texto (limitación del formato)",
25
+ "Normalized/laundered field name": "GPX renombró un campo para ajustarlo a su esquema fijo",
26
+ "More than one layer found": "el archivo tiene varias capas; se leyó la predeterminada (usa capa= para elegir otra)",
27
+ }
28
+
29
+
30
+ @contextmanager
31
+ def _traducir_avisos() -> Iterator[None]:
32
+ """Captura avisos de GDAL/pyogrio y los reemite en español.
33
+
34
+ Los conocidos salen como 'Aviso: ...'; los desconocidos se reemiten tal cual
35
+ para no ocultar nada nuevo.
36
+ """
37
+ with warnings.catch_warnings(record=True) as capturados:
38
+ warnings.simplefilter("always")
39
+ yield
40
+ for a in capturados:
41
+ es = next((v for k, v in _AVISOS_ES.items() if k in str(a.message)), None)
42
+ if es:
43
+ print(f"Aviso: {es}")
44
+ else:
45
+ warnings.warn(a.message, a.category)
46
+
47
+
48
+ @contextmanager
49
+ def _env(var: str, valor: str) -> Iterator[None]:
50
+ """Fija una variable de entorno solo durante el bloque y luego la restaura."""
51
+ previo = os.environ.get(var)
52
+ os.environ[var] = valor
53
+ try:
54
+ yield
55
+ finally:
56
+ if previo is None:
57
+ del os.environ[var]
58
+ else:
59
+ os.environ[var] = previo
60
+
61
+
62
+ def cargar(
63
+ ruta: str | PathLike[str], capa: str | None = None, mostrar: bool = True
64
+ ) -> gpd.GeoDataFrame:
65
+ """Carga una capa vectorial (.shp, .gpkg, .geojson, .gpx...) como GeoDataFrame.
66
+
67
+ Parámetros
68
+ ----------
69
+ ruta : str
70
+ Ruta al archivo.
71
+ capa : str, opcional
72
+ Nombre de la capa a leer. Necesario en formatos multicapa: GPX
73
+ (waypoints/routes/tracks) y GeoPackage con varias capas. Si es None
74
+ lee la primera/única.
75
+ mostrar : bool
76
+ Si True imprime nº de entidades y CRS.
77
+
78
+ Devuelve
79
+ --------
80
+ GeoDataFrame
81
+ """
82
+ if not os.path.exists(ruta):
83
+ raise FileNotFoundError(f"no existe la ruta '{ruta}'")
84
+ with _traducir_avisos():
85
+ gdf = gpd.read_file(ruta, layer=capa)
86
+ if mostrar:
87
+ print(f"Cargado '{ruta}': {len(gdf)} entidades | CRS: {gdf.crs}")
88
+ if len(gdf) == 0:
89
+ print(f"Aviso: '{ruta}' no tiene entidades")
90
+ return gdf
91
+
92
+
93
+ def cargar_puntos(
94
+ ruta: str | PathLike[str],
95
+ x: str = "x",
96
+ y: str = "y",
97
+ epsg: int | None = None,
98
+ orden: str | None = None,
99
+ mostrar: bool = True,
100
+ ) -> gpd.GeoDataFrame:
101
+ """Carga un CSV o Excel con columnas de coordenadas como capa de puntos.
102
+
103
+ Parámetros
104
+ ----------
105
+ x, y : str
106
+ Nombres de las columnas con la coordenada X (este/lon) e Y (norte/lat).
107
+ epsg : int, opcional
108
+ CRS de esas coordenadas (p.ej. 32613 para UTM 13N, 4326 para lon/lat).
109
+ El archivo de texto no lo trae, así que se define aquí. Sin él la capa
110
+ queda sin CRS y no podrás reproyectar ni medir áreas.
111
+ orden : str, opcional
112
+ Columna de secuencia por la que ordenar las filas antes de armar la
113
+ capa. Si es None se respeta el orden del archivo. Solo importa si luego
114
+ conviertes los puntos a línea o polígono.
115
+
116
+ Devuelve
117
+ --------
118
+ GeoDataFrame de puntos con todas las columnas del archivo como atributos.
119
+ """
120
+ if not os.path.exists(ruta):
121
+ raise FileNotFoundError(f"no existe la ruta '{ruta}'")
122
+ ext = os.path.splitext(str(ruta))[1].lower()
123
+ leer = pd.read_excel if ext in (".xlsx", ".xls") else pd.read_csv
124
+ df = leer(ruta)
125
+ for col in (x, y):
126
+ if col not in df.columns:
127
+ raise ValueError(
128
+ f"el archivo no tiene la columna '{col}'; columnas: {list(df.columns)}"
129
+ )
130
+ if orden is not None:
131
+ if orden not in df.columns:
132
+ raise ValueError(f"el archivo no tiene la columna de orden '{orden}'")
133
+ df = df.sort_values(orden)
134
+ gdf = gpd.GeoDataFrame(
135
+ df,
136
+ geometry=gpd.points_from_xy(df[x], df[y]),
137
+ crs=f"EPSG:{epsg}" if epsg else None,
138
+ )
139
+ if mostrar:
140
+ print(f"Cargado '{ruta}': {len(gdf)} puntos | CRS: {gdf.crs}")
141
+ if len(gdf) == 0:
142
+ print(f"Aviso: '{ruta}' no tiene filas")
143
+ return gdf
144
+
145
+
146
+ def guardar(
147
+ gdf: gpd.GeoDataFrame,
148
+ ruta: str | PathLike[str],
149
+ gpx_como: str = "track",
150
+ mostrar: bool = True,
151
+ ) -> str | PathLike[str]:
152
+ """Guarda un GeoDataFrame en disco.
153
+
154
+ El formato se deduce de la extensión de `ruta`. Para .gpx/.kml/.kmz
155
+ reproyecta automáticamente a EPSG:4326 (esos formatos solo aceptan
156
+ lon/lat) y usa el driver adecuado. Así, convertir es cargar + guardar:
157
+
158
+ sig.guardar(sig.cargar("predio.shp"), "predio.kmz") # SHP -> KMZ
159
+ sig.guardar(sig.cargar("ruta.shp"), "ruta.gpx") # SHP -> GPX
160
+ sig.guardar(sig.cargar("nueva.gpx", capa="tracks"), "x.shp") # GPX -> SHP
161
+
162
+ Parámetros
163
+ ----------
164
+ gpx_como : "track" | "route"
165
+ Solo aplica a GPX y a geometrías de línea. El driver GPX decide por
166
+ tipo de geometría: MultiLineString -> track, LineString -> route. Con
167
+ "track" (por defecto, que es como trabaja el GPS aquí) se envuelven
168
+ las líneas como MultiLineString. Los puntos siempre van a waypoints.
169
+ """
170
+ if len(gdf) == 0:
171
+ print(f"Aviso: guardando capa vacía en '{ruta}'")
172
+ ext = os.path.splitext(str(ruta))[1].lower()
173
+ carpeta = os.path.dirname(os.path.abspath(ruta))
174
+ os.makedirs(carpeta, exist_ok=True)
175
+
176
+ if ext in _DRIVERS_GEO:
177
+ gdf = _a_geografico(gdf, ext)
178
+ ctx = nullcontext()
179
+ if ext == ".gpx":
180
+ # extensiones ON para no perder atributos al escribir GPX.
181
+ ctx = _env("GPX_USE_EXTENSIONS", "YES")
182
+ # GPX reserva 'fid'; un SHP sin atributos trae esa columna y revienta.
183
+ gdf = gdf.drop(columns=[c for c in gdf.columns if c.lower() == "fid"])
184
+ gdf = _poligono_a_borde(gdf)
185
+ if gpx_como == "track":
186
+ gdf = _lineas_a_track(gdf)
187
+ with _traducir_avisos(), ctx:
188
+ gdf.to_file(ruta, driver=_DRIVERS_GEO[ext])
189
+ else:
190
+ with _traducir_avisos():
191
+ gdf.to_file(ruta)
192
+
193
+ if mostrar:
194
+ print(f"Guardado '{ruta}': {len(gdf)} entidades")
195
+ return ruta
196
+
197
+
198
+ def convertir(
199
+ entrada: str | PathLike[str],
200
+ salida: str | PathLike[str],
201
+ capa: str | None = None,
202
+ gpx_como: str = "track",
203
+ mostrar: bool = True,
204
+ ) -> str | PathLike[str]:
205
+ """Convierte un archivo vectorial de un formato a otro (cargar + guardar).
206
+
207
+ sig.convertir("ruta.shp", "ruta.gpx") # SHP -> GPX
208
+ sig.convertir("nueva.gpx", "nueva.shp") # GPX -> SHP
209
+ sig.convertir("predio.shp", "predio.kmz") # SHP -> KMZ
210
+
211
+ Si la entrada es GPX y no se indica `capa`, elige automáticamente la que
212
+ trae datos: un GPX de solo tracks leído "a secas" sale vacío porque la
213
+ capa por defecto es waypoints. `gpx_como` aplica solo a la salida GPX.
214
+ """
215
+ if str(entrada).lower().endswith(".gpx"):
216
+ gdf = _cargar_gpx_limpio(entrada, capa, mostrar=mostrar)
217
+ else:
218
+ gdf = cargar(entrada, capa=capa, mostrar=mostrar)
219
+ return guardar(gdf, salida, gpx_como=gpx_como, mostrar=mostrar)
220
+
221
+
222
+ def _cargar_gpx_limpio(
223
+ ruta: str | PathLike[str], capa: str | None, mostrar: bool = True
224
+ ) -> gpd.GeoDataFrame:
225
+ """Carga un GPX con la tabla de atributos limpia (estilo Global Mapper).
226
+
227
+ Waypoints -> cargar_waypoints; tracks -> cargar_tracks. Si no se indica
228
+ `capa`, elige la que trae datos. Rutas (routes) u otras capas caen al
229
+ lector genérico. ponytail: solo waypoints y tracks tienen tabla limpia,
230
+ que son los que exporta el GPS aquí; agrega routes si algún día hace falta.
231
+ """
232
+ if capa is None:
233
+ capa = _capa_gpx_con_datos(ruta)
234
+ if capa == "waypoints":
235
+ return cargar_waypoints(ruta, mostrar=mostrar)
236
+ if capa == "tracks":
237
+ return cargar_tracks(ruta, mostrar=mostrar)
238
+ return cargar(ruta, capa=capa, mostrar=mostrar)
239
+
240
+
241
+ def _capa_gpx_con_datos(ruta: str | PathLike[str]) -> str | None:
242
+ """Primera capa GPX (waypoints > routes > tracks) que trae entidades.
243
+
244
+ ponytail: si un GPX mezclara waypoints y tracks devuelve solo el primero;
245
+ en ese caso pasa `capa=` explícito. No ha pasado con los archivos reales.
246
+ """
247
+ for capa in ("waypoints", "routes", "tracks"):
248
+ try:
249
+ if len(gpd.read_file(ruta, layer=capa)) > 0:
250
+ return capa
251
+ except Exception:
252
+ pass
253
+ return None
254
+
255
+
256
+ def _a_geografico(gdf: gpd.GeoDataFrame, ext: str) -> gpd.GeoDataFrame:
257
+ """Reproyecta a EPSG:4326 si hace falta; GPX/KML/KMZ lo exigen."""
258
+ _exigir_crs(gdf, "capa")
259
+ if gdf.crs.to_epsg() != 4326:
260
+ print(f"Reproyectando a EPSG:4326 para exportar a {ext}")
261
+ return gdf.to_crs(epsg=4326)
262
+ return gdf
263
+
264
+
265
+ def _poligono_a_borde(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
266
+ """GPX no admite polígonos; exporta su contorno como línea."""
267
+ if set(gdf.geom_type) & {"Polygon", "MultiPolygon"}:
268
+ print("GPX no admite polígonos; exporto su contorno como línea")
269
+ gdf = gdf.copy()
270
+ gdf["geometry"] = gdf.boundary
271
+ return gdf
272
+
273
+
274
+ def _fmt_hora_es(dt: pd.Timestamp) -> str:
275
+ """Formatea a 'dd/mm/aaaa hh:mm:ss a. m./p. m.' (formato local español)."""
276
+ if pd.isna(dt):
277
+ return "" # ponytail: track sin tiempo -> campo vacío
278
+ h12 = ((dt.hour + 11) % 12) + 1
279
+ suf = "a. m." if dt.hour < 12 else "p. m."
280
+ return f"{dt.day:02d}/{dt.month:02d}/{dt.year} {h12:02d}:{dt.minute:02d}:{dt.second:02d} {suf}"
281
+
282
+
283
+ def cargar_waypoints(
284
+ ruta: str | PathLike[str], mostrar: bool = True
285
+ ) -> gpd.GeoDataFrame:
286
+ """Carga los waypoints de un GPX en una tabla de atributos limpia.
287
+
288
+ Columnas de salida: NAME, LAYER (constante 'Waypoint'), ELEVATION, time
289
+ (ISO UTC tal cual, p.ej. 2026-02-21T16:09:47Z), sym. Descarta las ~18
290
+ columnas vacías del esquema fijo de GPX (magvar, hdop, link*, cmt...).
291
+ """
292
+ with _traducir_avisos():
293
+ gdf = gpd.read_file(ruta, layer="waypoints")
294
+ # ele/time pueden faltar en GPX de otras fuentes; None en vez de reventar
295
+ ele = gdf.get("ele")
296
+ t = pd.to_datetime(gdf["time"], utc=True) if "time" in gdf.columns else None
297
+ salida = gpd.GeoDataFrame(
298
+ {
299
+ "NAME": gdf.get("name"),
300
+ "LAYER": "Waypoint", # ponytail: etiqueta de capa; no viene en el GPX
301
+ "ELEVATION": ele.round(6) if ele is not None else None,
302
+ "time": t.dt.strftime("%Y-%m-%dT%H:%M:%SZ") if t is not None else None,
303
+ "sym": gdf.get("sym"),
304
+ },
305
+ geometry=gdf.geometry,
306
+ crs=gdf.crs,
307
+ )
308
+ if mostrar:
309
+ print(f"Waypoints '{ruta}': {len(salida)} entidades")
310
+ return salida
311
+
312
+
313
+ def cargar_tracks(
314
+ ruta: str | PathLike[str], utc_offset: int = -6, mostrar: bool = True
315
+ ) -> gpd.GeoDataFrame:
316
+ """Carga los tracks de un GPX en una tabla de atributos limpia, una linea por tramo.
317
+
318
+ Una línea por tramo (<trkseg>). Columnas: NAME, LAYER (constante
319
+ 'Tracklog'), gpxx_DisplayColor (el SHP lo recorta a 'gpxx_Displ'),
320
+ START_TIME, END_TIME. Los tiempos salen del primer/último punto del tramo,
321
+ convertidos a hora local (`utc_offset`, por defecto -6 = Jalisco) y con
322
+ formato español.
323
+ """
324
+ with _traducir_avisos():
325
+ try:
326
+ meta = gpd.read_file(ruta, layer="tracks")
327
+ except Exception as e:
328
+ # GDAL no puede armar la geometria si un <trkseg> trae 1 solo punto
329
+ if "point array" in str(e):
330
+ raise ValueError(
331
+ f"'{ruta}' tiene un tramo de un solo punto y no se puede leer; "
332
+ "borra ese tramo en el GPS o edita el GPX"
333
+ ) from e
334
+ raise
335
+ pts = gpd.read_file(ruta, layer="track_points")
336
+ pts = pts.sort_values(["track_fid", "track_seg_id", "track_seg_point_id"])
337
+ t_local = pd.to_datetime(pts["time"], utc=True) + pd.Timedelta(hours=utc_offset)
338
+ pts = pts.assign(_t=t_local.values)
339
+
340
+ filas = []
341
+ for (fid, _seg), sub in pts.groupby(["track_fid", "track_seg_id"], sort=True):
342
+ coords = [(p.x, p.y) for p in sub.geometry]
343
+ if len(coords) < 2:
344
+ continue # ponytail: un tramo de 1 punto no es línea, se salta
345
+ trk = meta.iloc[int(fid)]
346
+ color = re.search(
347
+ r"DisplayColor>([^<]+)<", str(trk.get("gpxx_TrackExtension", ""))
348
+ )
349
+ filas.append(
350
+ {
351
+ "NAME": trk.get("name"),
352
+ "LAYER": "Tracklog", # ponytail: etiqueta de capa constante
353
+ "gpxx_DisplayColor": color.group(1) if color else None,
354
+ "START_TIME": _fmt_hora_es(sub["_t"].iloc[0]),
355
+ "END_TIME": _fmt_hora_es(sub["_t"].iloc[-1]),
356
+ "geometry": LineString(coords),
357
+ }
358
+ )
359
+ salida = gpd.GeoDataFrame(filas, crs=pts.crs)
360
+ if mostrar:
361
+ print(f"Tracks '{ruta}': {len(salida)} tramos")
362
+ return salida
363
+
364
+
365
+ def _lineas_a_track(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
366
+ """Envuelve LineString como MultiLineString para que GPX las escriba como track."""
367
+
368
+ def a_multi(g: BaseGeometry | None) -> BaseGeometry | None:
369
+ return (
370
+ MultiLineString([g]) if g is not None and g.geom_type == "LineString" else g
371
+ )
372
+
373
+ gdf = gdf.copy()
374
+ gdf["geometry"] = gdf.geometry.apply(a_multi)
375
+ return gdf
cfasig/campos.py ADDED
@@ -0,0 +1,18 @@
1
+ """Utilidades para manejar campos (columnas) de atributos."""
2
+
3
+ import uuid
4
+
5
+ import geopandas as gpd
6
+
7
+
8
+ def campo_seguro(gdf: gpd.GeoDataFrame, campo: str) -> str:
9
+ """Devuelve un nombre de campo que no choque con los existentes.
10
+
11
+ Si `campo` no existe en la capa, lo devuelve tal cual.
12
+ Si ya existe, devuelve una variante única (campo_ab12) para no pisar datos.
13
+ """
14
+ if campo not in gdf.columns:
15
+ return campo
16
+ nuevo = f"{campo}_{uuid.uuid4().hex[:4]}"
17
+ print(f"'{campo}' ya existe, uso '{nuevo}'")
18
+ return nuevo
cfasig/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """CLI de cfasig: convierte uno o varios archivos entre formatos SIG.
2
+
3
+ Un solo comando. ponytail: sin subcomando porque hoy solo convierte. Si algun
4
+ dia agregas mas operaciones (buffer, recortar...), migra a subparsers y el uso
5
+ pasa a `cfasig convertir ARCHIVO TIPO`. El lote es simplemente varios archivos
6
+ (arrastrados a la terminal); sin glob ni flags porque el usuario no tecnico los
7
+ arrastra, no escribe comodines.
8
+ """
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+
14
+ TIPOS = ("shp", "gpkg", "geojson", "gpx", "kml", "kmz")
15
+
16
+ AYUDA = """\
17
+ uso: cfasig ARCHIVO [ARCHIVO ...] TIPO
18
+
19
+ Convierte uno o varios archivos entre formatos SIG.
20
+
21
+ argumentos:
22
+ ARCHIVO archivo(s) a convertir. Para varios, arrastralos juntos a la
23
+ terminal (quedan separados por espacios) y luego escribe el TIPO.
24
+ TIPO formato de salida: shp, gpkg, geojson, gpx, kml, kmz
25
+
26
+ ejemplos:
27
+ cfasig predio.shp kmz -> crea predio.kmz
28
+ cfasig ruta.gpx shp -> crea ruta.shp
29
+ cfasig a.shp b.shp c.shp kmz -> crea a.kmz, b.kmz, c.kmz
30
+
31
+ opciones avanzadas (normalmente no hacen falta):
32
+ --capa NOMBRE capa a leer si el archivo trae varias (GPX/GeoPackage)
33
+ --gpx-como {track,route} como escribir lineas al exportar a GPX (def: track)
34
+ -h, --ayuda muestra esta ayuda
35
+ """
36
+
37
+
38
+ class _Parser(argparse.ArgumentParser):
39
+ """Error breve y en español en vez del volcado tecnico de argparse."""
40
+
41
+ def error(self, message: str) -> None:
42
+ sys.stderr.write(
43
+ f"Error: {message}\nEscribe 'cfasig ayuda' para ver como se usa.\n"
44
+ )
45
+ raise SystemExit(2)
46
+
47
+
48
+ def _build() -> _Parser:
49
+ p = _Parser(add_help=False)
50
+ p.add_argument(
51
+ "archivos", nargs="+"
52
+ ) # uno o varios; argparse deja el ultimo para tipo
53
+ p.add_argument("tipo")
54
+ p.add_argument("--capa")
55
+ p.add_argument(
56
+ "--gpx-como", dest="gpx_como", choices=("track", "route"), default="track"
57
+ )
58
+ return p
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ argv = list(sys.argv[1:] if argv is None else argv)
63
+
64
+ # Sin argumentos o pidiendo ayuda: muestra la ayuda y sale.
65
+ if not argv or argv[0] in ("ayuda", "help", "-h", "--ayuda", "--help"):
66
+ print(AYUDA)
67
+ return 0
68
+
69
+ args = _build().parse_args(argv)
70
+
71
+ tipo = args.tipo.lower().lstrip(".")
72
+ if tipo not in TIPOS:
73
+ print(f"Error: tipo '{args.tipo}' no valido. Usa uno de: {', '.join(TIPOS)}.")
74
+ return 2
75
+
76
+ from . import convertir # import perezoso: la ayuda no carga geopandas
77
+
78
+ ok = fallo = 0
79
+ for archivo in args.archivos:
80
+ if not os.path.exists(archivo):
81
+ print(f"Error: no existe el archivo '{archivo}'.")
82
+ fallo += 1
83
+ continue
84
+ # Salida autonombrada: misma carpeta y nombre, extension nueva.
85
+ salida = os.path.splitext(archivo)[0] + "." + tipo
86
+ if os.path.abspath(salida) == os.path.abspath(archivo):
87
+ print(f"Aviso: '{archivo}' ya es {tipo}, se salta.")
88
+ continue
89
+ if os.path.exists(salida):
90
+ resp = input(f"'{salida}' ya existe. Sobrescribir? [s/N]: ").strip().lower()
91
+ if resp not in ("s", "si", "sí"):
92
+ print("Saltado.")
93
+ continue
94
+ try:
95
+ convertir(archivo, salida, capa=args.capa, gpx_como=args.gpx_como)
96
+ ok += 1
97
+ except Exception as e:
98
+ print(f"Error convirtiendo '{archivo}': {e}")
99
+ fallo += 1
100
+
101
+ if len(args.archivos) > 1:
102
+ print(f"\nListo: {ok} convertidos, {fallo} con error.")
103
+ return 1 if fallo else 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())