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.
- tracereports/__init__.py +31 -0
- tracereports/_env.py +90 -0
- tracereports/client.py +582 -0
- tracereports/context.py +43 -0
- tracereports/dom.py +50 -0
- tracereports/network.py +291 -0
- tracereports/pytest_plugin.py +290 -0
- tracereports/resend.py +63 -0
- tracereports/transport.py +478 -0
- tracereports-0.2.0.dist-info/METADATA +33 -0
- tracereports-0.2.0.dist-info/RECORD +14 -0
- tracereports-0.2.0.dist-info/WHEEL +5 -0
- tracereports-0.2.0.dist-info/entry_points.txt +2 -0
- tracereports-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
tracereports
|