trodo-python 2.19.0__py3-none-any.whl → 2.21.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.
trodo/__init__.py CHANGED
@@ -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.19.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
@@ -56,10 +61,12 @@ def _to_variables(raw: Any) -> List[PromptVariable]:
56
61
  out.append(v)
57
62
  elif isinstance(v, dict) and isinstance(v.get("name"), str):
58
63
  out.append(
64
+ # Named fields only — a stored version may carry keys this SDK
65
+ # no longer models (`required`, retired in 2.20.0), and fetching
66
+ # one must never raise.
59
67
  PromptVariable(
60
68
  name=v["name"],
61
69
  type=v.get("type") or "string",
62
- required=bool(v.get("required")),
63
70
  default=v.get("default"),
64
71
  description=v.get("description"),
65
72
  )
@@ -195,7 +202,14 @@ class PromptManager:
195
202
  Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
196
203
  raise. A prompt fetch is on your hot path, so a Trodo outage degrades
197
204
  rather than takes your app down. Check ``prompt.is_fallback`` to detect
198
- 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
199
213
  development).
200
214
 
201
215
  :raises ValueError: if *name* is empty, or both ``label`` and ``version``
@@ -222,6 +236,22 @@ class PromptManager:
222
236
  if not res or res.get("__error") or not res.get("prompt"):
223
237
  status = res.get("status") if isinstance(res, dict) else None
