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.
Files changed (103) hide show
  1. {fixtureqa-0.5.0/fixtureqa.egg-info → fixtureqa-0.6.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/schemas.py +3 -0
  3. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_payload.py +10 -41
  4. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/scenario_runner.py +99 -57
  5. fixtureqa-0.6.0/fixture/core/value_gen.py +100 -0
  6. fixtureqa-0.5.0/fixture/static/assets/index-vqpCGgnx.js → fixtureqa-0.6.0/fixture/static/assets/index-C46Em7NG.js +22 -22
  7. fixtureqa-0.5.0/fixture/static/assets/index-C6r9qHOJ.css → fixtureqa-0.6.0/fixture/static/assets/index-DKAYJxvL.css +1 -1
  8. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/index.html +2 -2
  9. {fixtureqa-0.5.0 → fixtureqa-0.6.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  10. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
  11. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/pyproject.toml +1 -1
  12. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_scenarios.py +77 -0
  13. fixtureqa-0.6.0/tests/test_value_gen.py +45 -0
  14. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/LICENSE +0 -0
  15. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/README.md +0 -0
  16. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/__init__.py +0 -0
  17. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/__main__.py +0 -0
  18. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/__init__.py +0 -0
  19. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/app.py +0 -0
  20. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/connection_manager.py +0 -0
  21. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/deps.py +0 -0
  22. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/__init__.py +0 -0
  23. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/admin.py +0 -0
  24. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/auth.py +0 -0
  25. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/branding.py +0 -0
  26. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/custom_tags.py +0 -0
  27. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/fix_spec.py +0 -0
  28. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/messages.py +0 -0
  29. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/perf.py +0 -0
  30. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/scenarios.py +0 -0
  31. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/sessions.py +0 -0
  32. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/setup.py +0 -0
  33. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/spec_overlay.py +0 -0
  34. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/templates.py +0 -0
  35. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/api/routers/ws.py +0 -0
  36. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/config/__init__.py +0 -0
  37. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/__init__.py +0 -0
  38. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/atomic_io.py +0 -0
  39. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/auth.py +0 -0
  40. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/config_store.py +0 -0
  41. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/custom_tag_store.py +0 -0
  42. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/db_migrations.py +0 -0
  43. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/events.py +0 -0
  44. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_application.py +0 -0
  45. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_builder.py +0 -0
  46. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_parser.py +0 -0
  47. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_spec_parser.py +0 -0
  48. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_tags.py +0 -0
  49. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/fix_time.py +0 -0
  50. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/housekeeping.py +0 -0
  51. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/inbound.py +0 -0
  52. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/json_store.py +0 -0
  53. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/latency_csv_writer.py +0 -0
  54. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_charts.py +0 -0
  58. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_engine.py +0 -0
  59. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_models.py +0 -0
  60. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_stats.py +0 -0
  61. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_store.py +0 -0
  62. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/perf_writer.py +0 -0
  63. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/scenario_store.py +0 -0
  64. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/session.py +0 -0
  65. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/session_manager.py +0 -0
  66. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/spec_overlay_store.py +0 -0
  67. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/template_store.py +0 -0
  68. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/user_store.py +0 -0
  69. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/core/venue_responses.py +0 -0
  70. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/fix_specs/FIX42.xml +0 -0
  71. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/fix_specs/FIX44.xml +0 -0
  72. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/server.py +0 -0
  73. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  74. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  75. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  76. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/static/favicon.svg +0 -0
  77. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixture/ui/__init__.py +0 -0
  78. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  79. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  80. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/requires.txt +0 -0
  81. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/setup.cfg +0 -0
  83. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_health.py +0 -0
  90. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_api.py +0 -0
  94. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_engine.py +0 -0
  95. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_models.py +0 -0
  96. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_payload.py +0 -0
  97. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_perf_rehydrate.py +0 -0
  98. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_session_auto_response.py +0 -0
  99. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_session_lifecycle.py +0 -0
  100. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_session_manager_concurrency.py +0 -0
  101. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_sessions.py +0 -0
  102. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_templates.py +0 -0
  103. {fixtureqa-0.5.0 → fixtureqa-0.6.0}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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 (mirror frontend SendOptions.evalGenerator) ----------
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 = re.compile(r"^(\w+)\(([^)]*)\)$")
102
- _ALNUM = string.ascii_letters + string.digits
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
- """Evaluate one generator call; None = unknown name / invalid args."""
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
- """Evaluate a whole-value generator expression; pass literals through."""
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
- """True when a template value renders differently per message
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
- step_count = len(steps)
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
- for idx, step in enumerate(steps):
343
- if abort_flag.is_set():
344
- overall_status = "aborted"
345
- break
346
-
347
- label = step.get("label", f"Step {idx + 1}")
348
- step_type = step.get("type", "")
349
-
350
- self._broadcast(uid, {
351
- "type": "scenario_step",
352
- "run_id": run_id,
353
- "group_run_id": group_run_id,
354
- "step_index": idx,
355
- "label": label,
356
- "status": "running",
357
- "error": None,
358
- "duration_ms": 0,
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
- step_start = _now_ms()
362
- error: Optional[str] = None
363
- status = "pass"
412
+ status = "fail" if error is not None else "pass"
413
+ duration_ms = int(_now_ms() - step_start)
364
414
 
365
- try:
366
- if step_type == "send":
367
- error = self._run_send(step, scenario.get("session_id", ""), uid)
368
- elif step_type == "expect":
369
- error = self._run_expect(step, inbox, abort_flag)
370
- elif step_type == "delay":
371
- self._run_delay(step, abort_flag)
372
- if abort_flag.is_set():
373
- overall_status = "aborted"
374
- status = "fail"
375
- error = "Aborted"
376
- except Exception as exc:
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
- if status == "fail":
396
- if overall_status != "aborted":
397
- overall_status = "fail"
398
- break
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) -> Optional[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), str(value))
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)