fixtureqa 0.5.0__tar.gz → 0.6.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.
- {fixtureqa-0.5.0/fixtureqa.egg-info → fixtureqa-0.6.0}/PKG-INFO +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/schemas.py +3 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_payload.py +10 -41
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/scenario_runner.py +99 -57
- fixtureqa-0.6.0/fixture/core/value_gen.py +100 -0
- fixtureqa-0.5.0/fixture/static/assets/index-vqpCGgnx.js → fixtureqa-0.6.0/fixture/static/assets/index-C46Em7NG.js +22 -22
- fixtureqa-0.5.0/fixture/static/assets/index-C6r9qHOJ.css → fixtureqa-0.6.0/fixture/static/assets/index-DKAYJxvL.css +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/index.html +2 -2
- {fixtureqa-0.5.0 → fixtureqa-0.6.0/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/pyproject.toml +1 -1
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_scenarios.py +77 -0
- fixtureqa-0.6.0/tests/test_value_gen.py +45 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/LICENSE +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/README.md +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/app.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/deps.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/session.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/server.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/setup.cfg +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_config_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_health.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_ws.py +0 -0
|
@@ -266,12 +266,14 @@ class ScenarioStepDef(BaseModel):
|
|
|
266
266
|
timeout_ms: int = 5000
|
|
267
267
|
assertions: list[AssertionDef] = []
|
|
268
268
|
delay_ms: int = 0
|
|
269
|
+
repeat: int = 1 # run this step N times (one reported row, "(×N)")
|
|
269
270
|
|
|
270
271
|
|
|
271
272
|
class ScenarioRequest(BaseModel):
|
|
272
273
|
name: str
|
|
273
274
|
session_id: str
|
|
274
275
|
steps: list[ScenarioStepDef]
|
|
276
|
+
repeat: int = 1 # replay the whole step list N times
|
|
275
277
|
|
|
276
278
|
|
|
277
279
|
class ScenarioResponse(BaseModel):
|
|
@@ -279,6 +281,7 @@ class ScenarioResponse(BaseModel):
|
|
|
279
281
|
name: str
|
|
280
282
|
session_id: str
|
|
281
283
|
steps: list[ScenarioStepDef]
|
|
284
|
+
repeat: int = 1
|
|
282
285
|
|
|
283
286
|
|
|
284
287
|
class ScenarioGroupRequest(BaseModel):
|
|
@@ -40,12 +40,12 @@ from __future__ import annotations
|
|
|
40
40
|
|
|
41
41
|
import random
|
|
42
42
|
import re
|
|
43
|
-
import string
|
|
44
43
|
import uuid
|
|
45
44
|
from typing import Optional
|
|
46
45
|
|
|
47
46
|
from fixcore.message.message import Message
|
|
48
47
|
|
|
48
|
+
from . import value_gen
|
|
49
49
|
from .fix_builder import CONTROL_TAGS
|
|
50
50
|
from .fix_time import utc_timestamp
|
|
51
51
|
from .perf_models import RunConfig, ScenarioConfig
|
|
@@ -96,57 +96,26 @@ def _tokens(value: str) -> list[str]:
|
|
|
96
96
|
return _TOKEN_RE.findall(value) if isinstance(value, str) else []
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
# -- value generators
|
|
99
|
+
# -- value generators -------------------------------------------------------
|
|
100
|
+
# The generator set (uuid()/seq()/random_*()/timestamp()/date()) lives in the
|
|
101
|
+
# shared core.value_gen module so perf and the scenario runner evaluate field
|
|
102
|
+
# values identically. Aliased here to keep the call sites below unchanged.
|
|
100
103
|
|
|
101
|
-
_GEN_RE =
|
|
102
|
-
|
|
103
|
-
_GENERATOR_NAMES = {"uuid", "random_str", "random_int", "seq", "timestamp", "date"}
|
|
104
|
+
_GEN_RE = value_gen.GEN_RE
|
|
105
|
+
_GENERATOR_NAMES = value_gen.GENERATOR_NAMES
|
|
104
106
|
_EXPIRY_UNIT_S = {"minutes": 60, "hours": 3600, "days": 86400}
|
|
105
107
|
|
|
106
108
|
|
|
107
109
|
def _gen_call(name: str, raw_args: str, tag: int, counters: dict[int, int]) -> Optional[str]:
|
|
108
|
-
|
|
109
|
-
args = [a.strip() for a in raw_args.split(",")] if raw_args.strip() else []
|
|
110
|
-
if name == "uuid":
|
|
111
|
-
return _uuid()
|
|
112
|
-
if name == "random_str":
|
|
113
|
-
n = _to_int(args[0], 8) if args else 8
|
|
114
|
-
return "".join(random.choices(_ALNUM, k=min(n, 256) if n > 0 else 8))
|
|
115
|
-
if name == "random_int":
|
|
116
|
-
try:
|
|
117
|
-
a, b = int(float(args[0])), int(float(args[1]))
|
|
118
|
-
except (IndexError, TypeError, ValueError):
|
|
119
|
-
return None
|
|
120
|
-
return str(random.randint(min(a, b), max(a, b)))
|
|
121
|
-
if name == "seq":
|
|
122
|
-
# seq() / seq(width) / seq(width, start) — incrementing counter,
|
|
123
|
-
# zero-padded to width; one counter per tag per run.
|
|
124
|
-
width = _to_int(args[0], 0) if args else 0
|
|
125
|
-
start = _to_int(args[1], 1) if len(args) > 1 else 1
|
|
126
|
-
n = counters.get(tag, start)
|
|
127
|
-
counters[tag] = n + 1
|
|
128
|
-
return str(n).zfill(width) if width > 0 else str(n)
|
|
129
|
-
if name == "timestamp":
|
|
130
|
-
return utc_timestamp()
|
|
131
|
-
if name == "date":
|
|
132
|
-
return utc_timestamp()[:8]
|
|
133
|
-
return None
|
|
110
|
+
return value_gen.gen_call(name, raw_args, tag, counters)
|
|
134
111
|
|
|
135
112
|
|
|
136
113
|
def _eval_generator(value: str, tag: int, counters: dict[int, int]) -> str:
|
|
137
|
-
|
|
138
|
-
m = _GEN_RE.match(value.strip())
|
|
139
|
-
if not m:
|
|
140
|
-
return value
|
|
141
|
-
out = _gen_call(m.group(1), m.group(2), tag, counters)
|
|
142
|
-
return value if out is None else out
|
|
114
|
+
return value_gen.eval_generator(value, tag, counters)
|
|
143
115
|
|
|
144
116
|
|
|
145
117
|
def _is_dynamic(value: str) -> bool:
|
|
146
|
-
|
|
147
|
-
(contains a {token} or is a whole-value generator call)."""
|
|
148
|
-
return isinstance(value, str) and (
|
|
149
|
-
"{" in value or _GEN_RE.match(value.strip()) is not None)
|
|
118
|
+
return value_gen.is_dynamic(value)
|
|
150
119
|
|
|
151
120
|
|
|
152
121
|
class PayloadFactory:
|
|
@@ -21,6 +21,7 @@ from .fix_builder import _ordered_pairs
|
|
|
21
21
|
from .fix_time import utc_timestamp
|
|
22
22
|
from .session_manager import SessionManager
|
|
23
23
|
from .template_store import TemplateStore
|
|
24
|
+
from . import value_gen
|
|
24
25
|
|
|
25
26
|
SKIP_TAGS = {8, 9, 10, 34, 35, 49, 52, 56}
|
|
26
27
|
|
|
@@ -29,6 +30,15 @@ def _now_ms() -> float:
|
|
|
29
30
|
return time.monotonic() * 1000
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def _coerce_repeat(value) -> int:
|
|
34
|
+
"""Repeat counts default to and are floored at 1 — 0/negative/None/garbage
|
|
35
|
+
all become 1 so a malformed config can't silently skip a step."""
|
|
36
|
+
try:
|
|
37
|
+
return max(1, int(value))
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
|
|
32
42
|
def _utc_ts() -> str:
|
|
33
43
|
return utc_timestamp()
|
|
34
44
|
|
|
@@ -264,7 +274,9 @@ class ScenarioRunner:
|
|
|
264
274
|
scenario_id = scenario.get("id", "")
|
|
265
275
|
scenario_name = scenario.get("name", "")
|
|
266
276
|
steps = scenario.get("steps", [])
|
|
267
|
-
|
|
277
|
+
# Scenario-level repeat replays the whole list; each replay's steps get
|
|
278
|
+
# their own rows, so the run view expects len(steps) * repeat rows.
|
|
279
|
+
step_count = len(steps) * _coerce_repeat(scenario.get("repeat", 1))
|
|
268
280
|
|
|
269
281
|
self._broadcast(uid, {
|
|
270
282
|
"type": "scenario_start",
|
|
@@ -339,76 +351,105 @@ class ScenarioRunner:
|
|
|
339
351
|
def _run_steps(self, scenario: dict, uid: str, run_id: str,
|
|
340
352
|
abort_flag: threading.Event, steps: list, inbox: _RunInbox,
|
|
341
353
|
overall_status: str, group_run_id: Optional[str] = None) -> str:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
354
|
+
# The whole step list can run several times (scenario-level repeat); each
|
|
355
|
+
# iteration's steps get fresh, continuous step indices (so step_count =
|
|
356
|
+
# len(steps) * scenario_repeat, computed in _execute). A step can itself
|
|
357
|
+
# repeat (step-level repeat) — that stays ONE reported row labelled "(×N)",
|
|
358
|
+
# fail-fast on the first bad iteration, with its duration summed.
|
|
359
|
+
scenario_repeat = _coerce_repeat(scenario.get("repeat", 1))
|
|
360
|
+
session_id = scenario.get("session_id", "")
|
|
361
|
+
# Run-scoped seq() state so 11=ORD-{seq(4)} keeps counting across a
|
|
362
|
+
# repeated step and across scenario-level repeats (uuid()/random are
|
|
363
|
+
# always fresh regardless).
|
|
364
|
+
gen_counters: dict = {}
|
|
365
|
+
idx = 0
|
|
366
|
+
for run_iter in range(scenario_repeat):
|
|
367
|
+
for step in steps:
|
|
368
|
+
if abort_flag.is_set():
|
|
369
|
+
return "aborted"
|
|
370
|
+
|
|
371
|
+
label = step.get("label", f"Step {idx + 1}")
|
|
372
|
+
step_type = step.get("type", "")
|
|
373
|
+
step_repeat = _coerce_repeat(step.get("repeat", 1))
|
|
374
|
+
if step_repeat > 1:
|
|
375
|
+
label += f" (×{step_repeat})"
|
|
376
|
+
if scenario_repeat > 1:
|
|
377
|
+
label += f" (run {run_iter + 1}/{scenario_repeat})"
|
|
378
|
+
|
|
379
|
+
self._broadcast(uid, {
|
|
380
|
+
"type": "scenario_step",
|
|
381
|
+
"run_id": run_id,
|
|
382
|
+
"group_run_id": group_run_id,
|
|
383
|
+
"step_index": idx,
|
|
384
|
+
"label": label,
|
|
385
|
+
"status": "running",
|
|
386
|
+
"error": None,
|
|
387
|
+
"duration_ms": 0,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
step_start = _now_ms()
|
|
391
|
+
error: Optional[str] = None
|
|
392
|
+
for iteration in range(step_repeat):
|
|
393
|
+
try:
|
|
394
|
+
if step_type == "send":
|
|
395
|
+
error = self._run_send(step, session_id, uid, gen_counters)
|
|
396
|
+
elif step_type == "expect":
|
|
397
|
+
error = self._run_expect(step, inbox, abort_flag)
|
|
398
|
+
elif step_type == "delay":
|
|
399
|
+
self._run_delay(step, abort_flag)
|
|
400
|
+
if abort_flag.is_set():
|
|
401
|
+
overall_status = "aborted"
|
|
402
|
+
error = "Aborted"
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
error = str(exc)
|
|
405
|
+
if error is not None:
|
|
406
|
+
if step_repeat > 1:
|
|
407
|
+
error = f"iteration {iteration + 1}/{step_repeat}: {error}"
|
|
408
|
+
break
|
|
409
|
+
if abort_flag.is_set():
|
|
410
|
+
break
|
|
360
411
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
status = "pass"
|
|
412
|
+
status = "fail" if error is not None else "pass"
|
|
413
|
+
duration_ms = int(_now_ms() - step_start)
|
|
364
414
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
error = str(exc)
|
|
378
|
-
|
|
379
|
-
if error is not None:
|
|
380
|
-
status = "fail"
|
|
381
|
-
|
|
382
|
-
duration_ms = int(_now_ms() - step_start)
|
|
383
|
-
|
|
384
|
-
self._broadcast(uid, {
|
|
385
|
-
"type": "scenario_step",
|
|
386
|
-
"run_id": run_id,
|
|
387
|
-
"group_run_id": group_run_id,
|
|
388
|
-
"step_index": idx,
|
|
389
|
-
"label": label,
|
|
390
|
-
"status": status,
|
|
391
|
-
"error": error,
|
|
392
|
-
"duration_ms": duration_ms,
|
|
393
|
-
})
|
|
415
|
+
self._broadcast(uid, {
|
|
416
|
+
"type": "scenario_step",
|
|
417
|
+
"run_id": run_id,
|
|
418
|
+
"group_run_id": group_run_id,
|
|
419
|
+
"step_index": idx,
|
|
420
|
+
"label": label,
|
|
421
|
+
"status": status,
|
|
422
|
+
"error": error,
|
|
423
|
+
"duration_ms": duration_ms,
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
idx += 1
|
|
394
427
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
428
|
+
if status == "fail":
|
|
429
|
+
if overall_status != "aborted":
|
|
430
|
+
overall_status = "fail"
|
|
431
|
+
return overall_status
|
|
399
432
|
|
|
400
433
|
return overall_status
|
|
401
434
|
|
|
402
|
-
def _run_send(self, step: dict, session_id: str, uid: str
|
|
435
|
+
def _run_send(self, step: dict, session_id: str, uid: str,
|
|
436
|
+
counters: dict) -> Optional[str]:
|
|
403
437
|
"""Build and send a FIX message. Returns error string or None.
|
|
404
438
|
|
|
405
439
|
A 'raw' field is built group-aware via the session's DataDictionary, so
|
|
406
440
|
repeating groups survive. Otherwise template + step fields merge into a
|
|
407
441
|
flat dict (no repeating groups possible from a dict).
|
|
442
|
+
|
|
443
|
+
Field values may use the same generators as the Send Options UI —
|
|
444
|
+
uuid()/seq()/random_*()/timestamp()/date(), whole-value or embedded as
|
|
445
|
+
{gen(...)} — evaluated fresh per send. `counters` is the run-scoped seq()
|
|
446
|
+
state (passed in so seq() keeps incrementing across a repeated step / a
|
|
447
|
+
repeated scenario, e.g. 11=ORD-{seq(4)} → ORD-0001, ORD-0002, …).
|
|
408
448
|
"""
|
|
409
449
|
raw_field = step.get("raw", "").strip()
|
|
410
450
|
if raw_field:
|
|
411
451
|
from .fix_builder import build_message_from_raw
|
|
452
|
+
raw_field = value_gen.eval_field_value(raw_field, counters)
|
|
412
453
|
try:
|
|
413
454
|
msg = build_message_from_raw(
|
|
414
455
|
raw_field, self._sm.get_data_dictionary(session_id), transact_time=_utc_ts())
|
|
@@ -448,7 +489,8 @@ class ScenarioRunner:
|
|
|
448
489
|
for tag, value in fields.items():
|
|
449
490
|
if tag not in SKIP_TAGS:
|
|
450
491
|
try:
|
|
451
|
-
msg.set_field(int(tag),
|
|
492
|
+
msg.set_field(int(tag),
|
|
493
|
+
value_gen.eval_field_value(str(value), counters, tag))
|
|
452
494
|
except Exception:
|
|
453
495
|
pass
|
|
454
496
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Server-side value generators for outbound FIX field values.
|
|
2
|
+
|
|
3
|
+
uuid() · random_str(n) · random_int(a,b) · seq(width[,start]) · timestamp() ·
|
|
4
|
+
date() — mirrors the frontend SendOptions.evalFieldValue so a value typed in
|
|
5
|
+
either place behaves the same. A value that is exactly one recognised call is
|
|
6
|
+
replaced wholesale; generators also embed in text as {gen(...)} —
|
|
7
|
+
e.g. "ORD-{seq(8)}" → "ORD-00000001". Anything else passes through unchanged.
|
|
8
|
+
|
|
9
|
+
This is the single server-side implementation, shared by the perf payload
|
|
10
|
+
factory (perf_payload.py) and the scenario runner (scenario_runner.py).
|
|
11
|
+
|
|
12
|
+
seq() state lives in a caller-supplied `counters` dict keyed by an arbitrary
|
|
13
|
+
`key` (the perf factory and scenario fields use the FIX tag, so each tag has
|
|
14
|
+
its own running counter); the caller owns the dict's lifetime (one per run /
|
|
15
|
+
per send).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import random
|
|
21
|
+
import re
|
|
22
|
+
import string
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from .fix_time import utc_timestamp
|
|
26
|
+
|
|
27
|
+
GENERATOR_NAMES = {"uuid", "random_str", "random_int", "seq", "timestamp", "date"}
|
|
28
|
+
GEN_RE = re.compile(r"^(\w+)\(([^)]*)\)$")
|
|
29
|
+
_EMBED_RE = re.compile(r"\{(\w+)\(([^)]*)\)\}")
|
|
30
|
+
_ALNUM = string.ascii_letters + string.digits
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_int(v, default: int = 0) -> int:
|
|
34
|
+
try:
|
|
35
|
+
return int(float(v))
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def gen_call(name: str, raw_args: str, key, counters: dict) -> Optional[str]:
|
|
41
|
+
"""Evaluate one generator call; None = unknown name / invalid args."""
|
|
42
|
+
import uuid
|
|
43
|
+
args = [a.strip() for a in raw_args.split(",")] if raw_args.strip() else []
|
|
44
|
+
if name == "uuid":
|
|
45
|
+
return str(uuid.uuid4())
|
|
46
|
+
if name == "random_str":
|
|
47
|
+
n = _to_int(args[0], 8) if args else 8
|
|
48
|
+
return "".join(random.choices(_ALNUM, k=min(n, 256) if n > 0 else 8))
|
|
49
|
+
if name == "random_int":
|
|
50
|
+
try:
|
|
51
|
+
a, b = int(float(args[0])), int(float(args[1]))
|
|
52
|
+
except (IndexError, TypeError, ValueError):
|
|
53
|
+
return None
|
|
54
|
+
return str(random.randint(min(a, b), max(a, b)))
|
|
55
|
+
if name == "seq":
|
|
56
|
+
# seq() / seq(width) / seq(width, start) — incrementing counter,
|
|
57
|
+
# zero-padded to width; one counter per key.
|
|
58
|
+
width = _to_int(args[0], 0) if args else 0
|
|
59
|
+
start = _to_int(args[1], 1) if len(args) > 1 else 1
|
|
60
|
+
n = counters.get(key, start)
|
|
61
|
+
counters[key] = n + 1
|
|
62
|
+
return str(n).zfill(width) if width > 0 else str(n)
|
|
63
|
+
if name == "timestamp":
|
|
64
|
+
return utc_timestamp()
|
|
65
|
+
if name == "date":
|
|
66
|
+
return utc_timestamp()[:8]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def eval_generator(value: str, key, counters: dict) -> str:
|
|
71
|
+
"""Evaluate a whole-value generator expression; pass literals through."""
|
|
72
|
+
m = GEN_RE.match(value.strip())
|
|
73
|
+
if not m:
|
|
74
|
+
return value
|
|
75
|
+
out = gen_call(m.group(1), m.group(2), key, counters)
|
|
76
|
+
return value if out is None else out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def eval_field_value(value: str, counters: dict, key=0) -> str:
|
|
80
|
+
"""Whole-value generator OR generators embedded in text as {gen(...)}.
|
|
81
|
+
e.g. "uuid()" → a UUID; "ORD-{seq(8)}" → "ORD-00000001"; literals unchanged."""
|
|
82
|
+
if not isinstance(value, str):
|
|
83
|
+
return value
|
|
84
|
+
m = GEN_RE.match(value.strip())
|
|
85
|
+
if m:
|
|
86
|
+
out = gen_call(m.group(1), m.group(2), key, counters)
|
|
87
|
+
return value if out is None else out
|
|
88
|
+
|
|
89
|
+
def _sub(mm: re.Match) -> str:
|
|
90
|
+
out = gen_call(mm.group(1), mm.group(2), key, counters)
|
|
91
|
+
return mm.group(0) if out is None else out
|
|
92
|
+
|
|
93
|
+
return _EMBED_RE.sub(_sub, value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_dynamic(value: str) -> bool:
|
|
97
|
+
"""True when a value renders differently per message (a {token} or a
|
|
98
|
+
whole-value generator call)."""
|
|
99
|
+
return isinstance(value, str) and (
|
|
100
|
+
"{" in value or GEN_RE.match(value.strip()) is not None)
|