eco-back 0.1.0__py3-none-any.whl → 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.
eco_back/__init__.py CHANGED
@@ -2,10 +2,12 @@
2
2
  eco-back - Librería Python
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
 
7
7
  # Módulos principales
8
8
  from . import database
9
9
  from . import api
10
+ from . import documento
11
+ from . import registro
10
12
 
11
- __all__ = ["database", "api", "__version__"]
13
+ __all__ = ["database", "api", "documento", "registro", "__version__"]
@@ -0,0 +1,6 @@
1
+ """
2
+ Módulo de gestión de documentos y anexos
3
+ """
4
+ from .anexos import AnexoManager, AnexoConfig
5
+
6
+ __all__ = ['AnexoManager', 'AnexoConfig']
@@ -0,0 +1,481 @@
1
+ """
2
+ Cliente para gestión de anexos y documentos UNP
3
+
4
+ Maneja el proceso de validación, escaneo y envío de documentos
5
+ a los servicios de la API UNP.
6
+ """
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Optional, Dict, Any, Callable, BinaryIO, Union
18
+ from io import BytesIO
19
+
20
+ import requests
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Constantes
26
+ MAX_SIZE = 10 * 1024 * 1024 # 10MB
27
+ MAX_RETRIES = 3
28
+ TIMEOUT = 30
29
+
30
+ # Extensiones MIME permitidas
31
+ ALLOWED_EXTENSIONS = {
32
+ 'pdf': 'application/pdf',
33
+ 'jpg': 'image/jpeg',
34
+ 'jpeg': 'image/jpeg',
35
+ 'png': 'image/png',
36
+ 'doc': 'application/msword',
37
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
38
+ 'xls': 'application/vnd.ms-excel',
39
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class AnexoConfig:
45
+ """
46
+ Configuración para el gestor de anexos
47
+
48
+ Attributes:
49
+ url_base: URL base del API UNP
50
+ max_size: Tamaño máximo de archivo en bytes (default: 10MB)
51
+ max_retries: Número máximo de reintentos (default: 3)
52
+ timeout: Timeout para las peticiones en segundos (default: 30)
53
+ scan_function: Función opcional para escaneo de archivos
54
+ diccionario_registro: Función para mapear tipos de documento de registro
55
+ diccionario_persona: Función para mapear tipos de documento de persona
56
+ diccionario_colectivo: Función para mapear tipos de documento de colectivo
57
+ """
58
+ url_base: str
59
+ max_size: int = MAX_SIZE
60
+ max_retries: int = MAX_RETRIES
61
+ timeout: int = TIMEOUT
62
+ scan_function: Optional[Callable] = None
63
+ diccionario_registro: Optional[Callable] = None
64
+ diccionario_persona: Optional[Callable] = None
65
+ diccionario_colectivo: Optional[Callable] = None
66
+
67
+
68
+ class DocumentoResult:
69
+ """Resultado del procesamiento de un documento"""
70
+
71
+ def __init__(self, status: str, mensaje: str, detalles: Optional[Dict] = None):
72
+ self.status = status
73
+ self.mensaje = mensaje
74
+ self.detalles = detalles or {}
75
+ self.exitoso = status in ['success', 'processing']
76
+
77
+ def to_dict(self) -> Dict[str, Any]:
78
+ """Convierte el resultado a diccionario"""
79
+ return {
80
+ 'status': self.status,
81
+ 'mensaje': self.mensaje,
82
+ 'detalles': self.detalles,
83
+ 'exitoso': self.exitoso
84
+ }
85
+
86
+
87
+ class AnexoManager:
88
+ """
89
+ Gestor de anexos y documentos para la API UNP
90
+
91
+ Maneja el proceso completo de:
92
+ - Validación de archivos
93
+ - Escaneo antivirus (opcional)
94
+ - Envío asíncrono a la API UNP
95
+
96
+ Ejemplo:
97
+ >>> from eco_back.documento import AnexoManager, AnexoConfig
98
+ >>>
99
+ >>> config = AnexoConfig(url_base="https://api.unp.gov.co")
100
+ >>> manager = AnexoManager(config)
101
+ >>>
102
+ >>> # Subir archivo desde ruta
103
+ >>> with open("documento.pdf", "rb") as f:
104
+ ... resultado = manager.guardar_anexo(
105
+ ... archivo=f,
106
+ ... nombre_archivo="documento.pdf",
107
+ ... llave="12345",
108
+ ... grupo="grupo1",
109
+ ... tipo_documento="cedula",
110
+ ... diccionario=1
111
+ ... )
112
+ >>> print(resultado.mensaje)
113
+ """
114
+
115
+ def __init__(self, config: AnexoConfig):
116
+ """
117
+ Inicializa el gestor de anexos
118
+
119
+ Args:
120
+ config: Configuración del gestor
121
+ """
122
+ self.config = config
123
+
124
+ def obtener_extension(self, nombre_archivo: str) -> str:
125
+ """
126
+ Obtiene la extensión del archivo (incluye el punto, ej: '.pdf')
127
+
128
+ Args:
129
+ nombre_archivo: Nombre del archivo
130
+
131
+ Returns:
132
+ Extensión del archivo en minúsculas (con punto)
133
+ """
134
+ return os.path.splitext(nombre_archivo)[1].lower()
135
+
136
+ def validar_extension(self, extension: str) -> bool:
137
+ """
138
+ Valida si la extensión es permitida
139
+
140
+ Args:
141
+ extension: Extensión a validar (con o sin punto)
142
+
143
+ Returns:
144
+ True si es válida, False en caso contrario
145
+ """
146
+ ext = extension.lstrip('.')
147
+ return ext in ALLOWED_EXTENSIONS
148
+
149
+ def obtener_mime_type(self, nombre_archivo: str) -> str:
150
+ """
151
+ Obtiene el tipo MIME basado en la extensión del archivo
152
+
153
+ Args:
154
+ nombre_archivo: Nombre del archivo
155
+
156
+ Returns:
157
+ Tipo MIME correspondiente
158
+ """
159
+ ext = nombre_archivo.split('.')[-1].lower()
160
+ return ALLOWED_EXTENSIONS.get(ext, 'application/octet-stream')
161
+
162
+ def _escanear_archivo(self, ruta_archivo: str, extension: str) -> Dict[str, Any]:
163
+ """
164
+ Escanea el archivo en busca de amenazas
165
+
166
+ Args:
167
+ ruta_archivo: Ruta del archivo a escanear
168
+ extension: Extensión del archivo
169
+
170
+ Returns:
171
+ Diccionario con status y detalles del escaneo
172
+ """
173
+ if self.config.scan_function:
174
+ try:
175
+ return self.config.scan_function(ruta_archivo, extension)
176
+ except Exception as e:
177
+ logger.error(f"Error en escaneo: {e}")
178
+ return {'status': 'ERROR', 'details': str(e)}
179
+
180
+ # Sin función de escaneo, asumir limpio
181
+ return {'status': 'CLEAN', 'details': 'No scan configured'}
182
+
183
+ def guardar_anexo(
184
+ self,
185
+ archivo: Union[BinaryIO, BytesIO, bytes],
186
+ nombre_archivo: str,
187
+ llave: str,
188
+ grupo: str,
189
+ tipo_documento: str,
190
+ diccionario: int,
191
+ asincrono: bool = True
192
+ ) -> DocumentoResult:
193
+ """
194
+ Guarda y envía un anexo a la API UNP
195
+
196
+ Args:
197
+ archivo: Archivo a subir (file-like object o bytes)
198
+ nombre_archivo: Nombre original del archivo
199
+ llave: Identificador único del registro
200
+ grupo: Grupo del documento
201
+ tipo_documento: Tipo de documento a enviar
202
+ diccionario: Tipo de diccionario (1=registro, 2=persona, 3=colectivo)
203
+ asincrono: Si True, envía en hilo separado (default: True)
204
+
205
+ Returns:
206
+ DocumentoResult con el resultado del procesamiento
207
+ """
208
+ temp_dir = tempfile.mkdtemp()
209
+
210
+ try:
211
+ # Validar extensión
212
+ extension = self.obtener_extension(nombre_archivo)
213
+ if not self.validar_extension(extension):
214
+ return DocumentoResult(
215
+ status='error',
216
+ mensaje=f"Extensión {extension} no permitida",
217
+ detalles={'extensiones_permitidas': list(ALLOWED_EXTENSIONS.keys())}
218
+ )
219
+
220
+ # Leer contenido del archivo
221
+ if isinstance(archivo, bytes):
222
+ contenido = archivo
223
+ else:
224
+ contenido = archivo.read()
225
+
226
+ # Validar tamaño
227
+ if len(contenido) > self.config.max_size:
228
+ return DocumentoResult(
229
+ status='error',
230
+ mensaje=f"Archivo excede tamaño máximo de {self.config.max_size / 1024 / 1024}MB",
231
+ detalles={'tamaño': len(contenido), 'max_size': self.config.max_size}
232
+ )
233
+
234
+ # Guardar temporalmente
235
+ with tempfile.NamedTemporaryFile(delete=False, dir=temp_dir, suffix=extension) as tmp:
236
+ tmp.write(contenido)
237
+ temp_path = tmp.name
238
+
239
+ # Escaneo antivirus
240
+ scan_result = self._escanear_archivo(temp_path, extension)
241
+ if scan_result['status'] != "CLEAN":
242
+ os.remove(temp_path)
243
+ shutil.rmtree(temp_dir, ignore_errors=True)
244
+ return DocumentoResult(
245
+ status='blocked',
246
+ mensaje=f"Archivo {nombre_archivo} bloqueado por escaneo",
247
+ detalles=scan_result
248
+ )
249
+
250
+ # Renombrar archivo con timestamp
251
+ ext = extension.lstrip('.') or 'bin'
252
+ timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
253
+ nuevo_nombre = f"{llave}_anexo_{timestamp}.{ext}"
254
+ destino = os.path.join(temp_dir, nuevo_nombre)
255
+ os.replace(temp_path, destino)
256
+
257
+ file_meta = {
258
+ 'field': 'anexo',
259
+ 'path': destino,
260
+ 'original_name': nombre_archivo,
261
+ 'tipo_documento': tipo_documento,
262
+ }
263
+
264
+ # Enviar archivo
265
+ if asincrono:
266
+ threading.Thread(
267
+ target=self._enviar_documento,
268
+ args=(file_meta, llave, grupo, tipo_documento, temp_dir, diccionario)
269
+ ).start()
270
+
271
+ return DocumentoResult(
272
+ status='processing',
273
+ mensaje='Documento en proceso de envío',
274
+ detalles={'archivo': nuevo_nombre}
275
+ )
276
+ else:
277
+ # Envío síncrono
278
+ resultado = self._enviar_documento_sincrono(
279
+ file_meta, llave, grupo, tipo_documento, temp_dir, diccionario
280
+ )
281
+ return resultado
282
+
283
+ except Exception as e:
284
+ logger.error(f"Error en guardar_anexo: {e}")
285
+ shutil.rmtree(temp_dir, ignore_errors=True)
286
+ return DocumentoResult(
287
+ status='error',
288
+ mensaje=str(e),
289
+ detalles={'error_type': type(e).__name__}
290
+ )
291
+
292
+ def _obtener_tipo_documento(self, tipo: str, diccionario: int) -> Optional[str]:
293
+ """
294
+ Obtiene el tipo de documento usando el diccionario correspondiente
295
+
296
+ Args:
297
+ tipo: Tipo de documento original
298
+ diccionario: Tipo de diccionario (1=registro, 2=persona, 3=colectivo)
299
+
300
+ Returns:
301
+ Tipo de documento mapeado o None
302
+ """
303
+ if diccionario == 1 and self.config.diccionario_registro:
304
+ return self.config.diccionario_registro(tipo)
305
+ elif diccionario == 2 and self.config.diccionario_persona:
306
+ return self.config.diccionario_persona(tipo)
307
+ elif diccionario == 3 and self.config.diccionario_colectivo:
308
+ return self.config.diccionario_colectivo(tipo)
309
+
310
+ # Si no hay diccionario, devolver el tipo original
311
+ return tipo
312
+
313
+ def _obtener_endpoint(self, diccionario: int) -> str:
314
+ """
315
+ Obtiene el endpoint según el tipo de diccionario
316
+
317
+ Args:
318
+ diccionario: Tipo de diccionario (1=registro, 2=persona, 3=colectivo)
319
+
320
+ Returns:
321
+ Endpoint correspondiente
322
+ """
323
+ endpoints = {
324
+ 1: 'usuario',
325
+ 2: 'persona',
326
+ 3: 'colectivo'
327
+ }
328
+ return endpoints.get(diccionario, 'usuario')
329
+
330
+ def _enviar_documento(
331
+ self,
332
+ file_meta: Dict[str, str],
333
+ llave: str,
334
+ grupo: str,
335
+ tipo: str,
336
+ temp_dir: str,
337
+ diccionario: int
338
+ ) -> None:
339
+ """
340
+ Envía el documento a la API UNP (versión asíncrona)
341
+
342
+ Args:
343
+ file_meta: Metadatos del archivo
344
+ llave: Identificador del registro
345
+ grupo: Grupo del documento
346
+ tipo: Tipo de documento
347
+ temp_dir: Directorio temporal
348
+ diccionario: Tipo de diccionario
349
+ """
350
+ try:
351
+ resultado = self._enviar_documento_sincrono(
352
+ file_meta, llave, grupo, tipo, temp_dir, diccionario
353
+ )
354
+ if resultado.exitoso:
355
+ logger.info(f"Documento enviado exitosamente: {file_meta['original_name']}")
356
+ else:
357
+ logger.error(f"Error enviando documento: {resultado.mensaje}")
358
+ except Exception as e:
359
+ logger.error(f"Error en envío asíncrono: {e}")
360
+ finally:
361
+ shutil.rmtree(temp_dir, ignore_errors=True)
362
+
363
+ def _enviar_documento_sincrono(
364
+ self,
365
+ file_meta: Dict[str, str],
366
+ llave: str,
367
+ grupo: str,
368
+ tipo: str,
369
+ temp_dir: str,
370
+ diccionario: int
371
+ ) -> DocumentoResult:
372
+ """
373
+ Envía el documento a la API UNP de forma síncrona
374
+
375
+ Args:
376
+ file_meta: Metadatos del archivo
377
+ llave: Identificador del registro
378
+ grupo: Grupo del documento
379
+ tipo: Tipo de documento
380
+ temp_dir: Directorio temporal
381
+ diccionario: Tipo de diccionario
382
+
383
+ Returns:
384
+ DocumentoResult con el resultado del envío
385
+ """
386
+ try:
387
+ if not os.path.exists(file_meta['path']):
388
+ return DocumentoResult(
389
+ status='error',
390
+ mensaje=f"Archivo no encontrado: {file_meta['path']}"
391
+ )
392
+
393
+ # Leer contenido
394
+ with open(file_meta['path'], 'rb') as f:
395
+ content = f.read()
396
+ if len(content) > self.config.max_size:
397
+ return DocumentoResult(
398
+ status='error',
399
+ mensaje=f"Archivo {file_meta['original_name']} excede tamaño permitido"
400
+ )
401
+
402
+ # Determinar MIME
403
+ mime_type = self.obtener_mime_type(file_meta['original_name'])
404
+
405
+ # Preparar archivos para envío
406
+ files = [
407
+ (file_meta['field'], (file_meta['original_name'], content, mime_type))
408
+ ]
409
+
410
+ # Headers
411
+ headers = {
412
+ 'User-Agent': 'EcoBack-DocumentSender/1.0',
413
+ 'X-Request-ID': str(uuid.uuid4())
414
+ }
415
+
416
+ # Obtener endpoint y tipo de documento
417
+ endpoint = self._obtener_endpoint(diccionario)
418
+ tipo_docu = self._obtener_tipo_documento(tipo, diccionario)
419
+
420
+ # URL completa
421
+ url = f"{self.config.url_base}/api-doc/{endpoint}/guardaranexo"
422
+
423
+ # Datos del formulario
424
+ data = {
425
+ 'llave': llave,
426
+ 'grupo': grupo,
427
+ 'tipo_documento': tipo_docu
428
+ }
429
+
430
+ # Envío con reintentos
431
+ for intento in range(self.config.max_retries):
432
+ try:
433
+ response = requests.post(
434
+ url,
435
+ files=files,
436
+ data=data,
437
+ headers=headers,
438
+ timeout=self.config.timeout
439
+ )
440
+
441
+ if response.status_code in [200, 201]:
442
+ logger.info(f"Envío exitoso en intento {intento + 1}")
443
+ return DocumentoResult(
444
+ status='success',
445
+ mensaje='Documento enviado exitosamente',
446
+ detalles={
447
+ 'intentos': intento + 1,
448
+ 'response': response.json() if response.content else {}
449
+ }
450
+ )
451
+ elif 500 <= response.status_code < 600:
452
+ # Error del servidor, reintentar
453
+ logger.warning(f"Error del servidor ({response.status_code}), reintentando...")
454
+ time.sleep(2 ** intento)
455
+ continue
456
+ else:
457
+ # Error del cliente, no reintentar
458
+ return DocumentoResult(
459
+ status='error',
460
+ mensaje=f"Error en envío: {response.status_code}",
461
+ detalles={'response_text': response.text}
462
+ )
463
+
464
+ except requests.RequestException as e:
465
+ logger.error(f"Error de conexión en intento {intento + 1}: {e}")
466
+ if intento == self.config.max_retries - 1:
467
+ return DocumentoResult(
468
+ status='error',
469
+ mensaje='Máximo número de reintentos alcanzado',
470
+ detalles={'error': str(e)}
471
+ )
472
+ time.sleep(2 ** intento)
473
+
474
+ return DocumentoResult(
475
+ status='error',
476
+ mensaje='No se pudo enviar el documento después de todos los reintentos'
477
+ )
478
+
479
+ finally:
480
+ if os.path.exists(temp_dir):
481
+ shutil.rmtree(temp_dir, ignore_errors=True)
@@ -0,0 +1,7 @@
1
+ """
2
+ Módulo de registro y trazabilidad
3
+ """
4
+
5
+ from .trazabilidad import TrazabilidadRegistroPermiso
6
+
7
+ __all__ = ["TrazabilidadRegistroPermiso"]
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eco-back
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Librería Python eco-back
5
5
  Author-email: EI UNP <ecosistema@unp.gov.co>
6
6
  License: MIT
@@ -22,6 +22,8 @@ License-File: LICENSE
22
22
  Requires-Dist: psycopg2-binary>=2.9.0
23
23
  Requires-Dist: geoalchemy2>=0.14.0
24
24
  Requires-Dist: requests>=2.31.0
25
+ Requires-Dist: djangorestframework>=3.14.0
26
+ Requires-Dist: django>=4.0.0
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: pytest>=7.0; extra == "dev"
27
29
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -34,16 +36,16 @@ Requires-Dist: shapely>=2.0.0; extra == "geo"
34
36
  Requires-Dist: geojson>=3.0.0; extra == "geo"
35
37
  Dynamic: license-file
36
38
 
37
- # eco-back
39
+ # eco-back
38
40
 
39
41
  Librería Python para backend con soporte para PostgreSQL/PostGIS y clientes API
40
42
 
41
43
  ## Características
42
44
 
43
- -**Base de datos**: Conexión y operaciones con PostgreSQL
44
- -**PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
45
- -**Cliente API**: Cliente HTTP genérico y cliente específico UNP
46
- -**Patrón Repository**: Implementación de patrones de diseño para acceso a datos
45
+ - **Base de datos**: Conexión y operaciones con PostgreSQL
46
+ - **PostGIS**: Operaciones geoespaciales (puntos, polígonos, búsquedas por proximidad)
47
+ - **Cliente API**: Cliente HTTP genérico y cliente específico UNP
48
+ - **Patrón Repository**: Implementación de patrones de diseño para acceso a datos
47
49
 
48
50
  ## Descripción
49
51
 
@@ -51,6 +53,12 @@ eco-back es una librería modular que facilita el desarrollo de aplicaciones bac
51
53
 
52
54
  ## Instalación
53
55
 
56
+ ### Desde PyPI
57
+
58
+ ```bash
59
+ pip install eco-back
60
+ ```
61
+
54
62
  ### Desde el código fuente
55
63
 
56
64
  ```bash
@@ -1,4 +1,4 @@
1
- eco_back/__init__.py,sha256=wqxLfLLFexz8P2eytcAlniZpADuCEbcT5tYhEfvh280,181
1
+ eco_back/__init__.py,sha256=iBHCjaKEjk8WemoNUlZQh4O3AEingB6rJU9fF0RVDAA,255
2
2
  eco_back/api/__init__.py,sha256=9moiU5H8jGlva8k65bUK49LRIkE7bcLH1SK1dk23wcM,202
3
3
  eco_back/api/client.py,sha256=b2J9ty7mRtPUsMD-YJui60B9GEa-85VAAb-sS5y1Rp8,8537
4
4
  eco_back/api/config.py,sha256=dyERZY_4lXtRjhqLaHI0F4WlgPOQ_sw35eHNWGkQNLo,1590
@@ -9,8 +9,12 @@ eco_back/database/connection.py,sha256=wGafOl_0dpgtFeIwe1f6QBtyrQ2BiEbw50Yfs1E-R
9
9
  eco_back/database/models.py,sha256=832FKC-4lfWWai0b_mtCv-H1MdHLJaaFY5NvsW8BqGs,1161
10
10
  eco_back/database/postgis.py,sha256=oTuz8OzGeFiM3Aro3Zlid31t7tJJNSvqAUYTTaYUzoI,10954
11
11
  eco_back/database/repository.py,sha256=NFh0pfvf7Pw878qOoVhR8ARMFC0CiJBQ_EwKshY8lOE,4433
12
- eco_back-0.1.0.dist-info/licenses/LICENSE,sha256=XKKSDU9WlUEAyPNlRhq6e2xhVNpJc097JwPZJ1rUnRE,1077
13
- eco_back-0.1.0.dist-info/METADATA,sha256=k-MEYCgvA2RKFIT5tfeYR8C3LhRA2Ac2SFzlxTxm0VM,4615
14
- eco_back-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- eco_back-0.1.0.dist-info/top_level.txt,sha256=ViJZv023782XNNFMcAsRC9iN1CU3tb8lP9wDAHacMyc,9
16
- eco_back-0.1.0.dist-info/RECORD,,
12
+ eco_back/documento/__init__.py,sha256=0rIgJ52in_BMg7VYr2pVlu9EnTPftgdqJ4hOZU5I4j0,146
13
+ eco_back/documento/anexos.py,sha256=KIzGNEVJ6tV3awhq4WGiqb8eSV2PluRRZ0PRbqeO5VE,17511
14
+ eco_back/registro/__init__.py,sha256=CRDv6OSKzt1r-RnKSfv86Ceb7yfL2Fn-OhHGCx467lE,148
15
+ eco_back/registro/trazabilidad.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ eco_back-0.2.0.dist-info/licenses/LICENSE,sha256=XKKSDU9WlUEAyPNlRhq6e2xhVNpJc097JwPZJ1rUnRE,1077
17
+ eco_back-0.2.0.dist-info/METADATA,sha256=iIpsl-7xCh5pq6JxOYu7pJkHxORwlruAP_1F-Ydz7So,4736
18
+ eco_back-0.2.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
19
+ eco_back-0.2.0.dist-info/top_level.txt,sha256=ViJZv023782XNNFMcAsRC9iN1CU3tb8lP9wDAHacMyc,9
20
+ eco_back-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5