spl2-testing-framework 1.7.3__tar.gz → 1.8.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.
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/PKG-INFO +34 -7
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/README.md +33 -6
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/pyproject.toml +1 -1
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/conftest.py +85 -11
- spl2_testing_framework-1.8.0/spl2_testing_framework/spl2_test_config.json +18 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/performance.py +13 -4
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/results.py +1 -3
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/spl2test_runner.py +63 -21
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +17 -6
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/test_discovery.py +9 -9
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/test_types.py +2 -2
- spl2_testing_framework-1.8.0/spl2_testing_framework/tools/utils.py +331 -0
- spl2_testing_framework-1.7.3/spl2_testing_framework/spl2_test_config.json +0 -18
- spl2_testing_framework-1.7.3/spl2_testing_framework/tools/utils.py +0 -159
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/LICENSE +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/__init__.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/logger_manager.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/pytest.ini.sample +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/test_runner.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/__init__.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/jobs/cli_job.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/jobs/job.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/search_clients/cli_search_client.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
- {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/tools/splunk_integration/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: spl2-testing-framework
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: SPL2 Testing Framework
|
|
5
5
|
Home-page: https://github.com/splunk/spl2-testing-framework
|
|
6
6
|
Keywords: SPL2 testing framework
|
|
@@ -35,7 +35,7 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
|
|
36
36
|
## Overview
|
|
37
37
|
|
|
38
|
-
The **SPL2 Testing Framework** enables running SPL2 tests both locally (
|
|
38
|
+
The **SPL2 Testing Framework** enables running SPL2 tests both locally (or on any Splunk instance with SPL2
|
|
39
39
|
orchestrator), remotely (using external cloud environments) or using cli.
|
|
40
40
|
|
|
41
41
|
- **For Cloud**: It uses
|
|
@@ -138,6 +138,9 @@ Additionally, the following options are supported:
|
|
|
138
138
|
* `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
|
|
139
139
|
* `--cli_bench` - when running tests with CLI, this option enables benchmarking mode by adding `-b` and `-n` flags to the spl2-processor-cli command. The value specifies the number of events to test (e.g., `--cli_bench=1000` will add `-b -n 1000` to the CLI command). **In bench mode, all tests always succeed and CLI output is printed to logs** - this is useful for performance testing and benchmarking. This is only applicable when using `--type cli`.
|
|
140
140
|
* `--check_splunk_results` - option to validate box test output by sending events to a Splunk instance (using HEC) and querying them back, with support for **CIM** (Common Information Model) and **TA** (Technology Add-on) checks.
|
|
141
|
+
* `--reduction_stats` - when set, **box tests** record a reduction rate per run: input `_raw` vs output `_raw` (after pairing input/output events). Rates are grouped by **pipeline** (the `.spl2` code module stem) and **sourcetype** (from the input event if set, else the output event, else `UNKNOWN`). Off by default.
|
|
142
|
+
* `--reduction_stats_file` - path to a JSON file written at the **end of the pytest session** with collected rates (default `reduction_stats.json`). Only used when `--reduction_stats` is set.
|
|
143
|
+
* `--reduction_stats_full` - when set together with `--reduction_stats`, each pipeline’s JSON payload also includes a pandas **`describe()`** summary (count, mean, std, min, quartiles, max) per sourcetype column. Only used when `--reduction_stats` is set.
|
|
141
144
|
|
|
142
145
|
* Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
|
|
143
146
|
file by removing the `.sample` extension, and execute the command.
|
|
@@ -153,6 +156,7 @@ Sample configuration which may be used:
|
|
|
153
156
|
- Module: `spl2_testing_framework.test_runner`
|
|
154
157
|
- Parameters:
|
|
155
158
|
`--type [cli | splunk | cloud] --test_dir /tests/resources -o log_cli=true --log-cli-level=INFO --verbose`
|
|
159
|
+
- Optional: append `--reduction_stats` (and `--reduction_stats_file`, `--reduction_stats_full` if needed) to collect box-test reduction JSON as described above.
|
|
156
160
|
- If test dir is not specified, current working directory will be used
|
|
157
161
|
- Use `--code_dir` if SPL2 code modules are in a separate directory from tests
|
|
158
162
|
- If necessary another pytest options can be added
|
|
@@ -252,7 +256,31 @@ command ("|")
|
|
|
252
256
|
Content of this file will also be printed to stdout.
|
|
253
257
|
|
|
254
258
|
This checks can be applied only to box tests, as assertions which are used in unit tests may impact spl2 pipeline
|
|
255
|
-
performance.
|
|
259
|
+
performance.
|
|
260
|
+
|
|
261
|
+
### Reduction rate statistics (box tests)
|
|
262
|
+
|
|
263
|
+
Optional telemetry for **how much** each pipeline shrinks event payloads (length of input `_raw` vs matched output `_raw`), broken down by **pipeline** and **sourcetype**.
|
|
264
|
+
|
|
265
|
+
**Enable**
|
|
266
|
+
|
|
267
|
+
- Pass `--reduction_stats` to pytest (or add it in `pytest.ini` / your IDE run configuration).
|
|
268
|
+
|
|
269
|
+
**Output**
|
|
270
|
+
|
|
271
|
+
- During the session, each box test appends one or more floating-point rates into an in-memory structure: `pipeline_name → sourcetype → [rates…]`.
|
|
272
|
+
- After all tests finish, if `--reduction_stats` was set, the framework logs a short **pandas** summary per pipeline and writes **`--reduction_stats_file`** (default `reduction_stats.json`).
|
|
273
|
+
- With **`--reduction_stats_full`**, the JSON file also embeds per-pipeline **`describe()`** statistics for each sourcetype column (handy for dashboards or quick distribution checks).
|
|
274
|
+
|
|
275
|
+
**How rates are computed (high level)**
|
|
276
|
+
|
|
277
|
+
- Input events are parsed from the box test `source` string; output rows come from the job’s destination results.
|
|
278
|
+
- Input/output rows are **paired** using similarity on `_raw` (with a threshold and a same-length positional fallback). Only **matched** pairs contribute rates.
|
|
279
|
+
- For each pair, lengths use a **canonical JSON length** when `_raw` parses as JSON or a Python literal; otherwise the raw string length is used, so identical logical content can yield a **0** reduction rate even if string formatting differs.
|
|
280
|
+
|
|
281
|
+
**Scope**
|
|
282
|
+
|
|
283
|
+
- Applies to **box tests** only. Single-SPL2 runs and unit tests do not populate reduction stats.
|
|
256
284
|
|
|
257
285
|
### Check splunk results
|
|
258
286
|
|
|
@@ -263,13 +291,13 @@ When set (`--check_splunk_results=<option>`), box tests (after running the SPL2
|
|
|
263
291
|
| **CIM** | For each output event, ingest via HEC, load back from Splunk, then check CIM fields from `expected_cim_fields.cim_fields` with `validate_compatibility(actual, expected)`. Skips if the box test has no `cim_fields`. |
|
|
264
292
|
| **TA** | For each output event, ingest via HEC, load back from Splunk, then assert equality with the expected row for that index. |
|
|
265
293
|
|
|
266
|
-
|
|
294
|
+
You can name top-level fields to drop on **both** the pipeline/actual row and each matching `expected_destination_result` row before they are compared by adding **`ignore_fields_in_destination_compare`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
|
|
267
295
|
|
|
268
296
|
```json
|
|
269
297
|
"test": {
|
|
270
298
|
"source": "...",
|
|
271
299
|
"expected_destination_result": [ ... ],
|
|
272
|
-
"
|
|
300
|
+
"ignore_fields_in_destination_compare": {
|
|
273
301
|
"_raw": "Raw event differs after Splunk indexing",
|
|
274
302
|
"_time": "Timestamp may vary due to ingestion latency"
|
|
275
303
|
}
|
|
@@ -277,8 +305,7 @@ For **TA** only, you can name top-level fields to drop on **both** the queried e
|
|
|
277
305
|
```
|
|
278
306
|
|
|
279
307
|
|
|
280
|
-
If the key is omitted, no fields are ignored.
|
|
281
|
-
in-memory box test assert). If the file or key is missing, no fields are ignored.
|
|
308
|
+
If the key is omitted, no fields are ignored. It applies to the **default in-memory** box test assert (no `--check_splunk_results`) and to **`--check_splunk_results=TA`**. It does **not** apply to **`--check_splunk_results=CIM`**. If the file or key is missing, no fields are ignored.
|
|
282
309
|
|
|
283
310
|
#### Configuration:
|
|
284
311
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
The **SPL2 Testing Framework** enables running SPL2 tests both locally (
|
|
5
|
+
The **SPL2 Testing Framework** enables running SPL2 tests both locally (or on any Splunk instance with SPL2
|
|
6
6
|
orchestrator), remotely (using external cloud environments) or using cli.
|
|
7
7
|
|
|
8
8
|
- **For Cloud**: It uses
|
|
@@ -105,6 +105,9 @@ Additionally, the following options are supported:
|
|
|
105
105
|
* `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
|
|
106
106
|
* `--cli_bench` - when running tests with CLI, this option enables benchmarking mode by adding `-b` and `-n` flags to the spl2-processor-cli command. The value specifies the number of events to test (e.g., `--cli_bench=1000` will add `-b -n 1000` to the CLI command). **In bench mode, all tests always succeed and CLI output is printed to logs** - this is useful for performance testing and benchmarking. This is only applicable when using `--type cli`.
|
|
107
107
|
* `--check_splunk_results` - option to validate box test output by sending events to a Splunk instance (using HEC) and querying them back, with support for **CIM** (Common Information Model) and **TA** (Technology Add-on) checks.
|
|
108
|
+
* `--reduction_stats` - when set, **box tests** record a reduction rate per run: input `_raw` vs output `_raw` (after pairing input/output events). Rates are grouped by **pipeline** (the `.spl2` code module stem) and **sourcetype** (from the input event if set, else the output event, else `UNKNOWN`). Off by default.
|
|
109
|
+
* `--reduction_stats_file` - path to a JSON file written at the **end of the pytest session** with collected rates (default `reduction_stats.json`). Only used when `--reduction_stats` is set.
|
|
110
|
+
* `--reduction_stats_full` - when set together with `--reduction_stats`, each pipeline’s JSON payload also includes a pandas **`describe()`** summary (count, mean, std, min, quartiles, max) per sourcetype column. Only used when `--reduction_stats` is set.
|
|
108
111
|
|
|
109
112
|
* Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
|
|
110
113
|
file by removing the `.sample` extension, and execute the command.
|
|
@@ -120,6 +123,7 @@ Sample configuration which may be used:
|
|
|
120
123
|
- Module: `spl2_testing_framework.test_runner`
|
|
121
124
|
- Parameters:
|
|
122
125
|
`--type [cli | splunk | cloud] --test_dir /tests/resources -o log_cli=true --log-cli-level=INFO --verbose`
|
|
126
|
+
- Optional: append `--reduction_stats` (and `--reduction_stats_file`, `--reduction_stats_full` if needed) to collect box-test reduction JSON as described above.
|
|
123
127
|
- If test dir is not specified, current working directory will be used
|
|
124
128
|
- Use `--code_dir` if SPL2 code modules are in a separate directory from tests
|
|
125
129
|
- If necessary another pytest options can be added
|
|
@@ -219,7 +223,31 @@ command ("|")
|
|
|
219
223
|
Content of this file will also be printed to stdout.
|
|
220
224
|
|
|
221
225
|
This checks can be applied only to box tests, as assertions which are used in unit tests may impact spl2 pipeline
|
|
222
|
-
performance.
|
|
226
|
+
performance.
|
|
227
|
+
|
|
228
|
+
### Reduction rate statistics (box tests)
|
|
229
|
+
|
|
230
|
+
Optional telemetry for **how much** each pipeline shrinks event payloads (length of input `_raw` vs matched output `_raw`), broken down by **pipeline** and **sourcetype**.
|
|
231
|
+
|
|
232
|
+
**Enable**
|
|
233
|
+
|
|
234
|
+
- Pass `--reduction_stats` to pytest (or add it in `pytest.ini` / your IDE run configuration).
|
|
235
|
+
|
|
236
|
+
**Output**
|
|
237
|
+
|
|
238
|
+
- During the session, each box test appends one or more floating-point rates into an in-memory structure: `pipeline_name → sourcetype → [rates…]`.
|
|
239
|
+
- After all tests finish, if `--reduction_stats` was set, the framework logs a short **pandas** summary per pipeline and writes **`--reduction_stats_file`** (default `reduction_stats.json`).
|
|
240
|
+
- With **`--reduction_stats_full`**, the JSON file also embeds per-pipeline **`describe()`** statistics for each sourcetype column (handy for dashboards or quick distribution checks).
|
|
241
|
+
|
|
242
|
+
**How rates are computed (high level)**
|
|
243
|
+
|
|
244
|
+
- Input events are parsed from the box test `source` string; output rows come from the job’s destination results.
|
|
245
|
+
- Input/output rows are **paired** using similarity on `_raw` (with a threshold and a same-length positional fallback). Only **matched** pairs contribute rates.
|
|
246
|
+
- For each pair, lengths use a **canonical JSON length** when `_raw` parses as JSON or a Python literal; otherwise the raw string length is used, so identical logical content can yield a **0** reduction rate even if string formatting differs.
|
|
247
|
+
|
|
248
|
+
**Scope**
|
|
249
|
+
|
|
250
|
+
- Applies to **box tests** only. Single-SPL2 runs and unit tests do not populate reduction stats.
|
|
223
251
|
|
|
224
252
|
### Check splunk results
|
|
225
253
|
|
|
@@ -230,13 +258,13 @@ When set (`--check_splunk_results=<option>`), box tests (after running the SPL2
|
|
|
230
258
|
| **CIM** | For each output event, ingest via HEC, load back from Splunk, then check CIM fields from `expected_cim_fields.cim_fields` with `validate_compatibility(actual, expected)`. Skips if the box test has no `cim_fields`. |
|
|
231
259
|
| **TA** | For each output event, ingest via HEC, load back from Splunk, then assert equality with the expected row for that index. |
|
|
232
260
|
|
|
233
|
-
|
|
261
|
+
You can name top-level fields to drop on **both** the pipeline/actual row and each matching `expected_destination_result` row before they are compared by adding **`ignore_fields_in_destination_compare`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
|
|
234
262
|
|
|
235
263
|
```json
|
|
236
264
|
"test": {
|
|
237
265
|
"source": "...",
|
|
238
266
|
"expected_destination_result": [ ... ],
|
|
239
|
-
"
|
|
267
|
+
"ignore_fields_in_destination_compare": {
|
|
240
268
|
"_raw": "Raw event differs after Splunk indexing",
|
|
241
269
|
"_time": "Timestamp may vary due to ingestion latency"
|
|
242
270
|
}
|
|
@@ -244,8 +272,7 @@ For **TA** only, you can name top-level fields to drop on **both** the queried e
|
|
|
244
272
|
```
|
|
245
273
|
|
|
246
274
|
|
|
247
|
-
If the key is omitted, no fields are ignored.
|
|
248
|
-
in-memory box test assert). If the file or key is missing, no fields are ignored.
|
|
275
|
+
If the key is omitted, no fields are ignored. It applies to the **default in-memory** box test assert (no `--check_splunk_results`) and to **`--check_splunk_results=TA`**. It does **not** apply to **`--check_splunk_results=CIM`**. If the file or key is missing, no fields are ignored.
|
|
249
276
|
|
|
250
277
|
#### Configuration:
|
|
251
278
|
|
{spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/conftest.py
RENAMED
|
@@ -14,19 +14,21 @@
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
17
19
|
import os
|
|
18
20
|
from collections import defaultdict
|
|
19
21
|
|
|
20
|
-
import pandas as pd
|
|
21
22
|
import pytest
|
|
22
23
|
|
|
23
|
-
from .tools.results import Results
|
|
24
|
-
from .tools.splunk_integration.splunk_handler import SplunkHandler
|
|
25
|
-
|
|
26
24
|
pytest.register_assert_rewrite("spl2_testing_framework")
|
|
27
25
|
|
|
28
26
|
from requests.auth import AuthBase, HTTPBasicAuth
|
|
29
27
|
|
|
28
|
+
from .logger_manager import setup_logging
|
|
29
|
+
from .tools.results import Results
|
|
30
|
+
from .tools.splunk_integration.splunk_handler import SplunkHandler
|
|
31
|
+
|
|
30
32
|
from spl2_testing_framework.tools.search_clients.cli_search_client import (
|
|
31
33
|
CLISearchClient,
|
|
32
34
|
)
|
|
@@ -58,10 +60,7 @@ TEST_DIR = "test_dir"
|
|
|
58
60
|
CODE_DIR = "code_dir"
|
|
59
61
|
LIMIT_TESTS = "limit_tests"
|
|
60
62
|
CLI_BENCH = "cli_bench"
|
|
61
|
-
|
|
62
|
-
import logging
|
|
63
|
-
|
|
64
|
-
from .logger_manager import setup_logging
|
|
63
|
+
REDUCTION_STATS = "reduction_stats"
|
|
65
64
|
|
|
66
65
|
setup_logging()
|
|
67
66
|
|
|
@@ -89,6 +88,24 @@ def pytest_addoption(parser):
|
|
|
89
88
|
parser.addoption(f"--{IGNORE_ADDITIONAL_FIELDS_IN_ACTUAL}", action="store_true")
|
|
90
89
|
parser.addoption(f"--{LIMIT_TESTS}", action="store", type=int, default=None)
|
|
91
90
|
parser.addoption(f"--{CLI_BENCH}", action="store", type=int, default=None)
|
|
91
|
+
parser.addoption(
|
|
92
|
+
f"--{REDUCTION_STATS}",
|
|
93
|
+
action="store_true",
|
|
94
|
+
default=False,
|
|
95
|
+
help="Collect reduction rates per pipeline/sourcetype and save to file (see --reduction_stats_file, --reduction_stats_full). By default disabled.",
|
|
96
|
+
)
|
|
97
|
+
parser.addoption(
|
|
98
|
+
"--reduction_stats_file",
|
|
99
|
+
action="store",
|
|
100
|
+
default="reduction_stats.json",
|
|
101
|
+
help="Path to save reduction rates (JSON). Used only when --reduction_stats is set.",
|
|
102
|
+
)
|
|
103
|
+
parser.addoption(
|
|
104
|
+
"--reduction_stats_full",
|
|
105
|
+
action="store_true",
|
|
106
|
+
default=False,
|
|
107
|
+
help="Include full dataframe stats (count, mean, std, min, 25%%, 50%%, 75%%, max) in the saved file. Used only when --reduction_stats is set.",
|
|
108
|
+
)
|
|
92
109
|
|
|
93
110
|
|
|
94
111
|
def pytest_generate_tests(metafunc):
|
|
@@ -169,7 +186,7 @@ def spl2_test_runner(
|
|
|
169
186
|
url = f"https://{config['tenant']}.api.{config['cloud_instance']}/{config['tenant']}"
|
|
170
187
|
search_client = CloudSearchClient(url=url, auth=bearer_token_authentication)
|
|
171
188
|
else:
|
|
172
|
-
raise
|
|
189
|
+
raise ValueError(
|
|
173
190
|
"Unknown test type: {}. Supported tests types: 'splunk', 'cloud', 'cli'".format(
|
|
174
191
|
test_type
|
|
175
192
|
)
|
|
@@ -188,6 +205,9 @@ def run_configurations(request):
|
|
|
188
205
|
),
|
|
189
206
|
CLI_BENCH: request.config.getoption(f"--{CLI_BENCH}"),
|
|
190
207
|
CHECK_SPLUNK_RESULTS: request.config.getoption(f"--{CHECK_SPLUNK_RESULTS}"),
|
|
208
|
+
REDUCTION_STATS: request.config.getoption(
|
|
209
|
+
f"--{REDUCTION_STATS}", default=False
|
|
210
|
+
),
|
|
191
211
|
}
|
|
192
212
|
|
|
193
213
|
|
|
@@ -328,17 +348,71 @@ def splunk_handler(config, splunk_token_authentication):
|
|
|
328
348
|
|
|
329
349
|
|
|
330
350
|
@pytest.fixture(scope="session")
|
|
331
|
-
def stats():
|
|
351
|
+
def stats(request):
|
|
332
352
|
s = defaultdict(lambda: defaultdict(list))
|
|
333
353
|
yield s
|
|
334
354
|
|
|
355
|
+
collect_reduction = request.config.getoption(f"--{REDUCTION_STATS}", default=False)
|
|
356
|
+
if not collect_reduction:
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
_LOGGER.info("Reduction stats: reporting (--reduction_stats was set)")
|
|
360
|
+
if not s:
|
|
361
|
+
_LOGGER.info(
|
|
362
|
+
"Reduction stats: no data (no box tests ran or no rates collected)"
|
|
363
|
+
)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
import pandas as pd # lazy import — only needed when --reduction_stats is set
|
|
367
|
+
|
|
368
|
+
def _pad_to_equal_length(rates_dict):
|
|
369
|
+
"""Pad lists to same length with NaN so pd.DataFrame accepts different-length columns."""
|
|
370
|
+
if not rates_dict:
|
|
371
|
+
return rates_dict
|
|
372
|
+
max_len = max(len(lst) for lst in rates_dict.values())
|
|
373
|
+
return {
|
|
374
|
+
k: list(lst) + [float("nan")] * (max_len - len(lst))
|
|
375
|
+
for k, lst in rates_dict.items()
|
|
376
|
+
}
|
|
377
|
+
|
|
335
378
|
for template, v in s.items():
|
|
336
|
-
|
|
379
|
+
v_padded = _pad_to_equal_length(dict(v))
|
|
380
|
+
x = pd.DataFrame(v_padded)
|
|
337
381
|
_LOGGER.info(" *** *** ***")
|
|
338
382
|
_LOGGER.info("Collected statistics: ")
|
|
339
383
|
_LOGGER.info("Template name: %s ", template)
|
|
340
384
|
_LOGGER.info(x.describe())
|
|
341
385
|
|
|
386
|
+
out_path = request.config.getoption(
|
|
387
|
+
"--reduction_stats_file", default="reduction_stats.json"
|
|
388
|
+
)
|
|
389
|
+
include_full = request.config.getoption("--reduction_stats_full", default=False)
|
|
390
|
+
if out_path:
|
|
391
|
+
data = {}
|
|
392
|
+
for pipeline, sourcetype_rates in s.items():
|
|
393
|
+
rates_dict = dict(sourcetype_rates)
|
|
394
|
+
if include_full:
|
|
395
|
+
rates_padded = _pad_to_equal_length(rates_dict)
|
|
396
|
+
df = pd.DataFrame(rates_padded)
|
|
397
|
+
describe = df.describe().to_dict()
|
|
398
|
+
# NaN is not JSON-serializable; convert to None
|
|
399
|
+
describe_serializable = {
|
|
400
|
+
k: {
|
|
401
|
+
kk: (None if isinstance(vv, float) and math.isnan(vv) else vv)
|
|
402
|
+
for kk, vv in v.items()
|
|
403
|
+
}
|
|
404
|
+
for k, v in describe.items()
|
|
405
|
+
}
|
|
406
|
+
data[pipeline] = {
|
|
407
|
+
"rates": rates_dict,
|
|
408
|
+
"describe": describe_serializable,
|
|
409
|
+
}
|
|
410
|
+
else:
|
|
411
|
+
data[pipeline] = rates_dict
|
|
412
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
413
|
+
json.dump(data, f, indent=2)
|
|
414
|
+
_LOGGER.info("Reduction rates saved to %s", out_path)
|
|
415
|
+
|
|
342
416
|
|
|
343
417
|
def _set_results_options(metafunc):
|
|
344
418
|
ignore_empty_strings = metafunc.config.getoption(IGNORE_EMPTY_STRINGS)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"host": "",
|
|
3
|
+
"cloud_instance": "",
|
|
4
|
+
"tenant": "",
|
|
5
|
+
"bearer_token": "",
|
|
6
|
+
"port": "",
|
|
7
|
+
"user": "",
|
|
8
|
+
"password": "",
|
|
9
|
+
"SPLUNK_INSTANCE": {
|
|
10
|
+
"ip": "localhost",
|
|
11
|
+
"port": 8088,
|
|
12
|
+
"api_port": 8089,
|
|
13
|
+
"username": "admin",
|
|
14
|
+
"password": "Chang3d!",
|
|
15
|
+
"index": "testx",
|
|
16
|
+
"hec_token": "4a8a737d-5452-426c-a6f7-106dca4e813f"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -249,7 +249,14 @@ class PerformanceCheck:
|
|
|
249
249
|
|
|
250
250
|
@staticmethod
|
|
251
251
|
def _preserve_timestamps_through_fields(code_module: str) -> str:
|
|
252
|
-
"""Replace '| fields' keep-mode with a no-op so _timestamp_perf* fields are not stripped.
|
|
252
|
+
"""Replace '| fields' keep-mode with a no-op so _timestamp_perf* fields are not stripped.
|
|
253
|
+
|
|
254
|
+
During performance instrumentation the pipeline runs with a slightly different shape:
|
|
255
|
+
keep-mode ``| fields`` commands are removed to prevent them from stripping the injected
|
|
256
|
+
``_timestamp_perf*`` variables. This means perf numbers reflect a pipeline with all
|
|
257
|
+
fields passing through, which is slightly heavier than production but avoids silently
|
|
258
|
+
dropping the instrumentation fields and producing zeroed measurements.
|
|
259
|
+
"""
|
|
253
260
|
return re.sub(
|
|
254
261
|
r"\|\s*fields\s+(?!-)(.+)",
|
|
255
262
|
r"| eval _perf_fields_preserved=1",
|
|
@@ -297,11 +304,13 @@ class PerformanceCheck:
|
|
|
297
304
|
flags=re.M,
|
|
298
305
|
)
|
|
299
306
|
|
|
300
|
-
func_timestamps = re.findall(
|
|
307
|
+
func_timestamps = re.findall(
|
|
308
|
+
rf"_time_{re.escape(f['f_name'])}_placeholder", new_function
|
|
309
|
+
)
|
|
301
310
|
|
|
302
311
|
for n, timestamp in enumerate(func_timestamps):
|
|
303
312
|
new_function = re.sub(
|
|
304
|
-
timestamp,
|
|
313
|
+
re.escape(timestamp),
|
|
305
314
|
rf"_timestamp_perf{f['f_name']}_{n:03d}",
|
|
306
315
|
new_function,
|
|
307
316
|
count=1,
|
|
@@ -318,7 +327,7 @@ class PerformanceCheck:
|
|
|
318
327
|
)
|
|
319
328
|
|
|
320
329
|
func_timestamps = re.findall(
|
|
321
|
-
rf"\$_time_{f['f_name']}_placeholder", new_function
|
|
330
|
+
rf"\$_time_{re.escape(f['f_name'])}_placeholder", new_function
|
|
322
331
|
)
|
|
323
332
|
|
|
324
333
|
for n, timestamp in enumerate(func_timestamps):
|
|
@@ -175,14 +175,12 @@ class Results(UserDict):
|
|
|
175
175
|
actual_data[key][i], expected_data[key][i]
|
|
176
176
|
)
|
|
177
177
|
|
|
178
|
-
return actual_data
|
|
179
|
-
|
|
180
178
|
def remove_fields_not_in_expected(self, expected):
|
|
181
179
|
"""Remove fields from this Results object that are not present in the expected Results object.
|
|
182
180
|
This is the public method to be called when comparing results.
|
|
183
181
|
"""
|
|
184
182
|
if self._ignore_additional_fields_in_actual and isinstance(expected, Results):
|
|
185
|
-
self.
|
|
183
|
+
self._remove_extra_fields(self.data, expected.data)
|
|
186
184
|
return self
|
|
187
185
|
|
|
188
186
|
def __lt__(self, other):
|
|
@@ -33,9 +33,7 @@ from spl2_testing_framework.tools.test_types import UnitTest, BoxTest, SingleSPL
|
|
|
33
33
|
from spl2_testing_framework.tools.utils import (
|
|
34
34
|
_make_functions_visible_for_testing,
|
|
35
35
|
validate_compatibility,
|
|
36
|
-
|
|
37
|
-
parse_raw_from_input,
|
|
38
|
-
parse_raw_from_reduced_events,
|
|
36
|
+
calculate_reduction_rate_and_update_stats,
|
|
39
37
|
)
|
|
40
38
|
|
|
41
39
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -44,13 +42,21 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
44
42
|
class SPL2TestRunner:
|
|
45
43
|
"""Main class responsible for running tests"""
|
|
46
44
|
|
|
47
|
-
def __init__(
|
|
45
|
+
def __init__(
|
|
46
|
+
self, search_client, run_configurations, splunk_handler=None, stats=None
|
|
47
|
+
):
|
|
48
48
|
self._search_client = search_client
|
|
49
49
|
self.create_comparison_sheet = run_configurations.get("create_comparison_sheet")
|
|
50
50
|
self.check_splunk_results = run_configurations.get("check_splunk_results")
|
|
51
|
+
self.collect_reduction_stats = run_configurations.get("reduction_stats", False)
|
|
51
52
|
self.comp_obj = ComparisonSheet()
|
|
52
53
|
self.reduction_stats = stats
|
|
53
54
|
if self.check_splunk_results:
|
|
55
|
+
if splunk_handler is None:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"check_splunk_results={self.check_splunk_results!r} requires a "
|
|
58
|
+
"splunk_handler to be provided, but got None."
|
|
59
|
+
)
|
|
54
60
|
splunk_handler.setup()
|
|
55
61
|
self.splunk_handler = splunk_handler
|
|
56
62
|
|
|
@@ -181,26 +187,48 @@ class SPL2TestRunner:
|
|
|
181
187
|
|
|
182
188
|
perf.check_performance(output_result)
|
|
183
189
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
if self.collect_reduction_stats:
|
|
191
|
+
reduction_rate = calculate_reduction_rate_and_update_stats(
|
|
192
|
+
box_test.code_module_path.stem,
|
|
193
|
+
box_test.input,
|
|
194
|
+
output_result,
|
|
195
|
+
self.reduction_stats,
|
|
196
|
+
)
|
|
197
|
+
_LOGGER.info("Reduction rate for this event: %s", reduction_rate)
|
|
191
198
|
|
|
192
199
|
if self.check_splunk_results:
|
|
193
200
|
self._check_splunk_results(box_test, output_result)
|
|
194
201
|
else:
|
|
195
202
|
if box_test.output:
|
|
196
|
-
|
|
203
|
+
ignore_keys = frozenset(
|
|
204
|
+
box_test.ignore_fields_in_destination_compare.keys()
|
|
205
|
+
)
|
|
197
206
|
for i, actual_result in enumerate(output_result):
|
|
198
207
|
if i < len(box_test.output):
|
|
199
208
|
actual_result.remove_fields_not_in_expected(box_test.output[i])
|
|
200
209
|
|
|
201
210
|
if self.create_comparison_sheet:
|
|
202
211
|
self.comp_obj.create_comparison_sheet(output_result, box_test)
|
|
203
|
-
|
|
212
|
+
if ignore_keys:
|
|
213
|
+
stripped_actual = [
|
|
214
|
+
Results(
|
|
215
|
+
self._strip_ignored_fields_for_destination_compare(
|
|
216
|
+
row.data, ignore_keys
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
for row in output_result
|
|
220
|
+
]
|
|
221
|
+
stripped_expected = [
|
|
222
|
+
Results(
|
|
223
|
+
self._strip_ignored_fields_for_destination_compare(
|
|
224
|
+
row.data, ignore_keys
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
for row in box_test.output
|
|
228
|
+
]
|
|
229
|
+
assert sorted(stripped_actual) == sorted(stripped_expected)
|
|
230
|
+
else:
|
|
231
|
+
assert sorted(output_result) == sorted(box_test.output)
|
|
204
232
|
_LOGGER.info(f"Output check passed: {box_test.name}")
|
|
205
233
|
_LOGGER.debug("Received: \n%s", str(output_result))
|
|
206
234
|
_LOGGER.debug("Expected: \n%s", str(box_test.output))
|
|
@@ -243,7 +271,7 @@ class SPL2TestRunner:
|
|
|
243
271
|
return copy.deepcopy(d)
|
|
244
272
|
|
|
245
273
|
@staticmethod
|
|
246
|
-
def
|
|
274
|
+
def _strip_ignored_fields_for_destination_compare(
|
|
247
275
|
row: dict, ignore_keys: frozenset
|
|
248
276
|
) -> dict:
|
|
249
277
|
if not ignore_keys:
|
|
@@ -260,7 +288,12 @@ class SPL2TestRunner:
|
|
|
260
288
|
) from e
|
|
261
289
|
|
|
262
290
|
def _check_splunk_results(self, box_test, destination_rows):
|
|
263
|
-
"""
|
|
291
|
+
"""Validate HEC round-trip rows against the box test (CIM or TA).
|
|
292
|
+
|
|
293
|
+
``destination_rows`` must be the same list returned from
|
|
294
|
+
``get_job_results(job)["destination"]`` as used for reduction stats and
|
|
295
|
+
non-Splunk output assertions — not ``job.result``.
|
|
296
|
+
"""
|
|
264
297
|
if self.check_splunk_results == "CIM":
|
|
265
298
|
if not box_test.cim_fields:
|
|
266
299
|
pytest.skip("CIM output not available for this test")
|
|
@@ -279,8 +312,13 @@ class SPL2TestRunner:
|
|
|
279
312
|
if not box_test.output:
|
|
280
313
|
pytest.skip("TA expected output not available for this test")
|
|
281
314
|
|
|
282
|
-
ignore_spec = box_test.
|
|
315
|
+
ignore_spec = box_test.ignore_fields_in_destination_compare
|
|
283
316
|
ignore_keys = frozenset(ignore_spec.keys())
|
|
317
|
+
assert len(destination_rows) == len(box_test.output), (
|
|
318
|
+
f"TA Splunk check for {box_test.name!r}: "
|
|
319
|
+
f"got {len(destination_rows)} destination row(s) but "
|
|
320
|
+
f"{len(box_test.output)} expected output(s); zip would hide a mismatch."
|
|
321
|
+
)
|
|
284
322
|
for event, expected in zip(destination_rows, box_test.output):
|
|
285
323
|
processed_event = self._splunk_process_event(
|
|
286
324
|
box_test, self._row_as_dict(event)
|
|
@@ -290,15 +328,19 @@ class SPL2TestRunner:
|
|
|
290
328
|
# dropping keys not present on the expected row before asserting.
|
|
291
329
|
actual_dict = dict(processed_event)
|
|
292
330
|
Results._remove_extra_fields(actual_dict, expected_data)
|
|
293
|
-
actual_cmp = self.
|
|
331
|
+
actual_cmp = self._strip_ignored_fields_for_destination_compare(
|
|
294
332
|
actual_dict, ignore_keys
|
|
295
333
|
)
|
|
296
|
-
expected_cmp = self.
|
|
334
|
+
expected_cmp = self._strip_ignored_fields_for_destination_compare(
|
|
297
335
|
expected_data, ignore_keys
|
|
298
336
|
)
|
|
299
337
|
assert actual_cmp == expected_cmp, (
|
|
300
|
-
f"TA
|
|
301
|
-
f"
|
|
338
|
+
f"TA destination compare mismatch for {box_test.name}; "
|
|
339
|
+
f"ignore_fields_in_destination_compare={ignore_spec!r}"
|
|
302
340
|
)
|
|
303
341
|
else:
|
|
304
|
-
|
|
342
|
+
_LOGGER.warning(
|
|
343
|
+
"Unknown check_splunk_results value %r — expected 'CIM' or 'TA'. "
|
|
344
|
+
"No Splunk validation performed.",
|
|
345
|
+
self.check_splunk_results,
|
|
346
|
+
)
|
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
15
|
import logging
|
|
16
|
+
import time
|
|
16
17
|
import uuid
|
|
17
18
|
from typing import Optional
|
|
18
19
|
|
|
19
20
|
import requests
|
|
21
|
+
import urllib3
|
|
20
22
|
import splunklib.client as client
|
|
21
23
|
import splunklib.results as results
|
|
22
|
-
|
|
23
|
-
import time
|
|
24
|
-
|
|
25
24
|
from requests.auth import HTTPBasicAuth
|
|
25
|
+
import json
|
|
26
|
+
|
|
27
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
26
28
|
|
|
27
29
|
_LOGGER = logging.getLogger(__name__)
|
|
28
30
|
|
|
@@ -82,10 +84,12 @@ class SplunkHandler:
|
|
|
82
84
|
def _raw_as_event_string(raw):
|
|
83
85
|
"""Normalize pipeline `_raw` for HEC JSON `event` (must be a string).
|
|
84
86
|
|
|
85
|
-
``
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
``dict`` is serialized as valid JSON (double-quoted) so that Splunk's
|
|
88
|
+
field extractions (e.g. GCP TA KV_MODE=json) can parse it correctly.
|
|
89
|
+
``bytes`` are decoded as UTF-8. Everything else uses ``str()``.
|
|
88
90
|
"""
|
|
91
|
+
if isinstance(raw, dict):
|
|
92
|
+
return json.dumps(raw) # {"data": {...}} - valid JSON
|
|
89
93
|
if isinstance(raw, bytes):
|
|
90
94
|
return raw.decode("utf-8", errors="replace")
|
|
91
95
|
return str(raw)
|
|
@@ -165,4 +169,11 @@ class SplunkHandler:
|
|
|
165
169
|
),
|
|
166
170
|
verify=False,
|
|
167
171
|
)
|
|
172
|
+
if response.status_code == 409:
|
|
173
|
+
_LOGGER.info(
|
|
174
|
+
"Index '%s' already exists — skipping creation.",
|
|
175
|
+
self.splunk_config["index"],
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
response.raise_for_status()
|
|
168
179
|
return response.json()
|
|
@@ -179,11 +179,11 @@ class TestDiscovery(abc.ABC):
|
|
|
179
179
|
class BoxTestDiscovery(TestDiscovery):
|
|
180
180
|
"""Discovers tests for box tests, looking into module.test.json files"""
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
_IGNORE_FIELDS_IN_DESTINATION_COMPARE_KEY = "ignore_fields_in_destination_compare"
|
|
183
183
|
|
|
184
184
|
@classmethod
|
|
185
|
-
def
|
|
186
|
-
raw = test_block.get(cls.
|
|
185
|
+
def _parse_ignore_fields_in_destination_compare(cls, test_block: dict) -> dict:
|
|
186
|
+
raw = test_block.get(cls._IGNORE_FIELDS_IN_DESTINATION_COMPARE_KEY)
|
|
187
187
|
if raw is None:
|
|
188
188
|
return {}
|
|
189
189
|
if isinstance(raw, list):
|
|
@@ -194,14 +194,14 @@ class BoxTestDiscovery(TestDiscovery):
|
|
|
194
194
|
else:
|
|
195
195
|
_LOGGER.warning(
|
|
196
196
|
"%s list entries must be strings; skipping %r",
|
|
197
|
-
cls.
|
|
197
|
+
cls._IGNORE_FIELDS_IN_DESTINATION_COMPARE_KEY,
|
|
198
198
|
k,
|
|
199
199
|
)
|
|
200
200
|
return out
|
|
201
201
|
if not isinstance(raw, dict):
|
|
202
202
|
_LOGGER.warning(
|
|
203
203
|
"%s must be an object (field -> reason) or a list of field names; ignoring %r",
|
|
204
|
-
cls.
|
|
204
|
+
cls._IGNORE_FIELDS_IN_DESTINATION_COMPARE_KEY,
|
|
205
205
|
raw,
|
|
206
206
|
)
|
|
207
207
|
return {}
|
|
@@ -210,7 +210,7 @@ class BoxTestDiscovery(TestDiscovery):
|
|
|
210
210
|
if not isinstance(key, str):
|
|
211
211
|
_LOGGER.warning(
|
|
212
212
|
"%s keys must be strings; skipping %r",
|
|
213
|
-
cls.
|
|
213
|
+
cls._IGNORE_FIELDS_IN_DESTINATION_COMPARE_KEY,
|
|
214
214
|
key,
|
|
215
215
|
)
|
|
216
216
|
continue
|
|
@@ -252,8 +252,8 @@ class BoxTestDiscovery(TestDiscovery):
|
|
|
252
252
|
Metrics(x)
|
|
253
253
|
for x in test_block.get("expected_metrics_destination_result", [])
|
|
254
254
|
]
|
|
255
|
-
|
|
256
|
-
self.
|
|
255
|
+
ignore_fields_in_destination_compare = (
|
|
256
|
+
self._parse_ignore_fields_in_destination_compare(test_block)
|
|
257
257
|
)
|
|
258
258
|
box_test = BoxTest(
|
|
259
259
|
name=name,
|
|
@@ -263,7 +263,7 @@ class BoxTestDiscovery(TestDiscovery):
|
|
|
263
263
|
module_path=module_path,
|
|
264
264
|
code_module_path=code_module_path,
|
|
265
265
|
cim_fields=cim_fields,
|
|
266
|
-
|
|
266
|
+
ignore_fields_in_destination_compare=ignore_fields_in_destination_compare,
|
|
267
267
|
)
|
|
268
268
|
box_test.meta["performance"] = self._performance_check
|
|
269
269
|
self.tests.add(box_test)
|
|
@@ -46,8 +46,8 @@ class BoxTest(Test):
|
|
|
46
46
|
code_module_path: Path
|
|
47
47
|
metrics: list = field(default_factory=list)
|
|
48
48
|
cim_fields: dict = field(default_factory=dict)
|
|
49
|
-
#
|
|
50
|
-
|
|
49
|
+
# Field name -> reason; stripped on both sides before destination asserts (not used for CIM).
|
|
50
|
+
ignore_fields_in_destination_compare: dict = field(default_factory=dict)
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
@dataclass
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# Copyright 2026 Splunk Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
import ast
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import pathlib
|
|
18
|
+
import re
|
|
19
|
+
from difflib import SequenceMatcher
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Minimum similarity (0-1) for an output event to be paired with an input event.
|
|
25
|
+
# SequenceMatcher.ratio() is used; below this threshold the output is left unpaired.
|
|
26
|
+
MATCH_SIMILARITY_THRESHOLD = 0.3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_framework_root() -> pathlib.Path:
|
|
30
|
+
framework_root = pathlib.Path(__file__).parents[1]
|
|
31
|
+
return framework_root
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_functions_visible_for_testing(code_module_path):
|
|
35
|
+
code_module = code_module_path.read_text(encoding="utf-8")
|
|
36
|
+
functions = re.findall(r"^function\s+([^(]+)\(", code_module, flags=re.M)
|
|
37
|
+
for function in functions:
|
|
38
|
+
if not re.search(r"export\s" + function, code_module):
|
|
39
|
+
code_module = re.sub(
|
|
40
|
+
r"^function\s+" + function,
|
|
41
|
+
f"export function {function}",
|
|
42
|
+
code_module,
|
|
43
|
+
flags=re.M,
|
|
44
|
+
)
|
|
45
|
+
return code_module
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(None)
|
|
49
|
+
def read_assertions_module():
|
|
50
|
+
root_folder = get_framework_root()
|
|
51
|
+
assertions_file = root_folder / "spl2_utils" / "assertions.spl2"
|
|
52
|
+
return assertions_file.read_text(encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@lru_cache(None)
|
|
56
|
+
def read_commands_modules():
|
|
57
|
+
root_folder = get_framework_root()
|
|
58
|
+
commands_module = root_folder / "spl2_utils" / "logs_to_metrics.spl2"
|
|
59
|
+
return commands_module.read_text(encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mapping_from_event_row(ev):
|
|
63
|
+
"""Return a dict-like mapping for a destination row (plain dict or ``Results``-style ``.data``)."""
|
|
64
|
+
if isinstance(ev, dict):
|
|
65
|
+
return ev
|
|
66
|
+
inner = getattr(ev, "data", None)
|
|
67
|
+
if isinstance(inner, dict):
|
|
68
|
+
return inner
|
|
69
|
+
return ev
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _raw_from_event(ev):
|
|
73
|
+
m = _mapping_from_event_row(ev)
|
|
74
|
+
return (
|
|
75
|
+
str(m.get("_raw", "")) if isinstance(m, dict) else str(getattr(m, "_raw", ""))
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _similarity(a: str, b: str) -> float:
|
|
80
|
+
"""Return similarity ratio in [0, 1] using SequenceMatcher."""
|
|
81
|
+
if not a and not b:
|
|
82
|
+
return 1.0
|
|
83
|
+
if not a or not b:
|
|
84
|
+
return 0.0
|
|
85
|
+
return SequenceMatcher(None, a, b).ratio()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def match_input_output_events(input_events, output_events, min_similarity=None):
|
|
89
|
+
"""Pair input events with output events by content similarity using difflib.SequenceMatcher.
|
|
90
|
+
|
|
91
|
+
For each output event, the unused input event with the highest similarity ratio is chosen.
|
|
92
|
+
Pairs are only formed when the ratio is at least min_similarity (default MATCH_SIMILARITY_THRESHOLD).
|
|
93
|
+
Each input is used at most once. Handles reordering and dropped events.
|
|
94
|
+
|
|
95
|
+
Fallback: when similarity matching yields no pairs, input/output lengths are equal, and
|
|
96
|
+
there are at least two events on each side, pair by position (input[i], output[i]).
|
|
97
|
+
This covers pipelines (e.g. JSON/CrowdStrike) where _raw is heavily transformed so
|
|
98
|
+
similarity is low but order is preserved. Single-event cases are not paired here so
|
|
99
|
+
unrelated one-to-one runs do not get a false match.
|
|
100
|
+
"""
|
|
101
|
+
if not output_events or not input_events:
|
|
102
|
+
return []
|
|
103
|
+
threshold = (
|
|
104
|
+
min_similarity if min_similarity is not None else MATCH_SIMILARITY_THRESHOLD
|
|
105
|
+
)
|
|
106
|
+
used_input_indices = set()
|
|
107
|
+
pairs = []
|
|
108
|
+
|
|
109
|
+
for out_ev in output_events:
|
|
110
|
+
out_r = _raw_from_event(out_ev)
|
|
111
|
+
best_in_idx = None
|
|
112
|
+
best_ratio = -1.0
|
|
113
|
+
|
|
114
|
+
for i, in_ev in enumerate(input_events):
|
|
115
|
+
if i in used_input_indices:
|
|
116
|
+
continue
|
|
117
|
+
# Input first: SequenceMatcher.ratio() is not symmetric when autojunk is on;
|
|
118
|
+
# input-then-output matches historical behavior and keeps unrelated pairs below threshold.
|
|
119
|
+
ratio = _similarity(_raw_from_event(in_ev), out_r)
|
|
120
|
+
if ratio > best_ratio and ratio >= threshold:
|
|
121
|
+
best_ratio = ratio
|
|
122
|
+
best_in_idx = i
|
|
123
|
+
|
|
124
|
+
if best_in_idx is not None:
|
|
125
|
+
used_input_indices.add(best_in_idx)
|
|
126
|
+
pairs.append((input_events[best_in_idx], out_ev))
|
|
127
|
+
else:
|
|
128
|
+
_LOGGER.debug(
|
|
129
|
+
"No input matched output _raw (len=%s, threshold=%.2f)",
|
|
130
|
+
len(out_r),
|
|
131
|
+
threshold,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not pairs and len(input_events) == len(output_events) and len(input_events) >= 2:
|
|
135
|
+
pairs = list(zip(input_events, output_events))
|
|
136
|
+
_LOGGER.debug(
|
|
137
|
+
"Similarity matching produced no pairs; using position-based pairing "
|
|
138
|
+
"(equal lengths %s, >=2)",
|
|
139
|
+
len(input_events),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return pairs
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _content_length_for_rate(s):
|
|
146
|
+
"""Length used for reduction rate: canonical JSON length if parseable, else raw string length.
|
|
147
|
+
|
|
148
|
+
So identical JSON/dict content (e.g. original as JSON string, reduced as dict repr) gives
|
|
149
|
+
the same length and rate 0.
|
|
150
|
+
"""
|
|
151
|
+
raw = str(s)
|
|
152
|
+
for parse in (json.loads, ast.literal_eval):
|
|
153
|
+
try:
|
|
154
|
+
obj = parse(raw)
|
|
155
|
+
return len(json.dumps(obj, sort_keys=True, separators=(",", ":")))
|
|
156
|
+
except (json.JSONDecodeError, ValueError, SyntaxError, TypeError):
|
|
157
|
+
continue
|
|
158
|
+
return len(raw)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def calculate_reduction_rate(original_event, reduced_event):
|
|
162
|
+
"""Calculate how much each event was reduced. Both lists must be the same length (matched pairs).
|
|
163
|
+
|
|
164
|
+
When both values are parseable as JSON or Python dict, lengths are compared in canonical form
|
|
165
|
+
so identical content (e.g. JSON string vs dict repr) yields rate 0.
|
|
166
|
+
"""
|
|
167
|
+
if len(original_event) != len(reduced_event):
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"original_event and reduced_event must have the same length (use match_input_output_events first)"
|
|
170
|
+
)
|
|
171
|
+
reduction_rates = []
|
|
172
|
+
for original, reduced in zip(original_event, reduced_event):
|
|
173
|
+
original_event_len = _content_length_for_rate(original)
|
|
174
|
+
reduced_event_len = _content_length_for_rate(reduced)
|
|
175
|
+
if original_event_len == 0:
|
|
176
|
+
reduction_rates.append(0.0)
|
|
177
|
+
else:
|
|
178
|
+
reduction_rates.append(
|
|
179
|
+
(original_event_len - reduced_event_len) / original_event_len
|
|
180
|
+
)
|
|
181
|
+
return reduction_rates
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _sourcetype_for_pair(input_ev, output_ev, default="UNKNOWN"):
|
|
185
|
+
"""Sourcetype for a matched pair: from input if set, else from output, else default."""
|
|
186
|
+
input_ev = _mapping_from_event_row(input_ev)
|
|
187
|
+
output_ev = _mapping_from_event_row(output_ev)
|
|
188
|
+
return input_ev.get("sourcetype") or output_ev.get("sourcetype") or default
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def calculate_reduction_rate_and_update_stats(
|
|
192
|
+
pipeline, input_str, output_events, stats
|
|
193
|
+
):
|
|
194
|
+
"""Compute reduction rates for input vs output events and update stats by pipeline and sourcetype.
|
|
195
|
+
|
|
196
|
+
Pairs input and output events via match_input_output_events (SequenceMatcher similarity).
|
|
197
|
+
Appends each rate to stats[pipeline][sourcetype]. Sourcetype is taken from the input event
|
|
198
|
+
if present, otherwise from the output event, otherwise \"UNKNOWN\". Returns the list of
|
|
199
|
+
reduction rates for matched pairs only.
|
|
200
|
+
"""
|
|
201
|
+
input_events = parse_events_from_input(input_str)
|
|
202
|
+
pairs = match_input_output_events(input_events, output_events)
|
|
203
|
+
if not pairs:
|
|
204
|
+
_LOGGER.debug(
|
|
205
|
+
"No input-output pairs for reduction rate (pipeline=%s)", pipeline
|
|
206
|
+
)
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
original_raw = [_raw_from_event(in_ev) for in_ev, _ in pairs]
|
|
210
|
+
reduced_raw = [_raw_from_event(out_ev) for _, out_ev in pairs]
|
|
211
|
+
reduction_rates = calculate_reduction_rate(original_raw, reduced_raw)
|
|
212
|
+
sourcetypes = [_sourcetype_for_pair(in_ev, out_ev) for in_ev, out_ev in pairs]
|
|
213
|
+
for sourcetype, rate in zip(sourcetypes, reduction_rates):
|
|
214
|
+
stats[pipeline][sourcetype].append(rate)
|
|
215
|
+
return reduction_rates
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def validate_compatibility(actual, expected):
|
|
219
|
+
"""Check that every field in *expected* exists in *actual* with the same value.
|
|
220
|
+
|
|
221
|
+
Order matches the usual test convention ``assert actual == expected`` (observed
|
|
222
|
+
value first, reference second). For Splunk CIM checks, the runner passes both
|
|
223
|
+
sides through :class:`~spl2_testing_framework.tools.results.Results` first so
|
|
224
|
+
numeric and other types match pipeline/CLI normalization.
|
|
225
|
+
Extra fields in *actual* that are not in *expected* are silently ignored,
|
|
226
|
+
so callers only need to specify the subset of fields they care about.
|
|
227
|
+
|
|
228
|
+
Raises ``AssertionError`` with a human-readable summary listing every
|
|
229
|
+
mismatched and every missing field in a single message.
|
|
230
|
+
"""
|
|
231
|
+
wrong_value = {}
|
|
232
|
+
missing = {}
|
|
233
|
+
|
|
234
|
+
for field, expected_val in expected.items():
|
|
235
|
+
if field not in actual:
|
|
236
|
+
missing[field] = expected_val
|
|
237
|
+
_LOGGER.error("FIELD %s is missing from actual event", field)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
actual_val = actual[field]
|
|
241
|
+
if actual_val == expected_val:
|
|
242
|
+
_LOGGER.info("FIELD %s OK", field)
|
|
243
|
+
else:
|
|
244
|
+
wrong_value[field] = {"actual": actual_val, "expected": expected_val}
|
|
245
|
+
_LOGGER.error(
|
|
246
|
+
"FIELD %s mismatch: actual=%r, expected=%r",
|
|
247
|
+
field,
|
|
248
|
+
actual_val,
|
|
249
|
+
expected_val,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
message = _format_compatibility_errors(wrong_value, missing)
|
|
253
|
+
assert message is None, message
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _format_compatibility_errors(wrong_value, missing):
|
|
257
|
+
"""Return a formatted error string, or ``None`` when there are no errors."""
|
|
258
|
+
sections = []
|
|
259
|
+
if wrong_value:
|
|
260
|
+
lines = [
|
|
261
|
+
f" {field}: actual={d['actual']!r}, expected={d['expected']!r}"
|
|
262
|
+
for field, d in wrong_value.items()
|
|
263
|
+
]
|
|
264
|
+
sections.append("Wrong values:\n" + "\n".join(lines))
|
|
265
|
+
if missing:
|
|
266
|
+
lines = [f" {field}: expected={val!r}" for field, val in missing.items()]
|
|
267
|
+
sections.append("Missing fields:\n" + "\n".join(lines))
|
|
268
|
+
if not sections:
|
|
269
|
+
return None
|
|
270
|
+
return "\n" + "\n".join(sections)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_QUOTED_STRING_OR_BARE_KEY = re.compile(
|
|
274
|
+
r'"(?:[^"\\]|\\.)*"' r"|" r"([{,])\s*([A-Za-z_]+)\s*(:)"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _quote_unquoted_keys(input_str):
|
|
279
|
+
"""Wrap bare (unquoted) object keys in double quotes to produce valid JSON,
|
|
280
|
+
without corrupting content inside JSON string values.
|
|
281
|
+
|
|
282
|
+
Uses regex alternation: the first branch consumes quoted strings (so their
|
|
283
|
+
content is never touched), the second branch captures bare keys to quote.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
def _replacer(m):
|
|
287
|
+
if m.group(1) is None:
|
|
288
|
+
return m.group(0)
|
|
289
|
+
return f'{m.group(1)}"{m.group(2)}":'
|
|
290
|
+
|
|
291
|
+
return _QUOTED_STRING_OR_BARE_KEY.sub(_replacer, input_str)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def parse_events_from_input(input_str):
|
|
295
|
+
"""Parse SPL2-style or JSON array of events and return list of event dicts.
|
|
296
|
+
Each dict has at least '_raw'; may also have 'source', 'sourcetype', 'host'.
|
|
297
|
+
Handles unquoted keys (e.g. {_raw: "x", sourcetype: "st"}) by converting to JSON.
|
|
298
|
+
"""
|
|
299
|
+
normalized = _quote_unquoted_keys(input_str)
|
|
300
|
+
try:
|
|
301
|
+
parsed = json.loads(normalized)
|
|
302
|
+
except json.JSONDecodeError:
|
|
303
|
+
parsed = ast.literal_eval(normalized)
|
|
304
|
+
return list(parsed)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def parse_raw_from_input(input_str):
|
|
308
|
+
"""Parse SPL2-style or JSON array of events and return list of _raw field values.
|
|
309
|
+
Handles unquoted keys (e.g. {_raw: "x"}) by converting them to JSON-quoted keys.
|
|
310
|
+
"""
|
|
311
|
+
return [item["_raw"] for item in parse_events_from_input(input_str)]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_sourcetypes_from_input(input_str, default="UNKNOWN"):
|
|
315
|
+
"""Return sourcetype for each event in the box test input.
|
|
316
|
+
Use this to associate each input event with a sourcetype for reduction-rate
|
|
317
|
+
calculation per pipeline and sourcetype.
|
|
318
|
+
"""
|
|
319
|
+
events = parse_events_from_input(input_str)
|
|
320
|
+
return [e.get("sourcetype", default) for e in events]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def get_sourcetypes_from_events(events, default="UNKNOWN"):
|
|
324
|
+
"""Return sourcetype for each event in a list of event dicts (e.g. pipeline output).
|
|
325
|
+
Use with run_box_test output_result to get sourcetype per reduced event.
|
|
326
|
+
"""
|
|
327
|
+
return [e.get("sourcetype", default) for e in events]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_raw_from_reduced_events(events):
|
|
331
|
+
return [str(x["_raw"]) for x in events]
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"host": "",
|
|
3
|
-
"cloud_instance": "",
|
|
4
|
-
"tenant": "",
|
|
5
|
-
"bearer_token": "",
|
|
6
|
-
"port": "",
|
|
7
|
-
"user": "",
|
|
8
|
-
"password": "",
|
|
9
|
-
"SPLUNK_INSTANCE": {
|
|
10
|
-
"ip": "",
|
|
11
|
-
"port": "",
|
|
12
|
-
"api_port": "",
|
|
13
|
-
"username": "",
|
|
14
|
-
"password": "",
|
|
15
|
-
"index": "",
|
|
16
|
-
"hec_token": ""
|
|
17
|
-
}
|
|
18
|
-
}
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
# Copyright 2026 Splunk Inc.
|
|
2
|
-
#
|
|
3
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
# you may not use this file except in compliance with the License.
|
|
5
|
-
# You may obtain a copy of the License at
|
|
6
|
-
#
|
|
7
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
#
|
|
9
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
# See the License for the specific language governing permissions and
|
|
13
|
-
# limitations under the License.
|
|
14
|
-
import ast
|
|
15
|
-
import json
|
|
16
|
-
import logging
|
|
17
|
-
import pathlib
|
|
18
|
-
import re
|
|
19
|
-
from functools import lru_cache
|
|
20
|
-
|
|
21
|
-
_LOGGER = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def get_framework_root() -> pathlib.Path:
|
|
25
|
-
framework_root = pathlib.Path(__file__).parents[1]
|
|
26
|
-
return framework_root
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _make_functions_visible_for_testing(code_module_path):
|
|
30
|
-
code_module = code_module_path.read_text(encoding="utf-8")
|
|
31
|
-
functions = re.findall(r"^function\s+([^(]+)\(", code_module, flags=re.M)
|
|
32
|
-
for function in functions:
|
|
33
|
-
if not re.search(r"export\s" + function, code_module):
|
|
34
|
-
code_module = re.sub(
|
|
35
|
-
r"^function\s+" + function,
|
|
36
|
-
f"export function {function}",
|
|
37
|
-
code_module,
|
|
38
|
-
flags=re.M,
|
|
39
|
-
)
|
|
40
|
-
return code_module
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@lru_cache(None)
|
|
44
|
-
def read_assertions_module():
|
|
45
|
-
root_folder = get_framework_root()
|
|
46
|
-
assertions_file = root_folder / "spl2_utils" / "assertions.spl2"
|
|
47
|
-
return assertions_file.read_text(encoding="utf-8")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@lru_cache(None)
|
|
51
|
-
def read_commands_modules():
|
|
52
|
-
root_folder = get_framework_root()
|
|
53
|
-
commands_module = root_folder / "spl2_utils" / "logs_to_metrics.spl2"
|
|
54
|
-
return commands_module.read_text(encoding="utf-8")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def calculate_reduction_rate(original_event, reduced_event):
|
|
58
|
-
"""Calculate how much the event was reduced."""
|
|
59
|
-
reduction_rates = []
|
|
60
|
-
for original, reduced in zip(original_event, reduced_event):
|
|
61
|
-
original_event_len = len(str(original))
|
|
62
|
-
reduced_event_len = len(str(reduced))
|
|
63
|
-
|
|
64
|
-
reduction_rates.append(
|
|
65
|
-
(original_event_len - reduced_event_len) / original_event_len
|
|
66
|
-
)
|
|
67
|
-
return reduction_rates
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def validate_compatibility(actual, expected):
|
|
71
|
-
"""Check that every field in *expected* exists in *actual* with the same value.
|
|
72
|
-
|
|
73
|
-
Order matches the usual test convention ``assert actual == expected`` (observed
|
|
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.
|
|
82
|
-
"""
|
|
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)
|
|
95
|
-
else:
|
|
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
|
-
)
|
|
128
|
-
|
|
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)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def parse_raw_from_input(input_str):
|
|
147
|
-
"""Parse SPL2-style or JSON array of events and return list of _raw field values.
|
|
148
|
-
Handles unquoted keys (e.g. {_raw: "x"}) by converting them to JSON-quoted keys.
|
|
149
|
-
"""
|
|
150
|
-
normalized = _quote_unquoted_keys(input_str)
|
|
151
|
-
try:
|
|
152
|
-
parsed = json.loads(normalized)
|
|
153
|
-
except json.JSONDecodeError:
|
|
154
|
-
parsed = ast.literal_eval(normalized)
|
|
155
|
-
return [item["_raw"] for item in parsed]
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def parse_raw_from_reduced_events(events):
|
|
159
|
-
return [str(x["_raw"]) for x in events]
|
|
File without changes
|
{spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.0}/spl2_testing_framework/__init__.py
RENAMED
|
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.3 → spl2_testing_framework-1.8.0}/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
|