commons-metrics 0.0.23__py3-none-any.whl → 0.0.25__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.
@@ -12,4 +12,4 @@ from .text_simplifier import TextSimplifier
12
12
  from .variable_finder import VariableFinder
13
13
 
14
14
  __all__ = ['Util', 'DatabaseConnection', 'ComponentRepository', 'UpdateDesignSystemComponents', 'GitHubAPIClient', 'AzureDevOpsClient', 'S3FileManager', 'CacheManager', 'CommonsReposClient', 'DateUtils', 'TextSimplifier', 'VariableFinder']
15
- __version__ = '0.0.23'
15
+ __version__ = '0.0.25'
@@ -1,65 +1,242 @@
1
1
  import requests
2
2
  import base64
3
+ import time
4
+ from threading import Lock
3
5
  from typing import List, Dict, Optional
4
6
  import os
5
7
  from .commons_repos_client import CommonsReposClient
6
8
 
9
+
10
+ class RateLimiter:
11
+ """
12
+ Controla el rate limiting para respetar los límites de API de GitHub.
13
+ GitHub permite 5000 requests/hora autenticados = ~1.4 TPS.
14
+
15
+ Estrategia adaptativa:
16
+ 1. Cuando está cerca del límite (>90%), aumenta delays si falta >10min para reset
17
+ 2. Si faltan <10min para reset, reduce delay a 10ms para usar toda la cuota
18
+ 3. Exponential backoff solo para errores 403/429 de rate limit
19
+ """
20
+ def __init__(self, delay: float = 0.72, token: str = None):
21
+ self.base_delay = delay
22
+ self.current_delay = delay
23
+ self.last_request_time = 0
24
+ self.lock = Lock()
25
+ self.token = token
26
+ self.last_rate_check = 0
27
+ self.rate_check_interval = 10 # Verificar rate limit cada 10 requests
28
+ self.request_count = 0
29
+ # Variables globales para rate limit
30
+ self.remaining_requests = None
31
+ self.limit = None
32
+ self.reset_time = None
33
+
34
+ def check_rate_limit(self):
35
+ """Obtiene el estado actual del rate limit desde GitHub y actualiza variables globales"""
36
+ if not self.token:
37
+ return None, None, None
38
+
39
+ try:
40
+ response = requests.get(
41
+ "https://api.github.com/rate_limit",
42
+ headers={"Authorization": f"token {self.token}"}
43
+ )
44
+ if response.status_code == 200:
45
+ data = response.json()
46
+ self.remaining_requests = data['rate']['remaining']
47
+ self.limit = data['rate']['limit']
48
+ self.reset_time = data['rate']['reset']
49
+ return self.remaining_requests, self.limit, self.reset_time
50
+ except Exception as e:
51
+ print(f"Warning: No se pudo verificar rate limit: {e}")
52
+
53
+ return None, None, None
54
+
55
+ def adjust_delay(self):
56
+ """
57
+ Ajusta el delay dinámicamente basado en el rate limit actual:
58
+ - >90% usado y falta >10min: aumentar delay
59
+ - <10min para reset: reducir delay a 10ms
60
+ """
61
+ remaining, limit, reset_time = self.check_rate_limit()
62
+
63
+ if remaining is None or limit is None or reset_time is None:
64
+ return
65
+
66
+ usage_percent = ((limit - remaining) / limit) * 100
67
+ time_to_reset = reset_time - time.time()
68
+ minutes_to_reset = time_to_reset / 60
69
+
70
+ # Estrategia 1: Cerca del límite (>90%) y falta >10min
71
+ if usage_percent > 90 and minutes_to_reset > 10:
72
+ # Aumentar delay progresivamente
73
+ self.current_delay = self.base_delay * 2
74
+ print(f"⚠️ Rate limit alto ({usage_percent:.1f}%). Aumentando delay a {self.current_delay:.2f}s")
75
+
76
+ # Estrategia 2: Faltan <10min para reset
77
+ elif minutes_to_reset < 10:
78
+ # Reducir delay para aprovechar requests restantes
79
+ self.current_delay = 0.01 # 10ms
80
+ print(f"🚀 Quedan {minutes_to_reset:.1f}min para reset. Acelerando a {self.current_delay*1000:.0f}ms")
81
+
82
+ # Normal: restaurar delay base
83
+ else:
84
+ self.current_delay = self.base_delay
85
+
86
+ def wait(self):
87
+ """Espera el tiempo necesario antes de hacer el siguiente request"""
88
+ with self.lock:
89
+ # Verificar y ajustar delay cada N requests
90
+ self.request_count += 1
91
+ if self.request_count % self.rate_check_interval == 0:
92
+ self.adjust_delay()
93
+
94
+ current_time = time.time()
95
+ time_since_last_request = current_time - self.last_request_time
96
+
97
+ if time_since_last_request < self.current_delay:
98
+ sleep_time = self.current_delay - time_since_last_request
99
+ time.sleep(sleep_time)
100
+
101
+ self.last_request_time = time.time()
102
+
103
+
7
104
  class GitHubAPIClient:
