opik 1.9.41__py3-none-any.whl → 1.9.86__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.
Files changed (192) hide show
  1. opik/api_objects/attachment/attachment_context.py +36 -0
  2. opik/api_objects/attachment/attachments_extractor.py +153 -0
  3. opik/api_objects/attachment/client.py +1 -0
  4. opik/api_objects/attachment/converters.py +2 -0
  5. opik/api_objects/attachment/decoder.py +18 -0
  6. opik/api_objects/attachment/decoder_base64.py +83 -0
  7. opik/api_objects/attachment/decoder_helpers.py +137 -0
  8. opik/api_objects/constants.py +2 -0
  9. opik/api_objects/dataset/dataset.py +133 -40
  10. opik/api_objects/dataset/rest_operations.py +2 -0
  11. opik/api_objects/experiment/experiment.py +6 -0
  12. opik/api_objects/helpers.py +8 -4
  13. opik/api_objects/local_recording.py +6 -5
  14. opik/api_objects/observation_data.py +101 -0
  15. opik/api_objects/opik_client.py +78 -45
  16. opik/api_objects/opik_query_language.py +9 -3
  17. opik/api_objects/prompt/chat/chat_prompt.py +18 -1
  18. opik/api_objects/prompt/client.py +8 -1
  19. opik/api_objects/span/span_data.py +3 -88
  20. opik/api_objects/threads/threads_client.py +7 -4
  21. opik/api_objects/trace/trace_data.py +3 -74
  22. opik/api_objects/validation_helpers.py +3 -3
  23. opik/cli/exports/__init__.py +131 -0
  24. opik/cli/exports/dataset.py +278 -0
  25. opik/cli/exports/experiment.py +784 -0
  26. opik/cli/exports/project.py +685 -0
  27. opik/cli/exports/prompt.py +578 -0
  28. opik/cli/exports/utils.py +406 -0
  29. opik/cli/harbor.py +39 -0
  30. opik/cli/imports/__init__.py +439 -0
  31. opik/cli/imports/dataset.py +143 -0
  32. opik/cli/imports/experiment.py +1192 -0
  33. opik/cli/imports/project.py +262 -0
  34. opik/cli/imports/prompt.py +177 -0
  35. opik/cli/imports/utils.py +280 -0
  36. opik/cli/main.py +14 -12
  37. opik/config.py +12 -1
  38. opik/datetime_helpers.py +12 -0
  39. opik/decorator/arguments_helpers.py +4 -1
  40. opik/decorator/base_track_decorator.py +111 -37
  41. opik/decorator/context_manager/span_context_manager.py +5 -1
  42. opik/decorator/generator_wrappers.py +5 -4
  43. opik/decorator/span_creation_handler.py +13 -4
  44. opik/evaluation/engine/engine.py +111 -28
  45. opik/evaluation/engine/evaluation_tasks_executor.py +71 -19
  46. opik/evaluation/evaluator.py +12 -0
  47. opik/evaluation/metrics/conversation/llm_judges/conversational_coherence/metric.py +3 -1
  48. opik/evaluation/metrics/conversation/llm_judges/session_completeness/metric.py +3 -1
  49. opik/evaluation/metrics/conversation/llm_judges/user_frustration/metric.py +3 -1
  50. opik/evaluation/metrics/heuristics/equals.py +11 -7
  51. opik/evaluation/metrics/llm_judges/answer_relevance/metric.py +3 -1
  52. opik/evaluation/metrics/llm_judges/context_precision/metric.py +3 -1
  53. opik/evaluation/metrics/llm_judges/context_recall/metric.py +3 -1
  54. opik/evaluation/metrics/llm_judges/factuality/metric.py +1 -1
  55. opik/evaluation/metrics/llm_judges/g_eval/metric.py +3 -1
  56. opik/evaluation/metrics/llm_judges/hallucination/metric.py +3 -1
  57. opik/evaluation/metrics/llm_judges/moderation/metric.py +3 -1
  58. opik/evaluation/metrics/llm_judges/structure_output_compliance/metric.py +3 -1
  59. opik/evaluation/metrics/llm_judges/syc_eval/metric.py +4 -2
  60. opik/evaluation/metrics/llm_judges/trajectory_accuracy/metric.py +3 -1
  61. opik/evaluation/metrics/llm_judges/usefulness/metric.py +3 -1
  62. opik/evaluation/metrics/ragas_metric.py +43 -23
  63. opik/evaluation/models/litellm/litellm_chat_model.py +7 -2
  64. opik/evaluation/models/litellm/util.py +4 -20
  65. opik/evaluation/models/models_factory.py +19 -5
  66. opik/evaluation/rest_operations.py +3 -3
  67. opik/evaluation/threads/helpers.py +3 -2
  68. opik/file_upload/file_uploader.py +13 -0
  69. opik/file_upload/upload_options.py +2 -0
  70. opik/integrations/adk/legacy_opik_tracer.py +9 -11
  71. opik/integrations/adk/opik_tracer.py +2 -2
  72. opik/integrations/adk/patchers/adk_otel_tracer/opik_adk_otel_tracer.py +2 -2
  73. opik/integrations/dspy/callback.py +100 -14
  74. opik/integrations/dspy/parsers.py +168 -0
  75. opik/integrations/harbor/__init__.py +17 -0
  76. opik/integrations/harbor/experiment_service.py +269 -0
  77. opik/integrations/harbor/opik_tracker.py +528 -0
  78. opik/integrations/haystack/opik_tracer.py +2 -2
  79. opik/integrations/langchain/__init__.py +15 -2
  80. opik/integrations/langchain/langgraph_tracer_injector.py +88 -0
  81. opik/integrations/langchain/opik_tracer.py +258 -160
  82. opik/integrations/langchain/provider_usage_extractors/langchain_run_helpers/helpers.py +7 -4
  83. opik/integrations/llama_index/callback.py +43 -6
  84. opik/integrations/openai/agents/opik_tracing_processor.py +8 -10
  85. opik/integrations/openai/opik_tracker.py +99 -4
  86. opik/integrations/openai/videos/__init__.py +9 -0
  87. opik/integrations/openai/videos/binary_response_write_to_file_decorator.py +88 -0
  88. opik/integrations/openai/videos/videos_create_decorator.py +159 -0
  89. opik/integrations/openai/videos/videos_download_decorator.py +110 -0
  90. opik/message_processing/batching/base_batcher.py +14 -21
  91. opik/message_processing/batching/batch_manager.py +22 -10
  92. opik/message_processing/batching/batchers.py +32 -40
  93. opik/message_processing/batching/flushing_thread.py +0 -3
  94. opik/message_processing/emulation/emulator_message_processor.py +36 -1
  95. opik/message_processing/emulation/models.py +21 -0
  96. opik/message_processing/messages.py +9 -0
  97. opik/message_processing/preprocessing/__init__.py +0 -0
  98. opik/message_processing/preprocessing/attachments_preprocessor.py +70 -0
  99. opik/message_processing/preprocessing/batching_preprocessor.py +53 -0
  100. opik/message_processing/preprocessing/constants.py +1 -0
  101. opik/message_processing/preprocessing/file_upload_preprocessor.py +38 -0
  102. opik/message_processing/preprocessing/preprocessor.py +36 -0
  103. opik/message_processing/processors/__init__.py +0 -0
  104. opik/message_processing/processors/attachments_extraction_processor.py +146 -0
  105. opik/message_processing/{message_processors.py → processors/message_processors.py} +15 -1
  106. opik/message_processing/{message_processors_chain.py → processors/message_processors_chain.py} +3 -2
  107. opik/message_processing/{online_message_processor.py → processors/online_message_processor.py} +11 -9
  108. opik/message_processing/queue_consumer.py +4 -2
  109. opik/message_processing/streamer.py +71 -33
  110. opik/message_processing/streamer_constructors.py +36 -8
  111. opik/plugins/pytest/experiment_runner.py +1 -1
  112. opik/plugins/pytest/hooks.py +5 -3
  113. opik/rest_api/__init__.py +38 -0
  114. opik/rest_api/datasets/client.py +249 -148
  115. opik/rest_api/datasets/raw_client.py +356 -217
  116. opik/rest_api/experiments/client.py +26 -0
  117. opik/rest_api/experiments/raw_client.py +26 -0
  118. opik/rest_api/llm_provider_key/client.py +4 -4
  119. opik/rest_api/llm_provider_key/raw_client.py +4 -4
  120. opik/rest_api/llm_provider_key/types/provider_api_key_write_provider.py +2 -1
  121. opik/rest_api/manual_evaluation/client.py +101 -0
  122. opik/rest_api/manual_evaluation/raw_client.py +172 -0
  123. opik/rest_api/optimizations/client.py +0 -166
  124. opik/rest_api/optimizations/raw_client.py +0 -248
  125. opik/rest_api/projects/client.py +9 -0
  126. opik/rest_api/projects/raw_client.py +13 -0
  127. opik/rest_api/projects/types/project_metric_request_public_metric_type.py +4 -0
  128. opik/rest_api/prompts/client.py +130 -2
  129. opik/rest_api/prompts/raw_client.py +175 -0
  130. opik/rest_api/traces/client.py +101 -0
  131. opik/rest_api/traces/raw_client.py +120 -0
  132. opik/rest_api/types/__init__.py +46 -0
  133. opik/rest_api/types/audio_url.py +19 -0
  134. opik/rest_api/types/audio_url_public.py +19 -0
  135. opik/rest_api/types/audio_url_write.py +19 -0
  136. opik/rest_api/types/automation_rule_evaluator.py +38 -2
  137. opik/rest_api/types/automation_rule_evaluator_object_object_public.py +33 -2
  138. opik/rest_api/types/automation_rule_evaluator_public.py +33 -2
  139. opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python.py +22 -0
  140. opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python_public.py +22 -0
  141. opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python_write.py +22 -0
  142. opik/rest_api/types/automation_rule_evaluator_update.py +27 -1
  143. opik/rest_api/types/automation_rule_evaluator_update_span_user_defined_metric_python.py +22 -0
  144. opik/rest_api/types/automation_rule_evaluator_write.py +27 -1
  145. opik/rest_api/types/dataset_item.py +1 -1
  146. opik/rest_api/types/dataset_item_batch.py +4 -0
  147. opik/rest_api/types/dataset_item_changes_public.py +5 -0
  148. opik/rest_api/types/dataset_item_compare.py +1 -1
  149. opik/rest_api/types/dataset_item_filter.py +4 -0
  150. opik/rest_api/types/dataset_item_page_compare.py +0 -1
  151. opik/rest_api/types/dataset_item_page_public.py +0 -1
  152. opik/rest_api/types/dataset_item_public.py +1 -1
  153. opik/rest_api/types/dataset_version_public.py +5 -0
  154. opik/rest_api/types/dataset_version_summary.py +5 -0
  155. opik/rest_api/types/dataset_version_summary_public.py +5 -0
  156. opik/rest_api/types/experiment.py +9 -0
  157. opik/rest_api/types/experiment_public.py +9 -0
  158. opik/rest_api/types/llm_as_judge_message_content.py +2 -0
  159. opik/rest_api/types/llm_as_judge_message_content_public.py +2 -0
  160. opik/rest_api/types/llm_as_judge_message_content_write.py +2 -0
  161. opik/rest_api/types/manual_evaluation_request_entity_type.py +1 -1
  162. opik/rest_api/types/project.py +1 -0
  163. opik/rest_api/types/project_detailed.py +1 -0
  164. opik/rest_api/types/project_metric_response_public_metric_type.py +4 -0
  165. opik/rest_api/types/project_reference.py +31 -0
  166. opik/rest_api/types/project_reference_public.py +31 -0
  167. opik/rest_api/types/project_stats_summary_item.py +1 -0
  168. opik/rest_api/types/prompt_version.py +1 -0
  169. opik/rest_api/types/prompt_version_detail.py +1 -0
  170. opik/rest_api/types/prompt_version_page_public.py +5 -0
  171. opik/rest_api/types/prompt_version_public.py +1 -0
  172. opik/rest_api/types/prompt_version_update.py +33 -0
  173. opik/rest_api/types/provider_api_key.py +5 -1
  174. opik/rest_api/types/provider_api_key_provider.py +2 -1
  175. opik/rest_api/types/provider_api_key_public.py +5 -1
  176. opik/rest_api/types/provider_api_key_public_provider.py +2 -1
  177. opik/rest_api/types/service_toggles_config.py +11 -1
  178. opik/rest_api/types/span_user_defined_metric_python_code.py +20 -0
  179. opik/rest_api/types/span_user_defined_metric_python_code_public.py +20 -0
  180. opik/rest_api/types/span_user_defined_metric_python_code_write.py +20 -0
  181. opik/types.py +36 -0
  182. opik/validation/chat_prompt_messages.py +241 -0
  183. opik/validation/feedback_score.py +3 -3
  184. opik/validation/validator.py +28 -0
  185. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/METADATA +5 -5
  186. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/RECORD +190 -141
  187. opik/cli/export.py +0 -791
  188. opik/cli/import_command.py +0 -575
  189. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/WHEEL +0 -0
  190. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/entry_points.txt +0 -0
  191. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/licenses/LICENSE +0 -0
  192. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,685 @@
