tracereports 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.
@@ -0,0 +1,478 @@
1
+ """
2
+ Transporte del cliente: la evidencia sale en segundo plano, sin frenar los tests.
3
+
4
+ - Cola acotada (por cantidad y por bytes) que un hilo envía en orden (FIFO).
5
+ - Reintentos con backoff exponencial ante caídas de red, timeouts, 5xx, 408 y 429. Cada envío
6
+ lleva una clave ``Idempotency-Key``: si el servidor ya lo aplicó, el reintento no lo duplica.
7
+ - Circuito: si el servidor no responde, las llamadas síncronas (crear ejecución/test) fallan al
8
+ instante durante un rato en vez de esperar su timeout en cada test.
9
+ - Al cerrar, ``flush`` espera como máximo un tiempo; lo que no se pudo enviar se guarda en una
10
+ cola local en disco (spool) si se configuró una carpeta, o se informa como perdido.
11
+
12
+ Cola llena: los eventos nuevos se descartan (se conservan los anteriores, para no romper el
13
+ orden de la evidencia ya encolada) y se cuentan en ``stats["dropped"]``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import collections
20
+ import glob
21
+ import itertools
22
+ import json
23
+ import logging
24
+ import os
25
+ import threading
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ import uuid
30
+ from typing import Callable, Optional
31
+
32
+ log = logging.getLogger("tracereports")
33
+
34
+ RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
35
+ CIRCUIT_SECONDS = 30.0
36
+ MAX_BACKOFF = 30.0
37
+ SPOOL_CHUNK_ITEMS = 200 # eventos por archivo de spool
38
+ SPOOL_CHUNK_BYTES = 8 << 20 # bytes por archivo de spool
39
+ SPOOL_STALE_SECONDS = 600.0 # un archivo reclamado que nadie toca en este tiempo se considera abandonado
40
+ SPOOL_HEARTBEAT_SECONDS = 60.0 # cada cuánto el dueño renueva la posesión de sus archivos reclamados
41
+
42
+
43
+ class _SpoolFile:
44
+ """Archivo de spool reclamado: se borra cuando ya no le quedan eventos sin destino."""
45
+ __slots__ = ("path", "remaining", "touched")
46
+
47
+ def __init__(self, path, remaining):
48
+ self.path, self.remaining = path, remaining
49
+ self.touched = time.time()
50
+
51
+
52
+ class _Item:
53
+ __slots__ = ("method", "path", "body", "content_type", "timeout", "key", "attempts", "abandoned", "fate", "src")
54
+
55
+ def __init__(self, method, path, body, content_type, timeout, key=None):
56
+ self.method, self.path, self.body = method, path, body
57
+ self.content_type, self.timeout = content_type, timeout
58
+ self.key = key or uuid.uuid4().hex
59
+ self.attempts = 0
60
+ self.abandoned = False # drain_to_spool se lo llevó: el hilo ya no decide su destino
61
+ self.fate = "" # "spooled" | "lost" cuando se lo llevó el drenado
62
+ self.src: "Optional[_SpoolFile]" = None
63
+
64
+ def to_json(self) -> str:
65
+ return json.dumps({"method": self.method, "path": self.path, "content_type": self.content_type,
66
+ "timeout": self.timeout, "key": self.key, "body": base64.b64encode(self.body).decode()})
67
+
68
+ @classmethod
69
+ def from_json(cls, line: str) -> "_Item":
70
+ d = json.loads(line)
71
+ return cls(d["method"], d["path"], base64.b64decode(d["body"]), d["content_type"], d.get("timeout", 10.0), d["key"])
72
+
73
+
74
+ class Sender:
75
+ """Envía requests HTTP al servidor: síncronas (con reintentos cortos) o encoladas."""
76
+
77
+ def __init__(self, base_url: str, headers: Callable[[dict], dict], max_items: int = 5000,
78
+ max_bytes: int = 64 << 20, spool_dir: Optional[str] = None) -> None:
79
+ self.base_url = base_url
80
+ self._headers = headers
81
+ self.max_items, self.max_bytes = max_items, max_bytes
82
+ self.spool_dir = spool_dir
83
+ self._q: "collections.deque[_Item]" = collections.deque() # eventos esperando (no el en vuelo)
84
+ self._bytes = 0 # bytes de lo que espera + el evento en vuelo
85
+ self._cv = threading.Condition()
86
+ self._inflight: "Optional[_Item]" = None # evento que el hilo está enviando
87
+ self._claims: "set[_SpoolFile]" = set() # archivos de spool reclamados con eventos pendientes
88
+ self._reserved_items = 0 # lugar reservado por load_spool mientras divide un archivo
89
+ self._reserved_bytes = 0
90
+ self.spool_report: dict = {"unreadable": [], "busy": 0}
91
+ self._thread: Optional[threading.Thread] = None
92
+ self._down_until = 0.0
93
+ self.stats = {"sent": 0, "retried": 0, "rejected": 0, "dropped": 0, "spooled": 0, "lost": 0}
94
+
95
+ # ------------------------------------------------------------- estado
96
+ @property
97
+ def pending(self) -> int:
98
+ with self._cv:
99
+ busy = self._inflight is not None and not self._inflight.abandoned
100
+ return len(self._q) + (1 if busy else 0)
101
+
102
+ def server_down(self) -> bool:
103
+ return time.time() < self._down_until
104
+
105
+ def _count(self, key: str, n: int = 1) -> None:
106
+ with self._cv:
107
+ self.stats[key] += n
108
+
109
+ # ---------------------------------------------------------- un request
110
+ def _post(self, item: _Item) -> "tuple[str, Optional[dict]]":
111
+ """Envía una vez. Devuelve ("ok", json) | ("retry", None) | ("reject", None)."""
112
+ req = urllib.request.Request(
113
+ self.base_url + item.path, data=item.body, method=item.method,
114
+ headers=self._headers({"Content-Type": item.content_type, "Accept": "application/json",
115
+ "Idempotency-Key": item.key}),
116
+ )
117
+ try:
118
+ with urllib.request.urlopen(req, timeout=item.timeout) as resp:
119
+ raw = resp.read()
120
+ self._down_until = 0.0
121
+ return "ok", (json.loads(raw) if raw else {})
122
+ except urllib.error.HTTPError as err:
123
+ detail = err.read().decode(errors="replace")[:300]
124
+ if err.code in RETRYABLE_STATUS:
125
+ log.debug("tracereports: %s %s -> HTTP %s (se reintenta)", item.method, item.path, err.code)
126
+ return "retry", None
127
+ if err.code == 401:
128
+ detail += (" (configura el token: TraceReports(token=...), la variable TRACEREPORTS_TOKEN"
129
+ " o TRACEREPORTS_TOKEN en el .env del proyecto)")
130
+ log.warning("tracereports: %s %s -> HTTP %s %s", item.method, item.path, err.code, detail)
131
+ return "reject", None
132
+ except (urllib.error.URLError, OSError, ValueError) as err:
133
+ if not self.server_down():
134
+ log.warning("tracereports: %s %s falló: %s", item.method, item.path, err)
135
+ self._down_until = time.time() + CIRCUIT_SECONDS
136
+ return "retry", None
137
+
138
+ def send_now(self, method: str, path: str, body: bytes, content_type: str, timeout: float,
139
+ retries: int = 2) -> Optional[dict]:
140
+ """Request síncrona (se necesita su respuesta: ids de ejecución o test) con reintentos
141
+ cortos e idempotencia. Con el circuito abierto falla al instante."""
142
+ if self.server_down():
143
+ return None
144
+ item = _Item(method, path, body, content_type, timeout)
145
+ for attempt in range(retries + 1):
146
+ kind, res = self._post(item)
147
+ if kind == "ok":
148
+ self._count("sent")
149
+ return res
150
+ if kind == "reject" or self.server_down() or attempt == retries:
151
+ break
152
+ self._count("retried")
153
+ time.sleep((0.3, 1.0, 2.0)[min(attempt, 2)])
154
+ return None
155
+
156
+ # --------------------------------------------------------------- cola
157
+ #
158
+ # Dueño de cada evento: o está esperando en ``_q``, o lo tiene el hilo en ``_inflight``. Solo el
159
+ # hilo saca de ``_q`` (popleft) y solo él decide el destino del evento en vuelo. ``drain_to_spool``
160
+ # se lleva lo que espera y marca el evento en vuelo como ``abandoned``: si después se confirma,
161
+ # queda además en el spool (reenviarlo no duplica nada: misma Idempotency-Key); si falla, ya está
162
+ # a salvo en el spool. Así ningún evento desaparece sin quedar enviado, en disco o contado como perdido.
163
+
164
+ def _free(self) -> "tuple[int, int]":
165
+ """Espacio libre de la cola (con el lock tomado), descontando lo reservado por cargas del
166
+ spool en curso: así ni enqueue() ni otra carga simultánea usan el mismo lugar."""
167
+ busy = 1 if self._inflight is not None and not self._inflight.abandoned else 0
168
+ items = self.max_items - len(self._q) - busy - self._reserved_items
169
+ return items, self.max_bytes - self._bytes - self._reserved_bytes
170
+
171
+ def _fits(self, n: int, size: int) -> bool:
172
+ free_items, free_bytes = self._free()
173
+ return n <= free_items and size <= free_bytes
174
+
175
+ def enqueue(self, method: str, path: str, body: bytes, content_type: str, timeout: float) -> bool:
176
+ item = _Item(method, path, body, content_type, timeout)
177
+ with self._cv:
178
+ if not self._fits(1, len(body)):
179
+ self.stats["dropped"] += 1
180
+ if self.stats["dropped"] == 1:
181
+ log.warning("tracereports: la cola de envío está llena (%d eventos / %d MB); se descartan "
182
+ "los eventos nuevos hasta que el servidor responda", self.max_items, self.max_bytes >> 20)
183
+ return False
184
+ self._q.append(item)
185
+ self._bytes += len(body)
186
+ self._cv.notify_all()
187
+ self._ensure_thread()
188
+ return True
189
+
190
+ def _ensure_thread(self) -> None:
191
+ if self._thread is None or not self._thread.is_alive():
192
+ self._thread = threading.Thread(target=self._run, name="tracereports-sender", daemon=True)
193
+ self._thread.start()
194
+
195
+ def _run(self) -> None:
196
+ while True:
197
+ with self._cv:
198
+ while not self._q:
199
+ self._cv.wait()
200
+ item = self._q.popleft()
201
+ self._inflight = item
202
+ try:
203
+ kind, _ = self._post(item)
204
+ except Exception as err: # noqa: BLE001 - el hilo nunca muere: se reintenta
205
+ log.debug("tracereports: error inesperado al enviar %s: %s", item.path, err)
206
+ kind = "retry"
207
+ delay = 0.0
208
+ with self._cv:
209
+ self._inflight = None
210
+ if self._claims:
211
+ self._heartbeat()
212
+ if kind == "retry":
213
+ if not item.abandoned: # si se fue al spool, ya está a salvo
214
+ item.attempts += 1
215
+ self.stats["retried"] += 1
216
+ self._q.appendleft(item)
217
+ delay = min(MAX_BACKOFF, 0.5 * (2 ** min(item.attempts, 6)))
218
+ else:
219
+ if not item.abandoned:
220
+ self._bytes -= len(item.body)
221
+ elif item.fate == "lost" and kind == "ok":
222
+ self.stats["lost"] -= 1 # se contó como perdido, pero llegó
223
+ self.stats["sent" if kind == "ok" else "rejected"] += 1
224
+ self._settle(item)
225
+ self._cv.notify_all()
226
+ if delay:
227
+ time.sleep(delay)
228
+
229
+ def flush(self, timeout: float) -> int:
230
+ """Espera a que la cola se vacíe (máx. ``timeout`` s). Devuelve lo que quedó pendiente."""
231
+ deadline = time.time() + max(timeout, 0)
232
+ with self._cv:
233
+ while (self._q or self._inflight is not None) and time.time() < deadline:
234
+ self._cv.wait(timeout=min(0.2, max(deadline - time.time(), 0.01)))
235
+ if self._claims:
236
+ self._heartbeat()
237
+ return len(self._q) + (1 if self._inflight is not None and not self._inflight.abandoned else 0)
238
+
239
+ # ----------------------------------------------------- spool (en disco)
240
+ #
241
+ # Nombre: tracereports_spool_<ms:13>_<seq:6>_<pid>-<uuid>_<parte>.jsonl. El uuid hace único cada
242
+ # guardado (dos drenados en el mismo milisegundo nunca se pisan) y <ms>_<seq> da el orden de
243
+ # reenvío. Un archivo se *reclama* tocándolo y renombrándolo a "<nombre>.claimed-<pid>-<id>"; el
244
+ # dueño lo vuelve a tocar mientras le queden eventos (SPOOL_HEARTBEAT_SECONDS), así que solo se
245
+ # considera abandonado si nadie lo tocó en SPOOL_STALE_SECONDS. Se borra cuando todos sus eventos
246
+ # se confirmaron o pasaron a un spool nuevo.
247
+
248
+ def _settle(self, item: "_Item") -> None:
249
+ """El evento ya no depende de su archivo de spool de origen: si era el último, se borra."""
250
+ src = item.src
251
+ item.src = None
252
+ if src is None:
253
+ return
254
+ src.remaining -= 1
255
+ if src.remaining <= 0:
256
+ self._claims.discard(src)
257
+ try:
258
+ os.remove(src.path)
259
+ except OSError:
260
+ pass
261
+
262
+ def _heartbeat(self) -> None:
263
+ """Renueva la posesión de los archivos reclamados que siguen con eventos pendientes."""
264
+ now = time.time()
265
+ for src in list(self._claims):
266
+ if now - src.touched >= SPOOL_HEARTBEAT_SECONDS:
267
+ try:
268
+ os.utime(src.path)
269
+ src.touched = now
270
+ except OSError:
271
+ pass
272
+
273
+ @staticmethod
274
+ def _spool_name(ms: int, seq: int, part: str) -> str:
275
+ return f"tracereports_spool_{ms:013d}_{seq:06d}_{os.getpid()}-{uuid.uuid4().hex[:12]}_{part}.jsonl"
276
+
277
+ def _write_file(self, path: str, items: "list[_Item]") -> None:
278
+ """Escritura atómica que nunca reemplaza otra evidencia: si el nombre existe, se elige otro."""
279
+ tmp = f"{path}.tmp-{uuid.uuid4().hex}"
280
+ with open(tmp, "w", encoding="utf-8") as fh:
281
+ for it in items:
282
+ fh.write(it.to_json() + "\n")
283
+ fh.flush()
284
+ os.fsync(fh.fileno())
285
+ os.replace(tmp, path)
286
+
287
+ def _new_spool_path(self, ms: int, seq: int, part: str) -> str:
288
+ while True:
289
+ path = os.path.join(self.spool_dir, self._spool_name(ms, seq, part))
290
+ if not os.path.exists(path):
291
+ return path
292
+
293
+ def _write_spool(self, items: "list[_Item]") -> bool:
294
+ """Escribe los eventos en archivos de hasta SPOOL_CHUNK_ITEMS / SPOOL_CHUNK_BYTES."""
295
+ os.makedirs(self.spool_dir, exist_ok=True)
296
+ ms, seq = int(time.time() * 1000), next(_SPOOL_SEQ)
297
+ chunk: "list[_Item]" = []
298
+ size = n = 0
299
+ for it in items + [None]:
300
+ if it is None or (chunk and (len(chunk) >= SPOOL_CHUNK_ITEMS or size + len(it.body) > SPOOL_CHUNK_BYTES)):
301
+ if chunk:
302
+ self._write_file(self._new_spool_path(ms, seq, f"{n:04d}"), chunk)
303
+ n += 1
304
+ chunk, size = [], 0
305
+ if it is not None:
306
+ chunk.append(it)
307
+ size += len(it.body)
308
+ return True
309
+
310
+ def drain_to_spool(self) -> int:
311
+ """Saca de la cola lo pendiente (también el evento en vuelo): al spool si hay carpeta, si no
312
+ lo cuenta como perdido. El archivo de spool de origen se borra solo cuando sus eventos ya
313
+ están en el spool nuevo."""
314
+ with self._cv:
315
+ items = list(self._q)
316
+ self._q.clear()
317
+ if self._inflight is not None and not self._inflight.abandoned:
318
+ items.insert(0, self._inflight)
319
+ for it in items:
320
+ it.abandoned = True
321
+ self._bytes = 0
322
+ if not items:
323
+ return 0
324
+ if self.spool_dir:
325
+ try:
326
+ self._write_spool(items)
327
+ with self._cv:
328
+ self.stats["spooled"] += len(items)
329
+ for it in items:
330
+ it.fate = "spooled"
331
+ self._settle(it) # ya está en el spool nuevo: el de origen puede irse
332
+ log.warning("tracereports: %d eventos sin enviar quedaron en %s (reenvío: python -m tracereports.resend %s)",
333
+ len(items), self.spool_dir, self.spool_dir)
334
+ return len(items)
335
+ except OSError as err:
336
+ log.warning("tracereports: no se pudo escribir la cola local %s: %s", self.spool_dir, err)
337
+ with self._cv:
338
+ self.stats["lost"] += len(items)
339
+ for it in items:
340
+ it.fate = "lost"
341
+ log.warning("tracereports: %d eventos de evidencia no se pudieron enviar y se perdieron "
342
+ "(configura TRACEREPORTS_SPOOL_DIR para guardarlos y reenviarlos después)", len(items))
343
+ return len(items)
344
+
345
+ def load_spool(self, spool_dir: Optional[str] = None) -> int:
346
+ """
347
+ Encola eventos guardados en el spool, en orden y mientras quepan en la cola. Un archivo más
348
+ grande que el espacio libre se carga por partes: lo que no entra se guarda primero en un
349
+ archivo nuevo (que conserva su lugar en el orden) y recién después se acota el reclamado.
350
+ ``self.spool_report`` dice qué quedó sin procesar: ilegibles y archivos de otro proceso.
351
+ """
352
+ folder = spool_dir or self.spool_dir
353
+ self.spool_report = {"unreadable": [], "busy": 0}
354
+ if not folder:
355
+ return 0
356
+ n = 0
357
+ for path in _spool_candidates(folder):
358
+ name = os.path.basename(path)
359
+ if ".claimed-" in name:
360
+ try:
361
+ if time.time() - os.path.getmtime(path) <= SPOOL_STALE_SECONDS:
362
+ self.spool_report["busy"] += 1 # su dueño está vivo
363
+ continue
364
+ except OSError:
365
+ continue
366
+ with self._cv:
367
+ if self._free()[0] <= 0: # sin lugar: no vale la pena reclamar
368
+ break
369
+ base = path.split(".claimed-")[0]
370
+ claimed = f"{base}.claimed-{os.getpid()}-{uuid.uuid4().hex[:6]}"
371
+ try:
372
+ os.utime(path) # vivo antes de reclamar: nadie lo ve abandonado en la ventana
373
+ os.replace(path, claimed) # reclamar: si otro lo tomó primero, falla y se sigue
374
+ except OSError:
375
+ continue
376
+ try:
377
+ with open(claimed, encoding="utf-8") as fh:
378
+ items = [_Item.from_json(line) for line in fh if line.strip()]
379
+ except (OSError, ValueError, KeyError) as err:
380
+ log.warning("tracereports: spool ilegible %s: %s", base, err)
381
+ bad = f"{base}.unreadable"
382
+ try:
383
+ os.replace(claimed, bad)
384
+ except OSError:
385
+ bad = claimed
386
+ self.spool_report["unreadable"].append(bad)
387
+ continue
388
+ if not items:
389
+ os.remove(claimed)
390
+ continue
391
+ # Lugar real AHORA (pudieron entrar eventos mientras se leía el archivo), reservado en el
392
+ # mismo bloque: hasta incorporarlos, nadie más puede ocuparlo.
393
+ with self._cv:
394
+ free_items, free_bytes = self._free()
395
+ k = size = 0
396
+ while k < len(items) and k < free_items and size + len(items[k].body) <= free_bytes:
397
+ size += len(items[k].body)
398
+ k += 1
399
+ self._reserved_items += k
400
+ self._reserved_bytes += size
401
+ if k == 0: # ni un evento entra ahora: se devuelve intacto
402
+ try:
403
+ os.replace(claimed, base)
404
+ except OSError:
405
+ pass
406
+ break
407
+ if k < len(items):
408
+ # primero el resto en un archivo propio (durable), después se acota el reclamado:
409
+ # si el proceso muere entre medio, a lo sumo se reenvía algo dos veces (Idempotency-Key)
410
+ ms, seq = _sort_key(os.path.basename(base))[:2]
411
+ try:
412
+ os.makedirs(folder, exist_ok=True)
413
+ rest = os.path.join(folder, self._spool_name(ms, max(seq, 0), f"{_part_of(base)}-r"))
414
+ self._write_file(rest, items[k:])
415
+ self._write_file(claimed, items[:k])
416
+ except OSError as err:
417
+ log.warning("tracereports: no se pudo dividir el spool %s: %s", base, err)
418
+ with self._cv:
419
+ self._reserved_items -= k
420
+ self._reserved_bytes -= size
421
+ try:
422
+ os.replace(claimed, base)
423
+ except OSError:
424
+ pass
425
+ break
426
+ items = items[:k]
427
+ src = _SpoolFile(claimed, len(items))
428
+ with self._cv:
429
+ self._reserved_items -= k # la reserva se convierte en lugar ocupado
430
+ self._reserved_bytes -= size
431
+ for it in items:
432
+ it.src = src
433
+ self._q.append(it)
434
+ self._bytes += size
435
+ self._claims.add(src)
436
+ self._cv.notify_all()
437
+ n += len(items)
438
+ if n:
439
+ self._ensure_thread()
440
+ return n
441
+
442
+
443
+ _SPOOL_SEQ = itertools.count()
444
+ SPOOL_PREFIX = "tracereports_spool"
445
+
446
+
447
+ def _sort_key(name: str) -> "tuple[int, int, str, str]":
448
+ """Orden de reenvío: (milisegundos, secuencia, parte, nombre). La parte ("0000", "0001",
449
+ "0001-r" para el resto de un archivo dividido) ordena los archivos de un mismo guardado; el
450
+ identificador aleatorio solo desempata."""
451
+ parts = name.split(".")[0].split("_") # tracereports_spool_<ms>_<seq>_<pid-uuid>_<parte>
452
+ try:
453
+ return int(parts[2]), int(parts[3]), parts[5], name
454
+ except (IndexError, ValueError):
455
+ return 0, -1, "", name
456
+
457
+
458
+ def _part_of(base: str) -> str:
459
+ return os.path.basename(base).split(".")[0].split("_")[-1]
460
+
461
+
462
+ def _spool_candidates(folder: str) -> "list[str]":
463
+ paths = [p for p in glob.glob(os.path.join(folder, f"{SPOOL_PREFIX}_*.jsonl*"))
464
+ if p.endswith(".jsonl") or ".jsonl.claimed-" in p]
465
+ return sorted(paths, key=lambda p: _sort_key(os.path.basename(p)))
466
+
467
+
468
+ def spool_files(folder: str) -> "dict[str, int]":
469
+ """Cuántos archivos quedan en un spool: pendientes, reclamados por algún proceso e ilegibles."""
470
+ out = {"pending": 0, "claimed": 0, "unreadable": 0}
471
+ for p in glob.glob(os.path.join(folder, f"{SPOOL_PREFIX}_*")):
472
+ if ".claimed-" in p:
473
+ out["claimed"] += 1
474
+ elif p.endswith(".unreadable"):
475
+ out["unreadable"] += 1
476
+ elif p.endswith(".jsonl"):
477
+ out["pending"] += 1
478
+ return out
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracereports
3
+ Version: 0.2.0
4
+ Summary: TraceReports client: reports test steps, screenshots and browser network to a TraceReports server. Includes a pytest plugin.
5
+ Author: Jose Lopez
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/josemiguellopez/tracereports
8
+ Project-URL: Documentation, https://github.com/josemiguellopez/tracereports/blob/main/docs/en/python.md
9
+ Project-URL: Issues, https://github.com/josemiguellopez/tracereports/issues
10
+ Keywords: testing,test-reporting,pytest,playwright,selenium,e2e,qa
11
+ Classifier: Framework :: Pytest
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: pytest
18
+ Requires-Dist: pytest>=7; extra == "pytest"
19
+ Provides-Extra: playwright
20
+ Requires-Dist: playwright>=1.40; extra == "playwright"
21
+
22
+ # tracereports (Python client)
23
+
24
+ 🌐 **English** · [Español](README.es.md)
25
+
26
+ Client for the [TraceReports](../../README.md) server. Standard library only.
27
+
28
+ ```bash
29
+ pip install ./client/python # from the repository root
30
+ pytest --tracereports # reports your pytest suite with no code changes
31
+ ```
32
+
33
+ Full documentation: [docs/en/python.md](../../docs/en/python.md).
@@ -0,0 +1,14 @@
1
+ tracereports/__init__.py,sha256=pWPCzvYjGajM4rlkPYRbPyHSctqZGBBBRFx9kfwjKYg,762
2
+ tracereports/_env.py,sha256=-7JQT_8IS5iuWSXPWdph8zvlCSGjTXRlKi3zmInM4gQ,3358
3
+ tracereports/client.py,sha256=vAxy4EF04T6cI2jVQsqgonYofhZSBaZ7CKTXGyUsz7U,26332
4
+ tracereports/context.py,sha256=b3xbZ1BRlsmeo8TOoIK3-ghg_T0xTznFKY1VKLawGJE,1619
5
+ tracereports/dom.py,sha256=c6WmJGNxSCO3PmDt6m8tflUi5sCydxcMHLXLgY7tCws,2669
6
+ tracereports/network.py,sha256=vn8JPIYHuctRLqY6uNbtUpa1YQmRH6DbOd8wmFxJnd8,12154
7
+ tracereports/pytest_plugin.py,sha256=d_GIhldpQ4RDtMkdIna8_cf3yPT56yIbgvwZOzzl6TA,16509
8
+ tracereports/resend.py,sha256=RVUCIzZYzO9JBpoIB6x5xbglrbaPI-hr9_ia8nZQRCs,2838
9
+ tracereports/transport.py,sha256=tv2cUSwlPQvSe8MVHKyF_AZ5y2nR-KWQPsd1NDjGVsQ,22351
10
+ tracereports-0.2.0.dist-info/METADATA,sha256=dcQCqDTT8hPjgrMnpVfXpEmgW_wDpwUZlIICa2xgrN8,1347
11
+ tracereports-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ tracereports-0.2.0.dist-info/entry_points.txt,sha256=Fgjx9hiMuoy306-bUU3FxCAvkt4D1sDNmWG2Ok0YOI8,53
13
+ tracereports-0.2.0.dist-info/top_level.txt,sha256=aNK9K0xIA6gF3Nh_N43-MwTx85PVX1HYntCvcVyl7-o,13
14
+ tracereports-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ tracereports = tracereports.pytest_plugin
@@ -0,0 +1 @@
1
+ tracereports