catalogmx 0.3.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.
Files changed (81) hide show
  1. catalogmx/__init__.py +56 -0
  2. catalogmx/catalogs/__init__.py +5 -0
  3. catalogmx/catalogs/banxico/__init__.py +24 -0
  4. catalogmx/catalogs/banxico/banks.py +136 -0
  5. catalogmx/catalogs/banxico/codigos_plaza.py +287 -0
  6. catalogmx/catalogs/banxico/instituciones_financieras.py +338 -0
  7. catalogmx/catalogs/banxico/monedas_divisas.py +386 -0
  8. catalogmx/catalogs/banxico/udis.py +279 -0
  9. catalogmx/catalogs/ift/__init__.py +15 -0
  10. catalogmx/catalogs/ift/codigos_lada.py +426 -0
  11. catalogmx/catalogs/ift/operadores_moviles.py +315 -0
  12. catalogmx/catalogs/inegi/__init__.py +21 -0
  13. catalogmx/catalogs/inegi/localidades.py +207 -0
  14. catalogmx/catalogs/inegi/municipios.py +73 -0
  15. catalogmx/catalogs/inegi/municipios_completo.py +236 -0
  16. catalogmx/catalogs/inegi/states.py +148 -0
  17. catalogmx/catalogs/mexico/__init__.py +17 -0
  18. catalogmx/catalogs/mexico/hoy_no_circula.py +215 -0
  19. catalogmx/catalogs/mexico/placas_formatos.py +184 -0
  20. catalogmx/catalogs/mexico/salarios_minimos.py +156 -0
  21. catalogmx/catalogs/mexico/uma.py +207 -0
  22. catalogmx/catalogs/sat/__init__.py +13 -0
  23. catalogmx/catalogs/sat/carta_porte/__init__.py +19 -0
  24. catalogmx/catalogs/sat/carta_porte/aeropuertos.py +76 -0
  25. catalogmx/catalogs/sat/carta_porte/carreteras.py +59 -0
  26. catalogmx/catalogs/sat/carta_porte/config_autotransporte.py +54 -0
  27. catalogmx/catalogs/sat/carta_porte/material_peligroso.py +66 -0
  28. catalogmx/catalogs/sat/carta_porte/puertos_maritimos.py +63 -0
  29. catalogmx/catalogs/sat/carta_porte/tipo_embalaje.py +48 -0
  30. catalogmx/catalogs/sat/carta_porte/tipo_permiso.py +54 -0
  31. catalogmx/catalogs/sat/cfdi_4/__init__.py +42 -0
  32. catalogmx/catalogs/sat/cfdi_4/clave_prod_serv.py +383 -0
  33. catalogmx/catalogs/sat/cfdi_4/clave_unidad.py +298 -0
  34. catalogmx/catalogs/sat/cfdi_4/exportacion.py +45 -0
  35. catalogmx/catalogs/sat/cfdi_4/forma_pago.py +45 -0
  36. catalogmx/catalogs/sat/cfdi_4/impuesto.py +57 -0
  37. catalogmx/catalogs/sat/cfdi_4/meses.py +34 -0
  38. catalogmx/catalogs/sat/cfdi_4/metodo_pago.py +45 -0
  39. catalogmx/catalogs/sat/cfdi_4/objeto_imp.py +45 -0
  40. catalogmx/catalogs/sat/cfdi_4/periodicidad.py +34 -0
  41. catalogmx/catalogs/sat/cfdi_4/regimen_fiscal.py +57 -0
  42. catalogmx/catalogs/sat/cfdi_4/tasa_o_cuota.py +42 -0
  43. catalogmx/catalogs/sat/cfdi_4/tipo_comprobante.py +45 -0
  44. catalogmx/catalogs/sat/cfdi_4/tipo_factor.py +34 -0
  45. catalogmx/catalogs/sat/cfdi_4/tipo_relacion.py +45 -0
  46. catalogmx/catalogs/sat/cfdi_4/uso_cfdi.py +45 -0
  47. catalogmx/catalogs/sat/comercio_exterior/__init__.py +39 -0
  48. catalogmx/catalogs/sat/comercio_exterior/claves_pedimento.py +77 -0
  49. catalogmx/catalogs/sat/comercio_exterior/estados.py +122 -0
  50. catalogmx/catalogs/sat/comercio_exterior/incoterms.py +226 -0
  51. catalogmx/catalogs/sat/comercio_exterior/monedas.py +107 -0
  52. catalogmx/catalogs/sat/comercio_exterior/motivos_traslado.py +54 -0
  53. catalogmx/catalogs/sat/comercio_exterior/paises.py +88 -0
  54. catalogmx/catalogs/sat/comercio_exterior/registro_ident_trib.py +76 -0
  55. catalogmx/catalogs/sat/comercio_exterior/unidades_aduana.py +54 -0
  56. catalogmx/catalogs/sat/comercio_exterior/validator.py +212 -0
  57. catalogmx/catalogs/sat/nomina/__init__.py +19 -0
  58. catalogmx/catalogs/sat/nomina/banco.py +50 -0
  59. catalogmx/catalogs/sat/nomina/periodicidad_pago.py +48 -0
  60. catalogmx/catalogs/sat/nomina/riesgo_puesto.py +56 -0
  61. catalogmx/catalogs/sat/nomina/tipo_contrato.py +47 -0
  62. catalogmx/catalogs/sat/nomina/tipo_jornada.py +42 -0
  63. catalogmx/catalogs/sat/nomina/tipo_nomina.py +52 -0
  64. catalogmx/catalogs/sat/nomina/tipo_regimen.py +47 -0
  65. catalogmx/catalogs/sepomex/__init__.py +5 -0
  66. catalogmx/catalogs/sepomex/codigos_postales.py +184 -0
  67. catalogmx/cli.py +185 -0
  68. catalogmx/helpers.py +324 -0
  69. catalogmx/utils/text.py +55 -0
  70. catalogmx/validators/__init__.py +0 -0
  71. catalogmx/validators/clabe.py +233 -0
  72. catalogmx/validators/curp.py +623 -0
  73. catalogmx/validators/nss.py +255 -0
  74. catalogmx/validators/rfc.py +1004 -0
  75. catalogmx-0.3.0.dist-info/METADATA +644 -0
  76. catalogmx-0.3.0.dist-info/RECORD +81 -0
  77. catalogmx-0.3.0.dist-info/WHEEL +5 -0
  78. catalogmx-0.3.0.dist-info/entry_points.txt +2 -0
  79. catalogmx-0.3.0.dist-info/licenses/AUTHORS.rst +5 -0
  80. catalogmx-0.3.0.dist-info/licenses/LICENSE +19 -0
  81. catalogmx-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,315 @@
