openrunner-sdk 0.2.0__tar.gz → 0.3.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 (70) hide show
  1. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/PKG-INFO +231 -1
  2. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/README.md +230 -0
  3. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/__init__.py +16 -2
  4. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/media.py +177 -0
  5. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/run.py +42 -1
  6. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/sender.py +11 -0
  7. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/pyproject.toml +1 -1
  8. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_media.py +267 -0
  9. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/.gitignore +0 -0
  10. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/=6.0 +0 -0
  11. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/=8.1 +0 -0
  12. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/api_client.py +0 -0
  13. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/artifact.py +0 -0
  14. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/buffer.py +0 -0
  15. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/cache.py +0 -0
  16. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/cli.py +0 -0
  17. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/config.py +0 -0
  18. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/git_info.py +0 -0
  19. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/__init__.py +0 -0
  20. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/fastai.py +0 -0
  21. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/huggingface.py +0 -0
  22. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/keras.py +0 -0
  23. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/langchain.py +0 -0
  24. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/lightning.py +0 -0
  25. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/pytorch.py +0 -0
  26. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/sklearn.py +0 -0
  27. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/integration/xgboost.py +0 -0
  28. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/launch.py +0 -0
  29. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/offline.py +0 -0
  30. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/query_api.py +0 -0
  31. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/settings.py +0 -0
  32. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/summary.py +0 -0
  33. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/sweep.py +0 -0
  34. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/system_metrics.py +0 -0
  35. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/trace.py +0 -0
  36. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/wandb_compat/__init__.py +0 -0
  37. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/openrunner/wandb_compat/_shim.py +0 -0
  38. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/__init__.py +0 -0
  39. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/conftest.py +0 -0
  40. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_alert.py +0 -0
  41. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_aliases.py +0 -0
  42. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_api_client.py +0 -0
  43. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_artifact.py +0 -0
  44. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_buffer.py +0 -0
  45. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_cache.py +0 -0
  46. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_cli.py +0 -0
  47. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_config.py +0 -0
  48. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_finish.py +0 -0
  49. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_git_info.py +0 -0
  50. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_init.py +0 -0
  51. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_fastai.py +0 -0
  52. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_huggingface.py +0 -0
  53. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_keras.py +0 -0
  54. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_langchain.py +0 -0
  55. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_lightning.py +0 -0
  56. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_pytorch.py +0 -0
  57. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_sklearn.py +0 -0
  58. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_integration_xgboost.py +0 -0
  59. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_launch.py +0 -0
  60. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_log.py +0 -0
  61. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_offline.py +0 -0
  62. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_offline_sync.py +0 -0
  63. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_query_api.py +0 -0
  64. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_resume.py +0 -0
  65. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_sender.py +0 -0
  66. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_summary.py +0 -0
  67. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_sweep.py +0 -0
  68. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_system_metrics.py +0 -0
  69. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_trace.py +0 -0
  70. {openrunner_sdk-0.2.0 → openrunner_sdk-0.3.0}/tests/test_wandb_compat.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrunner-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: OpenRunner SDK - W&B-compatible ML experiment tracking client
5
5
  Project-URL: Homepage, https://github.com/jqueguiner/openrunner
6
6
  Project-URL: Repository, https://github.com/jqueguiner/openrunner
@@ -238,6 +238,175 @@ print(run.config) # Config object
238
238
  print(run.summary) # Summary object
