spl2-testing-framework 1.7.1__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.
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/PKG-INFO +1 -1
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/pyproject.toml +1 -1
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/results.py +26 -9
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/spl2test_runner.py +49 -12
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/test_discovery.py +3 -3
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/utils.py +69 -29
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/LICENSE +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/README.md +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/__init__.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/conftest.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/logger_manager.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/pytest.ini.sample +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/spl2_test_config.json +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/test_runner.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/__init__.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/cli_job.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/job.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/performance.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/cli_search_client.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/splunk_integration/__init__.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +0 -0
- {spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/test_types.py +0 -0
|
@@ -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)
|
|
47
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
243
|
-
|
|
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
|
|
249
|
-
processed_event = self._splunk_process_event(
|
|
250
|
-
|
|
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(
|
|
255
|
-
processed_event = self._splunk_process_event(
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
297
|
+
expected_data, ignore_keys
|
|
261
298
|
)
|
|
262
|
-
assert
|
|
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 =
|
|
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", [])
|
{spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/tools/utils.py
RENAMED
|
@@ -68,46 +68,86 @@ 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
|
|
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,
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
_LOGGER.error(
|
|
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
|
+
)
|
|
103
|
+
|
|
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)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_QUOTED_STRING_OR_BARE_KEY = re.compile(
|
|
126
|
+
r'"(?:[^"\\]|\\.)*"' r"|" r"([{,])\s*([A-Za-z_]+)\s*(:)"
|
|
127
|
+
)
|
|
91
128
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
|
|
130
|
+
def _quote_unquoted_keys(input_str):
|
|
131
|
+
"""Wrap bare (unquoted) object keys in double quotes to produce valid JSON,
|
|
132
|
+
without corrupting content inside JSON string values.
|
|
133
|
+
|
|
134
|
+
Uses regex alternation: the first branch consumes quoted strings (so their
|
|
135
|
+
content is never touched), the second branch captures bare keys to quote.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def _replacer(m):
|
|
139
|
+
if m.group(1) is None:
|
|
140
|
+
return m.group(0)
|
|
141
|
+
return f'{m.group(1)}"{m.group(2)}":'
|
|
142
|
+
|
|
143
|
+
return _QUOTED_STRING_OR_BARE_KEY.sub(_replacer, input_str)
|
|
96
144
|
|
|
97
145
|
|
|
98
146
|
def parse_raw_from_input(input_str):
|
|
99
147
|
"""Parse SPL2-style or JSON array of events and return list of _raw field values.
|
|
100
148
|
Handles unquoted keys (e.g. {_raw: "x"}) by converting them to JSON-quoted keys.
|
|
101
149
|
"""
|
|
102
|
-
|
|
103
|
-
key_pattern = re.compile(
|
|
104
|
-
r"(\{|,)\s*(_raw|source|sourcetype|host)\s*:", re.MULTILINE
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
def quote_key(match):
|
|
108
|
-
return f'{match.group(1)}"{match.group(2)}":'
|
|
109
|
-
|
|
110
|
-
normalized = key_pattern.sub(quote_key, input_str)
|
|
150
|
+
normalized = _quote_unquoted_keys(input_str)
|
|
111
151
|
try:
|
|
112
152
|
parsed = json.loads(normalized)
|
|
113
153
|
except json.JSONDecodeError:
|
|
File without changes
|
|
File without changes
|
{spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/__init__.py
RENAMED
|
File without changes
|
{spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/conftest.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{spl2_testing_framework-1.7.1 → spl2_testing_framework-1.7.3}/spl2_testing_framework/test_runner.py
RENAMED
|
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
|