trodo-python 3.0.1__py3-none-any.whl → 3.0.2__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.
trodo/_version.py CHANGED
@@ -1 +1 @@
1
- SDK_VERSION = "3.0.1"
1
+ SDK_VERSION = "3.0.2"
trodo/delivery/sender.py CHANGED
@@ -14,12 +14,18 @@ and PostHog have converged on (1 Oct 2026 survey):
14
14
  the traces being debugged right now survive; drops are counted and
15
15
  reported to the server with the next batch.
16
16
  - retries while nobody waits: 429 pauses the whole sender until
17
- Retry-After (a client over its limit delivers late, not never); 5xx, 408
18
- and network errors back off (1 s → 60 s) for up to 10 minutes per item,
19
- then it is dropped as ``gave_up``; 413 splits the batch, a single
17
+ Retry-After (a client over its limit delivers late, not never). A rate
18
+ 429 never counts toward giving up (3.0.2): the server answered, it is
19
+ only pacing. 5xx, 408, network errors and an exhausted allowance back off
20
+ (1 s → 60 s) for up to 10 minutes per item, then it is dropped as
21
+ ``gave_up``. Once a 429 names the team's ceiling (units per minute), a
22
+ batch carries at most that many units, so each one is admissible; 413 splits the batch, a single
20
23
  oversized item is dropped as ``too_large``; any other 4xx is a bad item:
21
24
  dropped as ``rejected``, reported once.
22
- - exit: ``atexit`` flushes (bounded by ``exit_flush_s``).
25
+ - exit: ``atexit`` flushes (bounded by ``exit_flush_s``) and says on stderr
26
+ how many traces were still undelivered, and why, if any were.
27
+ - the server holds over-rate items itself (202 ``queued``) and stores them
28
+ at the team's rate; the sender says so on stderr once per 10 minutes.
23
29
  - ``immediate`` mode for platforms that freeze between requests (Lambda,
24
30
  Cloud Functions, Cloud Run, Vercel): the item is sent now, with short
25
31
  retries, on the caller's thread. ``auto`` picks it from the platform's
@@ -124,6 +130,9 @@ class Sender:
124
130
  self._stats = {"sent": 0, "dropped": 0, "dropped_by_reason": {}}
125
131
  self._unreported = {"count": 0, "by_reason": {}}
126
132
  self._warned_drop = False
133
+ self._unit_cap: Optional[int] = None # the team's ceiling, once a 429 names it
134
+ self._last_rate: Optional[Dict[str, Any]] = None
135
+ self._warned_paced_at = 0.0
127
136
  self._thread: Optional[threading.Thread] = None
128
137
  if self.mode == "background" and register_atexit:
129
138
  atexit.register(self._atexit)
@@ -174,11 +183,13 @@ class Sender:
174
183
  with self._lock:
175
184
  self._stopped = True
176
185
  left = len(self._queue)
186
+ spans_left = sum(_units_of(q.item) for q in self._queue)
177
187
  self._queue.clear()
178
188
  self._queue_bytes = 0
179
189
  self._wake.notify_all()
180
190
  if left:
181
191
  self._drop(left, "shutdown", f"{left} items were still queued at shutdown")
192
+ self._warn_undelivered(left, spans_left)
182
193
 
183
194
  def stats(self) -> Dict[str, Any]:
184
195
  with self._lock:
@@ -229,21 +240,24 @@ class Sender:
229
240
  def _take_batch_locked(self) -> List[_Queued]:
230
241
  out: List[_Queued] = []
231
242
  nbytes = 0
243
+ units = 0
244
+ cap = self._unit_cap
232
245
  while self._queue and len(out) < self._o["flush_at"]:
233
246
  nxt = self._queue[0]
234
247
  if out and nbytes + nxt.nbytes > self._o["max_batch_bytes"]:
235
248
  break
249
+ u = _units_of(nxt.item)
250
+ if out and cap and units + u > cap:
251
+ break
236
252
  out.append(self._queue.popleft())