8
105
  """
9
- Cliente para interactuar con la API de GitHub
106
+ Cliente para interactuar con la API de GitHub con rate limiting integrado.
107
+ Controla automáticamente el TPS para no exceder los límites de GitHub (5000 req/hora).
108
+
109
+ Incluye:
110
+ - Rate limiting adaptativo (ajusta delays según uso)
111
+ - Exponential backoff para errores 403/429
112
+ - Optimización de requests en últimos 10 minutos antes de reset
10
113
  """
11
-
12
- def __init__(self, token: str, owner: str = None, repo: str = None):
114
+
115
+ # Rate limiter compartido entre todas las instancias
116
+ _rate_limiter = None
117
+
118
+ def __init__(self, token: str, owner: str = None, repo: str = None, enable_rate_limit: bool = True):
13
119
  """
14
120
  Inicializa el cliente de GitHub API
15
-
121
+
16
122
  Args:
17
123
  token: Personal Access Token de GitHub
18
124
  owner: Dueño del repositorio (ej: 'grupobancolombia-innersource') - Opcional
19
125
  repo: Nombre del repositorio (ej: 'NU0066001_BDS_MOBILE_Lib') - Opcional
126
+ enable_rate_limit: Si True, aplica rate limiting automático (por defecto True)
20
127
  """
21
128
  self.token = token
22
129
  self.owner = owner
23
130
  self.repo = repo
131
+ self.enable_rate_limit = enable_rate_limit
24
132
  self.base_url = f"https://api.github.com/repos/{owner}/{repo}" if owner and repo else "https://api.github.com"
25
133
  self.headers = {
26
134
  "Authorization": f"token {token}",
27
135
  "Accept": "application/vnd.github.v3+json"
28
136
  }
29
-
137
+
138
+ # Inicializar rate limiter compartido con token
139
+ if GitHubAPIClient._rate_limiter is None:
140
+ GitHubAPIClient._rate_limiter = RateLimiter(delay=0.72, token=token)
141
+
142
+ def _request_with_backoff(self, method: str, url: str, max_retries: int = 5, **kwargs):
143
+ """
144
+ Hace un request con exponential backoff SOLO para errores de rate limit (403/429)
145
+
146
+ Estrategia:
147
+ - Si hay X-RateLimit-Reset header, espera hasta ese momento
148
+ - Si no, usa exponential backoff: 2^attempt segundos (1s, 2s, 4s, 8s, 16s)
149
+ - Máximo 5 reintentos
150
+
151
+ Args:
152
+ method: Método HTTP ('get', 'post', etc)
153
+ url: URL del endpoint
154
+ max_retries: Número máximo de reintentos (default: 5)
155
+ **kwargs: Argumentos adicionales para requests
156
+
157
+ Returns:
158
+ Response object
159
+
160
+ Raises:
161
+ Exception: Si no es error de rate limit o se agotan los reintentos
162
+ """
163
+ request_func = getattr(requests, method.lower())
164
+
165
+ for attempt in range(max_retries):
166
+ if self.enable_rate_limit:
167
+ self._rate_limiter.wait()
168
+
169
+ response = request_func(url, **kwargs)
170
+
171
+ # Verificar si es específicamente un error de rate limit
172
+ if response.status_code in [403, 429]:
173
+ # Verificar que sea rate limit y no otro error 403
174
+ if 'rate limit' in response.text.lower() or response.status_code == 429:
175
+ if attempt < max_retries - 1:
176
+ # Usar el reset_time global del RateLimiter
177
+ reset_time = self._rate_limiter.reset_time
178
+
179
+ if reset_time:
180
+ # Esperar hasta el reset (con 5 segundos extra de margen)
181
+ wait_time = reset_time - time.time() + 5
182
+ if wait_time > 0:
183
+ wait_minutes = wait_time / 60
184
+ print(f"⚠️ Rate limit alcanzado. Esperando {wait_minutes:.1f} minutos hasta reset...")
185
+ time.sleep(wait_time)
186
+ # Verificar rate limit actualizado después de esperar
187
+ self._rate_limiter.check_rate_limit()
188
+ continue
189
+ else:
190
+ # Si no hay reset_time global, usar exponential backoff
191
+ # 1s, 2s, 4s, 8s, 16s (máx 60s)
192
+ wait_time = min(2 ** attempt, 60)
193
+ print(f"⚠️ Rate limit alcanzado. Esperando {wait_time}s (intento {attempt + 1}/{max_retries})...")
194
+ time.sleep(wait_time)
195
+ continue
196
+ else:
197
+ raise Exception(f"Rate limit excedido después de {max_retries} intentos")
198
+ else:
199
+ # Es un 403 pero NO de rate limit - no reintentar
200
+ raise Exception(f"Error 403 (no rate limit): {response.text}")
201
+
202
+ # Si es exitoso o cualquier otro error, retornar
203
+ return response
204
+
205
+ return response
206
+
30
207
  def get_directory_contents(self, path: str = "") -> List[Dict]:
31
208
  """
32
209
  Obtiene el contenido de un directorio en el repositorio
33
-
210
+
34
211
  Args:
35
212
  path: Ruta del directorio (ej: 'lib/atoms')
36
-
213
+
37
214
  Returns:
38
215
  Lista de diccionarios con información de archivos/carpetas
39
216
  """
40
217
  url = f"{self.base_url}/contents/{path}"
41
- response = requests.get(url, headers=self.headers)
42
-
218
+ response = self._request_with_backoff('get', url, headers=self.headers)
219
+
43
220
  if response.status_code == 200:
44
221
  return response.json()
45
222
  elif response.status_code == 404:
46
223
  return []
47
224
  else:
48
225
  raise Exception(f"Error getting directory contents: {response.status_code} - {response.text}")
49
-
226
+
50
227
  def get_file_content(self, path: str) -> Optional[str]:
51
228
  """
52
229
  Obtiene el contenido de un archivo
53
-
230
+
54
231
  Args:
55
232
  path: Ruta del archivo en el repositorio
56
-
233
+
57
234
  Returns:
58
235
  Contenido del archivo como string, o None si no existe
59
236
  """
60
237
  url = f"{self.base_url}/contents/{path}"
61
- response = requests.get(url, headers=self.headers)
62
-
238
+ response = self._request_with_backoff('get', url, headers=self.headers)
239
+
63
240
  if response.status_code == 200:
64
241
  content = response.json()
65
242
  if content.get('encoding') == 'base64':
@@ -70,85 +247,85 @@ class GitHubAPIClient:
70
247
  return None
71
248
  else:
72
249
  raise Exception(f"Error getting file content: {response.status_code} - {response.text}")
73
-
250
+
74
251
  def list_folders_in_directory(self, path: str) -> List[str]:
75
252
  """
76
253
  Lista solo las carpetas dentro de un directorio
77
-
254
+
78
255
  Args:
79
256
  path: Ruta del directorio
80
-
257
+
81
258
  Returns:
82
259
  Lista de nombres de carpetas
83
260
  """
84
261
  contents = self.get_directory_contents(path)
85
262
  folders = [
86
- item['name']
87
- for item in contents
263
+ item['name']
264
+ for item in contents
88
265
  if item['type'] == 'dir'
89
266
  ]
90
267
  return folders
91
-
268
+
92
269
  def walk_directory(self, path: str = "", extension: str = None, exclude_patterns: List[str] = None) -> List[Dict]:
93
270
  """
94
271
  Recorre recursivamente un directorio y retorna todos los archivos
95
-
272
+
96
273
  Args:
97
274
  path: Ruta del directorio inicial
98
275
  extension: Extensión a filtrar (ej: '.ts', '.dart')
99
276
  exclude_patterns: Lista de patrones a excluir (ej: ['.spec.', '.test.', '.d.ts'])
100
-
277
+
101
278
  Returns:
102
279
  Lista de diccionarios con información de archivos encontrados
103
280
  """
104
281
  all_files = []
105
282
  exclude_patterns = exclude_patterns or []
106
-
283
+
107
284
  def should_exclude(filename: str) -> bool:
