trodo-python 2.20.0__tar.gz → 2.21.0__tar.gz

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.
Files changed (64) hide show
  1. {trodo_python-2.20.0 → trodo_python-2.21.0}/PKG-INFO +1 -1
  2. {trodo_python-2.20.0 → trodo_python-2.21.0}/pyproject.toml +1 -1
  3. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_error_enrichment.py +25 -0
  4. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_prompts.py +64 -0
  5. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/__init__.py +1 -1
  6. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/prompt_manager.py +35 -2
  7. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/wrap_agent.py +22 -0
  8. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/prompts/compile.py +16 -3
  9. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo_python.egg-info/PKG-INFO +1 -1
  10. {trodo_python-2.20.0 → trodo_python-2.21.0}/README.md +0 -0
  11. {trodo_python-2.20.0 → trodo_python-2.21.0}/setup.cfg +0 -0
  12. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_anon_distinct_id.py +0 -0
  13. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_auto_instrument_fixes.py +0 -0
  14. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_cross_process_session.py +0 -0
  15. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_datasets.py +0 -0
  16. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_end_run.py +0 -0
  17. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_experiments.py +0 -0
  18. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_get_prompt_version_types.py +0 -0
  19. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_llm_usage_cost.py +0 -0
  20. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_multi_agent_shape.py +0 -0
  21. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_processor_methods.py +0 -0
  22. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_prompt_trace.py +0 -0
  23. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_register_otel.py +0 -0
  24. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_start_run.py +0 -0
  25. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_user_traits.py +0 -0
  26. {trodo_python-2.20.0 → trodo_python-2.21.0}/tests/test_wrap_agent_unchanged.py +0 -0
  27. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/api/__init__.py +0 -0
  28. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/api/async_client.py +0 -0
  29. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/api/endpoints.py +0 -0
  30. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/api/http_client.py +0 -0
  31. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/auto/__init__.py +0 -0
  32. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/auto/auto_event_manager.py +0 -0
  33. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/client.py +0 -0
  34. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/__init__.py +0 -0
  35. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/dataset_manager.py +0 -0
  36. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/experiment_manager.py +0 -0
  37. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/group_manager.py +0 -0
  38. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/people_manager.py +0 -0
  39. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/managers/user_manager.py +0 -0
  40. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/__init__.py +0 -0
  41. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/auto_instrument.py +0 -0
  42. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/context.py +0 -0
  43. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/helpers.py +0 -0
  44. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/processor.py +0 -0
  45. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/prompt_trace.py +0 -0
  46. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/register.py +0 -0
  47. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/otel/transport.py +0 -0
  48. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/prompts/__init__.py +0 -0
  49. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/prompts/template.py +0 -0
  50. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/prompts/types.py +0 -0
  51. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/queue/__init__.py +0 -0
  52. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/queue/batch_flusher.py +0 -0
  53. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/queue/event_queue.py +0 -0
  54. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/session/__init__.py +0 -0
  55. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/session/server_session.py +0 -0
  56. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/session/session_manager.py +0 -0
  57. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/types.py +0 -0
  58. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/user_context.py +0 -0
  59. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/util/__init__.py +0 -0
  60. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo/util/lru.py +0 -0
  61. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo_python.egg-info/SOURCES.txt +0 -0
  62. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  63. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo_python.egg-info/requires.txt +0 -0
  64. {trodo_python-2.20.0 → trodo_python-2.21.0}/trodo_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.20.0
3
+ Version: 2.21.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.20.0"
7
+ version = "2.21.0"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -70,6 +70,31 @@ def test_errored_span_and_run_carry_rich_error_fields(processor, http):
70
70
  assert "rate limit exceeded" in run_payload["error_summary"]
71
71
 
72
72
 
73
+ def test_raised_exception_carries_the_recorded_run_id(processor, http):
74
+ # The run IS recorded when an agent raises; this is the JOIN -- the
75
+ # exception a developer's error tracker captures must reference it.
76
+ with pytest.raises(RateLimitError) as e:
77
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
78
+ raise RateLimitError("boom")
79
+
80
+ run_payload = http.run_ingest[0]["run"]
81
+ assert e.value.trodo_run_id == run_payload["run_id"]
82
+ assert run_payload["status"] == "error"
83
+
84
+
85
+ def test_slotted_exception_still_propagates(processor, http):
86
+ # An exception that can't take attributes must not crash the error path --
87
+ # a failure inside error handling is the one unforgivable bug here.
88
+ class Slotted(Exception):
89
+ __slots__ = ()
90
+
91
+ with pytest.raises(Slotted):
92
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
93
+ raise Slotted("rigid")
94
+
95
+ assert http.run_ingest[0]["run"]["status"] == "error"
96
+
97
+
73
98
  def test_set_error_records_without_raising(processor, http):
