trodo-python 2.11.0__py3-none-any.whl → 2.12.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,13 +41,21 @@ 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.11.0"
44
+ __version__ = "2.12.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
48
48
  from .client import TrodoClient
49
49
  from .user_context import UserContext
50
50
  from .managers.group_manager import GroupProfile
51
+ from .managers.prompt_manager import ManagedPrompt, PromptSummary
52
+ from .prompts import (
53
+ CompiledPrompt,
54
+ CompileError,
55
+ PromptVariable,
56
+ TemplateError,
57
+ render as _render_template,
58
+ )
51
59
  from .otel.wrap_agent import (
52
60
  wrap_agent as _wrap_agent_ctx,
53
61
  span as _span_ctx,
@@ -100,6 +108,17 @@ __all__ = [
100
108
  "propagation_headers",
101
109
  "current_run_id",
102
110
  "current_span_id",
111
+ # Prompt management
112
+ "get_prompt",
113
+ "list_prompts",
114
+ "compile_prompt",
115
+ "render_template",
116
+ "CompiledPrompt",
117
+ "PromptVariable",
118
+ "CompileError",
119
+ "TemplateError",
120
+ "ManagedPrompt",
121
+ "PromptSummary",
103
122
  ]
104
123
 
105
124
  # ============================================================================
@@ -249,6 +268,82 @@ def reset(distinct_id: str) -> ResetResult:
249
268
  return _get_client().reset(distinct_id)
250
269
 
251
270
 
271
+ # ----------------------------------------------------------------------------
272
+ # Prompt management
273
+ # ----------------------------------------------------------------------------
274
+
275
+ def get_prompt(
276
+ name: str,
277
+ label: Optional[str] = None,
278
+ version: Optional[int] = None,
279
+ cache_ttl_seconds: Optional[float] = None,
280
+ fallback: Optional[Dict[str, Any]] = None,
281
+ max_retries: int = 2,
282
+ ) -> ManagedPrompt:
283
+ """Fetch a managed prompt by name.
284
+
285
+ Follows the ``production`` label by default; pass ``label`` for a different
286
+ one or ``version`` to pin exactly.
287
+
288
+ Cached for 60s with stale-while-revalidate, so a Trodo outage degrades
289
+ instead of taking your app down. Pass ``fallback`` to cover cold start::
290
+
291
+ p = trodo.get_prompt("support-triage", fallback={
292
+ "messages": [{"role": "user", "content": [{"type": "text", "text": "Help with {{q}}"}]}],
293
+ "variables": [{"name": "q", "type": "string", "required": True}],
294
+ })
295
+ compiled = p.compile(q="where is my order")
296
+ # compiled.messages / compiled.model / compiled.tools
297
+
298
+ :raises LookupError: if the prompt cannot be fetched and nothing is cached
299
+ and no ``fallback`` was given
300
+ """
301
+ return _get_client().prompts.get(
302
+ name,
303
+ label=label,
304
+ version=version,
305
+ cache_ttl_seconds=cache_ttl_seconds,
306
+ fallback=fallback,
307
+ max_retries=max_retries,
308
+ )
309
+
310
+
311
+ def list_prompts() -> List[PromptSummary]:
312
+ """List prompts available to this site's team (names + labels, no body)."""
313
+ return _get_client().prompts.list()
314
+
315
+
316
+ def compile_prompt(
317
+ prompt: ManagedPrompt,
318
+ variables: Optional[Dict[str, Any]] = None,
319
+ **kwargs: Any,
320
+ ) -> CompiledPrompt:
321
+ """Compile a managed prompt with variable values.
322
+
323
+ Returns ``CompiledPrompt(messages, model, tools, response_format)`` — hand it
324
+ straight to your provider client.
325
+ """
326
+ merged = dict(variables or {})
327
+ merged.update(kwargs)
328
+ return prompt.compile(merged)
329
+
330
+
331
+ def render_template(
332
+ template: str,
333
+ variables: Optional[Dict[str, Any]] = None,
334
+ strict: bool = True,
335
+ **kwargs: Any,
336
+ ) -> str:
337
+ """Render a template string with ``{{variables}}`` (no client required).
338
+
339
+ Strict by default: an unknown variable raises rather than silently leaving a
340
+ literal ``{{typo}}`` in the text you send to a model.
341
+ """
342
+ merged = dict(variables or {})
343
+ merged.update(kwargs)
344
+ return _render_template(template, merged, strict=strict)
345
+
346
+
252
347
  def enable_auto_events() -> None:
253
348
  _get_client().enable_auto_events()
254
349
 
trodo/api/http_client.py CHANGED
@@ -80,6 +80,59 @@ class HttpClient:
80
80
  self.on_error(exc)
81
81
  return {}
82
82
 
83
+ def _get(
84
+ self, path: str, params: Optional[Dict[str, Any]] = None, attempt: int = 0
85
+ ) -> ApiResult:
86
+ """GET helper for read endpoints (prompt management). Surfaces a non-2xx
87
+ as {"__error": True, "status": <code>, ...} so callers can tell
88
+ "not found" apart from a successful empty body."""
89
+ url = f"{self.api_base}{path}"
90
+ self._log(f"GET {url}")
91
+ try:
92
+ resp = self._session.get(url, params=params or {}, timeout=self.timeout)
93
+ if resp.status_code >= 500 and attempt < self.retries:
94
+ delay = 2 ** attempt
95
+ self._log(f"Retry {attempt + 1} after {delay}s (status {resp.status_code})")
96
+ time.sleep(delay)
97
+ return self._get(path, params, attempt + 1)
98
+ try:
99
+ data = resp.json()
100
+ except Exception:
101
+ data = {}
102
+ if resp.status_code >= 400:
103
+ base = {"__error": True, "status": resp.status_code}
104
+ if isinstance(data, dict):
105
+ base.update(data)
106
+ return base
107
+ return data
108
+ except Exception as exc:
109
+ if attempt < self.retries:
110
+ delay = 2 ** attempt
111
+ self._log(f"Retry {attempt + 1} after {delay}s (network error)")
112
+ time.sleep(delay)
113
+ return self._get(path, params, attempt + 1)
114
+ self._log(f"Error: {exc}")
115
+ if self.on_error:
116
+ self.on_error(exc)
117
+ return {"__error": True}
118
+
119
+ def get_prompt(
120
+ self,
121
+ name: str,
122
+ label: Optional[str] = None,
123
+ version: Optional[Any] = None,
124
+ ) -> ApiResult:
125
+ from urllib.parse import quote
126
+ params: Dict[str, Any] = {}
127
+ if label:
128
+ params["label"] = label
129
+ if version is not None and version != "":
130
+ params["version"] = version
131
+ return self._get(f"/api/sdk/prompts/{quote(str(name), safe='')}", params)
132
+
133
+ def list_prompts(self) -> ApiResult:
134
+ return self._get("/api/sdk/prompts")
135
+
83
136
  def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
84
137
  return self._request("/api/sdk/track", {"sessionData": session_data})
85
138
 
trodo/client.py CHANGED
@@ -76,6 +76,7 @@ class TrodoClient:
76
76
  )
