trodo-python 2.26.2__tar.gz → 3.0.1__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 (77) hide show
  1. {trodo_python-2.26.2 → trodo_python-3.0.1}/PKG-INFO +1 -1
  2. {trodo_python-2.26.2 → trodo_python-3.0.1}/pyproject.toml +1 -1
  3. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_datasets.py +82 -0
  4. trodo_python-3.0.1/tests/test_long_runs.py +34 -0
  5. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/__init__.py +24 -89
  6. trodo_python-3.0.1/trodo/_version.py +1 -0
  7. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/api/endpoints.py +1 -6
  8. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/api/http_client.py +0 -25
  9. trodo_python-3.0.1/trodo/api/management.py +90 -0
  10. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/client.py +0 -9
  11. trodo_python-3.0.1/trodo/managers/dataset_manager.py +287 -0
  12. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/processor.py +31 -6
  13. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo_python.egg-info/PKG-INFO +1 -1
  14. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo_python.egg-info/SOURCES.txt +2 -2
  15. trodo_python-2.26.2/tests/test_experiments.py +0 -176
  16. trodo_python-2.26.2/trodo/_version.py +0 -1
  17. trodo_python-2.26.2/trodo/managers/dataset_manager.py +0 -134
  18. trodo_python-2.26.2/trodo/managers/experiment_manager.py +0 -286
  19. {trodo_python-2.26.2 → trodo_python-3.0.1}/README.md +0 -0
  20. {trodo_python-2.26.2 → trodo_python-3.0.1}/setup.cfg +0 -0
  21. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_anon_distinct_id.py +0 -0
  22. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_auto_instrument_fixes.py +0 -0
  23. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_cross_process_session.py +0 -0
  24. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_end_run.py +0 -0
  25. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_end_run_order.py +0 -0
  26. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_error_enrichment.py +0 -0
  27. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_framework_deference.py +0 -0
  28. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_get_prompt_version_types.py +0 -0
  29. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_instrumentor_resolution.py +0 -0
  30. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_llm_usage_cost.py +0 -0
  31. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_multi_agent_shape.py +0 -0
  32. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_processor_methods.py +0 -0
  33. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_prompt_fingerprint.py +0 -0
  34. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_prompt_trace.py +0 -0
  35. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_prompts.py +0 -0
  36. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_register_otel.py +0 -0
  37. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_sender.py +0 -0
  38. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_start_run.py +0 -0
  39. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_user_traits.py +0 -0
  40. {trodo_python-2.26.2 → trodo_python-3.0.1}/tests/test_wrap_agent_unchanged.py +0 -0
  41. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/api/__init__.py +0 -0
  42. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/api/_retry.py +0 -0
  43. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/api/async_client.py +0 -0
  44. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/auto/__init__.py +0 -0
  45. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/auto/auto_event_manager.py +0 -0
  46. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/delivery/__init__.py +0 -0
  47. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/delivery/sender.py +0 -0
  48. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/managers/__init__.py +0 -0
  49. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/managers/group_manager.py +0 -0
  50. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/managers/people_manager.py +0 -0
  51. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/managers/prompt_manager.py +0 -0
  52. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/managers/user_manager.py +0 -0
  53. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/__init__.py +0 -0
  54. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/auto_instrument.py +0 -0
  55. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/context.py +0 -0
  56. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/helpers.py +0 -0
  57. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/prompt_trace.py +0 -0
  58. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/register.py +0 -0
  59. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/transport.py +0 -0
  60. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/otel/wrap_agent.py +0 -0
  61. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/prompts/__init__.py +0 -0
  62. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/prompts/compile.py +0 -0
  63. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/prompts/template.py +0 -0
  64. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/prompts/types.py +0 -0
  65. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/queue/__init__.py +0 -0
  66. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/queue/batch_flusher.py +0 -0
  67. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/queue/event_queue.py +0 -0
  68. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/session/__init__.py +0 -0
  69. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/session/server_session.py +0 -0
  70. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/session/session_manager.py +0 -0
  71. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/types.py +0 -0
  72. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/user_context.py +0 -0
  73. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/util/__init__.py +0 -0
  74. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo/util/lru.py +0 -0
  75. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo_python.egg-info/dependency_links.txt +0 -0
  76. {trodo_python-2.26.2 → trodo_python-3.0.1}/trodo_python.egg-info/requires.txt +0 -0
  77. {trodo_python-2.26.2 → trodo_python-3.0.1}/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.26.2
