aiqa-client 0.6.1__tar.gz → 0.7.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.
- {aiqa_client-0.6.1/aiqa_client.egg-info → aiqa_client-0.7.0}/PKG-INFO +1 -1
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/client.py +74 -4
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/constants.py +1 -1
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/experiment_runner.py +73 -108
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/llm_as_judge.py +3 -2
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/object_serialiser.py +5 -2
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/tracing.py +113 -34
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/types.py +1 -1
- {aiqa_client-0.6.1 → aiqa_client-0.7.0/aiqa_client.egg-info}/PKG-INFO +1 -1
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/pyproject.toml +1 -1
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_tracing.py +365 -1
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/LICENSE.txt +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/MANIFEST.in +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/README.md +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/__init__.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/http_utils.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/py.typed +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/span_helpers.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/tracing_llm_utils.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/SOURCES.txt +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/dependency_links.txt +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/requires.txt +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/top_level.txt +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/setup.cfg +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_chatbot.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_integration.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_integration_api_key.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_object_serialiser.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_span_helpers.py +0 -0
- {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_startup_reliability.py +0 -0
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import os
|
|
3
3
|
import logging
|
|
4
4
|
from functools import lru_cache
|
|
5
|
-
from typing import Optional, TYPE_CHECKING, Any, Dict
|
|
5
|
+
from typing import Optional, TYPE_CHECKING, Any, Dict, List
|
|
6
6
|
from opentelemetry import trace
|
|
7
7
|
from opentelemetry.sdk.trace import TracerProvider
|
|
8
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
|
|
8
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
|
|
9
9
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
10
10
|
from opentelemetry.sdk.trace import ReadableSpan
|
|
11
11
|
from opentelemetry.trace import SpanContext
|
|
@@ -52,6 +52,8 @@ class AIQAClient:
|
|
|
52
52
|
cls._instance._exporter = None # reduce circular import issues by not importing for typecheck here
|
|
53
53
|
cls._instance._enabled: bool = True
|
|
54
54
|
cls._instance._initialized: bool = False
|
|
55
|
+
cls._instance._default_ignore_patterns: List[str] = ["_*"] # Default: filter properties starting with '_'
|
|
56
|
+
cls._instance._ignore_recursive: bool = True # Default: recursive filtering enabled
|
|
55
57
|
return cls._instance
|
|
56
58
|
|
|
57
59
|
@property
|
|
@@ -90,6 +92,76 @@ class AIQAClient:
|
|
|
90
92
|
logger.info(f"AIQA tracing {'enabled' if value else 'disabled'}")
|
|
91
93
|
self._enabled = value
|
|
92
94
|
|
|
95
|
+
@property
|
|
96
|
+
def default_ignore_patterns(self) -> List[str]:
|
|
97
|
+
"""
|
|
98
|
+
Get the default ignore patterns applied to all traced inputs and outputs.
|
|
99
|
+
|
|
100
|
+
Default: ["_*"] (filters properties starting with '_')
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of ignore patterns (supports wildcards like "_*")
|
|
104
|
+
"""
|
|
105
|
+
return self._default_ignore_patterns.copy()
|
|
106
|
+
|
|
107
|
+
@default_ignore_patterns.setter
|
|
108
|
+
def default_ignore_patterns(self, value: Optional[List[str]]) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Set the default ignore patterns applied to all traced inputs and outputs.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
value: List of patterns to ignore (e.g., ["_*", "password"]).
|
|
114
|
+
Set to None or [] to disable default ignore patterns.
|
|
115
|
+
Supports wildcards (e.g., "_*" matches "_apple", "_fruit").
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
from aiqa import get_aiqa_client
|
|
119
|
+
|
|
120
|
+
client = get_aiqa_client()
|
|
121
|
+
# Add password to default ignore patterns
|
|
122
|
+
client.default_ignore_patterns = ["_*", "password", "api_key"]
|
|
123
|
+
# Disable default ignore patterns
|
|
124
|
+
client.default_ignore_patterns = []
|
|
125
|
+
"""
|
|
126
|
+
if value is None:
|
|
127
|
+
self._default_ignore_patterns = []
|
|
128
|
+
else:
|
|
129
|
+
self._default_ignore_patterns = list(value)
|
|
130
|
+
logger.info(f"Default ignore patterns set to: {self._default_ignore_patterns}")
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def ignore_recursive(self) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Get whether ignore patterns are applied recursively to nested objects.
|
|
136
|
+
|
|
137
|
+
Default: True (recursive filtering enabled)
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if recursive filtering is enabled, False otherwise
|
|
141
|
+
"""
|
|
142
|
+
return self._ignore_recursive
|
|
143
|
+
|
|
144
|
+
@ignore_recursive.setter
|
|
145
|
+
def ignore_recursive(self, value: bool) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Set whether ignore patterns are applied recursively to nested objects.
|
|
148
|
+
|
|
149
|
+
When True (default), ignore patterns are applied at all nesting levels.
|
|
150
|
+
When False, ignore patterns are only applied to top-level keys.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
value: True to enable recursive filtering, False to disable
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
from aiqa import get_aiqa_client
|
|
157
|
+
|
|
158
|
+
client = get_aiqa_client()
|
|
159
|
+
# Disable recursive filtering (only filter top-level keys)
|
|
160
|
+
client.ignore_recursive = False
|
|
161
|
+
"""
|
|
162
|
+
self._ignore_recursive = bool(value)
|
|
163
|
+
logger.info(f"Ignore recursive filtering {'enabled' if self._ignore_recursive else 'disabled'}")
|
|
164
|
+
|
|
93
165
|
def shutdown(self) -> None:
|
|
94
166
|
"""
|
|
95
167
|
Shutdown the tracer provider and exporter.
|
|
@@ -245,8 +317,6 @@ def _attach_aiqa_processor(provider: TracerProvider) -> None:
|
|
|
245
317
|
auth_headers = {}
|
|
246
318
|
if api_key:
|
|
247
319
|
auth_headers["Authorization"] = f"ApiKey {api_key}"
|
|
248
|
-
elif os.getenv("AIQA_API_KEY"):
|
|
249
|
-
auth_headers["Authorization"] = f"ApiKey {os.getenv('AIQA_API_KEY')}"
|
|
250
320
|
|
|
251
321
|
# OTLP HTTP exporter requires the full endpoint URL including /v1/traces
|
|
252
322
|
# Ensure server_url doesn't have trailing slash or /v1/traces, then append /v1/traces
|
|
@@ -3,6 +3,6 @@ Constants used across the AIQA client package.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
AIQA_TRACER_NAME = "aiqa-tracer"
|
|
6
|
-
VERSION = "0.
|
|
6
|
+
VERSION = "0.7.0" # automatically updated by set-version-json.sh
|
|
7
7
|
|
|
8
8
|
LOG_TAG = "AIQA" # Used in all logging output to identify AIQA messages
|
|
@@ -123,7 +123,17 @@ class ExperimentRunner:
|
|
|
123
123
|
|
|
124
124
|
return dataset
|
|
125
125
|
|
|
126
|
-
def
|
|
126
|
+
def get_example(self, example_id: str) -> Dict[str, Any]:
|
|
127
|
+
"""
|
|
128
|
+
Fetch an example by ID.
|
|
129
|
+
"""
|
|
130
|
+
response = requests.get(
|
|
131
|
+
f"{self.server_url}/example/{example_id}",
|
|
132
|
+
headers=self._get_headers(),
|
|
133
|
+
)
|
|
134
|
+
return response.json()
|
|
135
|
+
|
|
136
|
+
def get_examples_for_dataset(self, limit: int = 10000) -> List[Dict[str, Any]]:
|
|
127
137
|
"""
|
|
128
138
|
Fetch example inputs from the dataset.
|
|
129
139
|
|
|
@@ -162,7 +172,6 @@ class ExperimentRunner:
|
|
|
162
172
|
experiment_setup: Optional setup for the experiment object. You may wish to set:
|
|
163
173
|
- name (recommended for labelling the experiment)
|
|
164
174
|
- parameters
|
|
165
|
-
- comparison_parameters
|
|
166
175
|
|
|
167
176
|
Returns:
|
|
168
177
|
The created experiment object
|
|
@@ -184,7 +193,7 @@ class ExperimentRunner:
|
|
|
184
193
|
"organisation": self.organisation,
|
|
185
194
|
"dataset": self.dataset_id,
|
|
186
195
|
"results": [],
|
|
187
|
-
"
|
|
196
|
+
"summaries": {},
|
|
188
197
|
}
|
|
189
198
|
|
|
190
199
|
print(f"Creating experiment")
|
|
@@ -226,10 +235,7 @@ class ExperimentRunner:
|
|
|
226
235
|
if not example_id:
|
|
227
236
|
raise ValueError("Example must have an 'id' field")
|
|
228
237
|
if result is None:
|
|
229
|
-
example_id =
|
|
230
|
-
if not example_id:
|
|
231
|
-
raise ValueError("Example must have an 'id' field")
|
|
232
|
-
result = Result(exampleId=example_id, scores={}, messages={}, errors={})
|
|
238
|
+
result = Result(example=example_id, scores={}, messages={}, errors={})
|
|
233
239
|
scores = result.get("scores") or {}
|
|
234
240
|
|
|
235
241
|
|
|
@@ -243,7 +249,7 @@ class ExperimentRunner:
|
|
|
243
249
|
f"{self.server_url}/experiment/{self.experiment_id}/example/{example_id}/scoreAndStore",
|
|
244
250
|
json={
|
|
245
251
|
"output": result,
|
|
246
|
-
"traceId": example.get("
|
|
252
|
+
"traceId": example.get("trace"), # Server returns 'trace' (lowercase), but API expects 'traceId' (camelCase)
|
|
247
253
|
"scores": scores,
|
|
248
254
|
},
|
|
249
255
|
headers=self._get_headers(),
|
|
@@ -271,7 +277,7 @@ class ExperimentRunner:
|
|
|
271
277
|
engine: Function that takes input, returns output (can be async)
|
|
272
278
|
scorer: Optional function that scores the output given the example
|
|
273
279
|
"""
|
|
274
|
-
examples = self.
|
|
280
|
+
examples = self.get_examples_for_dataset()
|
|
275
281
|
|
|
276
282
|
# Wrap engine to match run_example signature (input, parameters)
|
|
277
283
|
async def wrapped_engine(input_data, parameters):
|
|
@@ -304,8 +310,7 @@ class ExperimentRunner:
|
|
|
304
310
|
scorer_for_metric_id: Optional[Dict[str, ScoreThisInputOutputMetricType]] = None,
|
|
305
311
|
) -> List[Result]:
|
|
306
312
|
"""
|
|
307
|
-
Run the engine on an example with the
|
|
308
|
-
and score the result. Also calls scoreAndStore to store the result in the server.
|
|
313
|
+
Run the engine on an example with the experiment's parameters, score the result, and store it.
|
|
309
314
|
|
|
310
315
|
Args:
|
|
311
316
|
example: The example to run. See Example.ts type
|
|
@@ -313,117 +318,76 @@ class ExperimentRunner:
|
|
|
313
318
|
scorer_for_metric_id: Optional dictionary of metric IDs to functions that score the output given the example and parameters
|
|
314
319
|
|
|
315
320
|
Returns:
|
|
316
|
-
|
|
317
|
-
returns an array of one.
|
|
321
|
+
List of one result (for API compatibility).
|
|
318
322
|
"""
|
|
319
|
-
# Ensure experiment exists
|
|
320
323
|
if not self.experiment:
|
|
321
324
|
self.create_experiment()
|
|
322
325
|
if not self.experiment:
|
|
323
326
|
raise Exception("Failed to create experiment")
|
|
324
327
|
|
|
325
|
-
|
|
326
|
-
parameters_fixed = self.experiment.get("parameters") or {}
|
|
327
|
-
# If comparison_parameters is empty/undefined, default to [{}] so we run at least once
|
|
328
|
-
parameters_loop = self.experiment.get("comparison_parameters") or [{}]
|
|
329
|
-
|
|
330
|
-
# Handle both spans array and input field
|
|
328
|
+
parameters_here = self.experiment.get("parameters") or {}
|
|
331
329
|
input_data = example.get("input")
|
|
332
330
|
if not input_data and example.get("spans") and len(example["spans"]) > 0:
|
|
333
331
|
input_data = example["spans"][0].get("attributes", {}).get("input")
|
|
334
|
-
|
|
335
332
|
if not input_data:
|
|
336
|
-
print(f"Warning: Example has no input field or spans with input attribute: {example}"
|
|
337
|
-
)
|
|
338
|
-
# Run engine anyway -- this could make sense if it's all about the parameters
|
|
333
|
+
print(f"Warning: Example has no input field or spans with input attribute: {example}")
|
|
339
334
|
|
|
340
|
-
# Set example.id on the root span (created by @WithTracing decorator)
|
|
341
|
-
# This ensures the root span from the trace has example=Example.id set
|
|
342
335
|
example_id = example.get("id")
|
|
343
336
|
if not example_id:
|
|
344
337
|
raise ValueError("Example must have an 'id' field")
|
|
345
338
|
set_span_attribute("example", example_id)
|
|
346
|
-
|
|
347
|
-
all_scores: List[Dict[str, Any]] = []
|
|
348
|
-
dataset_metrics = self.get_dataset().get("metrics", [])
|
|
349
|
-
specific_metrics = example.get("metrics", [])
|
|
350
|
-
metrics = [*dataset_metrics, *specific_metrics]
|
|
351
|
-
# This loop should not be parallelized - it should run sequentially, one after the other
|
|
352
|
-
# to avoid creating interference between the runs.
|
|
353
|
-
for parameters in parameters_loop:
|
|
354
|
-
parameters_here = {**parameters_fixed, **parameters}
|
|
355
|
-
print(f"Running with parameters: {parameters_here}")
|
|
356
|
-
|
|
357
|
-
# Save original env var values for cleanup
|
|
358
|
-
original_env_vars: Dict[str, Optional[str]] = {}
|
|
359
|
-
# Set env vars from parameters_here
|
|
360
|
-
for key, value in parameters_here.items():
|
|
361
|
-
if value:
|
|
362
|
-
original_env_vars[key] = os.environ.get(key)
|
|
363
|
-
os.environ[key] = str(value)
|
|
364
339
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
for key, original_value in original_env_vars.items():
|
|
415
|
-
if original_value is None:
|
|
416
|
-
# Variable didn't exist before, remove it
|
|
417
|
-
os.environ.pop(key, None)
|
|
418
|
-
else:
|
|
419
|
-
# Restore original value
|
|
420
|
-
os.environ[key] = original_value
|
|
421
|
-
|
|
422
|
-
return all_scores
|
|
423
|
-
|
|
424
|
-
def get_summary_results(self) -> Dict[str, Any]:
|
|
340
|
+
print(f"Running with parameters: {parameters_here}")
|
|
341
|
+
original_env_vars: Dict[str, Optional[str]] = {}
|
|
342
|
+
for key, value in parameters_here.items():
|
|
343
|
+
if value:
|
|
344
|
+
original_env_vars[key] = os.environ.get(key)
|
|
345
|
+
os.environ[key] = str(value)
|
|
346
|
+
try:
|
|
347
|
+
start = time.time() * 1000
|
|
348
|
+
output = call_my_code(input_data, parameters_here)
|
|
349
|
+
if hasattr(output, "__await__"):
|
|
350
|
+
output = await output
|
|
351
|
+
duration = int((time.time() * 1000) - start)
|
|
352
|
+
print(f"Output: {output}")
|
|
353
|
+
|
|
354
|
+
dataset_metrics = self.get_dataset().get("metrics", [])
|
|
355
|
+
specific_metrics = example.get("metrics", [])
|
|
356
|
+
metrics = [*dataset_metrics, *specific_metrics]
|
|
357
|
+
result = Result(example=example_id, scores={}, messages={}, errors={})
|
|
358
|
+
for metric in metrics:
|
|
359
|
+
metric_id = metric.get("id")
|
|
360
|
+
if not metric_id:
|
|
361
|
+
continue
|
|
362
|
+
scorer = scorer_for_metric_id.get(metric_id) if scorer_for_metric_id else None
|
|
363
|
+
if scorer:
|
|
364
|
+
metric_result = await scorer(input_data, output, metric)
|
|
365
|
+
elif metric.get("type") == "llm":
|
|
366
|
+
metric_result = await self._score_llm_metric(input_data, output, example, metric)
|
|
367
|
+
else:
|
|
368
|
+
continue
|
|
369
|
+
if not metric_result:
|
|
370
|
+
result["errors"][metric_id] = "Scoring function returned None"
|
|
371
|
+
continue
|
|
372
|
+
result["scores"][metric_id] = metric_result.get("score")
|
|
373
|
+
result["messages"][metric_id] = metric_result.get("message")
|
|
374
|
+
result["errors"][metric_id] = metric_result.get("error")
|
|
375
|
+
result["scores"]["duration"] = duration
|
|
376
|
+
await flush_tracing()
|
|
377
|
+
print(f"Call scoreAndStore ... for example: {example_id} with scores: {result['scores']}")
|
|
378
|
+
result = await self.score_and_store(example, output, result)
|
|
379
|
+
print(f"scoreAndStore returned: {result}")
|
|
380
|
+
return [result]
|
|
381
|
+
finally:
|
|
382
|
+
for key, original_value in original_env_vars.items():
|
|
383
|
+
if original_value is None:
|
|
384
|
+
os.environ.pop(key, None)
|
|
385
|
+
else:
|
|
386
|
+
os.environ[key] = original_value
|
|
387
|
+
|
|
388
|
+
def get_summaries(self) -> Dict[str, Any]:
|
|
425
389
|
"""
|
|
426
|
-
Get
|
|
390
|
+
Get summaries from the experiment.
|
|
427
391
|
|
|
428
392
|
Returns:
|
|
429
393
|
Dictionary of metric names to summary statistics
|
|
@@ -435,12 +399,12 @@ class ExperimentRunner:
|
|
|
435
399
|
f"{self.server_url}/experiment/{self.experiment_id}",
|
|
436
400
|
headers=self._get_headers(),
|
|
437
401
|
)
|
|
438
|
-
|
|
402
|
+
|
|
439
403
|
if not response.ok:
|
|
440
404
|
raise Exception(format_http_error(response, "fetch summary results"))
|
|
441
405
|
|
|
442
406
|
experiment2 = response.json()
|
|
443
|
-
return experiment2.get("
|
|
407
|
+
return experiment2.get("summaries", {})
|
|
444
408
|
|
|
445
409
|
async def _score_llm_metric(
|
|
446
410
|
self,
|
|
@@ -471,7 +435,8 @@ class ExperimentRunner:
|
|
|
471
435
|
model_id, self.server_url, self._get_headers()
|
|
472
436
|
)
|
|
473
437
|
if model_data:
|
|
474
|
-
|
|
438
|
+
# Server returns 'apiKey' (camelCase)
|
|
439
|
+
api_key = model_data.get("apiKey")
|
|
475
440
|
# If provider not set in metric, try to get it from model
|
|
476
441
|
if not provider and model_data.get("provider"):
|
|
477
442
|
provider = model_data.get("provider")
|
|
@@ -52,14 +52,15 @@ async def get_model_from_server(
|
|
|
52
52
|
try:
|
|
53
53
|
def _do_request():
|
|
54
54
|
return requests.get(
|
|
55
|
-
f"{server_url}/model/{model_id}?fields=
|
|
55
|
+
f"{server_url}/model/{model_id}?fields=apiKey", # Server uses camelCase 'apiKey' (also accepts 'api_key')
|
|
56
56
|
headers=headers,
|
|
57
57
|
)
|
|
58
58
|
|
|
59
59
|
response = await asyncio.to_thread(_do_request)
|
|
60
60
|
if response.ok:
|
|
61
61
|
model = response.json()
|
|
62
|
-
|
|
62
|
+
# Server returns 'apiKey' (camelCase)
|
|
63
|
+
if model.get("apiKey"):
|
|
63
64
|
return model
|
|
64
65
|
return None
|
|
65
66
|
except Exception as e:
|
|
@@ -25,7 +25,7 @@ def sanitize_string_for_utf8(text: str) -> str:
|
|
|
25
25
|
Returns:
|
|
26
26
|
A string with surrogate characters replaced by the Unicode replacement character (U+FFFD)
|
|
27
27
|
"""
|
|
28
|
-
if text
|
|
28
|
+
if text is None:
|
|
29
29
|
return None
|
|
30
30
|
if not isinstance(text, str): # paranoia
|
|
31
31
|
text = str(text)
|
|
@@ -43,7 +43,10 @@ def toNumber(value: str|int|None) -> int:
|
|
|
43
43
|
if value is None:
|
|
44
44
|
return 0
|
|
45
45
|
if isinstance(value, int):
|
|
46
|
-
return value
|
|
46
|
+
return value
|
|
47
|
+
# Convert to string if not already
|
|
48
|
+
if not isinstance(value, str):
|
|
49
|
+
value = str(value)
|
|
47
50
|
if value.endswith("b"): # drop the b
|
|
48
51
|
value = value[:-1]
|
|
49
52
|
if value.endswith("g"):
|
|
@@ -47,16 +47,16 @@ class TracingOptions:
|
|
|
47
47
|
|
|
48
48
|
ignore_input: Iterable of keys (e.g., list, set) to exclude from
|
|
49
49
|
input data when recording span attributes. Applies after filter_input if both are set.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
or `["_*", "password"]` to exclude sensitive fields from being traced.
|
|
50
|
+
Supports "self" and simple wildcards (e.g., `"_*"`
|
|
51
|
+
matches `"_apple"`, `"_fruit"`). The pattern `"_*"` is applied by default
|
|
52
|
+
to filter properties starting with '_' in nested objects.
|
|
54
53
|
|
|
55
54
|
ignore_output: Iterable of keys (e.g., list, set) to exclude from
|
|
56
55
|
output data when recording span attributes. Only applies when
|
|
57
56
|
output is a dictionary. Supports simple wildcards (e.g., `"_*"`
|
|
58
|
-
matches `"_apple"`, `"_fruit"`).
|
|
59
|
-
|
|
57
|
+
matches `"_apple"`, `"_fruit"`). The pattern `"_*"` is applied by default
|
|
58
|
+
to filter properties starting with '_' in nested objects. Useful for excluding
|
|
59
|
+
large or sensitive fields from traces.
|
|
60
60
|
|
|
61
61
|
filter_input: Callable function that receives the same arguments as the
|
|
62
62
|
decorated function (*args, **kwargs) and returns a filtered/transformed
|
|
@@ -96,7 +96,7 @@ class TracingOptions:
|
|
|
96
96
|
filter_input=lambda self, example: {
|
|
97
97
|
"dataset": self.dataset_id,
|
|
98
98
|
"experiment": self.experiment_id,
|
|
99
|
-
"
|
|
99
|
+
"example": example.id if hasattr(example, 'id') else None
|
|
100
100
|
}
|
|
101
101
|
)
|
|
102
102
|
def run_example(self, example):
|
|
@@ -168,33 +168,89 @@ def _prepare_input(args: tuple, kwargs: dict, sig: Optional[inspect.Signature] =
|
|
|
168
168
|
return result
|
|
169
169
|
|
|
170
170
|
|
|
171
|
-
def _apply_ignore_patterns(
|
|
171
|
+
def _apply_ignore_patterns(
|
|
172
|
+
data_dict: dict,
|
|
173
|
+
ignore_patterns: Optional[List[str]],
|
|
174
|
+
recursive: bool = True,
|
|
175
|
+
max_depth: int = 100,
|
|
176
|
+
current_depth: int = 0
|
|
177
|
+
) -> dict:
|
|
172
178
|
"""
|
|
173
|
-
Apply ignore patterns to a dict.
|
|
179
|
+
Apply ignore patterns to a dict, optionally recursively.
|
|
174
180
|
Supports string keys, wildcard patterns (*), and list of patterns.
|
|
175
181
|
Used for both ignore_input and ignore_output.
|
|
176
182
|
|
|
177
183
|
Args:
|
|
178
|
-
data_dict: Dictionary to filter
|
|
184
|
+
data_dict: Dictionary to filter (may contain nested dictionaries)
|
|
179
185
|
ignore_patterns: List of patterns to exclude (e.g., ["self", "_*", "password"])
|
|
186
|
+
recursive: Whether to apply patterns recursively to nested dictionaries
|
|
187
|
+
max_depth: Maximum recursion depth to prevent infinite loops (default: 100)
|
|
188
|
+
current_depth: Current recursion depth (internal use)
|
|
180
189
|
|
|
181
190
|
Returns:
|
|
182
191
|
Filtered dictionary with matching keys removed
|
|
183
192
|
"""
|
|
184
|
-
if not
|
|
193
|
+
if not isinstance(data_dict, dict):
|
|
185
194
|
return data_dict
|
|
186
195
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
# Safety check: prevent infinite loops from extremely deep nesting
|
|
197
|
+
if current_depth >= max_depth:
|
|
198
|
+
logger.warning(
|
|
199
|
+
f"_apply_ignore_patterns: max depth {max_depth} reached, "
|
|
200
|
+
f"stopping recursion to prevent infinite loop"
|
|
201
|
+
)
|
|
202
|
+
return data_dict
|
|
203
|
+
|
|
204
|
+
# If no patterns, return copy (no filtering needed, even if recursive=True)
|
|
205
|
+
if not ignore_patterns:
|
|
206
|
+
return data_dict.copy()
|
|
207
|
+
|
|
208
|
+
result = {}
|
|
209
|
+
for key, value in data_dict.items():
|
|
210
|
+
# Skip keys that match ignore patterns
|
|
211
|
+
if _matches_ignore_pattern(key, ignore_patterns):
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Recursively process nested dictionaries if recursive=True
|
|
215
|
+
if recursive and isinstance(value, dict):
|
|
216
|
+
result[key] = _apply_ignore_patterns(
|
|
217
|
+
value, ignore_patterns, recursive, max_depth, current_depth + 1
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
result[key] = value
|
|
194
221
|
|
|
195
222
|
return result
|
|
196
223
|
|
|
197
224
|
|
|
225
|
+
def _merge_with_default_ignore_patterns(
|
|
226
|
+
ignore_patterns: Optional[List[str]],
|
|
227
|
+
client: Optional[Any] = None
|
|
228
|
+
) -> List[str]:
|
|
229
|
+
"""
|
|
230
|
+
Merge user-provided ignore patterns with client's default ignore patterns.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
ignore_patterns: Optional list of user-provided patterns
|
|
234
|
+
client: Optional client instance (to avoid repeated get_aiqa_client() calls)
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
List of patterns including client's default ignore patterns
|
|
238
|
+
"""
|
|
239
|
+
if client is None:
|
|
240
|
+
client = get_aiqa_client()
|
|
241
|
+
default_patterns = client.default_ignore_patterns
|
|
242
|
+
|
|
243
|
+
if ignore_patterns is None:
|
|
244
|
+
return default_patterns.copy() if default_patterns else []
|
|
245
|
+
|
|
246
|
+
# Merge patterns, avoiding duplicates
|
|
247
|
+
merged = list(default_patterns)
|
|
248
|
+
for pattern in ignore_patterns:
|
|
249
|
+
if pattern not in merged:
|
|
250
|
+
merged.append(pattern)
|
|
251
|
+
return merged
|
|
252
|
+
|
|
253
|
+
|
|
198
254
|
def _prepare_and_filter_input(
|
|
199
255
|
args: tuple,
|
|
200
256
|
kwargs: dict,
|
|
@@ -209,6 +265,7 @@ def _prepare_and_filter_input(
|
|
|
209
265
|
1. Apply filter_input to args, kwargs (receives same inputs as decorated function, including self)
|
|
210
266
|
2. Convert into dict ready for span.attributes.input
|
|
211
267
|
3. Apply ignore_input to the dict (supports string, wildcard, and list patterns)
|
|
268
|
+
Client's default ignore patterns are automatically merged with ignore_input.
|
|
212
269
|
|
|
213
270
|
Args:
|
|
214
271
|
args: Positional arguments (including self for bound methods)
|
|
@@ -218,7 +275,7 @@ def _prepare_and_filter_input(
|
|
|
218
275
|
including `self` for bound methods. This allows extracting properties from any object.
|
|
219
276
|
ignore_input: Optional list of keys/patterns to exclude from the final dict.
|
|
220
277
|
If "self" is in ignore_input, it will be removed from the final dict but filter_input
|
|
221
|
-
still receives it.
|
|
278
|
+
still receives it. Client's default ignore patterns are automatically merged.
|
|
222
279
|
sig: Optional function signature for proper arg name resolution
|
|
223
280
|
|
|
224
281
|
Returns:
|
|
@@ -251,15 +308,23 @@ def _prepare_and_filter_input(
|
|
|
251
308
|
input_data = _prepare_input(args, kwargs, sig)
|
|
252
309
|
|
|
253
310
|
# Step 3: Apply ignore_input to the dict (removes "self" from final dict if specified)
|
|
254
|
-
|
|
311
|
+
# Merge with client's default ignore patterns
|
|
312
|
+
client = get_aiqa_client()
|
|
313
|
+
merged_ignore_input = _merge_with_default_ignore_patterns(ignore_input, client)
|
|
314
|
+
should_ignore_self = "self" in merged_ignore_input
|
|
315
|
+
|
|
255
316
|
if isinstance(input_data, dict):
|
|
256
|
-
input_data = _apply_ignore_patterns(
|
|
317
|
+
input_data = _apply_ignore_patterns(
|
|
318
|
+
input_data,
|
|
319
|
+
merged_ignore_input,
|
|
320
|
+
recursive=client.ignore_recursive
|
|
321
|
+
)
|
|
257
322
|
# Handle case where we removed self and there are no remaining args/kwargs
|
|
258
323
|
if should_ignore_self and not input_data:
|
|
259
324
|
return None
|
|
260
|
-
elif
|
|
261
|
-
# Warn if
|
|
262
|
-
logger.warning(f"_prepare_and_filter_input: skip:
|
|
325
|
+
elif merged_ignore_input:
|
|
326
|
+
# Warn if ignore patterns are set but input_data is not a dict
|
|
327
|
+
logger.warning(f"_prepare_and_filter_input: skip: ignore patterns are set but input_data is not a dict: {type(input_data)}")
|
|
263
328
|
|
|
264
329
|
return input_data
|
|
265
330
|
|
|
@@ -269,7 +334,10 @@ def _filter_and_serialize_output(
|
|
|
269
334
|
filter_output: Optional[Callable[[Any], Any]],
|
|
270
335
|
ignore_output: Optional[List[str]],
|
|
271
336
|
) -> Any:
|
|
272
|
-
"""
|
|
337
|
+
"""
|
|
338
|
+
Filter and serialize output for span attributes.
|
|
339
|
+
Client's default ignore patterns are automatically merged with ignore_output.
|
|
340
|
+
"""
|
|
273
341
|
output_data = result
|
|
274
342
|
if filter_output:
|
|
275
343
|
if isinstance(output_data, dict):
|
|
@@ -277,11 +345,19 @@ def _filter_and_serialize_output(
|
|
|
277
345
|
output_data = filter_output(output_data)
|
|
278
346
|
|
|
279
347
|
# Apply ignore_output patterns (supports key, wildcard, and list patterns)
|
|
348
|
+
# Merge with client's default ignore patterns
|
|
349
|
+
client = get_aiqa_client()
|
|
350
|
+
merged_ignore_output = _merge_with_default_ignore_patterns(ignore_output, client)
|
|
351
|
+
|
|
280
352
|
if isinstance(output_data, dict):
|
|
281
|
-
output_data = _apply_ignore_patterns(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
353
|
+
output_data = _apply_ignore_patterns(
|
|
354
|
+
output_data,
|
|
355
|
+
merged_ignore_output,
|
|
356
|
+
recursive=client.ignore_recursive
|
|
357
|
+
)
|
|
358
|
+
elif merged_ignore_output:
|
|
359
|
+
# Warn if ignore patterns are set but output_data is not a dict
|
|
360
|
+
logger.warning(f"_filter_and_serialize_output: skip: ignore patterns are set but output_data is not a dict: {type(output_data)}")
|
|
285
361
|
|
|
286
362
|
# Serialize immediately to create immutable result (removes mutable structures)
|
|
287
363
|
return serialize_for_span(output_data)
|
|
@@ -500,12 +576,14 @@ def WithTracing(
|
|
|
500
576
|
ignore_input: List of keys to exclude from input data when recording span attributes.
|
|
501
577
|
self is handled as "self"
|
|
502
578
|
Supports simple wildcards (e.g., "_*"
|
|
503
|
-
matches "_apple", "_fruit").
|
|
504
|
-
|
|
579
|
+
matches "_apple", "_fruit"). The pattern "_*" is applied by default
|
|
580
|
+
to filter properties starting with '_' in nested objects. For example, use
|
|
581
|
+
["password", "api_key"] to exclude additional sensitive fields from being traced.
|
|
505
582
|
ignore_output: List of keys to exclude from output data when recording span attributes.
|
|
506
583
|
Only applies when output is a dictionary. Supports simple wildcards (e.g., "_*"
|
|
507
|
-
matches "_apple", "_fruit").
|
|
508
|
-
|
|
584
|
+
matches "_apple", "_fruit"). The pattern "_*" is applied by default
|
|
585
|
+
to filter properties starting with '_' in nested objects. Useful for excluding
|
|
586
|
+
large or sensitive fields from traces.
|
|
509
587
|
filter_input: Function to filter/transform input before recording.
|
|
510
588
|
Receives the same arguments as the decorated function (*args, **kwargs),
|
|
511
589
|
including `self` for bound methods. This allows you to extract specific
|
|
@@ -678,7 +756,8 @@ def WithTracing(
|
|
|
678
756
|
# This is called lazily when the function runs, not at decorator definition time
|
|
679
757
|
client = get_aiqa_client()
|
|
680
758
|
if not client.enabled:
|
|
681
|
-
|
|
759
|
+
# executor() returns an async generator object, not a coroutine, so don't await it
|
|
760
|
+
return executor()
|
|
682
761
|
|
|
683
762
|
# Get tracer after initialization (lazy)
|
|
684
763
|
tracer = get_aiqa_tracer()
|
|
@@ -29,7 +29,7 @@ class MetricResult(TypedDict):
|
|
|
29
29
|
|
|
30
30
|
class Result(TypedDict):
|
|
31
31
|
"""Result of evaluating a set of metrics on an output (i.e. the full set of metrics for a single example)."""
|
|
32
|
-
|
|
32
|
+
example: str
|
|
33
33
|
scores: Dict[str, Number]
|
|
34
34
|
messages: Optional[Dict[str, str]] = None
|
|
35
35
|
errors: Optional[Dict[str, str]] = None
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "aiqa-client"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "OpenTelemetry-based Python client for tracing functions and sending traces to the AIQA server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -8,7 +8,7 @@ import pytest
|
|
|
8
8
|
from unittest.mock import patch, MagicMock
|
|
9
9
|
from aiqa.span_helpers import get_span
|
|
10
10
|
from aiqa.tracing import _prepare_input, _prepare_and_filter_input, _filter_and_serialize_output
|
|
11
|
-
from aiqa.tracing import _matches_ignore_pattern
|
|
11
|
+
from aiqa.tracing import _matches_ignore_pattern, _apply_ignore_patterns, _merge_with_default_ignore_patterns
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class TestGetSpan:
|
|
@@ -796,3 +796,367 @@ class TestFilterInputWithSelf:
|
|
|
796
796
|
assert "_trace_me_not" not in result # filtered by wildcard
|
|
797
797
|
assert "arg2" in result
|
|
798
798
|
assert "trace_me_yes" in result
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
class TestDefaultUnderscoreIgnore:
|
|
802
|
+
"""Tests for default '_*' pattern that filters properties starting with '_'."""
|
|
803
|
+
|
|
804
|
+
def test_default_underscore_ignore_input(self):
|
|
805
|
+
"""Test that '_*' pattern is applied by default to ignore_input."""
|
|
806
|
+
def test_func(user: dict, x: int):
|
|
807
|
+
pass
|
|
808
|
+
|
|
809
|
+
sig = inspect.signature(test_func)
|
|
810
|
+
user_data = {"name": "Alice", "_sa_instance_state": "object"}
|
|
811
|
+
result = _prepare_and_filter_input((user_data, 5), {}, None, None, sig)
|
|
812
|
+
|
|
813
|
+
assert isinstance(result, dict)
|
|
814
|
+
assert "user" in result
|
|
815
|
+
user_result = result["user"]
|
|
816
|
+
assert isinstance(user_result, dict)
|
|
817
|
+
assert "name" in user_result
|
|
818
|
+
assert user_result["name"] == "Alice"
|
|
819
|
+
assert "_sa_instance_state" not in user_result # filtered by default '_*' pattern
|
|
820
|
+
assert "x" in result
|
|
821
|
+
|
|
822
|
+
def test_default_underscore_ignore_output(self):
|
|
823
|
+
"""Test that '_*' pattern is applied by default to ignore_output."""
|
|
824
|
+
output_data = {
|
|
825
|
+
"user": {
|
|
826
|
+
"name": "Alice",
|
|
827
|
+
"_sa_instance_state": "object"
|
|
828
|
+
},
|
|
829
|
+
"result": "success"
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
result = _filter_and_serialize_output(output_data, None, None)
|
|
833
|
+
|
|
834
|
+
# Result is serialized to JSON string for dicts
|
|
835
|
+
assert isinstance(result, str)
|
|
836
|
+
|
|
837
|
+
# Parse it back to verify filtering worked
|
|
838
|
+
import json
|
|
839
|
+
parsed = json.loads(result)
|
|
840
|
+
assert "user" in parsed
|
|
841
|
+
assert "name" in parsed["user"]
|
|
842
|
+
assert parsed["user"]["name"] == "Alice"
|
|
843
|
+
assert "_sa_instance_state" not in parsed["user"] # filtered by default '_*' pattern
|
|
844
|
+
assert "result" in parsed
|
|
845
|
+
|
|
846
|
+
def test_nested_underscore_filtering(self):
|
|
847
|
+
"""Test that nested objects are filtered recursively."""
|
|
848
|
+
data = {
|
|
849
|
+
"level1": {
|
|
850
|
+
"public": "value1",
|
|
851
|
+
"_private": "value2",
|
|
852
|
+
"nested": {
|
|
853
|
+
"public": "value3",
|
|
854
|
+
"_private": "value4"
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
"_top_level": "value5"
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
result = _apply_ignore_patterns(data, None)
|
|
861
|
+
|
|
862
|
+
assert "level1" in result
|
|
863
|
+
assert "public" in result["level1"]
|
|
864
|
+
assert "_private" not in result["level1"]
|
|
865
|
+
assert "nested" in result["level1"]
|
|
866
|
+
assert "public" in result["level1"]["nested"]
|
|
867
|
+
assert "_private" not in result["level1"]["nested"]
|
|
868
|
+
assert "_top_level" not in result
|
|
869
|
+
|
|
870
|
+
def test_merge_with_default_patterns(self):
|
|
871
|
+
"""Test that user-provided patterns are merged with default '_*' pattern."""
|
|
872
|
+
user_patterns = ["password", "api_key"]
|
|
873
|
+
merged = _merge_with_default_ignore_patterns(user_patterns)
|
|
874
|
+
|
|
875
|
+
assert "_*" in merged
|
|
876
|
+
assert "password" in merged
|
|
877
|
+
assert "api_key" in merged
|
|
878
|
+
assert len(merged) == 3
|
|
879
|
+
|
|
880
|
+
def test_merge_with_default_patterns_none(self):
|
|
881
|
+
"""Test that None patterns result in just default '_*' pattern."""
|
|
882
|
+
merged = _merge_with_default_ignore_patterns(None)
|
|
883
|
+
|
|
884
|
+
assert merged == ["_*"]
|
|
885
|
+
|
|
886
|
+
def test_merge_with_default_patterns_duplicate(self):
|
|
887
|
+
"""Test that duplicate patterns are not added."""
|
|
888
|
+
user_patterns = ["_*", "password"]
|
|
889
|
+
merged = _merge_with_default_ignore_patterns(user_patterns)
|
|
890
|
+
|
|
891
|
+
assert merged == ["_*", "password"] # _* is first, password is second
|
|
892
|
+
assert merged.count("_*") == 1
|
|
893
|
+
|
|
894
|
+
def test_default_underscore_with_custom_patterns(self):
|
|
895
|
+
"""Test that default '_*' works alongside custom patterns."""
|
|
896
|
+
def test_func(user: dict, password: str, x: int):
|
|
897
|
+
pass
|
|
898
|
+
|
|
899
|
+
sig = inspect.signature(test_func)
|
|
900
|
+
user_data = {"name": "Alice", "_sa_instance_state": "object"}
|
|
901
|
+
result = _prepare_and_filter_input(
|
|
902
|
+
(user_data, "secret", 5),
|
|
903
|
+
{},
|
|
904
|
+
None,
|
|
905
|
+
["password"], # Only specify password, '_*' should be added automatically
|
|
906
|
+
sig
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
assert isinstance(result, dict)
|
|
910
|
+
assert "user" in result
|
|
911
|
+
user_result = result["user"]
|
|
912
|
+
assert "name" in user_result
|
|
913
|
+
assert "_sa_instance_state" not in user_result # filtered by default '_*'
|
|
914
|
+
assert "password" not in result # filtered by custom pattern
|
|
915
|
+
assert "x" in result
|
|
916
|
+
|
|
917
|
+
def test_nested_underscore_in_output(self):
|
|
918
|
+
"""Test nested underscore filtering in output."""
|
|
919
|
+
output_data = {
|
|
920
|
+
"result": "success",
|
|
921
|
+
"user": {
|
|
922
|
+
"name": "Alice",
|
|
923
|
+
"_sa_instance_state": "object",
|
|
924
|
+
"profile": {
|
|
925
|
+
"email": "alice@example.com",
|
|
926
|
+
"_internal_id": "12345"
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
result = _filter_and_serialize_output(output_data, None, None)
|
|
932
|
+
|
|
933
|
+
import json
|
|
934
|
+
parsed = json.loads(result)
|
|
935
|
+
assert "user" in parsed
|
|
936
|
+
assert "name" in parsed["user"]
|
|
937
|
+
assert "_sa_instance_state" not in parsed["user"]
|
|
938
|
+
assert "profile" in parsed["user"]
|
|
939
|
+
assert "email" in parsed["user"]["profile"]
|
|
940
|
+
assert "_internal_id" not in parsed["user"]["profile"]
|
|
941
|
+
|
|
942
|
+
def test_apply_ignore_patterns_with_none(self):
|
|
943
|
+
"""Test that _apply_ignore_patterns handles None patterns by recursively processing nested dicts."""
|
|
944
|
+
data = {
|
|
945
|
+
"public": "value",
|
|
946
|
+
"_private": "hidden",
|
|
947
|
+
"nested": {
|
|
948
|
+
"public": "value2",
|
|
949
|
+
"_private": "hidden2"
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
result = _apply_ignore_patterns(data, None)
|
|
954
|
+
|
|
955
|
+
# When patterns is None, it should still recursively process nested dicts
|
|
956
|
+
# but won't filter top-level keys (since no patterns provided)
|
|
957
|
+
assert "public" in result
|
|
958
|
+
assert "_private" in result # Not filtered when patterns is None
|
|
959
|
+
assert "nested" in result
|
|
960
|
+
# Nested dicts are still processed recursively (structure preserved)
|
|
961
|
+
assert isinstance(result["nested"], dict)
|
|
962
|
+
assert "public" in result["nested"]
|
|
963
|
+
assert "_private" in result["nested"] # Also not filtered when patterns is None
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
class TestClientIgnoreProperties:
|
|
967
|
+
"""Tests for client default_ignore_patterns and ignore_recursive properties."""
|
|
968
|
+
|
|
969
|
+
def test_default_ignore_patterns_property(self):
|
|
970
|
+
"""Test setting and getting default_ignore_patterns on client."""
|
|
971
|
+
from aiqa import get_aiqa_client
|
|
972
|
+
|
|
973
|
+
client = get_aiqa_client()
|
|
974
|
+
original = client.default_ignore_patterns
|
|
975
|
+
|
|
976
|
+
# Test setting new patterns
|
|
977
|
+
client.default_ignore_patterns = ["_*", "password"]
|
|
978
|
+
assert client.default_ignore_patterns == ["_*", "password"]
|
|
979
|
+
|
|
980
|
+
# Test that it's a copy (modifying returned list doesn't affect client)
|
|
981
|
+
patterns = client.default_ignore_patterns
|
|
982
|
+
patterns.append("new")
|
|
983
|
+
assert client.default_ignore_patterns == ["_*", "password"]
|
|
984
|
+
|
|
985
|
+
# Test setting to None (disables defaults)
|
|
986
|
+
client.default_ignore_patterns = None
|
|
987
|
+
assert client.default_ignore_patterns == []
|
|
988
|
+
|
|
989
|
+
# Test setting to empty list
|
|
990
|
+
client.default_ignore_patterns = []
|
|
991
|
+
assert client.default_ignore_patterns == []
|
|
992
|
+
|
|
993
|
+
# Restore original
|
|
994
|
+
client.default_ignore_patterns = original
|
|
995
|
+
|
|
996
|
+
def test_ignore_recursive_property(self):
|
|
997
|
+
"""Test setting and getting ignore_recursive on client."""
|
|
998
|
+
from aiqa import get_aiqa_client
|
|
999
|
+
|
|
1000
|
+
client = get_aiqa_client()
|
|
1001
|
+
original = client.ignore_recursive
|
|
1002
|
+
|
|
1003
|
+
# Test setting to False
|
|
1004
|
+
client.ignore_recursive = False
|
|
1005
|
+
assert client.ignore_recursive is False
|
|
1006
|
+
|
|
1007
|
+
# Test setting to True
|
|
1008
|
+
client.ignore_recursive = True
|
|
1009
|
+
assert client.ignore_recursive is True
|
|
1010
|
+
|
|
1011
|
+
# Restore original
|
|
1012
|
+
client.ignore_recursive = original
|
|
1013
|
+
|
|
1014
|
+
def test_custom_default_ignore_patterns_used(self):
|
|
1015
|
+
"""Test that custom default ignore patterns are used in tracing."""
|
|
1016
|
+
from aiqa import get_aiqa_client
|
|
1017
|
+
from aiqa.tracing import _prepare_and_filter_input
|
|
1018
|
+
import inspect
|
|
1019
|
+
|
|
1020
|
+
client = get_aiqa_client()
|
|
1021
|
+
original_patterns = client.default_ignore_patterns
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
# Set custom default patterns
|
|
1025
|
+
client.default_ignore_patterns = ["password", "secret"]
|
|
1026
|
+
|
|
1027
|
+
def test_func(password: str, secret: str, public: str):
|
|
1028
|
+
pass
|
|
1029
|
+
|
|
1030
|
+
sig = inspect.signature(test_func)
|
|
1031
|
+
result = _prepare_and_filter_input(
|
|
1032
|
+
("pwd", "sec", "pub"),
|
|
1033
|
+
{},
|
|
1034
|
+
None,
|
|
1035
|
+
None, # No explicit ignore_input
|
|
1036
|
+
sig
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Custom patterns should be applied
|
|
1040
|
+
assert "password" not in result
|
|
1041
|
+
assert "secret" not in result
|
|
1042
|
+
assert "public" in result
|
|
1043
|
+
finally:
|
|
1044
|
+
# Restore original
|
|
1045
|
+
client.default_ignore_patterns = original_patterns
|
|
1046
|
+
|
|
1047
|
+
def test_ignore_recursive_false(self):
|
|
1048
|
+
"""Test that ignore_recursive=False only filters top-level keys."""
|
|
1049
|
+
from aiqa import get_aiqa_client
|
|
1050
|
+
|
|
1051
|
+
client = get_aiqa_client()
|
|
1052
|
+
original_recursive = client.ignore_recursive
|
|
1053
|
+
|
|
1054
|
+
try:
|
|
1055
|
+
client.ignore_recursive = False
|
|
1056
|
+
|
|
1057
|
+
data = {
|
|
1058
|
+
"top_level": "value",
|
|
1059
|
+
"_top_private": "hidden",
|
|
1060
|
+
"nested": {
|
|
1061
|
+
"public": "value2",
|
|
1062
|
+
"_nested_private": "hidden2"
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
result = _apply_ignore_patterns(data, ["_*"], recursive=False)
|
|
1067
|
+
|
|
1068
|
+
# Top-level _* should be filtered
|
|
1069
|
+
assert "_top_private" not in result
|
|
1070
|
+
assert "top_level" in result
|
|
1071
|
+
# Nested dict should be preserved as-is (not filtered recursively)
|
|
1072
|
+
assert "nested" in result
|
|
1073
|
+
assert "_nested_private" in result["nested"] # Not filtered when recursive=False
|
|
1074
|
+
assert "public" in result["nested"]
|
|
1075
|
+
finally:
|
|
1076
|
+
client.ignore_recursive = original_recursive
|
|
1077
|
+
|
|
1078
|
+
def test_ignore_recursive_true(self):
|
|
1079
|
+
"""Test that ignore_recursive=True filters nested keys."""
|
|
1080
|
+
from aiqa import get_aiqa_client
|
|
1081
|
+
|
|
1082
|
+
client = get_aiqa_client()
|
|
1083
|
+
original_recursive = client.ignore_recursive
|
|
1084
|
+
|
|
1085
|
+
try:
|
|
1086
|
+
client.ignore_recursive = True
|
|
1087
|
+
|
|
1088
|
+
data = {
|
|
1089
|
+
"top_level": "value",
|
|
1090
|
+
"_top_private": "hidden",
|
|
1091
|
+
"nested": {
|
|
1092
|
+
"public": "value2",
|
|
1093
|
+
"_nested_private": "hidden2"
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
result = _apply_ignore_patterns(data, ["_*"], recursive=True)
|
|
1098
|
+
|
|
1099
|
+
# Top-level _* should be filtered
|
|
1100
|
+
assert "_top_private" not in result
|
|
1101
|
+
assert "top_level" in result
|
|
1102
|
+
# Nested dict should also be filtered
|
|
1103
|
+
assert "nested" in result
|
|
1104
|
+
assert "_nested_private" not in result["nested"] # Filtered when recursive=True
|
|
1105
|
+
assert "public" in result["nested"]
|
|
1106
|
+
finally:
|
|
1107
|
+
client.ignore_recursive = original_recursive
|
|
1108
|
+
|
|
1109
|
+
def test_max_depth_prevents_infinite_loop(self):
|
|
1110
|
+
"""Test that max_depth prevents infinite loops from deep nesting."""
|
|
1111
|
+
# Create a deeply nested structure
|
|
1112
|
+
data = {"level": 0}
|
|
1113
|
+
current = data
|
|
1114
|
+
for i in range(150): # Exceeds default max_depth of 100
|
|
1115
|
+
current["nested"] = {"level": i + 1}
|
|
1116
|
+
current = current["nested"]
|
|
1117
|
+
|
|
1118
|
+
# Should not raise exception or hang
|
|
1119
|
+
result = _apply_ignore_patterns(data, ["_*"], recursive=True, max_depth=100)
|
|
1120
|
+
|
|
1121
|
+
# Should return something (may be truncated at max_depth)
|
|
1122
|
+
assert isinstance(result, dict)
|
|
1123
|
+
assert "level" in result
|
|
1124
|
+
|
|
1125
|
+
def test_client_ignore_recursive_affects_tracing(self):
|
|
1126
|
+
"""Test that client.ignore_recursive setting affects actual tracing."""
|
|
1127
|
+
from aiqa import get_aiqa_client
|
|
1128
|
+
from aiqa.tracing import _filter_and_serialize_output
|
|
1129
|
+
|
|
1130
|
+
client = get_aiqa_client()
|
|
1131
|
+
original_recursive = client.ignore_recursive
|
|
1132
|
+
original_patterns = client.default_ignore_patterns
|
|
1133
|
+
|
|
1134
|
+
try:
|
|
1135
|
+
client.default_ignore_patterns = ["_*"]
|
|
1136
|
+
|
|
1137
|
+
output_data = {
|
|
1138
|
+
"top": "value",
|
|
1139
|
+
"_top_private": "hidden",
|
|
1140
|
+
"nested": {
|
|
1141
|
+
"public": "value2",
|
|
1142
|
+
"_nested_private": "hidden2"
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
# Test with recursive=True (default)
|
|
1147
|
+
client.ignore_recursive = True
|
|
1148
|
+
result_recursive = _filter_and_serialize_output(output_data, None, None)
|
|
1149
|
+
import json
|
|
1150
|
+
parsed_recursive = json.loads(result_recursive)
|
|
1151
|
+
assert "_top_private" not in parsed_recursive
|
|
1152
|
+
assert "_nested_private" not in parsed_recursive["nested"] # Filtered recursively
|
|
1153
|
+
|
|
1154
|
+
# Test with recursive=False
|
|
1155
|
+
client.ignore_recursive = False
|
|
1156
|
+
result_non_recursive = _filter_and_serialize_output(output_data, None, None)
|
|
1157
|
+
parsed_non_recursive = json.loads(result_non_recursive)
|
|
1158
|
+
assert "_top_private" not in parsed_non_recursive
|
|
1159
|
+
assert "_nested_private" in parsed_non_recursive["nested"] # Not filtered when recursive=False
|
|
1160
|
+
finally:
|
|
1161
|
+
client.ignore_recursive = original_recursive
|
|
1162
|
+
client.default_ignore_patterns = original_patterns
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|