239
239
  ```
240
240
 
241
+ ### HTML
242
+
243
+ ```python
244
+ # Log raw HTML for rich reports, custom visualizations, or formatted output
245
+ openrunner.log({"report": openrunner.Html("<h1>Training Report</h1><p>Loss converged at epoch 42.</p>")})
246
+ ```
247
+
248
+ ### Histograms
249
+
250
+ ```python
251
+ import numpy as np
252
+
253
+ weights = np.random.randn(10000)
254
+ openrunner.log({"weight_dist": openrunner.Histogram(weights, num_bins=50)})
255
+ ```
256
+
257
+ ### Plotly Charts
258
+
259
+ ```python
260
+ import plotly.graph_objects as go
261
+
262
+ fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6]))
263
+ openrunner.log({"interactive_plot": openrunner.Plotly(fig)})
264
+ ```
265
+
266
+ ### Point Clouds
267
+
268
+ ```python
269
+ import numpy as np
270
+
271
+ points = np.random.randn(1000, 3)
272
+ colors = np.random.randint(0, 255, (1000, 3), dtype=np.uint8)
273
+ openrunner.log({"lidar": openrunner.PointCloud3D(points, colors=colors)})
274
+ ```
275
+
276
+ ### Bounding Boxes
277
+
278
+ ```python
279
+ img = openrunner.Image("photo.jpg")
280
+ boxes = [{"position": {"minX": 10, "minY": 20, "maxX": 100, "maxY": 150}, "class_id": 0}]
281
+ openrunner.log({"detections": openrunner.BoundingBoxes2D(img, boxes, class_labels={0: "cat"})})
282
+ ```
283
+
284
+ ### Audio
285
+
286
+ ```python
287
+ import numpy as np
288
+
289
+ # From numpy array (mono, float32, -1 to 1)
290
+ audio = openrunner.Audio(np.random.randn(44100).astype(np.float32), sample_rate=44100)
291
+ openrunner.log({"audio_sample": audio})
292
+ ```
293
+
294
+ ### Video
295
+
296
+ ```python
297
+ # From file path
298
+ openrunner.log({"demo": openrunner.Video("output.mp4", caption="training demo")})
299
+ ```
300
+
301
+ ### Matplotlib Figures
302
+
303
+ ```python
304
+ import matplotlib.pyplot as plt
305
+
306
+ plt.figure()
307
+ plt.plot([1, 2, 3], [4, 5, 6])
308
+ openrunner.log({"chart": openrunner.MatplotlibFigure()}) # captures current figure
309
+ plt.close()
310
+ ```
311
+
312
+ ## LLM Tracing
313
+
314
+ Trace LLM API calls for debugging and cost tracking.
315
+
316
+ ```python
317
+ import openrunner
318
+
319
+ openrunner.init(project="llm-app")
320
+
321
+ # Auto-trace OpenAI calls
322
+ openrunner.trace.patch_openai()
323
+
324
+ # Or manually trace any function
325
+ @openrunner.trace
326
+ def generate(prompt):
327
+ return client.chat.completions.create(
328
+ model="gpt-4", messages=[{"role": "user", "content": prompt}]
329
+ )
330
+ ```
331
+
332
+ ## Hyperparameter Sweeps
333
+
334
+ Run distributed hyperparameter searches.
335
+
336
+ ```python
337
+ import openrunner
338
+
339
+ sweep_config = {
340
+ "method": "bayes",
341
+ "metric": {"name": "val_loss", "goal": "minimize"},
342
+ "parameters": {
343
+ "lr": {"min": 1e-5, "max": 1e-2, "distribution": "log_uniform"},
344
+ "epochs": {"values": [10, 20, 50]},
345
+ },
346
+ }
347
+
348
+ sweep_id = openrunner.sweep(sweep_config, project="my-project")
349
+
350
+ def train():
351
+ run = openrunner.init()
352
+ lr = openrunner.config.lr
353
+ # ... training loop ...
354
+ openrunner.finish()
355
+
356
+ openrunner.agent(sweep_id, function=train, count=20)
357
+ ```
358
+
359
+ ## Remote Launch
360
+
361
+ Submit training jobs to remote infrastructure.
362
+
363
+ ```python
364
+ import openrunner
365
+
366
+ job = openrunner.launch(
367
+ project="my-project",
368
+ config={"lr": 0.001, "epochs": 50},
369
+ resource="gpu-a100",
370
+ )
371
+
372
+ job.wait() # block until finished
373
+ print(job.state) # "finished"
374
+ ```
375
+
376
+ ## Model Registry
377
+
378
+ Version and alias models for production deployment.
379
+
380
+ ```python
381
+ # Log a model with aliases
382
+ artifact = openrunner.Artifact(name="classifier", type="model")
383
+ artifact.add_file("model.pt")
384
+ openrunner.link_artifact(artifact, aliases=["staging"])
385
+
386
+ # Use a model by alias
387
+ model_dir = openrunner.use_artifact("classifier:production")
388
+ ```
389
+
390
+ ## Alerts
391
+
392
+ Send notifications when training reaches milestones or encounters issues.
393
+
394
+ ```python
395
+ openrunner.alert(title="Training complete", text="Final accuracy: 95.2%", level="INFO")
396
+ openrunner.alert(title="Loss spike detected", level="WARN")
397
+ ```
398
+
399
+ ## Query API
400
+
401
+ Read-only access to runs, metrics, and projects for analysis and dashboards.
402
+
403
+ ```python
404
+ api = openrunner.Api()
405
+ runs = api.runs("my-project", filters={"state": "finished"})
406
+ for run in runs:
407
+ print(f"{run.name}: {run.summary.get('accuracy')}")
408
+ ```
409
+
241
410
  ## Migrating from W&B
242
411
 
243
412
  Change one import — everything else stays the same:
@@ -302,6 +471,67 @@ trainer = pl.Trainer(logger=logger)
302
471
  trainer.fit(model)
303
472
  ```
304
473
 
474
+ ### Keras
475
+
476
+ ```python
477
+ from openrunner.integration.keras import OpenRunnerCallback
478
+
479
+ openrunner.init(project="keras-example")
480
+
481
+ model.fit(x_train, y_train, callbacks=[OpenRunnerCallback()])
482
+
483
+ openrunner.finish()
484
+ ```
485
+
486
+ ### XGBoost
487
+
488
+ ```python
489
+ from openrunner.integration.xgboost import OpenRunnerCallback
490
+
491
+ openrunner.init(project="xgboost-example")
492
+
493
+ bst = xgb.train(params, dtrain, callbacks=[OpenRunnerCallback()])
494
+
495
+ openrunner.finish()
496
+ ```
497
+
498
+ ### scikit-learn
499
+
500
+ ```python
501
+ from openrunner.integration.sklearn import log_model
502
+
503
+ openrunner.init(project="sklearn-example")
504
+ model.fit(X_train, y_train)
505
+ log_model(model) # Logs parameters and metrics
506
+ openrunner.finish()
507
+ ```
508
+
509
+ ### FastAI
510
+
511
+ ```python
512
+ from openrunner.integration.fastai import OpenRunnerCallback
513
+
514
+ openrunner.init(project="fastai-example")
515
+
516
+ learn = cnn_learner(dls, resnet34, cbs=[OpenRunnerCallback()])
517
+ learn.fine_tune(5)
518
+
519
+ openrunner.finish()
520
+ ```
521
+
522
+ ### LangChain
523
+
524
+ ```python
525
+ from openrunner.integration.langchain import OpenRunnerTracer
526
+
527
+ openrunner.init(project="langchain-example")
528
+
529
+ tracer = OpenRunnerTracer()
530
+ chain.invoke({"input": "Hello"}, config={"callbacks": [tracer]})
531
+
532
+ openrunner.finish()
533
+ ```
534
+
305
535
  ## Offline Mode