3
+ Version: 3.0.1
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.26.2"
7
+ version = "3.0.1"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -144,3 +144,85 @@ def test_append_accepts_unit_references_without_input():
144
144
  {"conversation_id": "c1", "metadata": {"k": 1}},
145
145
  {"eval_run_id": "v1"},
146
146
  ]
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Management with an API key (the team API the CLI uses)
151
+ # ---------------------------------------------------------------------------
152
+
153
+ class FakeApi:
154
+ """Records every call to the team API; datasets are looked up by name."""
155
+
156
+ def __init__(self):
157
+ self.calls = []
158
+
159
+ def get(self, path, params=None):
160
+ self.calls.append(("GET", path, params))
161
+ if path == "/datasets":
162
+ return {"datasets": [{"id": "d1", "name": "Refunds"}]}
163
+ return {"dataset": {"id": "d1", "name": "Refunds", "columns": ["q"]}}
164
+
165
+ def post(self, path, body=None):
166
+ self.calls.append(("POST", path, body))
167
+ if path == "/datasets":
168
+ return {"dataset": {"id": "d2", "name": body["name"]}}
169
+ if path.endswith("/items/update"):
170
+ return {"updated": len(body["updates"]), "unchanged": 0, "missing": [], "version": 4}
171
+ if path.endswith("/items/archive"):
172
+ return {"archived": len(body["ids"]), "unchanged": 0, "version": 5}
173
+ if path.endswith("/items/restore"):
174
+ return {"restored": len(body["ids"]), "unchanged": 0, "version": 6}
175
+ if path.endswith("/columns/add"):
176
+ return {"added": 1, "columns": ["q", "lang"], "derived_columns": []}
177
+ return {"written": len(body.get("items", []))}
178
+
179
+ def lines(self, path, params=None):
180
+ self.calls.append(("LINES", path, params))
181
+ return iter([{"id": "i1", "input": {"query": "hi"}}])
182
+
183
+
184
+ def test_create_with_rows_sends_expected_as_expected_output():
185
+ api = FakeApi()
186
+ out = DatasetManager(None, api).create("New", items=[{"input": "2+2?", "expected": {"answer": 4}}])
187
+ assert out == {"dataset": {"id": "d2", "name": "New"}, "written": 1}
188
+ assert api.calls[-1] == ("POST", "/datasets/d2/items", {"items": [{"input": "2+2?", "expected_output": {"answer": 4}}]})
189
+
190
+
191
+ def test_update_resolves_the_name_and_sends_one_call():
192
+ api = FakeApi()
193
+ out = DatasetManager(None, api).update("refunds", [{"id": "i1", "input": "redacted"}, {"no": "id"}], label="derived", version_name="pii")
194
+ assert out == {"updated": 1, "unchanged": 0, "missing": [], "version": 4}
195
+ assert api.calls[-1] == ("POST", "/datasets/d1/items/update", {"updates": [{"id": "i1", "input": "redacted"}], "label": "derived", "version_name": "pii"})
196
+
197
+
198
+ def test_archive_restore_and_columns():
199
+ api = FakeApi()
200
+ mgr = DatasetManager(None, api)
201
+ assert mgr.archive("Refunds", ["i1", "i2"])["archived"] == 2
202
+ assert mgr.restore("Refunds", ["i1"])["restored"] == 1
203
+ assert mgr.add_columns("Refunds", ["lang"])["added"] == 1
204
+ assert [c[1] for c in api.calls if c[0] == "POST"] == ["/datasets/d1/items/archive", "/datasets/d1/items/restore", "/datasets/d1/columns/add"]
205
+
206
+
207
+ def test_rows_streams_the_export_with_its_filters():
208
+ api = FakeApi()
209
+ rows = list(DatasetManager(None, api).rows("Refunds", rows="failed", format="openai"))
210
+ assert rows[0]["id"] == "i1"
211
+ assert api.calls[-1] == ("LINES", "/datasets/d1/export", {"rows": "failed", "eval": None, "format": "openai", "target": None, "version": None})
212
+
213
+
214
+ def test_an_unknown_dataset_and_missing_ids_are_errors():
215
+ mgr = DatasetManager(None, FakeApi())
216
+ with pytest.raises(ValueError, match="no dataset"):
217
+ mgr.update("Nope", [{"id": "i1"}])
218
+ with pytest.raises(ValueError, match="row ids"):
219
+ mgr.archive("Refunds", [])
220
+
221
+
222
+ def test_management_needs_an_api_key(monkeypatch):
223
+ from trodo.api.management import ManagementClient
224
+ monkeypatch.delenv("TRODO_API_KEY", raising=False)
225
+ with pytest.raises(ValueError, match="TRODO_API_KEY"):
226
+ ManagementClient()
227
+ c = ManagementClient(api_key="trodo_mk_x", api_url="http://localhost:8002/")
228
+ assert c.base == "http://localhost:8002/api/v1"
@@ -0,0 +1,34 @@
1
+ """A run with more spans than one buffer holds keeps every span (it used to
2
+ drop everything after the 1,000th) and its totals still count all of them."""
3
+ from __future__ import annotations
4
+
5
+ from trodo.otel.processor import TrodoRun, TrodoSpan, TrodoSpanProcessor
6
+ from trodo.otel.wrap_agent import _aggregate
7
+
8
+
9
+ def span(i, run="r1"):
10
+ return TrodoSpan(span_id=f"s{i}", run_id=run, parent_span_id=None, kind="tool" if i % 2 else "llm",
11
+ name="step", status="error" if i == 4 else "ok", input_tokens=10, output_tokens=1)
12
+
13
+
14
+ def test_a_full_buffer_is_sent_ahead_and_nothing_is_dropped(http):
15
+ p = TrodoSpanProcessor(http, max_spans_per_run=3)
16
+ for i in range(8):
17
+ p.enqueue_span(span(i))
18
+ # Two full chunks went ahead through append; two spans wait for the run.
19
+ assert [(rid, [s["span_id"] for s in spans]) for rid, spans in http.spans_append] == [
20
+ ("r1", ["s0", "s1", "s2"]), ("r1", ["s3", "s4", "s5"])]
21
+ agg = _aggregate(p.get_pending("r1"))
22
+ assert agg["span_count"] == 8 and agg["total_tokens_in"] == 80 and agg["tool_count"] == 4 and agg["error_count"] == 1
23
+ p.ingest_run(TrodoRun(run_id="r1", agent_name="deep", **{k: v for k, v in agg.items() if k in ("span_count", "tool_count", "error_count")}))
24
+ sent = [s["span_id"] for _, spans in http.spans_append for s in spans] + [s["span_id"] for s in http.run_ingest[-1]["spans"]]
25
+ assert sorted(sent) == sorted(f"s{i}" for i in range(8))
26
+ assert len(sent) == len(set(sent)), "no span is sent twice"
27
+ assert p.get_pending("r1") == []
28
+
29
+
30
+ def test_runs_do_not_share_buffers(http):
31
+ p = TrodoSpanProcessor(http, max_spans_per_run=2)
32
+ p.enqueue_span(span(1, "a")); p.enqueue_span(span(2, "b")); p.enqueue_span(span(3, "a"))
33
+ assert [rid for rid, _ in http.spans_append] == ["a"]
34
+ assert len(p.get_pending("b")) == 1
@@ -120,15 +120,10 @@ __all__ = [
120
120
  "TemplateError",
121
121
  "ManagedPrompt",
122
122
  "PromptSummary",
123
- # Datasets & experiments
123
+ # Datasets
124
+ "datasets",
124
125
  "append_dataset",
125
126
  "export_dataset",
126
- "ingest_experiment",
127
- "create_experiment",
128
- "run_experiment",
129
- "get_experiment",
130
- "wait_experiment",
131
- "compare_experiments",
132
127
  ]