74
99
  with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as run:
75
100
  with span("soft-step", kind="tool") as s:
@@ -133,6 +133,16 @@ def test_compile_applies_a_default_when_omitted():
133
133
  assert out.messages[1]["content"][0] == {"type": "text", "text": "Hello world"}
134
134
 
135
135
 
136
+ def test_coercion_error_text_matches_node_byte_for_byte():
137
+ # Node formats the offending value with JSON.stringify; repr's single
138
+ # quotes were the ONE divergence a full cross-SDK parity run found. The
139
+ # double quotes here are the point of the test.
140
+ numeric = {**PROMPT, "variables": [{"name": "who", "type": "number"}]}
141
+ with pytest.raises(CompileError) as e:
142
+ compile_prompt(numeric, {"who": "not-a-number"})
143
+ assert 'variable \'who\': expected a number, got "not-a-number"' in str(e.value)
144
+
145
+
136
146
  def test_compile_rejects_undeclared_variable():
137
147
  # Passing a variable the prompt doesn't declare is the one variable mistake
138
148
  # compile still catches (it's almost always a typo/rename).
@@ -218,6 +228,8 @@ class FakeHttp:
218
228
  self.last = {"name": name, "label": label, "version": version}
219
229
  if self.fail:
220
230
  raise RuntimeError("API down")
231
+ if getattr(self, "error_body", None):
232
+ return dict(self.error_body)
221
233
  return {"prompt": self._prompt}
222
234
 
223
235
  def list_prompts(self):
@@ -289,6 +301,58 @@ def test_stale_cache_survives_an_outage():
289
301
  assert mgr.get("greeter", cache_ttl_seconds=0.01).version == 2
290
302
 
291
303
 
304
+ def test_missing_prompt_with_fallback_serves_fallback():
305
+ # Deleting a prompt mid-deploy is exactly what the fallback exists for --
306
+ # plain prompt_not_found stays an availability failure.
307
+ http = FakeHttp()
308
+ http.error_body = {"__error": True, "status": 404, "error": "prompt_not_found"}
309
+ mgr = PromptManager(http)
310
+ p = mgr.get("gone", cache_ttl_seconds=0, max_retries=0,
311
+ fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]})
312
+ assert p.is_fallback is True
313
+
314
+
315
+ def test_bad_version_raises_through_the_fallback():
316
+ # A selector naming nothing is a CONFIG error, not an outage. Being quietly
317
+ # handed the fallback would hide the typo for as long as it ships.
318
+ http = FakeHttp()
319
+ http.error_body = {"__error": True, "status": 404, "error": "version_not_found"}
320
+ mgr = PromptManager(http)
321
+ with pytest.raises(LookupError, match="version 999 does not exist on prompt 'greeter'") as e:
322
+ mgr.get("greeter", version=999, cache_ttl_seconds=0, max_retries=0,
323
+ fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]})
324
+ assert e.value.code == "version_not_found"
325
+
326
+
327
+ def test_bad_label_raises_through_the_fallback():
328
+ http = FakeHttp()
329
+ http.error_body = {"__error": True, "status": 404, "error": "label_not_found"}
330
+ mgr = PromptManager(http)
331
+ with pytest.raises(LookupError) as e:
332
+ mgr.get("greeter", label="prod-ue", cache_ttl_seconds=0, max_retries=0,
333
+ fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]})
334
+ assert e.value.code == "label_not_found"
335
+
336
+
337
+ def test_versionless_prompt_says_what_to_do():
338
+ http = FakeHttp()
339
+ http.error_body = {"__error": True, "status": 404, "error": "no_versions"}
340
+ mgr = PromptManager(http)
341
+ with pytest.raises(LookupError, match="has no versions yet"):
342
+ mgr.get("empty", cache_ttl_seconds=0, max_retries=0)
343
+
344
+
345
+ def test_config_error_not_masked_by_stale_cache():
346
+ # First fetch succeeds and caches; the label is then deleted server-side.
347
+ http = FakeHttp(prompt=WIRE)
348
+ mgr = PromptManager(http)
349
+ assert mgr.get("greeter", label="production", cache_ttl_seconds=0).version == 2
350
+ http.error_body = {"__error": True, "status": 404, "error": "label_not_found"}
351
+ with pytest.raises(LookupError) as e:
352
+ mgr.get("greeter", label="production", cache_ttl_seconds=0, max_retries=0)
353
+ assert e.value.code == "label_not_found"
354
+
355
+
292
356
  def test_fallback_used_when_api_down_and_nothing_cached():