306
536
 
307
537
  Train without connectivity, sync later:
@@ -190,6 +190,175 @@ print(run.config) # Config object
190
190
  print(run.summary) # Summary object
191
191
  ```
192
192
 
193
+ ### HTML
194
+
195
+ ```python
196
+ # Log raw HTML for rich reports, custom visualizations, or formatted output
197
+ openrunner.log({"report": openrunner.Html("<h1>Training Report</h1><p>Loss converged at epoch 42.</p>")})
198
+ ```
199
+
200
+ ### Histograms
201
+
202
+ ```python
203
+ import numpy as np
204
+
205
+ weights = np.random.randn(10000)
206
+ openrunner.log({"weight_dist": openrunner.Histogram(weights, num_bins=50)})
207
+ ```
208
+
209
+ ### Plotly Charts
210
+
211
+ ```python
212
+ import plotly.graph_objects as go
213
+
214
+ fig = go.Figure(data=go.Scatter(x=[1, 2, 3], y=[4, 5, 6]))
215
+ openrunner.log({"interactive_plot": openrunner.Plotly(fig)})
216
+ ```
217
+
218
+ ### Point Clouds
219
+
220
+ ```python
221
+ import numpy as np
222
+
223
+ points = np.random.randn(1000, 3)
224
+ colors = np.random.randint(0, 255, (1000, 3), dtype=np.uint8)
225
+ openrunner.log({"lidar": openrunner.PointCloud3D(points, colors=colors)})
226
+ ```
227
+
228
+ ### Bounding Boxes
229
+
230
+ ```python
231
+ img = openrunner.Image("photo.jpg")
232
+ boxes = [{"position": {"minX": 10, "minY": 20, "maxX": 100, "maxY": 150}, "class_id": 0}]
233
+ openrunner.log({"detections": openrunner.BoundingBoxes2D(img, boxes, class_labels={0: "cat"})})
234
+ ```
235
+
236
+ ### Audio
237
+
238
+ ```python
239
+ import numpy as np
240
+
241
+ # From numpy array (mono, float32, -1 to 1)
242
+ audio = openrunner.Audio(np.random.randn(44100).astype(np.float32), sample_rate=44100)
243
+ openrunner.log({"audio_sample": audio})
244
+ ```
245
+
246
+ ### Video
247
+
248
+ ```python
249
+ # From file path
250
+ openrunner.log({"demo": openrunner.Video("output.mp4", caption="training demo")})
251
+ ```
252
+
253
+ ### Matplotlib Figures
254
+
255
+ ```python
256
+ import matplotlib.pyplot as plt
257
+
258
+ plt.figure()
259
+ plt.plot([1, 2, 3], [4, 5, 6])
260
+ openrunner.log({"chart": openrunner.MatplotlibFigure()}) # captures current figure
261
+ plt.close()
262
+ ```
263
+
264
+ ## LLM Tracing
265
+
266
+ Trace LLM API calls for debugging and cost tracking.
267
+
268
+ ```python
269
+ import openrunner
270
+
271
+ openrunner.init(project="llm-app")
272
+
273
+ # Auto-trace OpenAI calls
274
+ openrunner.trace.patch_openai()
275
+
276
+ # Or manually trace any function
277
+ @openrunner.trace
278
+ def generate(prompt):
279
+ return client.chat.completions.create(
280
+ model="gpt-4", messages=[{"role": "user", "content": prompt}]
281
+ )
282
+ ```
283
+
284
+ ## Hyperparameter Sweeps
285
+
286
+ Run distributed hyperparameter searches.
287
+
288
+ ```python
289
+ import openrunner
290
+
291
+ sweep_config = {
292
+ "method": "bayes",
293
+ "metric": {"name": "val_loss", "goal": "minimize"},
294
+ "parameters": {
295
+ "lr": {"min": 1e-5, "max": 1e-2, "distribution": "log_uniform"},
296
+ "epochs": {"values": [10, 20, 50]},
297
+ },
298
+ }
299
+
300
+ sweep_id = openrunner.sweep(sweep_config, project="my-project")
301
+
302
+ def train():
303
+ run = openrunner.init()
304
+ lr = openrunner.config.lr
305
+ # ... training loop ...
306
+ openrunner.finish()
307
+
308
+ openrunner.agent(sweep_id, function=train, count=20)
309
+ ```
310
+
311
+ ## Remote Launch
312
+
313
+ Submit training jobs to remote infrastructure.
314
+
315
+ ```python
316
+ import openrunner
317
+
318
+ job = openrunner.launch(
319
+ project="my-project",
320
+ config={"lr": 0.001, "epochs": 50},
321
+ resource="gpu-a100",
322
+ )
323
+
324
+ job.wait() # block until finished
325
+ print(job.state) # "finished"
326
+ ```
327
+
328
+ ## Model Registry
329
+
330
+ Version and alias models for production deployment.
331
+
332
+ ```python
333
+ # Log a model with aliases
334
+ artifact = openrunner.Artifact(name="classifier", type="model")
335
+ artifact.add_file("model.pt")
336
+ openrunner.link_artifact(artifact, aliases=["staging"])
337
+
338
+ # Use a model by alias
339
+ model_dir = openrunner.use_artifact("classifier:production")
340
+ ```
341
+
342
+ ## Alerts
343
+
344
+ Send notifications when training reaches milestones or encounters issues.
345
+
346
+ ```python
347
+ openrunner.alert(title="Training complete", text="Final accuracy: 95.2%", level="INFO")
348
+ openrunner.alert(title="Loss spike detected", level="WARN")
349
+ ```
350
+
351
+ ## Query API
352
+
353
+ Read-only access to runs, metrics, and projects for analysis and dashboards.
354
+
355
+ ```python
356
+ api = openrunner.Api()
357
+ runs = api.runs("my-project", filters={"state": "finished"})
358
+ for run in runs:
359
+ print(f"{run.name}: {run.summary.get('accuracy')}")
360
+ ```
361
+
193
362
  ## Migrating from W&B
194
363
 
195
364
  Change one import — everything else stays the same:
@@ -254,6 +423,67 @@ trainer = pl.Trainer(logger=logger)
254
423
  trainer.fit(model)
255
424
  ```