133
128
 
134
129
  # ============================================================================
@@ -412,9 +407,30 @@ def render_template(
412
407
 
413
408
 
414
409
  # ----------------------------------------------------------------------------
415
- # Datasets & experiments ingest
410
+ # Datasets
416
411
  # ----------------------------------------------------------------------------
417
412
 
413
+ def datasets(api_key: Optional[str] = None, api_url: Optional[str] = None) -> Any:
414
+ """The team's datasets, managed with a Trodo API key (``TRODO_API_KEY``), as the CLI does::
415
+
416
+ ds = trodo.datasets()
417
+ ds.create("refund regressions", items=[{"input": "Refund order 42?", "expected": {"refund": True}}])
418
+ for row in ds.rows("refund regressions", rows="failed"):
419
+ ...
420
+ ds.update("refund regressions", [{"id": row["id"], "expected": "…"}])
421
+ ds.archive("refund regressions", [dup_id]) # restore() brings it back
422
+ ds.add_columns("refund regressions", ["lang"])
423
+
424
+ Works without ``trodo.init``; after it, ``append`` (site id) works here too.
425
+ Nothing deletes.
426
+ """
427
+ from .managers.dataset_manager import DatasetManager
428
+ from .api.management import ManagementClient
429
+ if api_key is None and api_url is None and _client is not None:
430
+ return _client.datasets
431
+ return DatasetManager(_client._http if _client is not None else None, ManagementClient(api_key, api_url))
432
+
433
+
418
434
  def append_dataset(
419
435
  ref: str,
420
436
  items: Union[Dict[str, Any], List[Dict[str, Any]]],
@@ -460,87 +476,6 @@ def export_dataset(
460
476
  )
461
477
 
462
478
 
463
- def ingest_experiment(
464
- dataset: str,
465
- outputs: List[Dict[str, Any]],
466
- name: Optional[str] = None,
467
- task_name: Optional[str] = None,
468
- judge: Optional[Dict[str, Any]] = None,
469
- evaluator_ids: Optional[List[str]] = None,
470
- dataset_version_no: Optional[int] = None,
471
- ) -> Dict[str, Any]:
472
- """Ingest a batch of model *outputs* for *dataset* and grade them.
473
-
474
- *outputs* is a list of dicts shaped ``{"item_position": int, "output": str,
475
- "expected_output"?, "query"?, "context"?, "cost_usd"?, "latency_ms"?}``.
476
- Configure grading with ``judge`` (``{"credential_id", "provider", "model"}``)
477
- and/or ``evaluator_ids``::
478
-
479
- exp = trodo.ingest_experiment("support-eval", [
480
- {"item_position": 0, "output": "here is your tracking link"},
481
- ], judge={"credential_id": "cred_1", "provider": "openai", "model": "gpt-4o"})
482
-
483
- Returns the experiment dict.
484
- """
485
- return _get_client().experiments.ingest(
486
- dataset,
487
- outputs,
488
- name=name,
489
- task_name=task_name,
490
- judge=judge,
491
- evaluator_ids=evaluator_ids,
492
- dataset_version_no=dataset_version_no,
493
- )
494
-
495
-
496
- def create_experiment(
497
- *,
498
- dataset: Optional[str] = None,
499
- dataset_id: Optional[str] = None,
500
- models: List[Dict[str, Any]],
501
- **kwargs: Any,
502
- ) -> Dict[str, Any]:
503
- """Create an experiment (immutable refs, status pending)."""
504
- return _get_client().experiments.create(
505
- dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
506
- )
507
-
508
-
509
- def run_experiment(
510
- *,
511
- dataset: Optional[str] = None,
512
- dataset_id: Optional[str] = None,
513
- models: List[Dict[str, Any]],
514
- **kwargs: Any,
515
- ) -> Dict[str, Any]:
516
- """Create and run an experiment synchronously."""
517
- return _get_client().experiments.run(
518
- dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
519
- )
520
-
521
-
522
- def get_experiment(experiment_id: str) -> Dict[str, Any]:
523
- """Fetch an experiment by id."""
524
- return _get_client().experiments.get(experiment_id)
525
-
526
-
527
- def wait_experiment(
528
- experiment_id: str,
529
- *,
530
- interval_ms: int = 2500,
531
- timeout_ms: int = 600_000,
532
- ) -> Dict[str, Any]:
533
- """Poll until the experiment completes or fails."""
534
- return _get_client().experiments.wait(
535
- experiment_id, interval_ms=interval_ms, timeout_ms=timeout_ms
536
- )
537
-
538
-
539
- def compare_experiments(a_id: str, b_id: str, *, strict: bool = False) -> Dict[str, Any]:
540
- """Compare two experiments side by side."""
541
- return _get_client().experiments.compare(a_id, b_id, strict=strict)
542
-
543
-
544
479
  def enable_auto_events() -> None:
545
480
  _get_client().enable_auto_events()
546
481
 
@@ -0,0 +1 @@
1
+ SDK_VERSION = "3.0.1"
@@ -28,10 +28,5 @@ RUNS_START = "/api/sdk/runs/start"
28
28
  RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
29
29
  SPANS_APPEND = "/api/sdk/spans/append"
30
30
  OTLP_TRACES = "/api/sdk/otel/v1/traces"
31
- # Datasets & experiments
31
+ # Datasets
32
32
  DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
33
- EXPERIMENTS = "/api/sdk/experiments"
34
- EXPERIMENTS_INGEST = "/api/sdk/experiments/ingest"
35
- EXPERIMENTS_CREATE = "/api/sdk/experiments/create"
36
- EXPERIMENTS_RUN = "/api/sdk/experiments/run"
37
- EXPERIMENTS_COMPARE = "/api/sdk/experiments/compare"
@@ -219,31 +219,6 @@ class HttpClient:
219
219
  "version": int(version) if version and version.isdigit() else None,
220
220
  }
221
221
 
222
- def ingest_experiment(self, body: Dict[str, Any]) -> ApiResult:
223
- return self._request("/api/sdk/experiments/ingest", body)
224
-
225
- def create_experiment(self, body: Dict[str, Any]) -> ApiResult:
226
- return self._request("/api/sdk/experiments/create", body)
227
-
228
- def run_experiment(self, body: Dict[str, Any]) -> ApiResult:
229
- return self._request("/api/sdk/experiments/run", body)
230
-
231
- def run_experiment_by_id(self, experiment_id: str) -> ApiResult:
232
- from urllib.parse import quote
233
- return self._request(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}/run", {})
234
-
235
- def get_experiment(self, experiment_id: str) -> ApiResult:
236
- from urllib.parse import quote
237
- return self._get(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}")
238
-
239
- def compare_experiments(
240
- self, a_id: str, b_id: str, strict: bool = False
241
- ) -> ApiResult:
242
- params: Dict[str, Any] = {"a": a_id, "b": b_id}
243
- if strict:
244
- params["strict"] = "true"
245
- return self._get("/api/sdk/experiments/compare", params)
246
-
247
222
  def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
248
223
  return self._request("/api/sdk/track", {"sessionData": session_data})
249
224
 
@@ -0,0 +1,90 @@
1
+ """The team API (/api/v1) with a Trodo API key — what the CLI uses.
2
+
3
+ Dataset management (create, read rows, edit, archive, add columns) goes
4
+ through here, never through the site id: a site id can sit in a browser page,
5
+ and nobody who only has that should be able to change a dataset. The key is a
6
+ Trodo API key (Settings → MCP, or ``trodo login``), passed in or read from
7
+ ``TRODO_API_KEY``; ``TRODO_API_URL`` overrides the address.
8
+
9
+ Reads retry on network errors and 502/503/504; writes are sent once.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from typing import Any, Dict, Iterator, Optional
18
+
19
+ import requests
20
+
21
+ from .._version import SDK_VERSION
22
+
23
+ DEFAULT_API_URL = "https://api.trodo.ai"
24
+
25
+
26
+ class TrodoApiError(RuntimeError):
27
+ """The API refused or failed. ``status`` and ``body`` say why."""
28
+
29
+ def __init__(self, message: str, status: Optional[int] = None, body: Any = None) -> None:
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.body = body
33
+
34
+
35
+ class ManagementClient:
36
+ def __init__(self, api_key: Optional[str] = None, api_url: Optional[str] = None, timeout: int = 60) -> None:
37
+ key = api_key or os.environ.get("TRODO_API_KEY")
38
+ if not key:
39
+ raise ValueError(
40
+ "trodo: dataset management needs a Trodo API key: pass api_key= or set TRODO_API_KEY "
41
+ "(Settings → MCP, or `trodo login`). The site id cannot change datasets."
42
+ )
43
+ self.base = (api_url or os.environ.get("TRODO_API_URL") or DEFAULT_API_URL).rstrip("/") + "/api/v1"
44
+ self.timeout = timeout
45
+ self._session = requests.Session()
46
+ self._session.headers.update({
47
+ "Authorization": f"Bearer {key}",
48
+ "Content-Type": "application/json",
49
+ "User-Agent": f"trodo-python/{SDK_VERSION}",
50
+ })
51
+
52
+ def _send(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
53
+ body: Any = None, stream: bool = False) -> requests.Response:
54
+ url = self.base + path
55
+ clean = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
56
+ tries = 4 if method == "GET" else 1
57
+ for attempt in range(tries):
58
+ try:
59
+ resp = self._session.request(method, url, params=clean, data=None if body is None else json.dumps(body),
60
+ timeout=self.timeout, stream=stream)
61
+ except requests.RequestException as exc:
62
+ if attempt + 1 < tries:
63
+ time.sleep(0.5 * (2 ** attempt))
64
+ continue
65
+ raise TrodoApiError(f"trodo: {method} {path} failed: {exc}") from exc
66
+ if resp.status_code in (502, 503, 504) and attempt + 1 < tries:
67
+ time.sleep(0.5 * (2 ** attempt))
68
+ continue
69
+ if resp.status_code >= 400:
70
+ try:
71
+ data = resp.json()
72
+ except ValueError:
73
+ data = {"message": resp.text[:300]}
74
+ msg = (data.get("message") or data.get("error")) if isinstance(data, dict) else None
75
+ raise TrodoApiError(f"trodo: {method} {path} → HTTP {resp.status_code} {msg or ''}".strip(), resp.status_code, data)
76
+ return resp
77
+ raise TrodoApiError(f"trodo: {method} {path} failed") # pragma: no cover
78
+
79
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
80
+ return self._send("GET", path, params=params).json()
81
+
82
+ def post(self, path: str, body: Any = None) -> Any:
83
+ return self._send("POST", path, body=body if body is not None else {}).json()
84
+
85
+ def lines(self, path: str, params: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]:
86
+ """A JSON Lines response, one object at a time."""
87
+ resp = self._send("GET", path, params=params, stream=True)
88
+ for raw in resp.iter_lines(decode_unicode=True):
89
+ if raw and raw.strip():
90
+ yield json.loads(raw)
@@ -84,7 +84,6 @@ class TrodoClient:
84
84
  self._session_manager = SessionManager(max_cached_users)
85
85
  self._prompts = None # lazily-built PromptManager
86
86
  self._datasets = None # lazily-built DatasetManager
87
- self._experiments = None # lazily-built ExperimentManager
88
87
  self._users = None # lazily-built UserManager
89
88
 
90
89
  if batch_enabled:
@@ -149,14 +148,6 @@ class TrodoClient:
149
148
  self._datasets = DatasetManager(self._http)
150
149
  return self._datasets
151
150
 
152
- @property
153
- def experiments(self):
154
- """Managed experiment runs + external ingest for server-side grading."""
155
- if self._experiments is None:
156
- from .managers.experiment_manager import ExperimentManager
157
- self._experiments = ExperimentManager(self._http)
158
- return self._experiments
159
-
160
151
  @property
161
152
  def users(self):
162
153
  """Create-or-update a user and their traits — the stateless-backend