293
357
  mgr = PromptManager(FakeHttp(fail=True))
294
358
  p = mgr.get(
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
- __version__ = "2.20.0"
44
+ __version__ = "2.21.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -38,6 +38,11 @@ __all__ = [
38
38
  DEFAULT_TTL_SECONDS = 60.0
39
39
 
40
40
 
41
+ # 404 bodies that mean "your SELECTOR is wrong", not "the prompt is gone".
42
+ # Mirrors backend/models/prompt.js missReason -- the wire contract's error half.
43
+ _CONFIG_ERROR_CODES = frozenset({"version_not_found", "label_not_found", "no_versions"})
44
+
45
+
41
46
  def _cache_key(name: str, version: Optional[Union[int, str]], label: Optional[str]) -> str:
42
47
  # Resolution happens server-side on every fetch; the client only caches
43
48
  # under whatever selector was asked for. So a label flip propagates within
@@ -197,7 +202,14 @@ class PromptManager:
197
202
  Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
198
203
  raise. A prompt fetch is on your hot path, so a Trodo outage degrades
199
204
  rather than takes your app down. Check ``prompt.is_fallback`` to detect
200
- the last rung. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
205
+ the last rung.
206
+
207
+ The ladder is for AVAILABILITY failures only. A selector that names
208
+ nothing — a ``version`` or ``label`` that doesn't exist on a prompt
209
+ that does — is a config error in your code and raises immediately
210
+ (``e.code`` is ``version_not_found`` | ``label_not_found`` |
211
+ ``no_versions``), because being quietly handed the fallback would hide
212
+ the typo for as long as it ships. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
201
213
  development).
202
214
 
203
215
  :raises ValueError: if *name* is empty, or both ``label`` and ``version``
@@ -224,6 +236,22 @@ class PromptManager:
224
236
  if not res or res.get("__error") or not res.get("prompt"):
225
237
  status = res.get("status") if isinstance(res, dict) else None
226
238
  detail = res.get("error") if isinstance(res, dict) else None