256
425
 
426
+ ### Keras
427
+
428
+ ```python
429
+ from openrunner.integration.keras import OpenRunnerCallback
430
+
431
+ openrunner.init(project="keras-example")
432
+
433
+ model.fit(x_train, y_train, callbacks=[OpenRunnerCallback()])
434
+
435
+ openrunner.finish()
436
+ ```
437
+
438
+ ### XGBoost
439
+
440
+ ```python
441
+ from openrunner.integration.xgboost import OpenRunnerCallback
442
+
443
+ openrunner.init(project="xgboost-example")
444
+
445
+ bst = xgb.train(params, dtrain, callbacks=[OpenRunnerCallback()])
446
+
447
+ openrunner.finish()
448
+ ```
449
+
450
+ ### scikit-learn
451
+
452
+ ```python
453
+ from openrunner.integration.sklearn import log_model
454
+
455
+ openrunner.init(project="sklearn-example")
456
+ model.fit(X_train, y_train)
457
+ log_model(model) # Logs parameters and metrics
458
+ openrunner.finish()
459
+ ```
460
+
461
+ ### FastAI
462
+
463
+ ```python
464
+ from openrunner.integration.fastai import OpenRunnerCallback
465
+
466
+ openrunner.init(project="fastai-example")
467
+
468
+ learn = cnn_learner(dls, resnet34, cbs=[OpenRunnerCallback()])
469
+ learn.fine_tune(5)
470
+
471
+ openrunner.finish()
472
+ ```
473
+
474
+ ### LangChain
475
+
476
+ ```python
477
+ from openrunner.integration.langchain import OpenRunnerTracer
478
+
479
+ openrunner.init(project="langchain-example")
480
+
481
+ tracer = OpenRunnerTracer()
482
+ chain.invoke({"input": "Hello"}, config={"callbacks": [tracer]})
483
+
484
+ openrunner.finish()
485
+ ```
486
+
257
487
  ## Offline Mode
258
488
 
259
489
  Train without connectivity, sync later:
@@ -13,6 +13,9 @@ Public API:
13
13
  openrunner.Video -> Video class for video logging
14
14
  openrunner.Histogram -> Histogram class for distribution logging
15
15
  openrunner.Plotly -> Plotly class for interactive charts
16
+ openrunner.PlotlyChart -> Enhanced Plotly with static PNG fallback
17
+ openrunner.MatplotlibFigure -> Capture matplotlib figure as PNG
18
+ openrunner.PointCloud3D -> 3D point cloud visualization
16
19
  openrunner.BoundingBoxes2D -> Bounding box overlay on images
17
20
  openrunner.Artifact -> Artifact class for versioned file collections
18
21
  openrunner.Api -> Query API for read-only access to runs/projects
@@ -37,7 +40,18 @@ from typing import Any
37
40
  from openrunner.artifact import Artifact
38
41
  from openrunner.config import Config
39
42
  from openrunner.launch import LaunchJob, launch, from_run as _launch_from_run
40
- from openrunner.media import Audio, BoundingBoxes2D, Histogram, Image, Plotly, Table, Video
43
+ from openrunner.media import (
44
+ Audio,
45
+ BoundingBoxes2D,
46
+ Histogram,
47
+ Image,
48
+ MatplotlibFigure,
49
+ Plotly,
50
+ PlotlyChart,
51
+ PointCloud3D,
52
+ Table,
53
+ Video,
54
+ )
41
55
  from openrunner.query_api import Api
42
56
  from openrunner.run import Run
43
57
  from openrunner.settings import SDKSettings
@@ -53,7 +67,7 @@ launch.from_run = _launch_from_run # type: ignore[attr-defined]
53
67
  # openrunner.trace.patch_openai() syntax
54
68
  trace.patch_openai = _patch_openai # type: ignore[attr-defined]
55
69
 
56
- __version__ = "0.1.0"
70
+ __version__ = "0.3.0"
57
71
 
58
72
  logger = logging.getLogger("openrunner")
59
73
 
@@ -421,6 +421,183 @@ class Plotly:
421
421
  )
422
422
 
423
423
 