77
77
 
78
78
  self._session_manager = SessionManager()
79
+ self._prompts = None # lazily-built PromptManager
79
80
 
80
81
  if batch_enabled:
81
82
  self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
@@ -116,6 +117,14 @@ class TrodoClient:
116
117
  the public surface; subject to change between minors."""
117
118
  return self._span_processor
118
119
 
120
+ @property
121
+ def prompts(self):
122
+ """Read-only access to the team's managed prompt registry."""
123
+ if self._prompts is None:
124
+ from .managers.prompt_manager import PromptManager
125
+ self._prompts = PromptManager(self._http)
126
+ return self._prompts
127
+
119
128
  # --------------------------------------------------------------------------
120
129
  # Primary pattern: for_user()
121
130
  # --------------------------------------------------------------------------
@@ -0,0 +1,286 @@
1
+ """Prompt management — read managed prompts from the Trodo registry.
2
+
3
+ Prompts are authored and versioned in the Trodo dashboard; the SDK fetches them
4
+ at runtime by name + optional label/version so application code never hard-codes
5
+ prompt text. A deploy label (e.g. ``production``) points at one version, so you
6
+ ship a new prompt by moving the label rather than redeploying.
7
+
8
+ ``compile()`` returns a structured :class:`CompiledPrompt` — ``{messages, model,
9
+ tools, response_format}``, not a string. A string has nowhere to put model
10
+ config or tools, which is why every product that returns one cannot carry them.
11
+
12
+ Mirrors ``sdks/trodo-node-sdk/src/managers/PromptManager.ts`` test-for-test.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ import time
19
+ from typing import Any, Callable, Dict, List, Optional, Set
20
+
21
+ from ..prompts.compile import CompileError, compile_prompt
22
+ from ..prompts.template import TemplateError, render
23
+ from ..prompts.types import CompiledPrompt, ManagedPrompt, PromptSummary, PromptVariable
24
+
25
+ __all__ = [
26
+ "PromptManager",
27
+ "ManagedPrompt",
28
+ "PromptSummary",
29
+ "PromptVariable",
30
+ "CompiledPrompt",
31
+ "CompileError",
32
+ "TemplateError",
33
+ "compile_prompt",
34
+ "render",
35
+ "DEFAULT_TTL_SECONDS",
36
+ ]
37
+
38
+ DEFAULT_TTL_SECONDS = 60.0
39
+
40
+
41
+ def _cache_key(name: str, version: Optional[int], label: Optional[str]) -> str:
42
+ # Resolution happens server-side on every fetch; the client only caches
43
+ # under whatever selector was asked for. So a label flip propagates within
44
+ # one TTL without the client knowing anything about labels.
45
+ if version is not None:
46
+ return f"{name}::version:{version}"
47
+ if label:
48
+ return f"{name}::label:{label}"
49
+ return f"{name}::label:production"
50
+
51
+
52
+ def _to_variables(raw: Any) -> List[PromptVariable]:
53
+ out: List[PromptVariable] = []
54
+ for v in raw or []:
55
+ if isinstance(v, PromptVariable):
56
+ out.append(v)
57
+ elif isinstance(v, dict) and isinstance(v.get("name"), str):
58
+ out.append(
59
+ PromptVariable(
60
+ name=v["name"],
61
+ type=v.get("type") or "string",
62
+ required=bool(v.get("required")),
63
+ default=v.get("default"),
64
+ description=v.get("description"),
65
+ )
66
+ )
67
+ return out
68
+
69
+
70
+ def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
71
+ return ManagedPrompt(
72
+ name=str(raw.get("name") or ""),
73
+ description=raw.get("description"),
74
+ tags=list(raw.get("tags") or []),
75
+ version=int(raw.get("version") or 0),
76
+ labels=list(raw.get("labels") or []),
77
+ messages=list(raw.get("messages") or []),
78
+ model_config=dict(raw.get("model_config") or {}),
79
+ tools=list(raw.get("tools") or []),
80
+ response_format=raw.get("response_format"),
81
+ variables=_to_variables(raw.get("variables")),
82
+ updated_at=raw.get("updated_at") or raw.get("updatedAt"),
83
+ is_fallback=is_fallback,
84
+ )
85
+
86
+
87
+ class _Cache:
88
+ """Prompt cache with stale-while-revalidate.
89
+
90
+ An LLM app must never hard-fail because the prompt service blinked. The
91
+ ladder, in order:
92
+
93
+ fresh cache -> stale cache -> caller's fallback -> raise
94
+
95
+ A prompt fetch sits on the hot path of every request an app makes. Without
96
+ this, adding Trodo prompt management would make an app strictly *less*
97
+ reliable than hardcoding the string — which is a good reason not to adopt it.
98
+ """
99
+
100
+ def __init__(self) -> None:
101
+ self._store: Dict[str, Any] = {}
102
+ self._expires: Dict[str, float] = {}
103
+ self._refreshing: Set[str] = set()
104
+ self._lock = threading.Lock()
105
+
106
+ def get_fresh(self, key: str) -> Optional[Any]:
107
+ with self._lock:
108
+ if key in self._store and self._expires.get(key, 0.0) > time.time():
109
+ return self._store[key]
110
+ return None
111
+
112
+ def get_stale(self, key: str) -> Optional[Any]:
113
+ with self._lock:
114
+ return self._store.get(key)
115
+
116
+ def set(self, key: str, value: Any, ttl: float) -> None:
117
+ with self._lock:
118
+ self._store[key] = value
119
+ self._expires[key] = time.time() + ttl
120
+
121
+ def revalidate(self, key: str, fetcher: Callable[[], Any], ttl: float) -> None:
122
+ with self._lock:
123
+ if key in self._refreshing:
124
+ return
125
+ self._refreshing.add(key)
126
+
127
+ def _run() -> None:
128
+ try:
129
+ self.set(key, fetcher(), ttl)
130
+ except Exception: # noqa: BLE001
131
+ # Deliberately swallowed. The caller already has a usable stale
132
+ # value; a background refresh failing is not their problem and
133
+ # must never surface. It retries on the next request.
134
+ pass
135
+ finally:
136
+ with self._lock:
137
+ self._refreshing.discard(key)
138
+
139
+ threading.Thread(target=_run, daemon=True).start()
140
+
141
+ def clear(self) -> None:
142
+ with self._lock:
143
+ self._store.clear()
144
+ self._expires.clear()
145
+ self._refreshing.clear()
146
+
147
+
148
+ def _with_retry(fn: Callable[[], Any], max_retries: int) -> Any:
149
+ """Retry with exponential backoff, capped. Mirrors the Node SDK."""
150
+ attempts = max(0, min(max_retries, 4))
151
+ last: Optional[BaseException] = None
152
+ for i in range(attempts + 1):
153
+ try:
154
+ return fn()
155
+ except Exception as e: # noqa: BLE001
156
+ last = e
157
+ if i == attempts:
158
+ break
159
+ time.sleep(min(2**i * 0.2, 10.0))
160
+ assert last is not None
161
+ raise last
162
+
163
+
164
+ class PromptManager:
165
+ """Read-only access to the team's managed prompt registry."""
166
+
167
+ def __init__(self, http_client: Any) -> None:
168
+ self._http = http_client
169
+ self._cache = _Cache()
170
+
171
+ def get(
172
+ self,
173
+ name: str,
174
+ label: Optional[str] = None,
175
+ version: Optional[int] = None,
176
+ cache_ttl_seconds: Optional[float] = None,
177
+ fallback: Optional[Dict[str, Any]] = None,
178
+ max_retries: int = 2,
179
+ ) -> ManagedPrompt:
180
+ """Fetch a managed prompt by name.
181
+
182
+ Without arguments you get the version labelled ``production``; pass
183
+ ``label`` to follow a different deploy label, or ``version`` to pin
184
+ exactly.
185
+
186
+ Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
187
+ raise. A prompt fetch is on your hot path, so a Trodo outage degrades
188
+ rather than takes your app down. Check ``prompt.is_fallback`` to detect
189
+ the last rung. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
190
+ development).
191
+
192
+ :raises ValueError: if *name* is empty, or both ``label`` and ``version``
193
+ :raises LookupError: if the prompt cannot be fetched and nothing is
194
+ cached and no ``fallback`` was given
195
+ """
196
+ if not name:
197
+ raise ValueError("trodo: get_prompt(name) requires a name")
198
+ if version is not None and label:
199
+ raise ValueError("trodo: pass either version or label, not both")
200
+
201
+ key = _cache_key(name, version, label)
202
+ ttl = DEFAULT_TTL_SECONDS if cache_ttl_seconds is None else float(cache_ttl_seconds)
203
+
204
+ def fetcher() -> Dict[str, Any]:
205
+ res = _with_retry(
206
+ lambda: self._http.get_prompt(name, label=label, version=version),
207
+ max_retries,
208
+ )
209
+ if not res or res.get("__error") or not res.get("prompt"):
210
+ status = res.get("status") if isinstance(res, dict) else None
211
+ detail = res.get("error") if isinstance(res, dict) else None
212
+ raise LookupError(
213
+ f"trodo: could not fetch prompt {name!r}"
214
+ + (f" (HTTP {status})" if status else "")
215
+ + (f": {detail}" if detail else "")
216
+ )
217
+ return res["prompt"]
218
+
219
+ if ttl > 0:
220
+ fresh = self._cache.get_fresh(key)
221
+ if fresh is not None:
222
+ return _to_prompt(fresh)
223
+
224
+ stale = self._cache.get_stale(key)
225
+ if stale is not None:
226
+ # Serve immediately, refresh behind the caller's back. A slow or
227
+ # dead API costs latency on nobody's request.
228
+ self._cache.revalidate(key, fetcher, ttl)
229
+ return _to_prompt(stale)
230
+
231
+ try:
232
+ raw = fetcher()
233
+ if ttl > 0:
234
+ self._cache.set(key, raw, ttl)
235
+ return _to_prompt(raw)
236
+ except Exception:
237
+ stale = self._cache.get_stale(key)
238
+ if stale is not None:
239
+ return _to_prompt(stale)
240
+ if fallback:
241
+ return _to_prompt(
242
+ {
243
+ "name": name,
244
+ "version": 0,
245
+ "labels": [],
246
+ "tags": [],
247
+ "messages": fallback.get("messages") or [],
248
+ "model_config": fallback.get("model_config") or {},
249
+ "tools": [],
250
+ "response_format": None,
251
+ "variables": fallback.get("variables") or [],
252
+ },
253
+ is_fallback=True,
254
+ )
255
+ raise
256
+
257
+ def list(self) -> List[PromptSummary]:
258
+ """List the prompts available to this site's team (names + labels)."""
259
+ res = self._http.list_prompts()
260
+ if not res or res.get("__error") or not isinstance(res.get("prompts"), list):
261
+ return []
262
+ return [
263
+ PromptSummary(
264
+ name=str(p.get("name") or ""),
265
+ description=p.get("description"),
266
+ version=int(p.get("version") or 0),
267
+ labels=list(p.get("labels") or []),
268
+ updated_at=p.get("updated_at") or p.get("updatedAt"),
269
+ )
270
+ for p in res["prompts"]
271
+ ]
272
+
273
+ def compile(
274
+ self,
275
+ prompt: ManagedPrompt,
276
+ variables: Optional[Dict[str, Any]] = None,
277
+ **kwargs: Any,
278
+ ) -> CompiledPrompt:
279
+ """Compile a managed prompt with variable values."""
280
+ values = dict(variables or {})
281
+ values.update(kwargs)
282
+ return prompt.compile(values)
283
+
284
+ def clear_cache(self) -> None:
285
+ """Drop everything cached. Mostly useful in tests."""
286
+ self._cache.clear()
@@ -0,0 +1,37 @@
1
+ """Managed prompt support: template rendering, compilation, and types."""
2
+
3
+ from .compile import CompileError, build_scope, compile_prompt
4
+ from .template import TemplateError, extract_names, parse, render, render_with_report
5
+ from .types import (
6
+ CompiledPrompt,
7
+ ContentBlock,
8
+ ManagedPrompt,
9
+ ModelConfig,
10
+ PromptMessage,
11
+ PromptNode,
12
+ PromptSummary,
13
+ PromptTool,
14
+ PromptVariable,
15
+ ResponseFormat,
16
+ )
17
+
18
+ __all__ = [
19
+ "CompileError",
20
+ "CompiledPrompt",
21
+ "ContentBlock",
22
+ "ManagedPrompt",
23
+ "ModelConfig",
24
+ "PromptMessage",
25
+ "PromptNode",
26
+ "PromptSummary",
27
+ "PromptTool",
28
+ "PromptVariable",
29
+ "ResponseFormat",
30
+ "TemplateError",
31
+ "build_scope",
32
+ "compile_prompt",
33
+ "extract_names",
34
+ "parse",
35
+ "render",
36
+ "render_with_report",
37
+ ]
@@ -0,0 +1,181 @@
1
+ """Compile a managed prompt into a ready-to-send payload.
2
+
3
+ Mirrors ``backend/services/prompts/compile.js`` and the Node SDK's
4
+ ``src/prompts/compile.ts`` exactly. The backend compiles for the playground and
5
+ the hosted ``/run`` path; this compiles in the caller's process. They must agree,
6
+ or the prompt you tested is not the prompt you shipped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .template import render
14
+ from .types import CompiledPrompt, PromptVariable
15
+
16
+
17
+ class CompileError(Exception):
18
+ """Raised when a prompt cannot be compiled with the supplied values.
19
+
20
+ Carries ``details`` — every problem found, not just the first, so a caller
21
+ can surface them all at once.
22
+ """
23
+
24
+ def __init__(self, message: str, details: Optional[List[str]] = None) -> None:
25
+ super().__init__(message)
26
+ self.details: List[str] = details or []
27
+
28
+
29
+ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
30
+ """Coerce a caller value to the declared type.
31
+
32
+ Values arrive over HTTP and from application code, so ``"5"`` for a number
33
+ variable is normal, not an error — but ``"abc"`` is.
34
+ """
35
+ if value is None:
36
+ return value
37
+
38
+ if type_ == "number":
39
+ if isinstance(value, bool):
40
+ errors.append(f"variable '{name}': expected a number, got {value!r}")
41
+ return value
42
+ if isinstance(value, (int, float)):
43
+ return value
44
+ try:
45
+ return float(value) if "." in str(value) else int(value)
46
+ except (TypeError, ValueError):
47
+ errors.append(f"variable '{name}': expected a number, got {value!r}")
48
+ return value
49
+
50
+ if type_ == "boolean":
51
+ if isinstance(value, bool):
52
+ return value
53
+ if value == "true":
54
+ return True
55
+ if value == "false":
56
+ return False
57
+ errors.append(f"variable '{name}': expected a boolean, got {value!r}")
58
+ return value
59
+
60
+ if type_ == "string":
61
+ return value if isinstance(value, str) else str(value)
62
+
63
+ if type_ == "messages":
64
+ if not isinstance(value, list):
65
+ errors.append(f"variable '{name}': expected an array of messages")
66
+ return value
67
+
68
+ return value
69
+
70
+
71
+ def build_scope(
72
+ variables: Optional[List[PromptVariable]],
73
+ values: Optional[Dict[str, Any]],
74
+ ) -> Dict[str, Any]:
75
+ """Build the render scope from declarations + caller values.
76
+
77
+ Resolution order: caller value → declared default → required-and-absent
78
+ raises → optional-and-absent renders empty.
79
+ """
80
+ errors: List[str] = []
81
+ scope: Dict[str, Any] = {}
82
+ declared = set()
83
+ values = values or {}
84
+
85
+ for v in variables or []:
86
+ name = getattr(v, "name", None) if not isinstance(v, dict) else v.get("name")
87
+ if not isinstance(name, str):
88
+ continue
89
+ declared.add(name)
90
+
91
+ if isinstance(v, dict):
92
+ type_ = v.get("type") or "string"
93
+ has_default = "default" in v and v.get("default") is not None
94
+ default = v.get("default")
95
+ else:
96
+ type_ = getattr(v, "type", None) or "string"
97
+ default = getattr(v, "default", None)
98
+ has_default = default is not None
99
+
100
+ has = name in values
101
+ value = values.get(name) if has else None
102
+
103
+ # Missing value -> the declared default, or empty. Nothing is
104
+ # "required": compile always succeeds with whatever the caller provides.
105
+ if not has or value is None:
106
+ value = default if has_default else ([] if type_ == "messages" else "")
107
+
108
+ scope[name] = _coerce(value, type_, name, errors)
109
+
110
+ # Passing something the prompt doesn't declare is nearly always a rename
111
+ # that didn't propagate. Fail loudly rather than render a prompt nobody
112
+ # intended.
113
+ for k in values:
114
+ if k not in declared:
115
+ errors.append(f"variable '{k}' is not declared on this prompt")
116
+
117
+ if errors:
118
+ raise CompileError("Cannot compile prompt: " + "; ".join(errors), errors)
119
+ return scope
120
+
121
+
122
+ def _render_block(block: Dict[str, Any], scope: Dict[str, Any]) -> Dict[str, Any]:
123
+ if block.get("type") == "text":
124
+ out = dict(block)
125
+ out["text"] = render(block.get("text"), scope, strict=True)
126
+ return out
127
+ return block
128
+
129
+
130
+ def _resolve_placeholder(
131
+ node: Dict[str, Any], scope: Dict[str, Any], errors: List[str]
132
+ ) -> List[Dict[str, Any]]:
133
+ name = node.get("name")
134
+ value = scope.get(name)
135
+ if value is None:
136
+ return []
137
+ if not isinstance(value, list):
138
+ # Langfuse coerces a non-list into {"role": "NOT_GIVEN", ...} plus a
139
+ # warning, and that sentinel role then reaches the provider. Refuse.
140
+ errors.append(
141
+ f"placeholder '{name}': expected an array of messages, got {type(value).__name__}"
142
+ )
143
+ return []
144
+ for i, m in enumerate(value):
145
+ if not isinstance(m, dict) or not isinstance(m.get("role"), str):
146
+ errors.append(f"placeholder '{name}'[{i}]: each item must be a message with a role")
147
+ return value
148
+
149
+
150
+ def compile_prompt(prompt: Any, values: Optional[Dict[str, Any]] = None) -> CompiledPrompt:
151
+ """Compile *prompt* with *values*.
152
+
153
+ Returns a :class:`CompiledPrompt` — ``{messages, model, tools,
154
+ response_format}``, not a string. A string has nowhere to put model config
155
+ or tools, which is why every product that returns one cannot carry them.
156
+ """
157
+ get = (lambda k, d=None: prompt.get(k, d)) if isinstance(prompt, dict) else (
158
+ lambda k, d=None: getattr(prompt, k, d)
159
+ )
160
+
161
+ scope = build_scope(get("variables") or [], values or {})
162
+ errors: List[str] = []
163
+ out: List[Dict[str, Any]] = []
164
+
165
+ for node in get("messages") or []:
166
+ if isinstance(node, dict) and node.get("type") == "placeholder":
167
+ out.extend(_resolve_placeholder(node, scope, errors))
168
+ continue
169
+ msg = dict(node)
170
+ msg["content"] = [_render_block(b, scope) for b in (node.get("content") or [])]
171
+ out.append(msg)
172
+
173
+ if errors:
174
+ raise CompileError("Cannot compile prompt: " + "; ".join(errors), errors)
175
+
176
+ return CompiledPrompt(
177
+ messages=out,
178
+ model=get("model_config") or {},
179
+ tools=get("tools") or [],
180
+ response_format=get("response_format"),
181
+ )
@@ -0,0 +1,280 @@
1
+ """Trodo prompt template engine — Python SDK port.
2
+
3
+ MUST stay byte-identical to:
4
+ backend/services/prompts/template.js (source of truth)
5
+ sdks/trodo-node-sdk/src/prompts/template.ts
6
+
7
+ All three are pinned by ``backend/services/prompts/template.vectors.json``,
8
+ which this package's tests load directly. If you change behaviour here without
9
+ adding a vector and updating the other two, the suites will disagree — which is
10
+ the point. Divergence between what the playground renders and what the SDK
11
+ renders is the single worst bug this feature can have.
12
+
13
+ Zero dependencies, deliberately: this SDK ships with only ``requests`` and that
14
+ is a feature for something embedded in customer apps. Notably we do NOT use
15
+ jinja2 here — it has no faithful JavaScript implementation, and cross-language
16
+ fidelity matters more than expressiveness. See docs/prompt-management-v2-plan.md §5.
17
+
18
+ Supported:
19
+ {{name}} interpolate (raw — we emit prompts, not HTML)
20
+ {{a.b.c}} dot-path resolution
21
+ {{#name}}...{{/name}} section: truthy renders once, list renders per item
22
+ {{^name}}...{{/name}} inverted section
23
+ {{.}} current item inside a list section
24
+ {{! comment }} comment
25
+
26
+ Not supported (raises, never silently ignored): partials, lambdas, set-delimiters.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import re
33
+ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
34
+
35
+ _OPEN = "{{"
36
+ _CLOSE = "}}"
37
+
38
+ _NAME_RE = re.compile(r"^(\.|[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*)$")
39
+
40
+
41
+ class TemplateError(Exception):
42
+ """Raised for malformed templates and (in strict mode) unknown variables."""
43
+
44
+
45
+ # Tokens are dicts to mirror the JS ports exactly:
46
+ # {"t": "text", "v": str}
47
+ # {"t": "var", "v": str}
48
+ # {"t": "section", "v": str, "inverted": bool, "children": [...]}
49
+ Token = Dict[str, Any]
50
+
51
+
52
+ def _assert_name(name: str, pos: int) -> None:
53
+ if not _NAME_RE.match(name):
54
+ raise TemplateError(f"Invalid variable name '{name}' at position {pos}")
55
+
56
+
57
+ def parse(template: Optional[str]) -> List[Token]:
58
+ """Parse a template into a token tree."""
59
+ src = "" if template is None else str(template)
60
+ root: List[Token] = []
61
+ stack: List[Tuple[Optional[str], List[Token]]] = [(None, root)]
62
+ i = 0
63
+
64
+ def push(tok: Token) -> None:
65
+ stack[-1][1].append(tok)
66
+
67
+ while i < len(src):
68
+ open_at = src.find(_OPEN, i)
69
+ if open_at == -1:
70
+ if i < len(src):
71
+ push({"t": "text", "v": src[i:]})
72
+ break
73
+ if open_at > i:
74
+ push({"t": "text", "v": src[i:open_at]})
75
+
76
+ close_at = src.find(_CLOSE, open_at + len(_OPEN))
77
+ if close_at == -1:
78
+ raise TemplateError(f"Unclosed '{{{{' at position {open_at}")
79
+
80
+ body = src[open_at + len(_OPEN) : close_at].strip()
81
+ i = close_at + len(_CLOSE)
82
+
83
+ if not body:
84
+ raise TemplateError(f"Empty tag '{{{{}}}}' at position {open_at}")
85
+
86
+ sigil = body[0]
87
+ rest = body[1:].strip()
88
+
89
+ if sigil == "!":
90
+ continue
91
+
92
+ if sigil in (">", "<"):
93
+ raise TemplateError(
94
+ f"Partials ({{{{>{rest}}}}}) are not supported. Inline the content instead."
95
+ )
96
+ if sigil == "=":
97
+ raise TemplateError("Set-delimiter tags ({{=...=}}) are not supported.")
98
+ if sigil == "&":
99
+ raise TemplateError(
100
+ f"Unescaped tags ({{{{&{rest}}}}}) are not supported: "
101
+ f"{{{{{rest}}}}} is already raw."
102
+ )
103
+
104
+ if sigil in ("#", "^"):
105
+ if not rest:
106
+ raise TemplateError(f"Section tag missing a name at position {open_at}")
107
+ _assert_name(rest, open_at)
108
+ tok: Token = {"t": "section", "v": rest, "inverted": sigil == "^", "children": []}
109
+ push(tok)
110
+ stack.append((rest, tok["children"]))
111
+ continue
112
+
113
+ if sigil == "/":
114
+ top_name, _ = stack[-1]
115
+ if top_name is None:
116
+ raise TemplateError(f"Unexpected closing tag {{{{/{rest}}}}} — no open section")
117
+ if top_name != rest:
118
+ raise TemplateError(
119
+ f"Mismatched closing tag: expected {{{{/{top_name}}}}} "
120
+ f"but found {{{{/{rest}}}}}"
121
+ )
122
+ stack.pop()
123
+ continue
124
+
125
+ _assert_name(body, open_at)
126
+ push({"t": "var", "v": body})
127
+
128
+ if len(stack) > 1:
129
+ raise TemplateError(f"Unclosed section {{{{#{stack[-1][0]}}}}}")
130
+ return root
131
+
132
+
133
+ def _lookup(path: str, scopes: Sequence[Any]) -> Tuple[bool, Any]:
134
+ """Resolve a dot-path against a scope chain (innermost first).
135
+
136
+ This is the piece every competitor gets wrong: Langfuse's parser lexes
137
+ ``{{user.name}}`` as a name but then does a flat key lookup, so the dot-path
138
+ only resolves if you literally pass a ``"user.name"`` key. We walk it.
139
+ """
140
+ if path == ".":
141
+ return (True, scopes[0]) if scopes else (False, None)
142
+
143
+ parts = path.split(".")
144
+ for scope in scopes:
145
+ if not isinstance(scope, dict):
146
+ continue
147
+ if parts[0] not in scope:
148
+ continue
149
+
150
+ cur: Any = scope
151
+ ok = True
152
+ for part in parts:
153
+ if not isinstance(cur, dict) or part not in cur:
154
+ ok = False
155
+ break
156
+ cur = cur[part]
157
+ if ok:
158
+ return True, cur
159
+ return False, None
160
+
161
+
162
+ def _stringify(value: Any) -> str:
163
+ if value is None:
164
+ return ""
165
+ if isinstance(value, str):
166
+ return value
167
+ if isinstance(value, bool):
168
+ # Must match JS: `true` / `false`, not Python's `True` / `False`.
169
+ return "true" if value else "false"
170
+ if isinstance(value, (int, float)):
171
+ # Match JS number formatting: 5.0 renders as "5", not "5.0".
172
+ if isinstance(value, float) and value.is_integer():
173
+ return str(int(value))
174
+ return str(value)
175
+ try:
176
+ return json.dumps(value, separators=(",", ":"))
177
+ except (TypeError, ValueError):
178
+ return str(value)
179
+
180
+
181
+ def _is_falsy(value: Any) -> bool:
182
+ if value is None or value is False or value == "":
183
+ return True
184
+ if isinstance(value, (list, tuple)) and len(value) == 0:
185
+ return True
186
+ return False
187
+
188
+
189
+ def _render_tokens(tokens: List[Token], scopes: List[Any], strict: bool, missing: Set[str]) -> str:
190
+ out: List[str] = []
191
+ for tok in tokens:
192
+ kind = tok["t"]
193
+
194
+ if kind == "text":
195
+ out.append(tok["v"])
196
+ continue
197
+
198
+ if kind == "var":
199
+ found, value = _lookup(tok["v"], scopes)
200
+ if not found:
201
+ if strict:
202
+ raise TemplateError(
203
+ f"Unknown variable '{{{{{tok['v']}}}}}'. "
204
+ "Declare it on the prompt or pass a value."
205
+ )
206
+ missing.add(tok["v"])
207
+ continue
208
+ out.append(_stringify(value))
209
+ continue
210
+
211
+ # section
212
+ found, value = _lookup(tok["v"], scopes)
213
+ if not found and strict and not tok["inverted"]:
214
+ raise TemplateError(
215
+ f"Unknown section '{{{{#{tok['v']}}}}}'. "
216
+ "Declare it on the prompt or pass a value."
217
+ )
218
+ if not found:
219
+ missing.add(tok["v"])
220
+
221
+ falsy = _is_falsy(value)
222
+ if tok["inverted"]:
223
+ if falsy:
224
+ out.append(_render_tokens(tok["children"], scopes, strict, missing))
225
+ continue
226
+ if falsy:
227
+ continue
228
+
229
+ if isinstance(value, (list, tuple)):
230
+ for item in value:
231
+ out.append(_render_tokens(tok["children"], [item, *scopes], strict, missing))
232
+ elif isinstance(value, dict):
233
+ out.append(_render_tokens(tok["children"], [value, *scopes], strict, missing))
234
+ else:
235
+ out.append(_render_tokens(tok["children"], scopes, strict, missing))
236
+
237
+ return "".join(out)
238
+
239
+
240
+ def render(
241
+ template: Optional[str],
242
+ values: Optional[Dict[str, Any]] = None,
243
+ strict: bool = True,
244
+ ) -> str:
245
+ """Render *template* with *values*.
246
+
247
+ Strict by default: an unknown variable raises rather than leaving a literal
248
+ ``{{typo}}`` in the text you send to a model.
249
+ """
250
+ return _render_tokens(parse(template), [values or {}], strict, set())
251
+
252
+
253
+ def render_with_report(
254
+ template: Optional[str],
255
+ values: Optional[Dict[str, Any]] = None,
256
+ strict: bool = False,
257
+ ) -> Tuple[str, List[str]]:
258
+ """Render, also reporting which names were missing. Returns (text, missing)."""
259
+ missing: Set[str] = set()
260
+ text = _render_tokens(parse(template), [values or {}], strict, missing)
261
+ return text, list(missing)
262
+
263
+
264
+ def extract_names(template: Optional[str]) -> List[str]:
265
+ """Names referenced by a template, first-appearance order, de-duplicated."""
266
+ seen: List[str] = []
267
+
268
+ def walk(tokens: List[Token]) -> None:
269
+ for tok in tokens:
270
+ if tok["t"] == "text":
271
+ continue
272
+ if tok["v"] != ".":
273
+ root = tok["v"].split(".")[0]
274
+ if root not in seen:
275
+ seen.append(root)
276
+ if tok["t"] == "section":
277
+ walk(tok["children"])
278
+
279
+ walk(parse(template))
280
+ return seen
trodo/prompts/types.py ADDED
@@ -0,0 +1,87 @@
1
+ """Public types for managed prompts.
2
+
3
+ Mirrors the wire contract in ``backend/controllers/sdkPromptController.js`` and
4
+ the Node SDK's ``src/prompts/types.ts``. All three move together.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+ # A message is a plain dict on the wire:
13
+ # {"role": "system", "content": [{"type": "text", "text": "..."}]}
14
+ # A placeholder — a slot where the caller injects a list of messages:
15
+ # {"type": "placeholder", "name": "chat_history"}
16
+ PromptMessage = Dict[str, Any]
17
+ PromptNode = Dict[str, Any]
18
+ ContentBlock = Dict[str, Any]
19
+ ModelConfig = Dict[str, Any]
20
+ PromptTool = Dict[str, Any]
21
+ ResponseFormat = Dict[str, Any]
22
+
23
+
24
+ @dataclass
25
+ class PromptVariable:
26
+ """A declared variable.
27
+
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
+
34
+ name: str
35
+ type: str = "string"
36
+ required: bool = False
37
+ default: Any = None
38
+ description: Optional[str] = None
39
+
40
+
41
+ @dataclass
42
+ class CompiledPrompt:
43
+ """The result of compiling a prompt — ready to hand to a provider client."""
44
+
45
+ messages: List[PromptMessage]
46
+ model: ModelConfig = field(default_factory=dict)
47
+ tools: List[PromptTool] = field(default_factory=list)
48
+ response_format: Optional[ResponseFormat] = None
49
+
50
+
51
+ @dataclass
52
+ class ManagedPrompt:
53
+ """A managed prompt resolved from the Trodo registry."""
54
+
55
+ name: str
56
+ messages: List[PromptNode] = field(default_factory=list)
57
+ model_config: ModelConfig = field(default_factory=dict)
58
+ tools: List[PromptTool] = field(default_factory=list)
59
+ response_format: Optional[ResponseFormat] = None
60
+ variables: List[PromptVariable] = field(default_factory=list)
61
+ version: int = 0
62
+ labels: List[str] = field(default_factory=list)
63
+ tags: List[str] = field(default_factory=list)
64
+ description: Optional[str] = None
65
+ updated_at: Optional[str] = None
66
+ #: True when this came from the ``fallback`` argument because the API was
67
+ #: unreachable and nothing was cached.
68
+ is_fallback: bool = False
69
+
70
+ def compile(self, variables: Optional[Dict[str, Any]] = None, **kwargs: Any) -> CompiledPrompt:
71
+ """Compile with variable values, as a dict or as keyword arguments."""
72
+ from .compile import compile_prompt # local import avoids a cycle
73
+
74
+ values = dict(variables or {})
75
+ values.update(kwargs)
76
+ return compile_prompt(self, values)
77
+
78
+
79
+ @dataclass
80
+ class PromptSummary:
81
+ """Lightweight prompt entry returned by ``list_prompts()``."""
82
+
83
+ name: str
84
+ version: int = 0
85
+ labels: List[str] = field(default_factory=list)
86
+ description: Optional[str] = None
87
+ updated_at: Optional[str] = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.11.0
3
+ Version: 2.12.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
@@ -438,6 +438,44 @@ Runnable scenarios that double as integration tests live in
438
438
 
439
439
  ---
440
440
 
441
+ ## Prompt Management (v2.9.0+)
442
+
443
+ Author and version prompts in the Trodo dashboard, then fetch them at runtime so
444
+ your application never hard-codes prompt text. A deploy **label** (e.g.
445
+ `production`) points at one version; ship a new prompt by moving the label — no
446
+ redeploy.
447
+
448
+ ```python
449
+ import trodo
450
+ trodo.init(site_id="your-site-id")
451
+
452
+ # Latest version (default), a label, or a pinned version number:
453
+ prompt = trodo.get_prompt("refund-agent", label="production")
454
+
455
+ # Fill {{variables}} — unknown tokens are left intact so a missing value shows.
456
+ system = prompt.compile(company="Acme", customer_name="Ada")
457
+
458
+ resp = openai.chat.completions.create(
459
+ model=prompt.config.get("model", "gpt-4o-mini"),
460
+ temperature=prompt.config.get("temperature", 0.2),
461
+ messages=[{"role": "system", "content": system},
462
+ {"role": "user", "content": query}],
463
+ )
464
+ ```
465
+
466
+ `get_prompt()` returns a `ManagedPrompt` with `name`, `version`, `labels`,
467
+ `template`, `config`, `variables`, and a `.compile(**vars)` method. It raises
468
+ `LookupError` if the prompt can't be found (you can't run without it).
469
+
470
+ ```python
471
+ trodo.get_prompt("refund-agent") # latest version
472
+ trodo.get_prompt("refund-agent", version=3) # pinned version
473
+ trodo.list_prompts() # [PromptSummary(name=..., labels=...), ...]
474
+ trodo.compile_prompt(template_or_prompt, {...}) # standalone {{var}} substitution
475
+ ```
476
+
477
+ ---
478
+
441
479
  ## Agent Analytics (legacy event-based API)
442
480
 
443
481
  The older per-event API below is still supported but superseded by
@@ -1,16 +1,17 @@
1
- trodo/__init__.py,sha256=fZ9qn_ObzXX7WLpEnhnlivN6sASv-u9vYXw_IWGNkf0,18229
2
- trodo/client.py,sha256=dhGiOJmxdWmXDDDabHN865_cAxttJs9LWPeDwEFbAwI,19107
1
+ trodo/__init__.py,sha256=F5asTaDmKcWtk6NJ_DVr4wo1S4HoYMWFiiyqHHDNSvE,21167
2
+ trodo/client.py,sha256=z2q1HZKQNENycv9CRD0kOfwEhmz3Ti9_B-uPsqpvY3U,19454
3
3
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
4
  trodo/user_context.py,sha256=9la6azzwEanVmdP4ps_xMoufbeWVeIGU-M8ychmgajg,7859
5
5
  trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  trodo/api/async_client.py,sha256=rZN4aJ2QiKyrHBK260bApCUB9JaMWU6BQtzoSJZh7xk,3408
7
7
  trodo/api/endpoints.py,sha256=HKQ3d_Mxf0y4HwlHor0XkSAwUVj-4Xvv--rzE9njxjM,1027
8
- trodo/api/http_client.py,sha256=6DsRCeojxPSYUDe7SbTVHcg6YXlKbM81kb670qXyb_4,5535
8
+ trodo/api/http_client.py,sha256=gVYVqn-M4Mjd0zTjx_Zsr246BL_xvIHOa2w8YlpVvD4,7638
9
9
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
11
11
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
13
13
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
14
+ trodo/managers/prompt_manager.py,sha256=Ub7rlg3dVVllr6qBf6sXLGvunnP_7FLeGEOal6sYFPY,10448
14
15
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
15
16
  trodo/otel/auto_instrument.py,sha256=hJesQMOOp86U66PampcGNINmBVvwVuU_WHVrbUMauug,20895
16
17
  trodo/otel/context.py,sha256=iJ1rE42-SbO8VZHAxhIl2ZJXgNwLIVps5xLg8GKgfFc,1165
@@ -19,13 +20,17 @@ trodo/otel/processor.py,sha256=aqcTmzTw9cESgIp829pu_XCa5_dG_2MaeJNsqJZeqQU,7495
19
20
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
20
21
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
21
22
  trodo/otel/wrap_agent.py,sha256=_nFDhxPyl0RlNKj29cBNeRc_zAY5FUiHvTiNUWxTt0M,39049
23
+ trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
24
+ trodo/prompts/compile.py,sha256=Jqy1yaSE1j931KIHP1opMpEEmBKHv-I4BUq-1xaNVWA,6352
25
+ trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
26
+ trodo/prompts/types.py,sha256=iCNoJk6_kk0JvEblGsDm6c9lEdQW6ixOOv6aCG6IP-k,2924
22
27
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
28
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
24
29
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
25
30
  trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
31
  trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
27
32
  trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
28
- trodo_python-2.11.0.dist-info/METADATA,sha256=kfO84FYYYUexa79fxuSjkj3ou4NJU1xTVeFAHYewz7M,21698
29
- trodo_python-2.11.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
30
- trodo_python-2.11.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
31
- trodo_python-2.11.0.dist-info/RECORD,,
33
+ trodo_python-2.12.0.dist-info/METADATA,sha256=pvC-YSCsk3GkCPdxnNGUkbgT-ZLBWIgO-MWLxqzzcrA,23115
34
+ trodo_python-2.12.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
35
+ trodo_python-2.12.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
36
+ trodo_python-2.12.0.dist-info/RECORD,,