108
285
  return any(pattern in filename for pattern in exclude_patterns)
109
-
286
+
110
287
  def recurse_directory(current_path: str):
111
288
  contents = self.get_directory_contents(current_path)
112
-
289
+
113
290
  for item in contents:
114
291
  item_path = f"{current_path}/{item['name']}" if current_path else item['name']
115
-
292
+
116
293
  if item['type'] == 'file':
117
294
  # Aplicar filtros de extensión y exclusión
118
295
  if extension and not item['name'].endswith(extension):
119
296
  continue
120
297
  if should_exclude(item['name']):
121
298
  continue
122
-
299
+
123
300
  all_files.append({
124
301
  'name': item['name'],
125
302
  'path': item_path,
126
303
  'url': item.get('url', ''),
127
304
  'download_url': item.get('download_url', '')
128
305
  })
129
-
306
+
130
307
  elif item['type'] == 'dir':
131
308
  # Excluir directorios comunes que no contienen componentes
132
309
  if item['name'] not in ['node_modules', 'dist', 'build', '.git', 'test', 'tests', '__pycache__']:
133
310
  recurse_directory(item_path)
134
-
311
+
135
312
  recurse_directory(path)
136
313
  return all_files
137
-
314
+
138
315
  def search_code(self, query: str, per_page: int = 100) -> List[Dict]:
139
316
  """
140
317
  Busca código en GitHub usando la API de búsqueda
141
-
318
+
142
319
  Args:
143
320
  query: Query de búsqueda (ej: '"bds_mobile" in:file filename:pubspec.yaml')
144
321
  per_page: Número de resultados por página (máximo 100)
145
-
322
+
146
323
  Returns:
147
324
  Lista de diccionarios con información de archivos encontrados
148
325
  """
149
326
  all_results = []
150
327
  page = 1
151
-
328
+
152
329
  while True:
153
330
  url = "https://api.github.com/search/code"
154
331
  params = {
@@ -156,53 +333,54 @@ class GitHubAPIClient:
156
333
  'per_page': per_page,
157
334
  'page': page
158
335
  }
159
-
160
- response = requests.get(url, headers=self.headers, params=params)
161
-
162
- if response.status_code == 200:
163
- data = response.json()
164
- items = data.get('items', [])
165
-
166
- if not items:
167
- break
168
-
169
- all_results.extend(items)
170
-
171
- # Si hay menos items que per_page, es la última página
172
- if len(items) < per_page:
173
- break
174
-
175
- page += 1
176
- elif response.status_code == 403:
177
- # Rate limit alcanzado
178
- print(f"⚠️ Rate limit alcanzado. Resultados obtenidos: {len(all_results)}")
336
+
337
+ try:
338
+ response = self._request_with_backoff('get', url, headers=self.headers, params=params)
339
+
340
+ if response.status_code == 200:
341
+ data = response.json()
342
+ items = data.get('items', [])
343
+
344
+ if not items:
345
+ break
346
+
347
+ all_results.extend(items)
348
+
349
+ # Si hay menos items que per_page, es la última página
350
+ if len(items) < per_page:
351
+ break
352
+
353
+ page += 1
354
+ else:
355
+ raise Exception(f"Error searching code: {response.status_code} - {response.text}")
356
+
357
+ except Exception as e:
358
+ print(f"⚠️ Error en búsqueda: {e}. Resultados obtenidos: {len(all_results)}")
179
359
  break
180
- else:
181
- raise Exception(f"Error searching code: {response.status_code} - {response.text}")
182
-
360
+
183
361
  return all_results
184
-
362
+
185
363
  def search_projects_with_bds(self, platform: str, design_system_name: str = None) -> List[Dict]:
186
364
  """
187
365
  Busca proyectos que usan el sistema de diseño BDS (mobile o web)
188
-
366
+
189
367
  Args:
190
368
  platform: 'mobile' o 'web'
191
369
  design_system_name: Nombre del paquete del sistema de diseño
192
- Si no se proporciona, usa valores por defecto:
193
- - mobile: "bds_mobile"
194
- - web: "@bancolombia/design-system-web"
195
-
370
+ Si no se proporciona, usa valores por defecto:
371
+ - mobile: "bds_mobile"
372
+ - web: "@bancolombia/design-system-web"
373
+
196
374
  Returns:
197
375
  Lista de proyectos con información del repositorio incluyendo la versión
198
-
376
+
199
377
  Raises:
200
378
  ValueError: Si platform no es 'mobile' o 'web'
201
379
  """