424
+ class MatplotlibFigure:
425
+ """Capture a Matplotlib figure as a PNG image for experiment logging.
426
+
427
+ Accepts an optional ``matplotlib.figure.Figure``. When *fig* is ``None``
428
+ the current active figure (``pyplot.gcf()``) is used, making the
429
+ common pattern ``openrunner.log({"chart": openrunner.MatplotlibFigure()})``
430
+ a one-liner.
431
+
432
+ Serialization is lazy -- the PNG bytes are produced only when the sender
433
+ thread calls ``_serialize()``.
434
+ """
435
+
436
+ def __init__(
437
+ self,
438
+ fig: Any | None = None,
439
+ caption: str | None = None,
440
+ dpi: int = 150,
441
+ ) -> None:
442
+ self._fig = fig
443
+ self.caption = caption
444
+ self.dpi = dpi
445
+
446
+ def _serialize(self) -> tuple[bytes, str]:
447
+ """Render figure to PNG bytes. Returns (bytes, content_type)."""
448
+ import matplotlib.pyplot as plt # lazy import
449
+
450
+ fig = self._fig if self._fig is not None else plt.gcf()
451
+ buf = io.BytesIO()
452
+ fig.savefig(buf, format="png", dpi=self.dpi, bbox_inches="tight")
453
+ buf.seek(0)
454
+ return buf.getvalue(), "image/png"
455
+
456
+
457
+ class PlotlyChart:
458
+ """Enhanced Plotly figure that stores both interactive JSON and static PNG.
459
+
460
+ Stores the full Plotly JSON for interactive rendering in the frontend,
461
+ and optionally generates a static PNG fallback via ``fig.to_image()``
462
+ (requires the ``kaleido`` package).
463
+
464
+ Use ``Plotly`` for JSON-only storage, ``PlotlyChart`` when you also
465
+ want a static image fallback.
466
+ """
467
+
468
+ def __init__(
469
+ self,
470
+ figure: Any,
471
+ caption: str | None = None,
472
+ ) -> None:
473
+ self._figure = figure
474
+ self.caption = caption
475
+
476
+ def _serialize(self) -> dict[str, Any]:
477
+ """Serialize Plotly figure to dict with optional static PNG.
478
+
479
+ Returns a dict with keys:
480
+ - ``data``, ``layout`` (standard Plotly JSON)
481
+ - ``static_image`` (base64-encoded PNG string, or None if kaleido
482
+ is not available)
483
+ """
484
+ import base64
485
+
486
+ # Get the JSON representation
487
+ if hasattr(self._figure, "to_json"):
488
+ json_str = self._figure.to_json()
489
+ result = json.loads(json_str)
490
+ elif hasattr(self._figure, "to_dict"):
491
+ result = self._figure.to_dict()
492
+ elif isinstance(self._figure, dict):
493
+ result = dict(self._figure)
494
+ else:
495
+ raise TypeError(
496
+ f"PlotlyChart: unsupported figure type {type(self._figure).__name__}. "
497
+ "Expected plotly.graph_objects.Figure or dict."
498
+ )
499
+
500
+ # Try to generate a static PNG fallback
501
+ static_image: str | None = None
502
+ if hasattr(self._figure, "to_image"):
503
+ try:
504
+ png_bytes = self._figure.to_image(format="png", width=800, height=500)
505
+ static_image = base64.b64encode(png_bytes).decode("ascii")
506
+ except Exception:
507
+ pass # kaleido not installed or failed -- skip
508
+
509
+ result["_static_image"] = static_image
510
+ return result
511
+
512
+
513
+ class PointCloud3D:
514
+ """A 3D point cloud for experiment logging.
515
+
516
+ Accepts XYZ coordinates as a numpy array of shape (N, 3), with optional
517
+ per-point RGB colors and string labels. Data is serialized inline as
518
+ JSON (no S3 upload needed).
519
+
520
+ Usage::
521
+
522
+ pts = np.random.randn(1000, 3)
523
+ colors = np.random.randint(0, 255, (1000, 3))
524
+ openrunner.log({"cloud": openrunner.PointCloud3D(pts, colors=colors)})
525
+ """
526
+
527
+ def __init__(
528
+ self,
529
+ points: Any,
530
+ colors: Any | None = None,
531
+ labels: list[str] | None = None,
532
+ caption: str | None = None,
533
+ ) -> None:
534
+ self._points = points
535
+ self._colors = colors
536
+ self._labels = labels
537
+ self.caption = caption
538
+
539
+ def _serialize(self) -> dict[str, Any]:
540
+ """Serialize point cloud data to dict for JSON transmission.
541
+
542
+ Returns a dict with:
543
+ - ``points``: list of [x, y, z] lists
544
+ - ``colors``: list of [r, g, b] lists (or None)
545
+ - ``labels``: list of strings (or None)
546
+ - ``num_points``: total point count
547
+ - ``bounds``: {min: [x,y,z], max: [x,y,z]}
548
+ """
549
+ import numpy as np
550
+
551
+ pts = np.asarray(self._points, dtype=np.float64)
552
+ if pts.ndim != 2 or pts.shape[1] != 3:
553
+ raise ValueError(
554
+ f"PointCloud3D: expected array of shape (N, 3), "
555
+ f"got {pts.shape}"
556
+ )
557
+
558
+ result: dict[str, Any] = {
559
+ "points": pts.tolist(),
560
+ "num_points": int(pts.shape[0]),
561
+ }
562
+
563
+ # Bounds for frontend camera setup
564
+ if pts.shape[0] > 0:
565
+ result["bounds"] = {
566
+ "min": pts.min(axis=0).tolist(),
567
+ "max": pts.max(axis=0).tolist(),
568
+ }
569
+ else:
570
+ result["bounds"] = {"min": [0, 0, 0], "max": [0, 0, 0]}
571
+
572
+ # Colors
573
+ if self._colors is not None:
574
+ clr = np.asarray(self._colors)
575
+ if clr.shape != pts.shape:
576
+ raise ValueError(
577
+ f"PointCloud3D: colors shape {clr.shape} does not match "
578
+ f"points shape {pts.shape}"
579
+ )
580
+ # Normalize float colors (0-1) to int (0-255)
581
+ if clr.dtype.kind == "f":
582
+ clr = (clr.clip(0.0, 1.0) * 255).astype(np.uint8)
583
+ result["colors"] = clr.tolist()
584
+ else:
585
+ result["colors"] = None
586
+
587
+ # Labels
588
+ if self._labels is not None:
589
+ if len(self._labels) != pts.shape[0]:
590
+ raise ValueError(
591
+ f"PointCloud3D: labels length {len(self._labels)} does not "
592
+ f"match points count {pts.shape[0]}"
593
+ )
594
+ result["labels"] = list(self._labels)
595
+ else:
596
+ result["labels"] = None
597
+
598
+ return result
599
+
600
+
424
601
  class BoundingBoxes2D:
425
602
  """Bounding box annotations overlaid on an image.
426
603
 
@@ -23,7 +23,18 @@ from openrunner.buffer import WriteBuffer
23
23
  from openrunner.cache import ArtifactCache
24
24
  from openrunner.config import Config
25
25
  from openrunner.git_info import capture_git_info
26
- from openrunner.media import Audio, BoundingBoxes2D, Histogram, Image, Plotly, Table, Video
26
+ from openrunner.media import (
27
+ Audio,
28
+ BoundingBoxes2D,
29
+ Histogram,
30
+ Image,
31
+ MatplotlibFigure,
32
+ Plotly,
33
+ PlotlyChart,
34
+ PointCloud3D,
35
+ Table,
36
+ Video,
37
+ )
27
38
  from openrunner.offline import OfflineStorage
28
39
  from openrunner.sender import Sender
29
40
  from openrunner.settings import SDKSettings
@@ -267,6 +278,16 @@ class Run:
267
278
  "caption": value.caption,
268
279
  "_histogram_data": value._serialize(),
269
280
  })
281
+ elif isinstance(value, PlotlyChart):
282
+ # Enhanced Plotly: JSON + optional static PNG (inline JSONB)
283
+ self._buffer.put({
284
+ "_media": True,
285
+ "_media_type": "plotly",
286
+ "key": key,
287
+ "step": current_step,
288
+ "caption": value.caption,
289
+ "_plotly_data": value._serialize(),
290
+ })
270
291
  elif isinstance(value, Plotly):
271
292
  # Queue plotly figure (inline JSONB, no S3)
272
293
  self._buffer.put({
@@ -277,6 +298,26 @@ class Run:
277
298
  "caption": value.caption,
278
299
  "_plotly_data": value._serialize(),
279
300
  })
301
+ elif isinstance(value, MatplotlibFigure):
302
+ # Capture matplotlib figure as PNG image
303
+ self._buffer.put({
304
+ "_media": True,
305
+ "_media_type": "image",
306
+ "key": key,
307
+ "step": current_step,
308
+ "caption": value.caption,
309
+ "_image_obj": value,
310
+ })
311
+ elif isinstance(value, PointCloud3D):
312
+ # Queue point cloud data (inline JSONB, no S3)
313
+ self._buffer.put({
314
+ "_media": True,
315
+ "_media_type": "point_cloud_3d",
316
+ "key": key,
317
+ "step": current_step,
318
+ "caption": value.caption,
319
+ "_point_cloud_data": value._serialize(),
320
+ })
280
321
  elif isinstance(value, BoundingBoxes2D):
281
322
  # Queue bbox overlay (image bytes + JSON box data)
282
323
  self._buffer.put({
@@ -252,6 +252,17 @@ class Sender:
252
252
  data=item.get("_plotly_data"),
253
253
  )
254
254
 
255
+ elif media_type == "point_cloud_3d":
256
+ # Point cloud data is already serialized -- send inline as JSONB
257
+ self._client.create_media_file(
258
+ run_id=self._run_id,
259
+ key=item["key"],
260
+ media_type="point_cloud_3d",
261
+ step=item.get("step"),
262
+ caption=item.get("caption"),
263
+ data=item.get("_point_cloud_data"),
264
+ )
265
+
255
266
  elif media_type == "bounding_boxes_2d":
256
267
  # BBox: upload the image first, then store box data as JSONB
257
268
  bbox_obj = item["_bbox_obj"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openrunner-sdk"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "OpenRunner SDK - W&B-compatible ML experiment tracking client"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -15,7 +15,10 @@ from openrunner.media import (
15
15
  BoundingBoxes2D,
16
16
  Histogram,
17
17
  Image,
18
+ MatplotlibFigure,
18
19
  Plotly,
20
+ PlotlyChart,
21
+ PointCloud3D,
19
22
  Table,
20
23
  Video,
21
24
  )
@@ -459,3 +462,267 @@ class TestBoundingBoxes2D:
459
462
  bbox = BoundingBoxes2D(img, [])
460
463
  box_data = bbox._serialize_boxes()
461
464
  assert box_data["boxes"] == []
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # MatplotlibFigure tests
469
+ # ---------------------------------------------------------------------------
470
+
471
+ class TestMatplotlibFigure:
472
+ """Tests for the MatplotlibFigure class."""
473
+
474
+ def test_matplotlib_explicit_figure(self) -> None:
475
+ """MatplotlibFigure from explicit figure serializes to PNG bytes."""
476
+ import matplotlib
477
+ matplotlib.use("Agg") # non-interactive backend
478
+ import matplotlib.pyplot as plt
479
+
480
+ fig, ax = plt.subplots()
481
+ ax.plot([1, 2, 3], [4, 5, 6])
482
+ mf = MatplotlibFigure(fig)
483
+ data, content_type = mf._serialize()
484
+ assert _is_png(data)
485
+ assert content_type == "image/png"
486
+ assert len(data) > 100 # non-trivial PNG
487
+ plt.close(fig)
488
+
489
+ def test_matplotlib_gcf(self) -> None:
490
+ """MatplotlibFigure with no fig arg captures gcf()."""
491
+ import matplotlib
492
+ matplotlib.use("Agg")
493
+ import matplotlib.pyplot as plt
494
+
495
+ plt.figure()
496
+ plt.plot([0, 1], [0, 1])
497
+ mf = MatplotlibFigure()
498
+ data, content_type = mf._serialize()
499
+ assert _is_png(data)
500
+ assert content_type == "image/png"
501
+ plt.close("all")
502
+
503
+ def test_matplotlib_caption(self) -> None:
504
+ """Caption attribute is stored correctly."""
505
+ mf = MatplotlibFigure(caption="loss curve")
506
+ assert mf.caption == "loss curve"
507
+
508
+ def test_matplotlib_custom_dpi(self) -> None:
509
+ """Custom DPI produces larger PNG than low DPI."""
510
+ import matplotlib
511
+ matplotlib.use("Agg")
512
+ import matplotlib.pyplot as plt
513
+
514
+ fig, ax = plt.subplots()
515
+ ax.plot([1, 2, 3], [4, 5, 6])
516
+
517
+ mf_low = MatplotlibFigure(fig, dpi=50)
518
+ data_low, _ = mf_low._serialize()
519
+
520
+ mf_high = MatplotlibFigure(fig, dpi=300)
521
+ data_high, _ = mf_high._serialize()
522
+
523
+ assert len(data_high) > len(data_low)
524
+ plt.close(fig)
525
+
526
+ def test_matplotlib_roundtrip(self) -> None:
527
+ """PNG output is a valid image loadable by PIL."""
528
+ import matplotlib
529
+ matplotlib.use("Agg")
530
+ import matplotlib.pyplot as plt
531
+
532
+ fig, ax = plt.subplots(figsize=(4, 3))
533
+ ax.bar([1, 2, 3], [10, 20, 15])
534
+ mf = MatplotlibFigure(fig)
535
+ data, _ = mf._serialize()
536
+
537
+ pil = PILImage.open(io.BytesIO(data))
538
+ assert pil.size[0] > 0
539
+ assert pil.size[1] > 0
540
+ plt.close(fig)
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # PlotlyChart tests
545
+ # ---------------------------------------------------------------------------
546
+
547
+ class TestPlotlyChart:
548
+ """Tests for the PlotlyChart class (enhanced Plotly)."""
549
+
550
+ def test_plotly_chart_from_dict(self) -> None:
551
+ """PlotlyChart from dict returns data/layout with _static_image."""
552
+ fig = {
553
+ "data": [{"type": "scatter", "x": [1, 2], "y": [3, 4]}],
554
+ "layout": {"title": "Test"},
555
+ }
556
+ pc = PlotlyChart(fig)
557
+ result = pc._serialize()
558
+ assert result["data"] == fig["data"]
559
+ assert result["layout"] == fig["layout"]
560
+ # Dict input has no to_image method, so static_image should be None
561
+ assert result["_static_image"] is None
562
+
563
+ def test_plotly_chart_from_object_with_to_json(self) -> None:
564
+ """PlotlyChart from object with to_json() serializes correctly."""
565
+
566
+ class FakeFigure:
567
+ def to_json(self):
568
+ return '{"data": [{"type": "bar"}], "layout": {}}'
569
+
570
+ pc = PlotlyChart(FakeFigure())
571
+ result = pc._serialize()
572
+ assert result["data"] == [{"type": "bar"}]
573
+ assert "_static_image" in result
574
+
575
+ def test_plotly_chart_from_object_with_to_dict(self) -> None:
576
+ """PlotlyChart from object with to_dict() serializes correctly."""
577
+
578
+ class FakeFigure:
579
+ def to_dict(self):
580
+ return {"data": [{"type": "pie"}], "layout": {"title": "Pie"}}
581
+
582
+ pc = PlotlyChart(FakeFigure())
583
+ result = pc._serialize()
584
+ assert result["data"][0]["type"] == "pie"
585
+
586
+ def test_plotly_chart_caption(self) -> None:
587
+ """Caption attribute is stored."""
588
+ pc = PlotlyChart({}, caption="interactive chart")
589
+ assert pc.caption == "interactive chart"
590
+
591
+ def test_plotly_chart_static_image_with_to_image(self) -> None:
592
+ """When to_image() is available, _static_image is populated."""
593
+ import base64
594
+
595
+ class FakeFigure:
596
+ def to_json(self):
597
+ return '{"data": [], "layout": {}}'
598
+
599
+ def to_image(self, format="png", width=800, height=500):
600
+ # Return fake PNG bytes
601
+ return b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
602
+
603
+ pc = PlotlyChart(FakeFigure())
604
+ result = pc._serialize()
605
+ assert result["_static_image"] is not None
606
+ # Verify it's valid base64
607
+ decoded = base64.b64decode(result["_static_image"])
608
+ assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
609
+
610
+ def test_plotly_chart_unsupported_type(self) -> None:
611
+ """Unsupported type raises TypeError."""
612
+ pc = PlotlyChart(42)
613
+ try:
614
+ pc._serialize()
615
+ assert False, "Should have raised"
616
+ except TypeError as e:
617
+ assert "int" in str(e)
618
+
619
+
620
+ # ---------------------------------------------------------------------------
621
+ # PointCloud3D tests
622
+ # ---------------------------------------------------------------------------
623
+
624
+ class TestPointCloud3D:
625
+ """Tests for the PointCloud3D class."""
626
+
627
+ def test_point_cloud_basic(self) -> None:
628
+ """PointCloud3D with xyz points serializes correctly."""
629
+ pts = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
630
+ pc = PointCloud3D(pts)
631
+ result = pc._serialize()
632
+
633
+ assert result["num_points"] == 2
634
+ assert len(result["points"]) == 2
635
+ assert result["points"][0] == [1.0, 2.0, 3.0]
636
+ assert result["points"][1] == [4.0, 5.0, 6.0]
637
+ assert result["colors"] is None
638
+ assert result["labels"] is None
639
+ assert result["bounds"]["min"] == [1.0, 2.0, 3.0]
640
+ assert result["bounds"]["max"] == [4.0, 5.0, 6.0]
641
+
642
+ def test_point_cloud_with_colors(self) -> None:
643
+ """PointCloud3D with per-point colors serializes correctly."""
644
+ pts = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
645
+ colors = np.array([[255, 0, 0], [0, 255, 0]], dtype=np.uint8)
646
+ pc = PointCloud3D(pts, colors=colors)
647
+ result = pc._serialize()
648
+
649
+ assert result["colors"] is not None
650
+ assert result["colors"][0] == [255, 0, 0]
651
+ assert result["colors"][1] == [0, 255, 0]
652
+
653
+ def test_point_cloud_float_colors(self) -> None:
654
+ """Float colors (0-1) are normalized to int (0-255)."""
655
+ pts = np.array([[0.0, 0.0, 0.0]])
656
+ colors = np.array([[1.0, 0.5, 0.0]])
657
+ pc = PointCloud3D(pts, colors=colors)
658
+ result = pc._serialize()
659
+
660
+ assert result["colors"][0] == [255, 127, 0] or result["colors"][0] == [255, 128, 0]
661
+
662
+ def test_point_cloud_with_labels(self) -> None:
663
+ """PointCloud3D with per-point labels serializes correctly."""
664
+ pts = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
665
+ labels = ["origin", "corner"]
666
+ pc = PointCloud3D(pts, labels=labels)
667
+ result = pc._serialize()
668
+
669
+ assert result["labels"] == ["origin", "corner"]
670
+
671
+ def test_point_cloud_caption(self) -> None:
672
+ """Caption attribute is stored correctly."""
673
+ pts = np.zeros((1, 3))
674
+ pc = PointCloud3D(pts, caption="lidar scan")
675
+ assert pc.caption == "lidar scan"
676
+
677
+ def test_point_cloud_wrong_shape(self) -> None:
678
+ """Non-(N,3) array raises ValueError."""
679
+ pts = np.zeros((5, 2))
680
+ pc = PointCloud3D(pts)
681
+ try:
682
+ pc._serialize()
683
+ assert False, "Should have raised"
684
+ except ValueError as e:
685
+ assert "(N, 3)" in str(e)
686
+
687
+ def test_point_cloud_color_shape_mismatch(self) -> None:
688
+ """Mismatched colors shape raises ValueError."""
689
+ pts = np.zeros((3, 3))
690
+ colors = np.zeros((2, 3))
691
+ pc = PointCloud3D(pts, colors=colors)
692
+ try:
693
+ pc._serialize()
694
+ assert False, "Should have raised"
695
+ except ValueError as e:
696
+ assert "colors shape" in str(e)
697
+
698
+ def test_point_cloud_label_length_mismatch(self) -> None:
699
+ """Mismatched labels length raises ValueError."""
700
+ pts = np.zeros((3, 3))
701
+ pc = PointCloud3D(pts, labels=["a", "b"])
702
+ try:
703
+ pc._serialize()
704
+ assert False, "Should have raised"
705
+ except ValueError as e:
706
+ assert "labels length" in str(e)
707
+
708
+ def test_point_cloud_empty(self) -> None:
709
+ """Empty point cloud (0 points) serializes correctly."""
710
+ pts = np.zeros((0, 3))
711
+ pc = PointCloud3D(pts)
712
+ result = pc._serialize()
713
+ assert result["num_points"] == 0
714
+ assert result["points"] == []
715
+ assert result["bounds"]["min"] == [0, 0, 0]
716
+ assert result["bounds"]["max"] == [0, 0, 0]
717
+
718
+ def test_point_cloud_large(self) -> None:
719
+ """Large point cloud (1000 points) serializes without error."""
720
+ pts = np.random.randn(1000, 3)
721
+ colors = np.random.randint(0, 255, (1000, 3), dtype=np.uint8)
722
+ labels = [f"pt_{i}" for i in range(1000)]
723
+ pc = PointCloud3D(pts, colors=colors, labels=labels)
724
+ result = pc._serialize()
725
+ assert result["num_points"] == 1000
726
+ assert len(result["points"]) == 1000
727
+ assert len(result["colors"]) == 1000
728
+ assert len(result["labels"]) == 1000
File without changes
File without changes