1
+ """
2
+ Catálogo de operadores de telefonía móvil en México (IFT)
3
+
4
+ Este módulo proporciona acceso al catálogo de operadores móviles
5
+ registrados ante el Instituto Federal de Telecomunicaciones (IFT).
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import TypedDict
11
+
12
+
13
+ class OperadorMovil(TypedDict):
14
+ """Estructura de un operador móvil"""
15
+
16
+ nombre_comercial: str
17
+ razon_social: str
18
+ tipo: str # OMR (Operador Móvil con Red) | OMV (Operador Móvil Virtual)
19
+ grupo_empresarial: str
20
+ tecnologias: list[str] # 2G, 3G, 4G, 5G
21
+ cobertura: str # nacional | regional
22
+ servicios: list[str] # prepago, postpago, datos
23
+ market_share_aprox: float
24
+ fecha_inicio_operaciones: str
25
+ activo: bool
26
+
27
+
28
+ class OperadoresMovilesCatalog:
29
+ """
30
+ Catálogo de operadores de telefonía móvil en México.
31
+
32
+ Incluye operadores móviles con red propia (OMR) y operadores móviles
33
+ virtuales (OMV).
34
+
35
+ Características:
36
+ - Operadores activos e históricos
37
+ - Información de tecnologías (2G, 3G, 4G, 5G)
38
+ - Market share aproximado
39
+ - Clasificación por tipo (OMR/OMV)
40
+ - Cobertura (nacional/regional)
41
+
42
+ Ejemplo:
43
+ >>> from catalogmx.catalogs.ift import OperadoresMovilesCatalog
44
+ >>>
45
+ >>> # Obtener operadores activos
46
+ >>> activos = OperadoresMovilesCatalog.get_activos()
47
+ >>> for op in activos:
48
+ ... print(f"{op['nombre_comercial']}: {op['market_share_aprox']}%")
49
+ >>>
50
+ >>> # Buscar por nombre
51
+ >>> telcel = OperadoresMovilesCatalog.buscar_por_nombre("Telcel")
52
+ >>> print(telcel['razon_social'])
53
+ """
54
+
55
+ _data: list[OperadorMovil] | None = None
56
+ _by_nombre: dict[str, OperadorMovil] | None = None
57
+
58
+ @classmethod
59
+ def _load_data(cls) -> None:
60
+ """Carga lazy de datos desde JSON"""
61
+ if cls._data is not None:
62
+ return
63
+
64
+ # Path: catalogmx/packages/python/catalogmx/catalogs/ift/operadores_moviles.py
65
+ # Target: catalogmx/packages/shared-data/ift/operadores_moviles.json
66
+ data_path = (
67
+ Path(__file__).parent.parent.parent.parent.parent
68
+ / "shared-data"
69
+ / "ift"
70
+ / "operadores_moviles.json"
71
+ )
72
+
73
+ with open(data_path, encoding="utf-8") as f:
74
+ json_data = json.load(f)
75
+ cls._data = json_data["operadores"]
76
+
77
+ # Índice por nombre comercial
78
+ cls._by_nombre = {item["nombre_comercial"].lower(): item for item in cls._data}
79
+
80
+ @classmethod
81
+ def get_all(cls) -> list[OperadorMovil]:
82
+ """
83
+ Obtiene todos los operadores móviles.
84
+
85
+ Returns:
86
+ Lista completa de operadores
87
+
88
+ Ejemplo:
89
+ >>> operadores = OperadoresMovilesCatalog.get_all()
90
+ >>> print(f"Total operadores: {len(operadores)}")
91
+ """
92
+ cls._load_data()
93
+ return cls._data.copy() # type: ignore
94
+
95
+ @classmethod
96
+ def get_activos(cls) -> list[OperadorMovil]:
97
+ """
98
+ Obtiene solo operadores activos.
99
+
100
+ Returns:
101
+ Lista de operadores actualmente operando
102
+
103
+ Ejemplo:
104
+ >>> activos = OperadoresMovilesCatalog.get_activos()
105
+ >>> for op in activos:
106
+ ... print(f"{op['nombre_comercial']} ({op['tipo']})")
107
+ """
108
+ cls._load_data()
109
+ return [op for op in cls._data if op["activo"]] # type: ignore
110
+
111
+ @classmethod
112
+ def get_inactivos(cls) -> list[OperadorMovil]:
113
+ """
114
+ Obtiene operadores que dejaron de operar.
115
+
116
+ Returns:
117
+ Lista de operadores inactivos
118
+
119
+ Ejemplo:
120
+ >>> inactivos = OperadoresMovilesCatalog.get_inactivos()
121
+ >>> print(f"Operadores históricos: {len(inactivos)}")
122
+ """
123
+ cls._load_data()
124
+ return [op for op in cls._data if not op["activo"]] # type: ignore
125
+
126
+ @classmethod
127
+ def buscar_por_nombre(cls, nombre: str) -> OperadorMovil | None:
128
+ """
129
+ Busca un operador por nombre comercial.
130
+
131
+ Args:
132
+ nombre: Nombre comercial del operador (ej: "Telcel", "AT&T")
133
+
134
+ Returns:
135
+ Información del operador o None si no existe
136
+
137
+ Ejemplo:
138
+ >>> telcel = OperadoresMovilesCatalog.buscar_por_nombre("Telcel")
139
+ >>> if telcel:
140
+ ... print(f"Razón social: {telcel['razon_social']}")
141
+ ... print(f"Market share: {telcel['market_share_aprox']}%")
142
+ """
143
+ cls._load_data()
144
+ nombre_lower = nombre.lower()
145
+
146
+ # Búsqueda exacta
147
+ exact = cls._by_nombre.get(nombre_lower) # type: ignore
148
+ if exact:
149
+ return exact
150
+
151
+ # Búsqueda parcial
152
+ for op in cls._data: # type: ignore
153
+ if nombre_lower in op["nombre_comercial"].lower():
154
+ return op
155
+
156
+ return None
157
+
158
+ @classmethod
159
+ def get_por_tipo(cls, tipo: str) -> list[OperadorMovil]:
160
+ """
161
+ Obtiene operadores por tipo.
162
+
163
+ Args:
164
+ tipo: "OMR" (con red propia) o "OMV" (virtual)
165
+
166
+ Returns:
167
+ Lista de operadores del tipo especificado
168
+
169
+ Ejemplo:
170
+ >>> omr = OperadoresMovilesCatalog.get_por_tipo("OMR")
171
+ >>> print(f"Operadores con red propia: {len(omr)}")
172
+ >>>
173
+ >>> omv = OperadoresMovilesCatalog.get_por_tipo("OMV")
174
+ >>> print(f"Operadores virtuales: {len(omv)}")
175
+ """
176
+ cls._load_data()
177
+ tipo_upper = tipo.upper()
178
+ return [op for op in cls._data if op["tipo"] == tipo_upper] # type: ignore
179
+
180
+ @classmethod
181
+ def get_con_tecnologia(cls, tecnologia: str) -> list[OperadorMovil]:
182
+ """
183
+ Obtiene operadores que soportan una tecnología específica.
184
+
185
+ Args:
186
+ tecnologia: "2G", "3G", "4G", "5G"
187
+
188
+ Returns:
189
+ Lista de operadores con la tecnología
190
+
191
+ Ejemplo:
192
+ >>> con_5g = OperadoresMovilesCatalog.get_con_tecnologia("5G")
193
+ >>> for op in con_5g:
194
+ ... print(f"{op['nombre_comercial']} tiene 5G")
195
+ """
196
+ cls._load_data()
197
+ tecnologia_upper = tecnologia.upper()
198
+ return [op for op in cls._data if tecnologia_upper in op["tecnologias"]] # type: ignore
199
+
200
+ @classmethod
201
+ def get_por_cobertura(cls, cobertura: str) -> list[OperadorMovil]:
202
+ """
203
+ Obtiene operadores por tipo de cobertura.
204
+
205
+ Args:
206
+ cobertura: "nacional" o "regional"
207
+
208
+ Returns:
209
+ Lista de operadores con ese tipo de cobertura
210
+
211
+ Ejemplo:
212
+ >>> nacionales = OperadoresMovilesCatalog.get_por_cobertura("nacional")
213
+ >>> print(f"Operadores con cobertura nacional: {len(nacionales)}")
214
+ """
215
+ cls._load_data()
216
+ cobertura_lower = cobertura.lower()
217
+ return [op for op in cls._data if op["cobertura"] == cobertura_lower] # type: ignore
218
+
219
+ @classmethod
220
+ def get_por_grupo(cls, grupo: str) -> list[OperadorMovil]:
221
+ """
222
+ Obtiene operadores de un grupo empresarial.
223
+
224
+ Args:
225
+ grupo: Nombre del grupo empresarial
226
+
227
+ Returns:
228
+ Lista de operadores del grupo
229
+
230
+ Ejemplo:
231
+ >>> america_movil = OperadoresMovilesCatalog.get_por_grupo("América Móvil")
232
+ >>> for op in america_movil:
233
+ ... print(op['nombre_comercial'])
234
+ """
235
+ cls._load_data()
236
+ grupo_lower = grupo.lower()
237
+ return [
238
+ op
239
+ for op in cls._data # type: ignore
240
+ if "grupo_empresarial" in op and grupo_lower in op["grupo_empresarial"].lower()
241
+ ]
242
+
243
+ @classmethod
244
+ def get_con_servicio(cls, servicio: str) -> list[OperadorMovil]:
245
+ """
246
+ Obtiene operadores que ofrecen un servicio específico.
247
+
248
+ Args:
249
+ servicio: "prepago", "postpago", "datos"
250
+
251
+ Returns:
252
+ Lista de operadores con el servicio
253
+
254
+ Ejemplo:
255
+ >>> prepago = OperadoresMovilesCatalog.get_con_servicio("prepago")
256
+ >>> print(f"Operadores con prepago: {len(prepago)}")
257
+ """
258
+ cls._load_data()
259
+ servicio_lower = servicio.lower()
260
+ return [op for op in cls._data if servicio_lower in op["servicios"]] # type: ignore
261
+
262
+ @classmethod
263
+ def get_top_por_market_share(cls, limit: int = 5) -> list[OperadorMovil]:
264
+ """
265
+ Obtiene los operadores con mayor market share.
266
+
267
+ Args:
268
+ limit: Número de operadores a retornar (default: 5)
269
+
270
+ Returns:
271
+ Lista de top operadores ordenados por market share
272
+
273
+ Ejemplo:
274
+ >>> top3 = OperadoresMovilesCatalog.get_top_por_market_share(3)
275
+ >>> for i, op in enumerate(top3, 1):
276
+ ... print(f"{i}. {op['nombre_comercial']}: {op['market_share_aprox']}%")
277
+ """
278
+ cls._load_data()
279
+ sorted_ops = sorted(
280
+ [op for op in cls._data if op["activo"]], # type: ignore
281
+ key=lambda x: x["market_share_aprox"],
282
+ reverse=True,
283
+ )
284
+ return sorted_ops[:limit]
285
+
286
+ @classmethod
287
+ def get_estadisticas(cls) -> dict[str, int | float | list]:
288
+ """
289
+ Obtiene estadísticas del catálogo.
290
+
291
+ Returns:
292
+ Diccionario con estadísticas
293
+
294
+ Ejemplo:
295
+ >>> stats = OperadoresMovilesCatalog.get_estadisticas()
296
+ >>> print(f"Total operadores: {stats['total_operadores']}")
297
+ >>> print(f"Activos: {stats['operadores_activos']}")
298
+ >>> print(f"Con 5G: {stats['operadores_con_5g']}")
299
+ """
300
+ cls._load_data()
301
+
302
+ activos = cls.get_activos()
303
+ con_5g = cls.get_con_tecnologia("5G")
304
+
305
+ return {
306
+ "total_operadores": len(cls._data), # type: ignore
307
+ "operadores_activos": len(activos),
308
+ "operadores_inactivos": len(cls.get_inactivos()),
309
+ "omr_count": len(cls.get_por_tipo("OMR")),
310
+ "omv_count": len(cls.get_por_tipo("OMV")),
311
+ "operadores_con_5g": len(con_5g),
312
+ "cobertura_nacional": len(cls.get_por_cobertura("nacional")),
313
+ "market_share_total": sum(op["market_share_aprox"] for op in activos),
314
+ "tecnologias_disponibles": ["2G", "3G", "4G", "5G"],
315
+ }
@@ -0,0 +1,21 @@
1
+ """
2
+ Catálogos INEGI
3
+
4
+ Catálogos incluidos:
5
+ - MunicipiosCatalog: Municipios de México
6
+ - MunicipiosCompletoCatalog: Catálogo completo de 2,469 municipios
7
+ - LocalidadesCatalog: Localidades con 1,000+ habitantes
8
+ - StateCatalog: Estados de México
9
+ """
10
+
11
+ from .localidades import LocalidadesCatalog
12
+ from .municipios import MunicipiosCatalog
13
+ from .municipios_completo import MunicipiosCompletoCatalog
14
+ from .states import StateCatalog
15
+
16
+ __all__ = [
17
+ "MunicipiosCatalog",
18
+ "MunicipiosCompletoCatalog",
19
+ "LocalidadesCatalog",
20
+ "StateCatalog",
21
+ ]
@@ -0,0 +1,207 @@
1
+ """Catálogo de Localidades INEGI (filtrado por población)"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from catalogmx.utils.text import normalize_text
7
+
8
+
9
+ class LocalidadesCatalog:
10
+ """
11
+ Catálogo de localidades de México con 1,000+ habitantes.
12
+
13
+ Incluye:
14
+ - 10,635 localidades con población >= 1,000 habitantes
15
+ - Coordenadas GPS (latitud, longitud)
16
+ - Población y viviendas habitadas
17
+ - Clasificación urbano/rural
18
+ """
19
+
20
+ _data: list[dict] | None = None
21
+ _by_cvegeo: dict[str, dict] | None = None
22
+ _by_municipio: dict[str, list[dict]] | None = None
23
+ _by_entidad: dict[str, list[dict]] | None = None
24
+
25
+ @classmethod
26
+ def _load_data(cls) -> None:
27
+ if cls._data is None:
28
+ # Path: catalogmx/packages/python/catalogmx/catalogs/inegi/localidades.py
29
+ # Target: catalogmx/packages/shared-data/inegi/localidades.json
30
+ path = (
31
+ Path(__file__).parent.parent.parent.parent.parent
32
+ / "shared-data"
33
+ / "inegi"
34
+ / "localidades.json"
35
+ )
36
+ with open(path, encoding="utf-8") as f:
37
+ cls._data = json.load(f)
38
+
39
+ # Crear índices
40
+ cls._by_cvegeo = {item["cvegeo"]: item for item in cls._data}
41
+
42
+ # Índice por municipio
43
+ cls._by_municipio = {}
44
+ for item in cls._data:
45
+ cve_mun = item["cve_municipio"]
46
+ if cve_mun not in cls._by_municipio:
47
+ cls._by_municipio[cve_mun] = []
48
+ cls._by_municipio[cve_mun].append(item)
49
+
50
+ # Índice por entidad
51
+ cls._by_entidad = {}
52
+ for item in cls._data:
53
+ cve_ent = item["cve_entidad"]
54
+ if cve_ent not in cls._by_entidad:
55
+ cls._by_entidad[cve_ent] = []
56
+ cls._by_entidad[cve_ent].append(item)
57
+
58
+ @classmethod
59
+ def get_localidad(cls, cvegeo: str) -> dict | None:
60
+ """
61
+ Obtiene una localidad por su clave geoestadística (CVEGEO).
62
+
63
+ Args:
64
+ cvegeo: Clave geoestadística (ej: "010010001")
65
+
66
+ Returns:
67
+ Diccionario con datos de la localidad o None si no existe
68
+ """
69
+ cls._load_data()
70
+ return cls._by_cvegeo.get(cvegeo)
71
+
72
+ @classmethod
73
+ def is_valid(cls, cvegeo: str) -> bool:
74
+ """Verifica si una clave geoestadística existe"""
75
+ return cls.get_localidad(cvegeo) is not None
76
+
77
+ @classmethod
78
+ def get_by_municipio(cls, cve_municipio: str) -> list[dict]:
79
+ """
80
+ Obtiene todas las localidades de un municipio.
81
+
82
+ Args:
83
+ cve_municipio: Código del municipio (ej: "001")
84
+
85
+ Returns:
86
+ Lista de localidades del municipio
87
+ """
88
+ cls._load_data()
89
+ return cls._by_municipio.get(cve_municipio, []).copy()
90
+
91
+ @classmethod
92
+ def get_by_entidad(cls, cve_entidad: str) -> list[dict]:
93
+ """
94
+ Obtiene todas las localidades de un estado.
95
+
96
+ Args:
97
+ cve_entidad: Código del estado (ej: "01")
98
+
99
+ Returns:
100
+ Lista de localidades del estado
101
+ """
102
+ cls._load_data()
103
+ cve_ent = cve_entidad.zfill(2)
104
+ return cls._by_entidad.get(cve_ent, []).copy()
105
+
106
+ @classmethod
107
+ def get_all(cls) -> list[dict]:
108
+ """Obtiene todas las localidades"""
109
+ cls._load_data()
110
+ return cls._data.copy()
111
+
112
+ @classmethod
113
+ def get_urbanas(cls) -> list[dict]:
114
+ """Obtiene solo localidades urbanas"""
115
+ cls._load_data()
116
+ return [loc for loc in cls._data if loc["ambito"] == "U"]
117
+
118
+ @classmethod
119
+ def get_rurales(cls) -> list[dict]:
120
+ """Obtiene solo localidades rurales"""
121
+ cls._load_data()
122
+ return [loc for loc in cls._data if loc["ambito"] == "R"]
123
+
124
+ @classmethod
125
+ def search_by_name(cls, nombre: str) -> list[dict]:
126
+ """
127
+ Busca localidades por nombre (búsqueda parcial, insensible a acentos).
128
+
129
+ Args:
130
+ nombre: Nombre o parte del nombre a buscar
131
+
132
+ Returns:
133
+ Lista de localidades que coinciden
134
+
135
+ Ejemplo:
136
+ >>> # Búsqueda con o sin acentos funciona igual
137
+ >>> locs = LocalidadesCatalog.search_by_name("san jose")
138
+ >>> locs = LocalidadesCatalog.search_by_name("san josé") # mismo resultado
139
+ """
140
+ cls._load_data()
141
+ nombre_normalized = normalize_text(nombre)
142
+ return [
143
+ loc for loc in cls._data if nombre_normalized in normalize_text(loc["nom_localidad"])
144
+ ]
145
+
146
+ @classmethod
147
+ def get_by_coordinates(cls, lat: float, lon: float, radio_km: float = 10) -> list[dict]:
148
+ """
149
+ Busca localidades cercanas a unas coordenadas.
150
+
151
+ Args:
152
+ lat: Latitud
153
+ lon: Longitud
154
+ radio_km: Radio de búsqueda en kilómetros (default: 10)
155
+
156
+ Returns:
157
+ Lista de localidades dentro del radio, ordenadas por distancia
158
+ """
159
+ from math import atan2, cos, radians, sin, sqrt
160
+
161
+ cls._load_data()
162
+
163
+ def distancia_haversine(lat1, lon1, lat2, lon2):
164
+ """Calcula distancia en km entre dos puntos GPS"""
165
+ R = 6371 # Radio de la Tierra en km
166
+
167
+ dlat = radians(lat2 - lat1)
168
+ dlon = radians(lon2 - lon1)
169
+
170
+ a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
171
+ c = 2 * atan2(sqrt(a), sqrt(1 - a))
172
+
173
+ return R * c
174
+
175
+ resultados = []
176
+ for loc in cls._data:
177
+ if loc["latitud"] is None or loc["longitud"] is None:
178
+ continue
179
+
180
+ distancia = distancia_haversine(lat, lon, loc["latitud"], loc["longitud"])
181
+ if distancia <= radio_km:
182
+ loc_con_distancia = loc.copy()
183
+ loc_con_distancia["distancia_km"] = round(distancia, 2)
184
+ resultados.append(loc_con_distancia)
185
+
186
+ # Ordenar por distancia
187
+ resultados.sort(key=lambda x: x["distancia_km"])
188
+ return resultados
189
+
190
+ @classmethod
191
+ def get_by_population_range(cls, min_pob: int, max_pob: int | None = None) -> list[dict]:
192
+ """
193
+ Obtiene localidades en un rango de población.
194
+
195
+ Args:
196
+ min_pob: Población mínima
197
+ max_pob: Población máxima (None para sin límite)
198
+
199
+ Returns:
200
+ Lista de localidades en el rango
201
+ """
202
+ cls._load_data()
203
+
204
+ if max_pob is None:
205
+ return [loc for loc in cls._data if loc["poblacion_total"] >= min_pob]
206
+ else:
207
+ return [loc for loc in cls._data if min_pob <= loc["poblacion_total"] <= max_pob]
@@ -0,0 +1,73 @@
1
+ """Catálogo de Municipios INEGI"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from catalogmx.utils.text import normalize_text
7
+
8
+
9
+ class MunicipiosCatalog:
10
+ _data: list[dict] | None = None
11
+ _by_cve_completa: dict[str, dict] | None = None
12
+ _by_entidad: dict[str, list[dict]] | None = None
13
+
14
+ @classmethod
15
+ def _load_data(cls) -> None:
16
+ if cls._data is None:
17
+ # Path: catalogmx/packages/python/catalogmx/catalogs/inegi/municipios.py
18
+ # Target: catalogmx/packages/shared-data/inegi/municipios_completo.json
19
+ path = (
20
+ Path(__file__).parent.parent.parent.parent.parent
21
+ / "shared-data"
22
+ / "inegi"
23
+ / "municipios_completo.json"
24
+ )
25
+ with open(path, encoding="utf-8") as f:
26
+ cls._data = json.load(f)
27
+
28
+ cls._by_cve_completa = {item["cve_completa"]: item for item in cls._data}
29
+
30
+ # Index by entidad
31
+ cls._by_entidad = {}
32
+ for item in cls._data:
33
+ entidad = item["cve_entidad"]
34
+ if entidad not in cls._by_entidad:
35
+ cls._by_entidad[entidad] = []
36
+ cls._by_entidad[entidad].append(item)
37
+
38
+ @classmethod
39
+ def get_municipio(cls, cve_completa: str) -> dict | None:
40
+ """Obtiene municipio por clave completa (5 dígitos)"""
41
+ cls._load_data()
42
+ return cls._by_cve_completa.get(cve_completa)
43
+
44
+ @classmethod
45
+ def get_by_entidad(cls, cve_entidad: str) -> list[dict]:
46
+ """Obtiene todos los municipios de una entidad"""
47
+ cls._load_data()
48
+ return cls._by_entidad.get(cve_entidad, [])
49
+
50
+ @classmethod
51
+ def is_valid(cls, cve_completa: str) -> bool:
52
+ """Verifica si una clave de municipio es válida"""
53
+ return cls.get_municipio(cve_completa) is not None
54
+
55
+ @classmethod
56
+ def get_all(cls) -> list[dict]:
57
+ """Obtiene todos los municipios"""
58
+ cls._load_data()
59
+ return cls._data.copy()
60
+
61
+ @classmethod
62
+ def search_by_name(cls, nombre: str) -> list[dict]:
63
+ """
64
+ Busca municipios por nombre (búsqueda parcial, insensible a acentos).
65
+
66
+ Ejemplo:
67
+ >>> # Ambas búsquedas funcionan igual
68
+ >>> munis = MunicipiosCatalog.search_by_name("leon")
69
+ >>> munis = MunicipiosCatalog.search_by_name("león") # mismo resultado
70
+ """
71
+ cls._load_data()
72
+ nombre_normalized = normalize_text(nombre)
73
+ return [m for m in cls._data if nombre_normalized in normalize_text(m["nom_municipio"])]