202
380
  # Validar plataforma
203
381
  if platform not in ['mobile', 'web']:
204
382
  raise ValueError(f"Platform debe ser 'mobile' o 'web', se recibió: {platform}")
205
-
383
+
206
384
  # Configurar valores según la plataforma
207
385
  if platform == 'mobile':
208
386
  config_file = 'pubspec.yaml'
@@ -212,21 +390,21 @@ class GitHubAPIClient:
212
390
  config_file = 'package.json'
213
391
  default_package_name = '@bancolombia/design-system-web'
214
392
  extract_version_method = CommonsReposClient.extract_package_version_from_package_json
215
-
393
+
216
394
  # Usar nombre de paquete por defecto si no se proporciona
217
395
  package_name = design_system_name or default_package_name
218
-
396
+
219
397
  # Agregar filtro de organización si self.owner está definido
220
398
  org_filter = f" org:{self.owner}" if self.owner else ""
221
399
  query = f'"{package_name}" in:file filename:{config_file}{org_filter}'
222
400
  results = self.search_code(query)
223
-
401
+
224
402
  projects = []
225
403
  for item in results:
226
404
  # Obtener contenido del archivo de configuración para extraer la versión
227
405
  file_content = self._get_file_content_from_url(item['url'])
228
406
  version = extract_version_method(file_content, package_name)
229
-
407
+
230
408
  project_info = {
231
409
  'name': item['repository']['name'],
232
410
  'full_name': item['repository']['full_name'],
@@ -236,66 +414,71 @@ class GitHubAPIClient:
236
414
  'bds_version': version
237
415
  }
238
416
  projects.append(project_info)
239
-
417
+
240
418
  return projects
241
-
419
+
242
420
  def search_repositories(self, query: str, per_page: int = 100) -> List[Dict]:
243
421
  """
244
422
  Busca repositorios en GitHub usando la API de búsqueda
245
-
423
+
246
424
  Args:
247
425
  query: Query de búsqueda (ej: 'NU0296001 mobile in:name org:grupobancolombia-innersource')
248
426
  per_page: Número de resultados por página (máximo 100)
249
-
427
+
250
428
  Returns:
251
429
  Lista de diccionarios con información de repositorios encontrados
252
430
  """
253
431
  all_results = []
254
432
  page = 1
255
433
  search_url = "https://api.github.com/search/repositories"
256
-
434
+
257
435
  while True:
258
436
  params = {
259
437
  'q': query,
260
438
  'per_page': per_page,
261
439
  'page': page
262
440
  }
263
-
264
- response = requests.get(search_url, headers=self.headers, params=params)
265
-
266
- if response.status_code != 200:
267
- print(f"Error searching repositories: {response.status_code}")
268
- print(f"Response: {response.text}")
269
- break
270
-
271
- data = response.json()
272
- items = data.get('items', [])
273
-
274
- if not items:
275
- break
276
-
277
- all_results.extend(items)
278
-
279
- # Verificar si hay más páginas
280
- if len(items) < per_page:
441
+
442
+ try:
443
+ response = self._request_with_backoff('get', search_url, headers=self.headers, params=params)
444
+
445
+ if response.status_code != 200:
446
+ print(f"Error searching repositories: {response.status_code}")
447
+ print(f"Response: {response.text}")
448
+ break
449
+
450
+ data = response.json()
451
+ items = data.get('items', [])
452
+
453
+ if not items:
454
+ break
455
+
456
+ all_results.extend(items)
457
+
458
+ # Verificar si hay más páginas
459
+ if len(items) < per_page:
460
+ break
461
+
462
+ page += 1
463
+
464
+ except Exception as e:
465
+ print(f"⚠️ Error buscando repositorios: {e}")
281
466
  break
282
-
283
- page += 1
284
-
467
+
285
468
  return all_results
286
-
469
+
287
470
  def _get_file_content_from_url(self, api_url: str) -> Optional[str]:
288
471
  """
289
472
  Obtiene el contenido de un archivo desde una URL de la API de GitHub
290
-
473
+
291
474
  Args:
292
475
  api_url: URL de la API de GitHub para el archivo
293
-
476
+
294
477
  Returns:
295
478
  Contenido del archivo como string, o None si no existe
296
479
  """
