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.
Files changed (30) hide show
  1. {aiqa_client-0.6.1/aiqa_client.egg-info → aiqa_client-0.7.0}/PKG-INFO +1 -1
  2. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/client.py +74 -4
  3. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/constants.py +1 -1
  4. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/experiment_runner.py +73 -108
  5. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/llm_as_judge.py +3 -2
  6. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/object_serialiser.py +5 -2
  7. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/tracing.py +113 -34
  8. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/types.py +1 -1
  9. {aiqa_client-0.6.1 → aiqa_client-0.7.0/aiqa_client.egg-info}/PKG-INFO +1 -1
  10. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/pyproject.toml +1 -1
  11. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_tracing.py +365 -1
  12. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/LICENSE.txt +0 -0
  13. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/MANIFEST.in +0 -0
  14. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/README.md +0 -0
  15. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/__init__.py +0 -0
  16. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/http_utils.py +0 -0
  17. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/py.typed +0 -0
  18. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/span_helpers.py +0 -0
  19. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa/tracing_llm_utils.py +0 -0
  20. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/SOURCES.txt +0 -0
  21. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/dependency_links.txt +0 -0
  22. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/requires.txt +0 -0
  23. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/aiqa_client.egg-info/top_level.txt +0 -0
  24. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/setup.cfg +0 -0
  25. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_chatbot.py +0 -0
  26. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_integration.py +0 -0
  27. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_integration_api_key.py +0 -0
  28. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_object_serialiser.py +0 -0
  29. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_span_helpers.py +0 -0
  30. {aiqa_client-0.6.1 → aiqa_client-0.7.0}/tests/test_startup_reliability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiqa-client
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: OpenTelemetry-based Python client for tracing functions and sending traces to the AIQA server
5
5
  Author-email: AIQA <info@aiqa.dev>
6
6
  License: MIT
@@ -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, SpanExporter as SpanExporterBase
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.1" # automatically updated by set-version-json.sh
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 get_example_inputs(self, limit: int = 10000) -> List[Dict[str, Any]]:
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
- "summary_results": {},
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 = example.get("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("traceId"),
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.get_example_inputs()
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 given parameters (looping over comparison parameters),
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
- One set of scores for each comparison parameter set. If no comparison parameters,
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
- # Make the parameters
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
- try:
366
- start = time.time() * 1000 # milliseconds
367
- output = call_my_code(input_data, parameters_here)
368
- # Handle async functions
369
- if hasattr(output, "__await__"):
370
- output = await output
371
- end = time.time() * 1000 # milliseconds
372
- duration = int(end - start)
373
-
374
- print(f"Output: {output}")
375
- # Score it
376
- result = Result(exampleId=example_id, scores={}, messages={}, errors={})
377
- for metric in metrics:
378
- metric_id = metric.get("id")
379
- if not metric_id:
380
- print(f"Warning: Metric missing 'id' field, skipping: {metric}")
381
- continue
382
- scorer = scorer_for_metric_id.get(metric_id) if scorer_for_metric_id else None
383
- if scorer:
384
- metric_result = await scorer(input_data, output, metric)
385
- elif metric.get("type") == "llm":
386
- metric_result = await self._score_llm_metric(input_data, output, example, metric)
387
- else:
388
- metric_type = metric.get("type", "unknown")
389
- print(f"Skipping metric: {metric_id} {metric_type} - no scorer")
390
- continue
391
-
392
- # Handle None metric_result (e.g., if scoring failed)
393
- if not metric_result:
394
- print(f"Warning: Metric {metric_id} returned None result, skipping")
395
- result["errors"][metric_id] = "Scoring function returned None"
396
- continue
397
-
398
- result["scores"][metric_id] = metric_result.get("score")
399
- result["messages"][metric_id] = metric_result.get("message")
400
- result["errors"][metric_id] = metric_result.get("error")
401
- # Always add duration to scores as a system metric
402
- result["scores"]["duration"] = duration
403
-
404
- # Flush spans before scoreAndStore to ensure they're indexed in ES
405
- # This prevents race condition where scoreAndStore looks up spans before they're indexed
406
- await flush_tracing()
407
-
408
- print(f"Call scoreAndStore ... for example: {example_id} with scores: {result['scores']}")
409
- result = await self.score_and_store(example, output, result)
410
- print(f"scoreAndStore returned: {result}")
411
- all_scores.append(result)
412
- finally:
413
- # Restore original env var values
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 summary results from the experiment.
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("summary_results", {})
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
- api_key = model_data.get("api_key")
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=api_key",
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
- if model.get("api_key"):
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 == None:
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
- Only applies when
51
- input is a dictionary. Supports simple wildcards (e.g., `"_*"`
52
- matches `"_apple"`, `"_fruit"`). For example, use `["password", "api_key"]`
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"`). Useful for excluding large or sensitive
59
- fields from traces.
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
- "example_id": example.id if hasattr(example, 'id') else None
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(data_dict: dict, ignore_patterns: Optional[List[str]]) -> dict:
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 ignore_patterns or not isinstance(data_dict, dict):
193
+ if not isinstance(data_dict, dict):
185
194
  return data_dict
186
195
 
187
- result = data_dict.copy()
188
- keys_to_delete = [
189
- key for key in result.keys()
190
- if _matches_ignore_pattern(key, ignore_patterns)
191
- ]
192
- for key in keys_to_delete:
193
- del result[key]
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
- should_ignore_self = ignore_input and "self" in ignore_input
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(input_data, ignore_input)
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 ignore_input:
261
- # Warn if ignore_input is set but input_data is not a dict
262
- logger.warning(f"_prepare_and_filter_input: skip: ignore_input is set but input_data is not a dict: {type(input_data)}")
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
- """Filter and serialize output for span attributes."""
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(output_data, ignore_output)
282
- elif ignore_output:
283
- # Warn if ignore_output is set but output_data is not a dict
284
- logger.warning(f"_filter_and_serialize_output: skip: ignore_output is set but output_data is not a dict: {type(output_data)}")
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"). For example, use ["password", "api_key"] or
504
- ["_*", "password"] to exclude sensitive fields from being traced.
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"). Useful for excluding large or sensitive
508
- fields from traces.
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
- return await executor()
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
- exampleId: str
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiqa-client
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: OpenTelemetry-based Python client for tracing functions and sending traces to the AIQA server
5
5
  Author-email: AIQA <info@aiqa.dev>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiqa-client"
7
- version = "0.6.1"
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