1
+ """Project export functionality."""
2
+
3
+ import sys
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ import opik
13
+ from opik.rest_api.core.api_error import ApiError
14
+ from opik.rest_api.types.project_public import ProjectPublic
15
+ from .utils import (
16
+ debug_print,
17
+ matches_name_pattern,
18
+ should_skip_file,
19
+ print_export_summary,
20
+ trace_to_csv_rows,
21
+ write_csv_data,
22
+ write_json_data,
23
+ )
24
+
25
+ console = Console()
26
+
27
+
28
+ def export_traces(
29
+ client: opik.Opik,
30
+ project_name: str,
31
+ project_dir: Path,
32
+ max_results: int,
33
+ filter_string: Optional[str],
34
+ project_name_filter: Optional[str] = None,
35
+ format: str = "json",
36
+ debug: bool = False,
37
+ force: bool = False,
38
+ ) -> tuple[int, int]:
39
+ """Download traces and their spans with pagination support for large projects."""
40
+ if debug:
41
+ debug_print(
42
+ f"DEBUG: _export_traces called with project_name: {project_name}, project_dir: {project_dir}",
43
+ debug,
44
+ )
45
+
46
+ # Validate filter syntax before using it
47
+ if filter_string:
48
+ try:
49
+ from opik.api_objects import opik_query_language
50
+
51
+ # Try to parse the filter to validate syntax
52
+ oql = opik_query_language.OpikQueryLanguage(filter_string)
53
+ if oql.get_filter_expressions() is None and filter_string.strip():
54
+ console.print(
55
+ f"[red]Error: Invalid filter syntax: Filter '{filter_string}' could not be parsed.[/red]"
56
+ )
57
+ console.print("[yellow]OQL filter syntax examples:[/yellow]")
58
+ console.print(' status = "running"')
59
+ console.print(' name contains "test"')
60
+ console.print(' start_time >= "2024-01-01T00:00:00Z"')
61
+ console.print(" usage.total_tokens > 1000")
62
+ console.print(
63
+ "[yellow]Note: String values must be in double quotes, use = not :[/yellow]"
64
+ )
65
+ raise ValueError(
66
+ f"Invalid filter syntax: '{filter_string}' could not be parsed"
67
+ )
68
+ except ValueError as e:
69
+ # If it's already our custom error message, re-raise it
70
+ if "Invalid filter syntax" in str(e) and "could not be parsed" in str(e):
71
+ raise
72
+ # Otherwise, format the error nicely
73
+ error_msg = str(e)
74
+ console.print(f"[red]Error: Invalid filter syntax: {error_msg}[/red]")
75
+ console.print("[yellow]OQL filter syntax examples:[/yellow]")
76
+ console.print(' status = "running"')
77
+ console.print(' name contains "test"')
78
+ console.print(' start_time >= "2024-01-01T00:00:00Z"')
79
+ console.print(" usage.total_tokens > 1000")
80
+ console.print(
81
+ "[yellow]Note: String values must be in double quotes, use = not :[/yellow]"
82
+ )
83
+ raise
84
+
85
+ exported_count = 0
86
+ skipped_count = 0
87
+ page_size = min(100, max_results) # Process in smaller batches
88
+ current_page = 1
89
+ total_processed = 0
90
+
91
+ with Progress(
92
+ SpinnerColumn(),
93
+ TextColumn("[progress.description]{task.description}"),
94
+ console=console,
95
+ ) as progress:
96
+ task = progress.add_task("Searching for traces...", total=None)
97
+
98
+ while total_processed < max_results:
99
+ # Calculate how many traces to fetch in this batch
100
+ remaining = max_results - total_processed
101
+ current_page_size = min(page_size, remaining)
102
+
103
+ try:
104
+ if debug:
105
+ debug_print(
106
+ f"DEBUG: Getting traces by project with project_name: {project_name}, filter: {filter_string}, page: {current_page}, size: {current_page_size}",
107
+ debug,
108
+ )
109
+
110
+ # Parse filter to JSON format - backend expects JSON, not OQL string
111
+ parsed_filter = None
112
+ if filter_string:
113
+ from opik.api_objects import opik_query_language
114
+
115
+ oql = opik_query_language.OpikQueryLanguage(filter_string)
116
+ parsed_filter = oql.parsed_filters # This is the JSON string
117
+ if debug:
118
+ debug_print(
119
+ f"DEBUG: Parsed filter to JSON: {parsed_filter}", debug
120
+ )
121
+
122
+ # Use get_traces_by_project for better performance when we know the project name
123
+ # Backend expects JSON string format for filters
124
+ trace_page = client.rest_client.traces.get_traces_by_project(
125
+ project_name=project_name,
126
+ filters=parsed_filter, # Pass parsed JSON string - backend expects JSON
127
+ page=current_page,
128
+ size=current_page_size,
129
+ truncate=False, # Don't truncate data for download
130
+ )
131
+ traces = trace_page.content or []
132
+ if debug:
133
+ debug_print(
134
+ f"DEBUG: Found {len(traces)} traces in project (page {current_page})",
135
+ debug,
136
+ )
137
+ except Exception as e:
138
+ # Check if it's an OQL parsing error and provide user-friendly message
139
+ error_msg = str(e)
140
+ if "Invalid value" in error_msg and (
141
+ "expected an string in double quotes" in error_msg
142
+ or "expected a string in double quotes" in error_msg
143
+ ):
144
+ console.print(
145
+ "[red]Error: Invalid filter format in export query.[/red]"
146
+ )
147
+ console.print(
148
+ '[yellow]String values in filters must be in double quotes, e.g., status = "running"[/yellow]'
149
+ )
150
+ if debug:
151
+ debug_print(f"Technical details: {e}", debug)
152
+ else:
153
+ console.print(f"[red]Error searching traces: {e}[/red]")
154
+ break
155
+
156
+ if not traces:
157
+ # No more traces to process
158
+ break
159
+
160
+ # Update progress description to show current page
161
+ progress.update(
162
+ task,
163
+ description=f"Downloading traces... (page {current_page}, {len(traces)} traces)",
164
+ )
165
+
166
+ # Filter traces by project name if specified
167
+ if project_name_filter:
168
+ original_count = len(traces)
169
+ traces = [
170
+ trace
171
+ for trace in traces
172
+ if matches_name_pattern(trace.name or "", project_name_filter)
173
+ ]
174
+ if len(traces) < original_count:
175
+ console.print(
176
+ f"[blue]Filtered to {len(traces)} traces matching project '{project_name_filter}' in current batch[/blue]"
177
+ )
178
+
179
+ if not traces:
180
+ # No traces match the name pattern, but we might have more to process
181
+ # Use original_traces for pagination, not the filtered empty list
182
+ total_processed += current_page_size
183
+ continue
184
+
185
+ # Update progress for downloading
186
+ progress.update(
187
+ task,
188
+ description=f"Downloading traces... (batch {total_processed // page_size + 1})",
189
+ )
190
+
191
+ # Download each trace with its spans
192
+ for trace in traces:
193
+ try:
194
+ # Get spans for this trace
195
+ spans = client.search_spans(
196
+ project_name=project_name,
197
+ trace_id=trace.id,
198
+ max_results=1000, # Get all spans for the trace
199
+ truncate=False,
200
+ )
201
+
202
+ # Create trace data structure
203
+ trace_data = {
204
+ "trace": trace.model_dump(),
205
+ "spans": [span.model_dump() for span in spans],
206
+ "downloaded_at": datetime.now().isoformat(),
207
+ "project_name": project_name,
208
+ }
209
+
210
+ # Determine file path based on format
211
+ if format.lower() == "csv":
212
+ file_path = project_dir / f"trace_{trace.id}.csv"
213
+ else:
214
+ file_path = project_dir / f"trace_{trace.id}.json"
215
+
216
+ # Check if file already exists and should be skipped
217
+ if should_skip_file(file_path, force):
218
+ if debug:
219
+ debug_print(
220
+ f"Skipping trace {trace.id} (already exists)",
221
+ debug,
222
+ )
223
+ skipped_count += 1
224
+ total_processed += 1
225
+ continue
226
+
227
+ # Save to file using the appropriate format
228
+ try:
229
+ if format.lower() == "csv":
230
+ write_csv_data(trace_data, file_path, trace_to_csv_rows)
231
+ if debug:
232
+ debug_print(f"Wrote CSV file: {file_path}", debug)
233
+ else:
234
+ write_json_data(trace_data, file_path)
235
+ if debug:
236
+ debug_print(f"Wrote JSON file: {file_path}", debug)
237
+
238
+ exported_count += 1
239
+ total_processed += 1
240
+ except Exception as write_error:
241
+ console.print(
242
+ f"[red]Error writing trace {trace.id} to file: {write_error}[/red]"
243
+ )
244
+ if debug:
245
+ import traceback
246
+
247
+ debug_print(f"Traceback: {traceback.format_exc()}", debug)
248
+ continue
249
+
250
+ except Exception as e:
251
+ console.print(f"[red]Error exporting trace {trace.id}: {e}[/red]")
252
+ continue
253
+
254
+ # Update pagination for next iteration
255
+ if traces:
256
+ current_page += 1
257
+ else:
258
+ # No more traces to process
259
+ break
260
+
261
+ # If we got fewer traces than requested, we've reached the end
262
+ if len(traces) < current_page_size:
263
+ break
264
+
265
+ # Final progress update
266
+ if exported_count == 0:
267
+ console.print("[yellow]No traces found in the project.[/yellow]")
268
+ else:
269
+ progress.update(task, description=f"Exported {exported_count} traces total")
270
+
271
+ return exported_count, skipped_count
272
+
273
+
274
+ def export_project_by_name(
275
+ name: str,
276
+ workspace: str,
277
+ output_path: str,
278
+ filter_string: Optional[str],
279
+ max_results: Optional[int],
280
+ force: bool,
281
+ debug: bool,
282
+ format: str,
283
+ api_key: Optional[str] = None,
284
+ ) -> None:
285
+ """Export a project by exact name."""
286
+ try:
287
+ if debug:
288
+ debug_print(f"Exporting project: {name}", debug)
289
+
290
+ # Validate filter syntax before doing anything else
291
+ if filter_string:
292
+ try:
293
+ from opik.api_objects import opik_query_language
294
+
295
+ # Try to parse the filter to validate syntax
296
+ oql = opik_query_language.OpikQueryLanguage(filter_string)
297
+ if oql.get_filter_expressions() is None and filter_string.strip():
298
+ console.print(
299
+ f"[red]Error: Invalid filter syntax: Filter '{filter_string}' could not be parsed.[/red]"
300
+ )
301
+ console.print("[yellow]OQL filter syntax examples:[/yellow]")
302
+ console.print(' status = "running"')
303
+ console.print(' name contains "test"')
304
+ console.print(' start_time >= "2024-01-01T00:00:00Z"')
305
+ console.print(" usage.total_tokens > 1000")
306
+ console.print(
307
+ "[yellow]Note: String values must be in double quotes, use = not :[/yellow]"
308
+ )
309
+ raise ValueError(
310
+ f"Invalid filter syntax: '{filter_string}' could not be parsed"
311
+ )
312
+ except ValueError as e:
313
+ # If it's already our custom error message, re-raise it
314
+ if "Invalid filter syntax" in str(e) and "could not be parsed" in str(
315
+ e
316
+ ):
317
+ raise
318
+ # Otherwise, format the error nicely
319
+ error_msg = str(e)
320
+ console.print(f"[red]Error: Invalid filter syntax: {error_msg}[/red]")
321
+ console.print("[yellow]OQL filter syntax examples:[/yellow]")
322
+ console.print(' status = "running"')
323
+ console.print(' name contains "test"')
324
+ console.print(' start_time >= "2024-01-01T00:00:00Z"')
325
+ console.print(" usage.total_tokens > 1000")
326
+ console.print(
327
+ "[yellow]Note: String values must be in double quotes, use = not :[/yellow]"
328
+ )
329
+ raise
330
+
331
+ # Initialize client
332
+ if api_key:
333
+ client = opik.Opik(api_key=api_key, workspace=workspace)
334
+ else:
335
+ client = opik.Opik(workspace=workspace)
336
+
337
+ # Create output directory
338
+ output_dir = Path(output_path) / workspace / "projects"
339
+ output_dir.mkdir(parents=True, exist_ok=True)
340
+
341
+ if debug:
342
+ debug_print(f"Target directory: {output_dir}", debug)
343
+
344
+ # Get projects and find exact match
345
+ # Use name filter to narrow down results (backend does partial match, case-insensitive)
346
+ # Then verify exact match on client side
347
+ projects_response = client.rest_client.projects.find_projects(name=name)
348
+ projects = projects_response.content or []
349
+ matching_project = None
350
+
351
+ for project in projects:
352
+ if project.name == name:
353
+ matching_project = project
354
+ break
355
+
356
+ # If not found with name filter, try paginating through all projects as fallback
357
+ # This handles edge cases where the name filter might not work as expected
358
+ if not matching_project:
359
+ if debug:
360
+ debug_print(
361
+ f"Project '{name}' not found in filtered results, searching all projects...",
362
+ debug,
363
+ )
364
+ # Paginate through all projects
365
+ page = 1
366
+ page_size = 100
367
+ while True:
368
+ projects_response = client.rest_client.projects.find_projects(
369
+ page=page, size=page_size
370
+ )
371
+ projects = projects_response.content or []
372
+ if not projects:
373
+ break
374
+
375
+ for project in projects:
376
+ if project.name == name:
377
+ matching_project = project
378
+ break
379
+
380
+ if matching_project:
381
+ break
382
+
383
+ # Check if there are more pages
384
+ if projects_response.total is not None:
385
+ total_pages = (projects_response.total + page_size - 1) // page_size
386
+ if page >= total_pages:
387
+ break
388
+ elif len(projects) < page_size:
389
+ # No more pages if we got fewer results than page size
390
+ break
391
+
392
+ page += 1
393
+
394
+ if not matching_project:
395
+ console.print(f"[red]Project '{name}' not found[/red]")
396
+ sys.exit(1)
397
+
398
+ if debug:
399
+ debug_print(f"Found project by exact match: {matching_project.name}", debug)
400
+
401
+ # Export the project
402
+ exported_count, traces_exported, traces_skipped = export_single_project(
403
+ client,
404
+ matching_project,
405
+ output_dir,
406
+ filter_string,
407
+ max_results,
408
+ force,
409
+ debug,
410
+ format,
411
+ )
412
+
413
+ # Collect statistics for summary using actual export counts
414
+ stats = {
415
+ "projects": 1 if exported_count > 0 else 0,
416
+ "projects_skipped": 0 if exported_count > 0 else 1,
417
+ "traces": traces_exported,
418
+ "traces_skipped": traces_skipped,
419
+ }
420
+
421
+ # Show export summary
422
+ print_export_summary(stats, format)
423
+
424
+ if exported_count > 0:
425
+ console.print(
426
+ f"[green]Successfully exported project '{name}' to {output_dir}[/green]"
427
+ )
428
+ else:
429
+ console.print(
430
+ f"[yellow]Project '{name}' already exists (use --force to re-download)[/yellow]"
431
+ )
432
+
433
+ except ValueError as e:
434
+ # Filter validation errors are already formatted, just exit
435
+ if "Invalid filter syntax" in str(e):
436
+ sys.exit(1)
437
+ # Other ValueErrors should be shown
438
+ console.print(f"[red]Error exporting project: {e}[/red]")
439
+ sys.exit(1)
440
+ except Exception as e:
441
+ console.print(f"[red]Error exporting project: {e}[/red]")
442
+ sys.exit(1)
443
+
444
+
445
+ def export_single_project(
446
+ client: opik.Opik,
447
+ project: ProjectPublic,
448
+ output_dir: Path,
449
+ filter_string: Optional[str],
450
+ max_results: Optional[int],
451
+ force: bool,
452
+ debug: bool,
453
+ format: str,
454
+ ) -> tuple[int, int, int]:
455
+ """Export a single project."""
456
+ try:
457
+ # Create project-specific directory for traces
458
+ project_traces_dir = output_dir / project.name
459
+ project_traces_dir.mkdir(parents=True, exist_ok=True)
460
+
461
+ # Check if traces directory already has files in the requested format and force is not set
462
+ # Skip early return if a filter is provided, as we need to check if existing traces match the filter
463
+ if not force and not filter_string:
464
+ # Only check for files in the requested format
465
+ if format.lower() == "csv":
466
+ existing_traces = list(project_traces_dir.glob("trace_*.csv"))
467
+ else:
468
+ existing_traces = list(project_traces_dir.glob("trace_*.json"))
469
+
470
+ if existing_traces:
471
+ if debug:
472
+ debug_print(
473
+ f"Skipping {project.name} (already has {len(existing_traces)} trace files in {format} format, use --force to re-download)",
474
+ debug,
475
+ )
476
+ # Return project status, and trace counts (all skipped)
477
+ return (1, 0, len(existing_traces))
478
+
479
+ # Export related traces for this project
480
+ traces_exported, traces_skipped = export_traces(
481
+ client,
482
+ project.name,
483
+ project_traces_dir,
484
+ max_results or 1000, # Use provided max_results or default to 1000
485
+ filter_string,
486
+ None, # project_name_filter
487
+ format,
488
+ debug,
489
+ force,
490
+ )
491
+
492
+ # Project export only exports traces - datasets and prompts must be exported separately
493
+ if traces_exported > 0:
494
+ if debug:
495
+ debug_print(
496
+ f"Exported project: {project.name} with {traces_exported} traces",
497
+ debug,
498
+ )
499
+ return (1, traces_exported, traces_skipped)
500
+ elif traces_skipped > 0:
501
+ # Traces were skipped (already exist)
502
+ if debug:
503
+ debug_print(
504
+ f"Project {project.name} already has {traces_skipped} trace files",
505
+ debug,
506
+ )
507
+ return (1, traces_exported, traces_skipped)
508
+ else:
509
+ # No traces found or exported
510
+ if debug:
511
+ debug_print(
512
+ f"No traces found in project: {project.name}",
513
+ debug,
514
+ )
515
+ # Still return 1 to indicate the project was processed
516
+ # (the empty directory will remain, but that's expected if there are no traces)
517
+ return (1, traces_exported, traces_skipped)
518
+
519
+ except Exception as e:
520
+ console.print(f"[red]Error exporting project {project.name}: {e}[/red]")
521
+ return (0, 0, 0)
522
+
523
+
524
+ def export_project_by_name_or_id(
525
+ name_or_id: str,
526
+ workspace: str,
527
+ output_path: str,
528
+ filter_string: Optional[str],
529
+ max_results: Optional[int],
530
+ force: bool,
531
+ debug: bool,
532
+ format: str,
533
+ api_key: Optional[str] = None,
534
+ ) -> None:
535
+ """Export a project by name or ID.
536
+
537
+ First tries to get the project by ID. If not found, tries by name.
538
+ """
539
+ try:
540
+ if debug:
541
+ debug_print(f"Attempting to export project: {name_or_id}", debug)
542
+
543
+ # Initialize client
544
+ if api_key:
545
+ client = opik.Opik(api_key=api_key, workspace=workspace)
546
+ else:
547
+ client = opik.Opik(workspace=workspace)
548
+
549
+ # Create output directory
550
+ output_dir = Path(output_path) / workspace / "projects"
551
+ output_dir.mkdir(parents=True, exist_ok=True)
552
+
553
+ if debug:
554
+ debug_print(f"Target directory: {output_dir}", debug)
555
+
556
+ # Try to get project by ID first
557
+ try:
558
+ if debug:
559
+ debug_print(f"Trying to get project by ID: {name_or_id}", debug)
560
+ project = client.get_project(name_or_id)
561
+
562
+ # Successfully found by ID, export it
563
+ if debug:
564
+ debug_print(
565
+ f"Found project by ID: {project.name} (ID: {project.id})", debug
566
+ )
567
+
568
+ # Export the project
569
+ exported_count, traces_exported, traces_skipped = export_single_project(
570
+ client,
571
+ project,
572
+ output_dir,
573
+ filter_string,
574
+ max_results,
575
+ force,
576
+ debug,
577
+ format,
578
+ )
579
+
580
+ # Show export summary
581
+ stats = {
582
+ "projects": exported_count,
583
+ "traces": traces_exported,
584
+ "traces_skipped": traces_skipped,
585
+ }
586
+ print_export_summary(stats, format)
587
+
588
+ if exported_count > 0:
589
+ console.print(
590
+ f"[green]Successfully exported project '{project.name}' (ID: {project.id}) to {output_dir}[/green]"
591
+ )
592
+ else:
593
+ console.print(
594
+ f"[yellow]Project '{project.name}' (ID: {project.id}) already exists (use --force to re-download)[/yellow]"
595
+ )
596
+ return
597
+
598
+ except ApiError as e:
599
+ # Check if it's a 404 (not found) error
600
+ if e.status_code == 404:
601
+ # Not found by ID, try by name
602
+ if debug:
603
+ debug_print(
604
+ f"Project not found by ID, trying by name: {name_or_id}", debug
605
+ )
606
+ # Fall through to name-based export
607
+ pass
608
+ else:
609
+ # Some other API error, re-raise it
610
+ raise
611
+
612
+ # Try by name (either because ID lookup failed or we're explicitly trying name)
613
+ export_project_by_name(
614
+ name_or_id,
615
+ workspace,
616
+ output_path,
617
+ filter_string,
618
+ max_results,
619
+ force,
620
+ debug,
621
+ format,
622
+ api_key,
623
+ )
624
+
625
+ except Exception as e:
626
+ console.print(f"[red]Error exporting project: {e}[/red]")
627
+ sys.exit(1)
628
+
629
+
630
+ @click.command(name="project")
631
+ @click.argument("name_or_id", type=str)
632
+ @click.option(
633
+ "--filter",
634
+ type=str,
635
+ help="Filter string to narrow down traces using Opik Query Language (OQL).",
636
+ )
637
+ @click.option(
638
+ "--max-results",
639
+ type=int,
640
+ help="Maximum number of traces to export. Limits the total number of traces downloaded.",
641
+ )
642
+ @click.option(
643
+ "--path",
644
+ "-p",
645
+ type=click.Path(file_okay=False, dir_okay=True, writable=True),
646
+ default="opik_exports",
647
+ help="Directory to save exported data. Defaults to opik_exports.",
648
+ )
649
+ @click.option(
650
+ "--force",
651
+ is_flag=True,
652
+ help="Re-download items even if they already exist locally.",
653
+ )
654
+ @click.option(
655
+ "--debug",
656
+ is_flag=True,
657
+ help="Enable debug output to show detailed information about the export process.",
658
+ )
659
+ @click.option(
660
+ "--format",
661
+ type=click.Choice(["json", "csv"], case_sensitive=False),
662
+ default="json",
663
+ help="Format for exporting data. Defaults to json.",
664
+ )
665
+ @click.pass_context
666
+ def export_project_command(
667
+ ctx: click.Context,
668
+ name_or_id: str,
669
+ filter: Optional[str],
670
+ max_results: Optional[int],
671
+ path: str,
672
+ force: bool,
673
+ debug: bool,
674
+ format: str,
675
+ ) -> None:
676
+ """Export a project by name or ID to workspace/projects.
677
+
678
+ The command will first try to find the project by ID. If not found, it will try by name.
679
+ """
680
+ # Get workspace and API key from context
681
+ workspace = ctx.obj["workspace"]
682
+ api_key = ctx.obj.get("api_key") if ctx.obj else None
683
+ export_project_by_name_or_id(
684
+ name_or_id, workspace, path, filter, max_results, force, debug, format, api_key
685
+ )