297
- response = requests.get(api_url, headers=self.headers)
298
-
480
+ response = self._request_with_backoff('get', api_url, headers=self.headers)
481
+
299
482
  if response.status_code == 200:
300
483
  content = response.json()
301
484
  if content.get('encoding') == 'base64':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commons_metrics
3
- Version: 0.0.23
3
+ Version: 0.0.25
4
4
  Summary: A simple library for basic statistical calculations
5
5
  Author: Bancolombia
6
6
  Author-email: omar.david.pino@email.com
@@ -1,18 +1,18 @@
1
- commons_metrics/__init__.py,sha256=oag-3Hgq5mcgLmEtYNzLqfUpioGJQVPjRSGzgWCfMLY,796
1
+ commons_metrics/__init__.py,sha256=Kge7GuDHOjv5eD6u5y1BDUs27R3Kzn65hj3dFYq9pM0,796
2
2
  commons_metrics/azure_devops_client.py,sha256=XrGqhr4voFUtGkdB27tnzsMSiNdpfY4stQUU51e1a0U,16755
3
3
  commons_metrics/cache_manager.py,sha256=HOeup9twUizjJAbh1MNXdPT8BMVeLFoolOWlAzMTXkE,2651
4
4
  commons_metrics/commons_repos_client.py,sha256=PiAMLWuDnI8AlZzE3sfQ3s2P23UrYbbqaq63AFRroHc,4695
5
5
  commons_metrics/database.py,sha256=wzcrK8q09J28NMLExkhWy8w7N2fQPkckrGf62C9rdQ0,1989
6
6
  commons_metrics/date_utils.py,sha256=8465712QJDGcshqry97Gi90lbMEbvbX3uiuHRVwGHbE,2654
7
- commons_metrics/github_api_client.py,sha256=hd8WH2mhuYBd6MHv06RdVv3uGKRlk9wfSTijahOakJ0,11442
7
+ commons_metrics/github_api_client.py,sha256=as6VePgfAn1zKBGi_1piPqbf3X7AJzwQ-InCjlwHnxs,18710
8
8
  commons_metrics/repositories.py,sha256=47JK9rhcpx_X6RWRkM3768qTwk39ODm8LLvi6QzZDdQ,9480
9
9
  commons_metrics/s3_file_manager.py,sha256=Mm7vlPJeXB46LnCXFs9oz7PQezcIcgYLlSJqMzVf72g,3384
10
10
  commons_metrics/text_simplifier.py,sha256=jRYckQ5APR0A3wY4hg70kOHxHUFuGzd6D-CJC3__j78,1518
11
11
  commons_metrics/update_design_components.py,sha256=QpY0GCCCMjdYOZ7b8oNigU9iTpiGx91CYsyWwN8WVDA,7660
12
12
  commons_metrics/util.py,sha256=98zuynalXumQRh-BB0Bcjyoh6vS2BTOUM8tVgr7iS9Q,1225
13
13
  commons_metrics/variable_finder.py,sha256=pxI_XSd-lq_AiUjDbcUC4knIZhWZwt7HQrQOa-F0ud4,8061
14
- commons_metrics-0.0.23.dist-info/licenses/LICENSE,sha256=jsHZ2Sh1wCL74HC25pDDGXCyQ0xgsTAy62FvEnehKIg,1067
15
- commons_metrics-0.0.23.dist-info/METADATA,sha256=Gk9umfg9S2nBPiVtkSgQqUjvE7Q4JnCAVVnpxdcYap8,402
16
- commons_metrics-0.0.23.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
- commons_metrics-0.0.23.dist-info/top_level.txt,sha256=lheUN-3OKdU3A8Tg8Y-1IEB_9i_vVRA0g_FOiUsTQz8,16
18
- commons_metrics-0.0.23.dist-info/RECORD,,
14
+ commons_metrics-0.0.25.dist-info/licenses/LICENSE,sha256=jsHZ2Sh1wCL74HC25pDDGXCyQ0xgsTAy62FvEnehKIg,1067
15
+ commons_metrics-0.0.25.dist-info/METADATA,sha256=Kc29zW719F2lFCX16tN6RTLIzyexfPvYJ7LJMT6F7ag,402
16
+ commons_metrics-0.0.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ commons_metrics-0.0.25.dist-info/top_level.txt,sha256=lheUN-3OKdU3A8Tg8Y-1IEB_9i_vVRA0g_FOiUsTQz8,16
18
+ commons_metrics-0.0.25.dist-info/RECORD,,