239
+ # The server distinguishes a selector that names nothing from a
240
+ # prompt that is missing. The first is a CONFIG error -- your
241
+ # code asks for a version or label that does not exist -- and
242
+ # must throw through the availability ladder below rather than
243
+ # be masked by stale content or the fallback.
244
+ if detail in _CONFIG_ERROR_CODES:
245
+ if detail == "version_not_found":
246
+ what = f"version {version!r} does not exist on prompt {name!r}"
247
+ elif detail == "label_not_found":
248
+ what = f"label {label!r} does not exist on prompt {name!r}"
249
+ else:
250
+ what = f"prompt {name!r} has no versions yet -- save one in the dashboard"
251
+ err = LookupError(f"trodo: {what}" + (f" (HTTP {status})" if status else ""))
252
+ err.code = detail # type: ignore[attr-defined]
253
+ err.prompt_config_error = True # type: ignore[attr-defined]
254
+ raise err
227
255
  raise LookupError(
228
256
  f"trodo: could not fetch prompt {name!r}"
229
257
  + (f" (HTTP {status})" if status else "")
@@ -248,7 +276,12 @@ class PromptManager:
248
276
  if ttl > 0:
249
277
  self._cache.set(key, raw, ttl)
250
278
  return _to_prompt(raw, trace_label=trace_label)
251
- except Exception:
279
+ except Exception as e:
280
+ # A config error is not an outage: a typo'd label silently serving
281
+ # the fallback forever would hide the mistake for as long as it
282
+ # ships. Config errors surface.
283
+ if getattr(e, "prompt_config_error", False):
284
+ raise
252
285
  stale = self._cache.get_stale(key)
253
286
  if stale is not None:
254
287
  return _to_prompt(stale, trace_label=trace_label)
@@ -258,6 +258,26 @@ def _mint_anon_distinct_id() -> str:
258
258
  return f"anon_{ts}_python_{uuid.uuid4()}_{rand}"
259
259
 
260
260
 
261
+ def _tag_error_with_run(exc: object, run_id: Optional[str]) -> None:
262
+ """Stamp the active run's id onto an exception about to propagate.
263
+
264
+ The run IS recorded server-side when an agent raises -- status, error type,
265
+ full message. What was missing is the join: the exception a developer's
266
+ error tracker captures had no reference to the recorded run, so the two
267
+ could only be matched by timestamp. ``exc.trodo_run_id`` is that join.
268
+
269
+ Guarded on purpose: exceptions can use ``__slots__`` or be otherwise
270
+ unwritable, and a crash inside error handling is the one unforgivable
271
+ failure mode here.
272
+ """
273
+ if exc is None or not run_id:
274
+ return
275
+ try:
276
+ exc.trodo_run_id = run_id # type: ignore[attr-defined]
277
+ except Exception: # noqa: BLE001 -- slots/frozen; the run is still recorded
278
+ pass
279
+
280
+
261
281
  class RunHandle:
262
282
  """Handle returned by wrap_agent for setting input/output and getting run_id."""
263
283
 
@@ -600,6 +620,7 @@ class wrap_agent:
600
620
  einfo = describe_error(exc_type, exc, tb)
601
621
  error_summary = einfo["error_message"]
602
622
  error_type = einfo["error_type"]
623
+ _tag_error_with_run(exc, self.handle.run_id)
603
624
  elif manual_run_error:
604
625
  status = "error"
605
626
  error_summary = self.handle.error_summary
@@ -704,6 +725,7 @@ class wrap_agent:
704
725
  for k, v in self.handle.metadata.items():
705
726
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
706
727
  if exc is not None:
728
+ _tag_error_with_run(exc, self.handle.run_id if self.handle else None)
707
729
  otel_span.record_exception(exc)
708
730
  _, status_cls, status_code = get_otel_helpers()
709
731
  if status_cls is not None and status_code is not None:
@@ -8,6 +8,7 @@ or the prompt you tested is not the prompt you shipped.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  from typing import Any, Dict, List, Optional
12
13
 
13
14
  from .template import render
@@ -26,6 +27,15 @@ class CompileError(Exception):
26
27
  self.details: List[str] = details or []
27
28
 
28
29
 
30
+ def _q(value: Any) -> str:
31
+ """Format an offending value the way Node's JSON.stringify does, so the two
32
+ engines produce byte-identical error text. repr() was the one divergence a
33
+ full cross-SDK parity run found."""
34
+ try:
35
+ return json.dumps(value, ensure_ascii=False)
36
+ except (TypeError, ValueError):
37
+ return repr(value)
38
+
29
39
  def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
30
40
  """Coerce a caller value to the declared type.
31
41
 
@@ -36,15 +46,18 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
36
46
  return value
37
47
 
38
48
  if type_ == "number":
49
+ # _q (json.dumps), not repr: the Node engine formats the offending
50
+ # value with JSON.stringify, and the two engines' error text is
51
+ # asserted byte-identical.
39
52
  if isinstance(value, bool):
40
- errors.append(f"variable '{name}': expected a number, got {value!r}")
53
+ errors.append(f"variable '{name}': expected a number, got {_q(value)}")
41
54
  return value
42
55
  if isinstance(value, (int, float)):
43
56
  return value
44
57
  try:
45
58
  return float(value) if "." in str(value) else int(value)
46
59
  except (TypeError, ValueError):
47
- errors.append(f"variable '{name}': expected a number, got {value!r}")
60
+ errors.append(f"variable '{name}': expected a number, got {_q(value)}")
48
61
  return value
49
62
 
50
63
  if type_ == "boolean":
@@ -54,7 +67,7 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
54
67
  return True
55
68
  if value == "false":
56
69
  return False
57
- errors.append(f"variable '{name}': expected a boolean, got {value!r}")
70
+ errors.append(f"variable '{name}': expected a boolean, got {_q(value)}")
58
71
  return value
59
72
 
60
73
  if type_ == "string":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.20.0
3
+ Version: 2.21.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
File without changes
File without changes