237
253
  nbytes += nxt.nbytes
254
+ units += u
238
255
  self._queue_bytes -= nbytes
239
256
  return out
240
257
 
241
258
  def _send_batch(self, batch: List[_Queued]) -> None:
242
- t = self._now()
243
259
  for q in batch:
244
260
  q.attempts += 1
245
- if q.first_tried_at is None:
246
- q.first_tried_at = t
247
261
  try:
248
262
  status, retry_after, body = self._http.post_raw(
249
263
  endpoints.BATCH, self._envelope(batch), timeout=self._o["request_timeout_s"], gzip_body=True,
@@ -257,8 +271,9 @@ class Sender:
257
271
  self._settle(batch, body)
258
272
  return
259
273
  if status == 429:
274
+ paced = self._note_rate(body if isinstance(body, dict) else {}, retry_after)
260
275
  self._pause(parse_retry_after_s(retry_after, self._now()))
261
- self._requeue(batch, 0.0)
276
+ self._requeue(batch, 0.0, paced=paced)
262
277
  return
263
278
  if status == 413:
264
279
  self._split(batch)
@@ -273,16 +288,23 @@ class Sender:
273
288
 
274
289
  def _settle(self, batch: List[_Queued], body: Dict[str, Any]) -> None:
275
290
  keep: List[_Queued] = []
291
+ paced: List[_Queued] = []
276
292
  rejected = 0
293
+ queued = 0
277
294
  first_error = None
278
295
  results = body["results"]
279
296
  for i, q in enumerate(batch):
280
297
  r = results[i] if i < len(results) and isinstance(results[i], dict) else {"status": 500}
281
298
  st = int(r.get("status", 500))
282
299
  if 200 <= st < 300:
300
+ if r.get("queued"):
301
+ queued += 1
283
302
  with self._lock:
284
303
  self._stats["sent"] += 1
285
304
  continue
305
+ if st == 429 and self._note_rate(r, None):
306
+ paced.append(q)
307
+ continue
286
308
  if st == 429 or st >= 500 or st == 408:
287
309
  keep.append(q)
288
310
  continue
@@ -293,17 +315,60 @@ class Sender:
293
315
  first_error = first_error or f"HTTP {st}: {r.get('error', '')}"
294
316
  if rejected:
295
317
  self._drop(rejected, "rejected", f"{rejected} item(s) rejected by the server ({first_error})")
318
+ if queued:
319
+ self._warn_paced(body.get("paced") if isinstance(body.get("paced"), dict) else {})
296
320
  ra = body.get("retry_after_ms")
297
321
  if ra:
298
322
  self._pause(float(ra) / 1000.0)
323
+ if paced:
324
+ self._requeue(paced, 0.0, paced=True)
299
325
  if keep:
300
326
  self._requeue(keep, 0.0 if ra else None)
301
327
 
302
- def _requeue(self, batch: List[_Queued], wait_s: Optional[float]) -> None:
328
+ def _note_rate(self, body: Dict[str, Any], retry_after: Optional[str]) -> bool:
329
+ """Is this 429 the rate ceiling (pacing: retry, never give up) rather
330
+ than an exhausted allowance? Learns the team's ceiling from it."""
331
+ ceiling = body.get("ceiling_units_min")
332
+ rate = bool(retry_after) or body.get("retry_after_seconds") is not None or body.get("deferred") or ceiling is not None
333
+ if not rate:
334
+ return False
335
+ try:
336
+ c = int(ceiling) if ceiling is not None else 0
337
+ except (TypeError, ValueError):
338
+ c = 0
339
+ with self._lock:
340
+ if c > 0:
341
+ self._unit_cap = c
342
+ self._last_rate = {"ceiling": c or None, "reason": body.get("reason"), "message": body.get("message")}
343
+ return True
344
+
345
+ def _warn_paced(self, paced: Dict[str, Any]) -> None:
346
+ t = self._now()
347
+ with self._lock:
348
+ if t - self._warned_paced_at < 600:
349
+ return
350
+ self._warned_paced_at = t
351
+ c = paced.get("ceiling_units_min")
352
+ limit = f" ({c} units/min)" if c else ""
353
+ sys.stderr.write(
354
+ f"[trodo-python] This team is sending faster than its rate limit{limit}. Trodo is holding the extra traces "
355
+ "and storing them at that rate, so they will appear late. Contact Trodo for a higher limit.\n"
356
+ )
357
+
358
+ def _requeue(self, batch: List[_Queued], wait_s: Optional[float], paced: bool = False) -> None:
303
359
  t = self._now()
304
360
  alive: List[_Queued] = []
305
361
  for q in batch:
306
- if q.first_tried_at is not None and t - q.first_tried_at > self._o["max_retry_s"]:
362
+ if paced:
363
+ # The server answered and is only pacing: the give-up clock
364
+ # (for outages and refusals) starts over.
365
+ q.first_tried_at = None
366
+ q.attempts = 0
367
+ alive.append(q)
368
+ elif q.first_tried_at is None:
369
+ q.first_tried_at = t
370
+ alive.append(q)
371
+ elif t - q.first_tried_at > self._o["max_retry_s"]:
307
372
  self._drop(1, "gave_up", f"an item was retried for {int(self._o['max_retry_s'])} s without being accepted")
308
373
  else:
309
374
  alive.append(q)
@@ -314,7 +379,7 @@ class Sender:
314
379
  self._queue.appendleft(q)
315
380
  self._queue_bytes += sum(q.nbytes for q in alive)
316
381
  shed = self._shed_locked()
317
- attempts = max(q.attempts for q in alive)
382
+ attempts = max(1, max(q.attempts for q in alive))
318
383
  backoff = wait_s if wait_s is not None else min(_MAX_BACKOFF_S, _MIN_BACKOFF_S * 2 ** (attempts - 1)) * (0.5 + random.random() / 2)
319
384
  self._not_before = t + backoff
320
385
  if shed:
@@ -342,6 +407,7 @@ class Sender:
342
407
  one attempt, the status read — a refusal keeps the item like the batch
343
408
  path would, instead of the old blocking request that dropped it."""
344
409
  keep: List[_Queued] = []
410
+ paced: List[_Queued] = []
345
411
  wait: Optional[float] = None
346
412
  for q in batch:
347
413
  path, body = _legacy_route(q.item)
@@ -353,14 +419,18 @@ class Sender:
353
419
  with self._lock:
354
420
  self._stats["sent"] += 1
355
421
  continue
422
+ if status == 429 and self._note_rate(resp if isinstance(resp, dict) else {}, retry_after):
423
+ paced.append(q)
424
+ wait = parse_retry_after_s(retry_after, self._now()) or wait
425
+ continue
356
426
  if status is None or status in _TRANSIENT:
357
427
  keep.append(q)
358
- if status == 429:
359
- wait = parse_retry_after_s(retry_after, self._now()) or wait
360
428
  continue
361
429
  self._drop(1, "too_large" if status == 413 else "rejected", f"POST {path} -> HTTP {status}: {json.dumps(resp)[:200]}")
362
430
  if wait is not None:
363
431
  self._pause(wait)
432
+ if paced:
433
+ self._requeue(paced, 0.0, paced=True)
364
434
  if keep:
365
435
  self._requeue(keep, 0.0 if wait is not None else None)
366
436
 
@@ -472,6 +542,27 @@ class Sender:
472
542
  self.flush(self._o["exit_flush_s"])
473
543
  except Exception:
474
544
  pass
545
+ with self._lock:
546
+ left = len(self._queue) + self._in_flight
547
+ units = sum(_units_of(q.item) for q in self._queue)
548
+ if left:
549
+ self._warn_undelivered(left, units)
550
+
551
+ def _warn_undelivered(self, items: int, units: int) -> None:
552
+ """Always on stderr, whatever was said before: data is about to be lost."""
553
+ with self._lock:
554
+ rate = self._last_rate if self._paused_until and self._paused_until > self._now() else None
555
+ why = ""
556
+ if rate and rate.get("message"):
557
+ why = f" Trodo said: {rate['message']}"
558
+ elif rate:
559
+ c = rate.get("ceiling")
560
+ why = f" The team is over its rate limit{f' ({c} units/min)' if c else ''} and Trodo could not hold more."
561
+ sys.stderr.write(
562
+ f"[trodo-python] {items} trace item(s), {units} units, were NOT delivered before the process exited "
563
+ f"(waited {self._o['exit_flush_s']:g} s).{why} Call trodo.flush() before exiting (it waits until they are "
564
+ "delivered), or raise delivery_options exit_flush_s.\n"
565
+ )
475
566
 
476
567
  def _log(self, msg: str) -> None:
477
568
  if self._debug:
@@ -489,6 +580,14 @@ def _legacy_route(item: Item):
489
580
  return endpoints.SPANS_APPEND, {"spans": item["spans"]}
490
581
 
491
582
 
583
+ def _units_of(item: Any) -> int:
584
+ """What an item costs against the rate ceiling: one per span, one for the run."""
585
+ if not isinstance(item, dict):
586
+ return 0
587
+ spans = item.get("spans")
588
+ return (len(spans) if isinstance(spans, list) else 0) + (1 if isinstance(item.get("run"), dict) else 0)
589
+
590
+
492
591
  def _size_of(item: Any) -> int:
493
592
  try:
494
593
  return len(json.dumps(item, default=str).encode("utf-8"))
trodo/otel/processor.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import threading
11
11
  from dataclasses import dataclass, asdict
12
+ import json
12
13
  from typing import Any, Dict, List, Optional, Union
13
14
 
14
15
  # How long end_run waits for the run's queued spans to send before sending the end.
@@ -97,6 +98,37 @@ class TrodoSpan:
97
98
  return {k: v for k, v in asdict(self).items() if v is not None}
98
99
 
99
100
 
101
+ # A run's spans travel in pieces of at most this many bytes (and at most
102
+ # max_spans_per_run spans). The sender drops any single item over its 4 MB
103
+ # request limit, so before this a run of a few hundred LLM spans with long
104
+ # prompts was dropped whole, run row included.
105
+ _CHUNK_BYTES = 1_000_000
106
+
107
+
108
+ def _size(d: Dict[str, Any]) -> int:
109
+ try:
110
+ return len(json.dumps(d, default=str))
111
+ except Exception:
112
+ return 0
113
+
114
+
115
+ def _pieces(dicts: List[Dict[str, Any]], limit: int = _CHUNK_BYTES) -> List[List[Dict[str, Any]]]:
116
+ """Split span dicts into consecutive pieces of at most ``limit`` bytes (one span always fits a piece)."""
117
+ out: List[List[Dict[str, Any]]] = []
118
+ cur: List[Dict[str, Any]] = []
119
+ size = 0
120
+ for d in dicts:
121
+ n = _size(d)
122
+ if cur and size + n > limit:
123
+ out.append(cur)
124
+ cur, size = [], 0
125
+ cur.append(d)
126
+ size += n
127
+ if cur:
128
+ out.append(cur)
129
+ return out
130
+
131
+
100
132
  class _SentSpan:
101
133
  """What the run's totals need of a span already sent ahead: no payloads."""
102
134
 
@@ -135,6 +167,7 @@ class TrodoSpanProcessor:
135
167
  # stores spans before their run arrives) and keeps only what its totals
136
168
  # need. Before this, every span after the 1,000th was dropped.
137
169
  self._sent_ahead: Dict[str, List[_SentSpan]] = {}
170
+ self._bucket_bytes: Dict[str, int] = {}
138
171
  self._lock = threading.Lock()
139
172
  self._shutdown = False
140
173
  # run_ids that are "joined" (owned by a remote service) — their spans
@@ -151,6 +184,7 @@ class TrodoSpanProcessor:
151
184
  self._joined_runs.discard(run_id)
152
185
  self._pending.pop(run_id, None)
153
186
  self._sent_ahead.pop(run_id, None)
187
+ self._bucket_bytes.pop(run_id, None)
154
188
 
155
189
  def enqueue_span(self, span: TrodoSpan) -> None:
156
190
  """Buffer a completed span under its run_id.
@@ -158,19 +192,34 @@ class TrodoSpanProcessor:
158
192
  Joined runs flush immediately (append_spans) instead of batching.
159
193
  """
160
194
  chunk: List[TrodoSpan] = []
195
+ d = span.to_dict()
196
+ n = _size(d)
161
197
  with self._lock:
162
198
  joined = span.run_id in self._joined_runs
163
199
  if not joined:
164
200
  bucket = self._pending.setdefault(span.run_id, [])
201
+ held = self._bucket_bytes.get(span.run_id, 0)
202
+ # Full by bytes: what is held goes ahead and this span starts the next piece.
203
+ if bucket and held + n > _CHUNK_BYTES:
204
+ chunk = bucket
205
+ bucket = []
206
+ self._pending[span.run_id] = bucket
207
+ held = 0
165
208
  bucket.append(span)
209
+ held += n
210
+ # Full by count: everything held goes ahead, this span included.
166
211
  if len(bucket) >= self._max_spans:
167
- chunk = bucket
212
+ chunk = chunk + bucket
168
213
  self._pending[span.run_id] = []
214
+ held = 0
215
+ self._bucket_bytes[span.run_id] = held
216
+ if chunk:
169
217
  self._sent_ahead.setdefault(span.run_id, []).extend(_SentSpan(x) for x in chunk)
170
218
  if joined:
171
- self._send_spans(span.run_id, [span.to_dict()])
219
+ self._send_spans(span.run_id, [d])
172
220
  elif chunk:
173
- self._send_spans(span.run_id, [x.to_dict() for x in chunk])
221
+ for piece in _pieces([x.to_dict() for x in chunk]):
222
+ self._send_spans(span.run_id, piece)
174
223
 
175
224
  def _send_spans(self, run_id: str, spans: List[Dict[str, Any]]) -> None:
176
225
  if self._sender is not None:
@@ -191,9 +240,14 @@ class TrodoSpanProcessor:
191
240
  with self._lock:
192
241
  spans = self._pending.pop(run.run_id, [])
193
242
  self._sent_ahead.pop(run.run_id, None)
243
+ self._bucket_bytes.pop(run.run_id, None)
194
244
  payload: Dict[str, Any] = {"run": run.to_dict()}
195
245
  if spans:
196
- payload["spans"] = [s.to_dict() for s in spans]
246
+ # All but the last piece go ahead; the run travels with the last.
247
+ pieces = _pieces([s.to_dict() for s in spans])
248
+ for piece in pieces[:-1]:
249
+ self._send_spans(run.run_id, piece)
250
+ payload["spans"] = pieces[-1]
197
251
  if self._sender is not None:
198
252
  # Queued: the agent's caller never waits on the network (2.24).
199
253
  self._sender.enqueue(payload)
@@ -226,8 +280,9 @@ class TrodoSpanProcessor:
226
280
  with self._lock:
227
281
  buffered = self._pending.pop(run_id, None) or []
228
282
  self._sent_ahead.pop(run_id, None)
229
- if buffered:
230
- self._send_spans(run_id, [s.to_dict() for s in buffered])
283
+ self._bucket_bytes.pop(run_id, None)
284
+ for piece in _pieces([s.to_dict() for s in buffered]):
285
+ self._send_spans(run_id, piece)
231
286
  if self._sender is not None:
232
287
  self._sender.flush(END_RUN_FLUSH_S)
233
288
  except Exception:
@@ -241,17 +296,18 @@ class TrodoSpanProcessor:
241
296
  """Stream spans for a long-running or joined run without finalising."""
242
297
  if not spans:
243
298
  return
244
- self._send_spans(run_id, [s.to_dict() for s in spans])
299
+ for piece in _pieces([s.to_dict() for s in spans]):
300
+ self._send_spans(run_id, piece)
245
301
 
246
302
  def force_flush(self) -> None:
247
303
  """Flush any buffered spans that don't have a run finalise yet."""
248
304
  with self._lock:
249
305
  pending = list(self._pending.items())
250
306
  self._pending.clear()
307
+ self._bucket_bytes.clear()
251
308
  for run_id, spans in pending:
252
- if not spans:
253
- continue
254
- self._send_spans(run_id, [s.to_dict() for s in spans])
309
+ for piece in _pieces([s.to_dict() for s in spans]):
310
+ self._send_spans(run_id, piece)
255
311
 
256
312
  def shutdown(self) -> None:
257
313
  self._shutdown = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 3.0.1
3
+ Version: 3.0.2
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -1,5 +1,5 @@
1
1
  trodo/__init__.py,sha256=x7zE63FK8CFJ6ATUvsXxeTkvZUaLKXqAJr99I6nnyZc,27099
2
- trodo/_version.py,sha256=_yhb2lUJxj5RzcysqVOZ_qSNlhsiiNpiHeBRAZw1zgc,22
2
+ trodo/_version.py,sha256=GmU1Hng0hfRPQUBIrIvzRCIVKBsPLckerj1Erc-Dt1g,22
3
3
  trodo/client.py,sha256=eaZtHMtOI-VLgfoqM_KVWMuP_I5Ivs7VFnR0oU96Yqk,23743
4
4
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
5
5
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
@@ -12,7 +12,7 @@ trodo/api/management.py,sha256=uwhr0ZI1pvguz5-itW1KcgRj0TXM2n4KDa-hGIaBdTA,4023
12
12
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
14
14
  trodo/delivery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- trodo/delivery/sender.py,sha256=urO7m6mRMnSEW7_cNAmJ4A6jtDNxuihZHOxCx1jQScI,21348
15
+ trodo/delivery/sender.py,sha256=kp9kNvS_nJZpRKGD05zXcytwuxCOYD8A27rNJySGqR0,26119
16
16
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  trodo/managers/dataset_manager.py,sha256=HTFszK-wKmwjW-aw7xJWr5EKvDpJ_om496MLDr3gelg,13920
18
18
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
@@ -23,7 +23,7 @@ trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
23
23
  trodo/otel/auto_instrument.py,sha256=M5YVpFoPqn9bQohDFM7yyADrVGJPqFBToJLnBO7nOSA,29029
24
24
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
25
25
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
26
- trodo/otel/processor.py,sha256=jX1gk7TSkevOw5hnFadYf3U2lNgya5VBuFqpxGjLZHg,10203
26
+ trodo/otel/processor.py,sha256=ezaesBaxgv4-Mx8aHyp3ur41rvHwnQnjCNlPTe8rhjU,12343
27
27
  trodo/otel/prompt_trace.py,sha256=-kywRBU0Q2gWntS9HU-P5i7Kc5U-IJU7UVLjkdC2jw4,13593
28
28
  trodo/otel/register.py,sha256=7X3AfFQ5YvHGOUmdq8J38LQGvOJFPD907yT3uAp90lI,9607
29
29
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
@@ -40,7 +40,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
40
40
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
41
41
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
42
42
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
43
- trodo_python-3.0.1.dist-info/METADATA,sha256=-8R6RpKklPbGLr-kXa0oW2pwwJ-3RvIsJZ2cn_ff-tA,25307
44
- trodo_python-3.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
45
- trodo_python-3.0.1.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
- trodo_python-3.0.1.dist-info/RECORD,,
43
+ trodo_python-3.0.2.dist-info/METADATA,sha256=o047XtyjWl3fOEhJOR12qlWtDqlBelbYvAsbY3wT4xc,25307
44
+ trodo_python-3.0.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
45
+ trodo_python-3.0.2.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
+ trodo_python-3.0.2.dist-info/RECORD,,