spl2-testing-framework 1.7.2__tar.gz → 1.7.3__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 (35) hide show
  1. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/PKG-INFO +1 -1
  2. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/pyproject.toml +1 -1
  3. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/results.py +26 -9
  4. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/spl2test_runner.py +49 -12
  5. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/test_discovery.py +3 -3
  6. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/utils.py +47 -20
  7. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/LICENSE +0 -0
  8. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/README.md +0 -0
  9. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/__init__.py +0 -0
  10. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/conftest.py +0 -0
  11. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
  12. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/logger_manager.py +0 -0
  13. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/pytest.ini.sample +0 -0
  14. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
  15. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/spl2_test_config.json +0 -0
  16. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
  17. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
  18. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/test_runner.py +0 -0
  19. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/__init__.py +0 -0
  20. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
  21. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
  22. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/cli_job.py +0 -0
  23. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
  24. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/job.py +0 -0
  25. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
  26. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
  27. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/performance.py +0 -0
  28. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
  29. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/cli_search_client.py +0 -0
  30. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
  31. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
  32. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
  33. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/splunk_integration/__init__.py +0 -0
  34. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +0 -0
  35. {spl2_testing_framework-1.7.2 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spl2-testing-framework
3
- Version: 1.7.2
3
+ Version: 1.7.3
4
4
  Summary: SPL2 Testing Framework
5
5
  Home-page: https://github.com/splunk/spl2-testing-framework
6
6
  Keywords: SPL2 testing framework
@@ -15,7 +15,7 @@
15
15
 
16
16
  [tool.poetry]
17
17
  name = "spl2-testing-framework"
18
- version = "1.7.2"
18
+ version = "1.7.3"
19
19
  description = "SPL2 Testing Framework"
20
20
  authors = ["mgazda-splunk <mgazda@splunk.com>"]
21
21
  readme = "README.md"
@@ -13,12 +13,11 @@
13
13
  # limitations under the License.
14
14
 
15
15
 
16
+ import ast
16
17
  import json
17
18
  from collections import UserDict
18
19
  from datetime import datetime, timezone
19
- from os import remove
20
20
  from typing import Any
21
- import pytest
22
21
 
23
22
 
24
23
  class Results(UserDict):
@@ -38,16 +37,34 @@ class Results(UserDict):
38
37
  self._remove_timestamps_if_necessary()
39
38
  self._remove_all_empty_strings_from_dicts_if_necessary()
40
39
 
40
+ @staticmethod
41
+ def _coerce_string_if_json_or_python_literal(value: str) -> Any:
42
+ """If ``value`` looks like JSON or a Python ``list``/``dict`` literal, parse it.
43
+
44
+ Test JSON and Splunk sometimes embed ``list``/``dict`` as strings; JSON only
45
+ accepts double quotes, so single-quoted Python reprs need ``literal_eval``.
46
+ """
47
+ candidate = value.strip()
48
+ if not (candidate.startswith("[") or candidate.startswith("{")):
49
+ return value
50
+ try:
51
+ return json.loads(candidate)
52
+ except Exception:
53
+ try:
54
+ parsed = ast.literal_eval(candidate)
55
+ except (ValueError, SyntaxError):
56
+ return value
57
+ if isinstance(parsed, (list, dict)):
58
+ return parsed
59
+ return value
60
+
41
61
  @staticmethod
42
62
  def _cast_types(data: Any):
43
63
  """Casts everything into dicts / lists / strings.
44
64
  This is necessary because Splunk, IP and CLI often return different types of data.
45
65
  """
46
- if isinstance(data, str) and (data.startswith("[") or data.startswith("{")):
47
- try:
48
- data = json.loads(data)
49
- except Exception:
50
- pass # If it fails, it's not a json, so it's just a normal string
66
+ if isinstance(data, str):
67
+ data = Results._coerce_string_if_json_or_python_literal(data)
51
68
 
52
69
  if isinstance(data, list):
53
70
  data = [Results._cast_types(item) for item in data]
@@ -55,7 +72,7 @@ class Results(UserDict):
55
72
  for key, value in data.items():
56
73
  data[key] = Results._cast_types(value)
57
74
  if key == "_time":
58
- data[key] = Results._convert_timestamp(value)
75
+ data[key] = Results._convert_timestamp(data[key])
59
76
  if isinstance(data, (int, float)):
60
77
  data = str(data)
61
78
 
@@ -66,7 +83,7 @@ class Results(UserDict):
66
83
  """We need to convert timestamp, as there are different formats returned by cloud / CLI / splunk"""
67
84
  try:
68
85
  ts = datetime.fromisoformat(timestamp)
69
- except ValueError:
86
+ except (ValueError, TypeError):
70
87
  ts = datetime.fromtimestamp(float(timestamp), timezone.utc)
71
88
 
72
89
  return ts.isoformat()
@@ -13,6 +13,8 @@
13
13
  # limitations under the License.
14
14
 
15
15
 
16
+ import copy
17
+ import json
16
18
  import logging
17
19
  import pytest
18
20
 
@@ -20,6 +22,7 @@ from tabulate import tabulate
20
22
 
21
23
  from spl2_testing_framework.tools.create_diff_sheet import ComparisonSheet
22
24
  from spl2_testing_framework.tools.performance import PerformanceCheck
25
+ from spl2_testing_framework.tools.results import Results
23
26
  from spl2_testing_framework.tools.search_clients.cli_search_client import (
24
27
  CLISearchClient,
25
28
  )
@@ -187,7 +190,7 @@ class SPL2TestRunner:
187
190
  _LOGGER.info("Reduction rate for this event: %s", reduction_rate)
188
191
 
189
192
  if self.check_splunk_results:
190
- self._check_splunk_results(box_test, job)
193
+ self._check_splunk_results(box_test, output_result)
191
194
  else:
192
195
  if box_test.output:
193
196
  # Remove fields from actual results that are not in expected results if configured
@@ -222,6 +225,23 @@ class SPL2TestRunner:
222
225
  def _row_as_dict(row):
223
226
  return row.data if hasattr(row, "data") else dict(row)
224
227
 
228
+ @staticmethod
229
+ def _tree_copy_for_results(d: dict) -> dict:
230
+ """Clone ``d`` so ``Results`` normalization cannot mutate caller data.
231
+
232
+ ``Results._cast_types`` updates dicts in place. Splunk-shaped rows are
233
+ JSON-like; cloning via :func:`json.dumps` / :func:`json.loads` is
234
+ typically faster than :func:`copy.deepcopy`. Non-JSON values use
235
+ ``default=str`` (same idea as serializing for HEC). Falls back to
236
+ deepcopy only if JSON cloning fails.
237
+ """
238
+ if not d:
239
+ return {}
240
+ try:
241
+ return json.loads(json.dumps(d, default=str))
242
+ except (TypeError, ValueError):
243
+ return copy.deepcopy(d)
244
+
225
245
  @staticmethod
226
246
  def _strip_ignored_fields_for_splunk_compare(
227
247
  row: dict, ignore_keys: frozenset
@@ -239,27 +259,44 @@ class SPL2TestRunner:
239
259
  context=f"box_test={box_test.name!r}",
240
260
  ) from e
241
261
 
242
- def _check_splunk_results(self, box_test, job):
243
- output_result = job.result.get("destination", [])
262
+ def _check_splunk_results(self, box_test, destination_rows):
263
+ """Use the same destination rows as ``run_box_test`` (from ``get_job_results``), not ``job.result``."""
244
264
  if self.check_splunk_results == "CIM":
245
265
  if not box_test.cim_fields:
246
266
  pytest.skip("CIM output not available for this test")
247
267
 
248
- for event in output_result:
249
- processed_event = self._splunk_process_event(box_test, event)
250
- validate_compatibility(processed_event, box_test.cim_fields)
268
+ for event in destination_rows:
269
+ processed_event = self._splunk_process_event(
270
+ box_test, self._row_as_dict(event)
271
+ )
272
+ # Same normalization as in-memory box compares (Results._cast_types).
273
+ actual_cmp = Results(self._tree_copy_for_results(processed_event)).data
274
+ expected_cmp = Results(
275
+ self._tree_copy_for_results(box_test.cim_fields)
276
+ ).data
277
+ validate_compatibility(actual_cmp, expected_cmp)
251
278
  elif self.check_splunk_results == "TA":
279
+ if not box_test.output:
280
+ pytest.skip("TA expected output not available for this test")
281
+
252
282
  ignore_spec = box_test.ignore_fields_in_splunk_check
253
283
  ignore_keys = frozenset(ignore_spec.keys())
254
- for event, expected in zip(output_result, box_test.output):
255
- processed_event = self._splunk_process_event(box_test, event)
256
- actual = self._strip_ignored_fields_for_splunk_compare(
257
- processed_event, ignore_keys
284
+ for event, expected in zip(destination_rows, box_test.output):
285
+ processed_event = self._splunk_process_event(
286
+ box_test, self._row_as_dict(event)
287
+ )
288
+ expected_data = self._row_as_dict(expected)
289
+ # Splunk adds many search-time fields; mirror in-memory compare by
290
+ # dropping keys not present on the expected row before asserting.
291
+ actual_dict = dict(processed_event)
292
+ Results._remove_extra_fields(actual_dict, expected_data)
293
+ actual_cmp = self._strip_ignored_fields_for_splunk_compare(
294
+ actual_dict, ignore_keys
258
295
  )
259
296
  expected_cmp = self._strip_ignored_fields_for_splunk_compare(
260
- self._row_as_dict(expected), ignore_keys
297
+ expected_data, ignore_keys
261
298
  )
262
- assert actual == expected_cmp, (
299
+ assert actual_cmp == expected_cmp, (
263
300
  f"TA Splunk compare mismatch for {box_test.name}; "
264
301
  f"ignored_fields={ignore_spec!r}"
265
302
  )
@@ -245,9 +245,9 @@ class BoxTestDiscovery(TestDiscovery):
245
245
  Results(x)
246
246
  for x in test_block.get("expected_destination_result", [])
247
247
  ]
248
- cim_fields = test_block.get("expected_cim_fields", {}).get(
249
- "cim_fields", {}
250
- )
248
+ cim_fields = Results(
249
+ test_block.get("expected_cim_fields", {}).get("cim_fields", {})
250
+ ).data
251
251
  metrics = [
252
252
  Metrics(x)
253
253
  for x in test_block.get("expected_metrics_destination_result", [])
@@ -68,31 +68,58 @@ def calculate_reduction_rate(original_event, reduced_event):
68
68
 
69
69
 
70
70
  def validate_compatibility(actual, expected):
71
- """Check that every field in ``expected`` exists in ``actual`` with the same value.
71
+ """Check that every field in *expected* exists in *actual* with the same value.
72
72
 
73
73
  Order matches the usual test convention ``assert actual == expected`` (observed
74
- value first, reference second). For CIM checks, ``actual`` is the Splunk-processed
75
- event and ``expected`` is ``box_test.cim_fields``.
74
+ value first, reference second). For Splunk CIM checks, the runner passes both
75
+ sides through :class:`~spl2_testing_framework.tools.results.Results` first so
76
+ numeric and other types match pipeline/CLI normalization.
77
+ Extra fields in *actual* that are not in *expected* are silently ignored,
78
+ so callers only need to specify the subset of fields they care about.
79
+
80
+ Raises ``AssertionError`` with a human-readable summary listing every
81
+ mismatched and every missing field in a single message.
76
82
  """
77
- extracted_but_wrong = {}
78
- not_extracted = {}
79
- for field, value in expected.items():
80
- if field in actual:
81
- if actual[field] == value:
82
- _LOGGER.info(f"FIELD {field} in reduced event")
83
- continue
84
- else:
85
- extracted_but_wrong[field] = value
86
- _LOGGER.error(f"FIELD {field} in reduced event has wrong value")
87
-
83
+ wrong_value = {}
84
+ missing = {}
85
+
86
+ for field, expected_val in expected.items():
87
+ if field not in actual:
88
+ missing[field] = expected_val
89
+ _LOGGER.error("FIELD %s is missing from actual event", field)
90
+ continue
91
+
92
+ actual_val = actual[field]
93
+ if actual_val == expected_val:
94
+ _LOGGER.info("FIELD %s OK", field)
88
95
  else:
89
- not_extracted[field] = value
90
- _LOGGER.error(f"FIELD {field} is not present in the reduced event")
96
+ wrong_value[field] = {"actual": actual_val, "expected": expected_val}
97
+ _LOGGER.error(
98
+ "FIELD %s mismatch: actual=%r, expected=%r",
99
+ field,
100
+ actual_val,
101
+ expected_val,
102
+ )
91
103
 
92
- _LOGGER.info(f"Checking wrong extractions")
93
- assert extracted_but_wrong == {}
94
- _LOGGER.info(f"Checking missing extractions")
95
- assert not_extracted == {}
104
+ message = _format_compatibility_errors(wrong_value, missing)
105
+ assert message is None, message
106
+
107
+
108
+ def _format_compatibility_errors(wrong_value, missing):
109
+ """Return a formatted error string, or ``None`` when there are no errors."""
110
+ sections = []
111
+ if wrong_value:
112
+ lines = [
113
+ f" {field}: actual={d['actual']!r}, expected={d['expected']!r}"
114
+ for field, d in wrong_value.items()
115
+ ]
116
+ sections.append("Wrong values:\n" + "\n".join(lines))
117
+ if missing:
118
+ lines = [f" {field}: expected={val!r}" for field, val in missing.items()]
119
+ sections.append("Missing fields:\n" + "\n".join(lines))
120
+ if not sections:
121
+ return None
122
+ return "\n" + "\n".join(sections)
96
123
 
97
124
 
98
125
  _QUOTED_STRING_OR_BARE_KEY = re.compile(