224
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
225
255
  raise LookupError(
226
256
  f"trodo: could not fetch prompt {name!r}"
227
257
  + (f" (HTTP {status})" if status else "")
@@ -246,7 +276,12 @@ class PromptManager:
246
276
  if ttl > 0:
247
277
  self._cache.set(key, raw, ttl)
248
278
  return _to_prompt(raw, trace_label=trace_label)
249
- 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
250
285
  stale = self._cache.get_stale(key)
251
286
  if stale is not None:
252
287
  return _to_prompt(stale, trace_label=trace_label)
trodo/otel/wrap_agent.py CHANGED
@@ -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:
trodo/prompts/compile.py CHANGED
@@ -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":
@@ -74,12 +87,10 @@ def build_scope(
74
87
  ) -> Dict[str, Any]:
75
88
  """Build the render scope from declarations + caller values.
76
89
 
77
- Resolution order: caller value -> declared default -> required-and-absent
78
- raises -> optional-and-absent renders empty.
90
+ Resolution order: caller value -> declared default -> empty.
79
91
 
80
- `required` is enforced HERE, in your process, before the model call -- Trodo
81
- only stores the declaration. A variable with a default can never fail this
82
- check, because the default is itself a value.
92
+ A missing value never raises. The one thing this does reject is a value for
93
+ a variable the prompt does not declare, which is almost always a typo.
83
94
  """
84
95
  errors: List[str] = []
85
96
  scope: Dict[str, Any] = {}
@@ -104,24 +115,13 @@ def build_scope(
104
115
  has = name in values
105
116
  value = values.get(name) if has else None
106
117
 
107
- # `required` asks a narrower question than default-substitution does:
108
- # did the caller supply something that will actually render? None and ""
109
- # both render as nothing, the very outcome the flag exists to prevent,
110
- # so neither counts. (0 and False do -- they render.)
111
- supplied = has and value is not None and value != ""
112
-
113
- # Missing value -> the declared default, or empty.
118
+ # A missing value resolves, it never fails: the declared default, or
119
+ # empty. That IS the point of declaring a default -- there is no third
120
+ # case where the caller has to have supplied something, and adding one
121
+ # would turn an empty render into a crash for no gain.
114
122
  if not has or value is None:
115
123
  value = default if has_default else ([] if type_ == "messages" else "")
116
124
 
117
- # A default always satisfies the requirement, so the two never both
118
- # apply; the editor won't let you author both, but a hand-written
119
- # declaration can.
120
- required = v.get("required") if isinstance(v, dict) else getattr(v, "required", None)
121
- if not supplied and required is True and not has_default:
122
- errors.append(f"variable '{name}': required, but no value was provided")
123
- continue
124
-
125
125
  scope[name] = _coerce(value, type_, name, errors)
126
126
 
127
127
  # Passing something the prompt doesn't declare is nearly always a rename
trodo/prompts/types.py CHANGED
@@ -26,24 +26,18 @@ class PromptVariable:
26
26
  """A declared variable.
27
27
 
28
28
  Every competitor leaves variables undeclared and untyped — discovered by
29
- regex, with no defaults and no required-ness. The declaration is what lets
30
- ``compile()`` fail before the model call rather than shipping a literal
31
- ``{{typo}}`` and finding out from the bill.
32
-
33
- ``required`` is enforced by ``compile()``, which runs in YOUR process
34
- Trodo only stores the declaration. Nothing server-side rejects a call for a
35
- missing variable, and nothing rejects a version for declaring one.
36
-
37
- Marking a variable required means the caller must pass a value that will
38
- actually render: an omitted key, ``None`` and ``""`` all fail, since all
39
- three render as nothing (``0`` and ``False`` pass). A variable with
40
- a ``default`` can never fail the check — the default is a value — so the two
41
- are mutually exclusive in practice.
29
+ regex, and with no defaults. Knowing the declared set is what lets
30
+ ``compile()`` catch a value passed for a variable that doesn't exist,
31
+ instead of silently rendering the wrong prompt.
32
+
33
+ A missing value never fails. It resolves to the declared ``default``, or to
34
+ empty that is what declaring a default is FOR. There is deliberately no
35
+ ``required`` flag: it would give the author a decision to get wrong and the
36
+ caller a crash where an empty render would do.
42
37
  """
43
38
 
44
39
  name: str
45
40
  type: str = "string"
46
- required: bool = False
47
41
  default: Any = None
48
42
  description: Optional[str] = None
49
43
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.19.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
@@ -1,4 +1,4 @@
1
- trodo/__init__.py,sha256=n7YUScfJqdXfIUwWL-g1PekBrYfQ0A0m4kAme26rwUM,26928
1
+ trodo/__init__.py,sha256=yv0gxvU4OpZhqck-U2YY0RZE7qRWDhD4ig4zenbMPI0,26928
2
2
  trodo/client.py,sha256=9UYaHZtWPMYdlG2xWVf77PQJ9DfFgN-zobTh60i8_Cc,22453
3
3
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
4
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
@@ -13,7 +13,7 @@ trodo/managers/dataset_manager.py,sha256=gx0S8ujG3cbw4IskBNMdceBjDbcEh_nebwKSzEv
13
13
  trodo/managers/experiment_manager.py,sha256=V-vemLtenfSI5VIcP0HPiXzEy8AyTrRrbJVCA9Z6wjo,10579
14
14
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
15
15
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
16
- trodo/managers/prompt_manager.py,sha256=jFHkdvDSxvvb9c53EQvBQygdDJ7X-ylNWEY_ag_SeC0,11227
16
+ trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
17
17
  trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
18
18
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
19
19
  trodo/otel/auto_instrument.py,sha256=Iae9A9lvh2PImE6gqnyEMXeRPRpjTxZcxx1zW2KDLac,21467
@@ -23,11 +23,11 @@ trodo/otel/processor.py,sha256=LKlXxP3BeQ7DP8SzYAgXZZOeJ6-6b7e43i19gCUC_0Y,7939
23
23
  trodo/otel/prompt_trace.py,sha256=BIrdLOpsR1_HoaCmWb_706GwZSs-UG3p76fjvX3CX3w,3391
24
24
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
25
25
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
26
- trodo/otel/wrap_agent.py,sha256=cJjrzlZNW2g6q_coLN7UR4uxn65h4u3-LNzMfE4dLVw,40999
26
+ trodo/otel/wrap_agent.py,sha256=ipMKpdgRF5ibgjDlt2yTBPqbwb8Hq30C7o3hms0QAdU,41987
27
27
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
28
- trodo/prompts/compile.py,sha256=ydJDI7YS3PiLzAJ4H-kmqGV-1kkUxlQrf_xtZZ7fR5g,7267
28
+ trodo/prompts/compile.py,sha256=fAYq55LdGIvglNVCnBF3YXyM8W9raLku1M8OAzaQExE,7135
29
29
  trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
30
- trodo/prompts/types.py,sha256=1TVGHp3vtWV1tr0l-IJfL7EKvrB8ZmQRwLvDqvbOfJ0,5007
30
+ trodo/prompts/types.py,sha256=A24njy6qcc8QNzWEfGu28BbBr360S7A5Pw7_ObIWNS4,4684
31
31
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
33
33
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
@@ -36,7 +36,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
36
36
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
37
37
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
38
38
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
39
- trodo_python-2.19.0.dist-info/METADATA,sha256=RD_p3hblnxk6pAacML6IkqzfdIqP33lD50GS1kApkW8,25308
40
- trodo_python-2.19.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
- trodo_python-2.19.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
- trodo_python-2.19.0.dist-info/RECORD,,
39
+ trodo_python-2.21.0.dist-info/METADATA,sha256=La6v97ZyzSSTa0CeVffS53LhsUvF29mDpm6i92YO63Q,25308
40
+ trodo_python-2.21.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
+ trodo_python-2.21.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
+ trodo_python-2.21.0.dist-info/RECORD,,